summaryrefslogtreecommitdiff
path: root/server/internal/skips/kinds.go
blob: f38f040c659c34df3b6b5a3d42366cbdc7c5c8e6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
package skips

import "github.com/terst/listen/server/internal/advertising"

// 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 (
	// adUnitMs is how far served may exceed declared before the excess is read as an insertion.
	// One advertising slot: a gap smaller than that cannot be a real insertion, it is the feed
	// being approximate, the same way encoders disagree about a file's length by a fraction of
	// a second.
	adUnitMs = advertising.UnitMs
	// 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 served no more than one ad unit longer than declared had nothing inserted -- the excess
// is the feed being approximate, not an advertisement -- so whatever recurs in it is the show's
// own audio however strongly it matched.
//
// A served length *shorter* than declared is also not evidence of insertion, by any amount: you
// cannot insert advertising into a file and have it come out shorter, so a negative gap is
// evidence that nothing was inserted, not evidence that the feed can't be trusted. (An earlier
// version of this function disagreed and called every materially-shorter episode advertising,
// worried that a feed wrong about its own length in the short direction couldn't be trusted to
// judge the other direction either. Measured against the owner's library of 334 mirrored
// episodes, that caution cost far more than it protected: 31% of episodes are served shorter than
// declared -- feeds are routinely sloppy by tens of seconds -- and every break in every one of
// them fell to the unjudgeable default and got labelled advertising, including closing credits
// three seconds from the end of a file. Reversed here: a negative gap is classified by position
// like any other furniture.)
//
// Everything else -- served more than one ad unit longer than declared, or either length unknown
// -- is called advertising. That is still deliberately the unrefined answer for the case that is
// genuinely judgeable -- 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 we can't judge (missing served or declared length), treat conservatively as inserted ads.
	// A served length more than one ad unit longer than declared is evidence of an insertion. A
	// served length shorter than declared -- by any amount -- is not: a file cannot come out
	// shorter than it started by having something added to it, so there is no insertion to find.
	inserted := servedMs <= 0 || declaredMs <= 0 || (servedMs-declaredMs) > adUnitMs

	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.
//
// For normal-length episodes, the opening and closing windows don't overlap. For very short
// episodes, they do: a break may be close to both the start and the end. In that case, classify
// by the nearest edge.
func furnitureKindAt(b AdBreak, servedMs int64) BreakKind {
	if servedMs <= 0 {
		// Unreachable today -- Classify sets inserted = true whenever servedMs <= 0, so this is
		// never called with one. Fail-safe anyway: if that precondition ever moves, an
		// unjudgeable length must still read as advertising, not as the show's own furniture.
		return KindAd
	}

	// Use a window size proportional to the episode length for very short episodes.
	// For long episodes (>= 240s), use the fixed edgeWindowMs (120s).
	// For short episodes, use a quarter of the episode length as the window.
	windowMs := int64(edgeWindowMs)
	if servedMs < 2*edgeWindowMs {
		// For short episodes (< 240s), use a quarter of the episode as the window
		windowMs = servedMs / 4
	}

	distToStart := b.StartMs
	distToEnd := servedMs - b.EndMs

	inOpeningWindow := distToStart <= windowMs
	inClosingWindow := distToEnd <= windowMs

	if inOpeningWindow && inClosingWindow {
		// In the overlap - use nearest edge
		if distToStart < distToEnd {
			return KindIntro
		} else if distToEnd < distToStart {
			return KindOutro
		} else {
			// Exactly in the middle - treat as cue (interior break)
			return KindCue
		}
	}

	if inOpeningWindow {
		return KindIntro
	}
	if inClosingWindow {
		return KindOutro
	}
	return KindCue
}