summaryrefslogtreecommitdiff
path: root/server/internal
diff options
context:
space:
mode:
Diffstat (limited to 'server/internal')
-rw-r--r--server/internal/skips/ads.go2
-rw-r--r--server/internal/skips/kinds.go67
-rw-r--r--server/internal/skips/kinds_test.go111
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)
+ }
+}