diff options
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) + } +} |
