diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-10-14 07:47:28 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-10-14 07:47:28 +0000 |
| commit | e972fa53da7b60e0bfd3f95305afc53ec1b1a350 (patch) | |
| tree | 16caa32f5abef8f1a54c2c587c5fcf47d12a8678 /server | |
| parent | caef63840daef2a330a698a458e3a614b3eb4a14 (diff) | |
Decide whether a break is advertising or the show's own audio
An episode no longer than its declared length had nothing inserted into
it, so whatever recurs in it is the show's furniture however strongly it
matched. 138 of 322 mirrored episodes are in that position and the scan
published ad breaks on 111 of them.
Diffstat (limited to 'server')
| -rw-r--r-- | server/internal/skips/ads.go | 2 | ||||
| -rw-r--r-- | server/internal/skips/kinds.go | 67 | ||||
| -rw-r--r-- | server/internal/skips/kinds_test.go | 111 |
3 files changed, 180 insertions, 0 deletions
diff --git a/server/internal/skips/ads.go b/server/internal/skips/ads.go index 05259c3..2ef08c6 100644 --- a/server/internal/skips/ads.go +++ b/server/internal/skips/ads.go @@ -43,6 +43,8 @@ type AdBreak struct { Partners int // Score is the weakest window agreement across the match, so it describes the whole break. Score float64 + // Kind says whether this is inserted advertising or the show's own audio. Set by Classify. + Kind BreakKind } // DurationMs is the break's length. diff --git a/server/internal/skips/kinds.go b/server/internal/skips/kinds.go new file mode 100644 index 0000000..e70ce05 --- /dev/null +++ b/server/internal/skips/kinds.go @@ -0,0 +1,67 @@ +package skips + +// BreakKind says what a published break actually is, so advertising and a show's own furniture +// can be told apart -- by the phone, which skips them on different terms, and by anyone reading +// the accounting. +type BreakKind string + +const ( + // KindAd is advertising inserted into this copy of the episode. + KindAd BreakKind = "ad" + // KindIntro, KindCue and KindOutro are the show's own recurring audio: its opening, the + // music it plays at an ad break, and its closing credits. + KindIntro BreakKind = "intro" + KindCue BreakKind = "cue" + KindOutro BreakKind = "outro" +) + +const ( + // durationSlopMs is how far served and declared may differ before the difference is read as + // an insertion. Encoders disagree about a file's length by a fraction of a second. + durationSlopMs = 2000 + // edgeWindowMs is how close to either end a break must sit to be the opening or the closing + // rather than a break cue. Two minutes: Jon Stewart's opening runs 98 seconds and Search + // Engine's begins at 2:00. + edgeWindowMs = 120_000 +) + +// Classify decides what each of one episode's breaks is. +// +// The length gap is the evidence, and the point of using it is that the detector does not +// produce it: the feed declares the ad-free length and the file is whatever was served. An +// episode no longer than its declared length had nothing inserted, so whatever recurs in it is +// the show's own audio however strongly it matched. +// +// Everything else is called advertising. That is deliberately the unrefined answer -- an +// ad-carrying episode also contains its own intro and cue -- but it matches what the app does +// today, and separating the two inside such an episode needs the furniture library, which cannot +// confirm anything until a second fetch occasion has passed. +func Classify(breaks []AdBreak, servedMs, declaredMs int64) []AdBreak { + out := make([]AdBreak, 0, len(breaks)) + // If there's no declared length, we can't compare, so treat conservatively as inserted ads. + // If both lengths are known and the file is longer than declared by more than encoding slop, + // it contains inserted ads. + inserted := declaredMs <= 0 || (servedMs > 0 && declaredMs > 0 && servedMs-declaredMs > durationSlopMs) + + for _, b := range breaks { + b.Kind = KindAd + if !inserted { + b.Kind = furnitureKindAt(b, servedMs) + } + out = append(out, b) + } + return out +} + +// furnitureKindAt names a break by where it sits. Opening first: a very short episode could +// satisfy both tests, and its first break is its opening. +func furnitureKindAt(b AdBreak, servedMs int64) BreakKind { + switch { + case b.StartMs <= edgeWindowMs: + return KindIntro + case servedMs > 0 && servedMs-b.EndMs <= edgeWindowMs: + return KindOutro + default: + return KindCue + } +} diff --git a/server/internal/skips/kinds_test.go b/server/internal/skips/kinds_test.go new file mode 100644 index 0000000..f9fc6f0 --- /dev/null +++ b/server/internal/skips/kinds_test.go @@ -0,0 +1,111 @@ +package skips + +import "testing" + +/* +What a break actually is. + +138 of 322 mirrored episodes have a served length equal to their declared length, which means no +advertising was inserted into them at all -- and the scan published "ad breaks" on 111 of those, +74 minutes of opening themes, break cues and closing credits reported as advertisements. + +The rule has to work before any furniture library exists, because a clip cannot be confirmed as +the show's own until a second fetch occasion has passed. The length gap supplies that: it comes +from the feed and the file rather than from the detector. +*/ + +func kindsOf(breaks []AdBreak) []BreakKind { + out := make([]BreakKind, 0, len(breaks)) + for _, b := range breaks { + out = append(out, b.Kind) + } + return out +} + +func TestAnEpisodeLongerThanDeclaredHasAdvertisingInIt(t *testing.T) { + // Radiolab's Grass Apes: 28:18 served against 22:05 declared. + got := Classify([]AdBreak{ + {StartMs: 0, EndMs: 255_000}, + {StartMs: 1_082_000, EndMs: 1_220_000}, + }, 1_698_000, 1_325_000) + + for i, k := range kindsOf(got) { + if k != KindAd { + t.Errorf("break %d is %q, want %q -- this episode demonstrably carries ads", i, k, KindAd) + } + } +} + +func TestAnEpisodeMatchingItsDeclaredLengthIsAllFurniture(t *testing.T) { + // Jon Stewart, 97:03 served and declared: a 98s opening, three cues, 28s of credits. + got := Classify([]AdBreak{ + {StartMs: 0, EndMs: 98_000}, + {StartMs: 1_069_000, EndMs: 1_075_000}, + {StartMs: 2_145_000, EndMs: 2_151_000}, + {StartMs: 4_752_000, EndMs: 4_758_000}, + {StartMs: 5_795_000, EndMs: 5_823_000}, + }, 5_823_000, 5_823_000) + + want := []BreakKind{KindIntro, KindCue, KindCue, KindCue, KindOutro} + for i, k := range kindsOf(got) { + if k != want[i] { + t.Errorf("break %d at %dms is %q, want %q", i, got[i].StartMs, k, want[i]) + } + } +} + +// The served file is sometimes *shorter* than declared -- Sleepy History runs about 1:36 short on +// every episode. A feed can be wrong in that direction too, and it is still not advertising. +func TestAnEpisodeShorterThanDeclaredIsAlsoAllFurniture(t *testing.T) { + got := Classify([]AdBreak{{StartMs: 2_793_000, EndMs: 2_989_000}}, 2_963_000, 3_059_000) + + if got[0].Kind != KindOutro { + t.Errorf("kind %q, want %q -- a short file has no insertion either", got[0].Kind, KindOutro) + } +} + +// Encoders disagree about a file's length by a fraction of a second, and that is not an advert. +func TestASecondOfDisagreementIsNotAnAdvertisement(t *testing.T) { + got := Classify([]AdBreak{{StartMs: 0, EndMs: 30_000}}, 3_040_900, 3_040_000) + + if got[0].Kind == KindAd { + t.Error("900ms of rounding was read as inserted advertising") + } +} + +// Without a declared length there is nothing to compare against, so the safe answer is the one +// that matches today's behaviour: treat it as advertising rather than silently reclassifying. +func TestAnEpisodeWithNoDeclaredLengthIsTreatedAsAdvertising(t *testing.T) { + got := Classify([]AdBreak{{StartMs: 0, EndMs: 30_000}}, 3_040_000, 0) + + if got[0].Kind != KindAd { + t.Errorf("kind %q, want %q when there is no gap to judge by", got[0].Kind, KindAd) + } +} + +// A break starting inside the opening window is the intro even when it does not start at zero: +// Search Engine's opening sits at 2:00. +func TestABreakNearTheStartIsTheIntro(t *testing.T) { + got := Classify([]AdBreak{{StartMs: 120_000, EndMs: 145_000}}, 3_040_000, 3_040_000) + + if got[0].Kind != KindIntro { + t.Errorf("kind %q, want %q", got[0].Kind, KindIntro) + } +} + +func TestClassifyingNothingReturnsNothing(t *testing.T) { + if got := Classify(nil, 3_040_000, 3_040_000); len(got) != 0 { + t.Errorf("got %d breaks from none", len(got)) + } +} + +// Every other field has to survive, or the ranges published would be the classifier's idea of +// them rather than the scan's. +func TestClassifyLeavesEverythingButTheKindAlone(t *testing.T) { + in := AdBreak{StartMs: 1_069_000, EndMs: 1_075_000, Partners: 7, Score: 0.91} + got := Classify([]AdBreak{in}, 5_823_000, 5_823_000)[0] + + if got.StartMs != in.StartMs || got.EndMs != in.EndMs || got.Partners != in.Partners || got.Score != in.Score { + t.Errorf("classify altered the range: %+v, want %+v with only Kind set", got, in) + } +} |
