summaryrefslogtreecommitdiff
path: root/server/internal/signature/shadow.go
blob: fe856a7150756a5bb1fb528505919ed28af52db1 (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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
package signature

import (
	"context"

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

// Audio supplies decoded audio at [SampleRate].
type Audio interface {
	// Episode decodes a whole episode.
	Episode(ctx context.Context, id string) ([]float64, error)
	// Range decodes one stretch of an episode.
	Range(ctx context.Context, id string, startMs, durationMs int64) ([]float64, error)
}

// ShadowBreak is one stretch detection decided about.
type ShadowBreak struct {
	StartMs int64
	EndMs   int64
	// Partners is how many other episodes detection found carrying this audio. It is what
	// makes an advertisement a confirmed repeat rather than a single sighting.
	Partners int
	// Kind is what skips.Classify called it: "ad", or one of the show's own "intro", "cue",
	// "outro".
	Kind string
}

// ShadowLabel is something the listener said about a stretch of this episode.
type ShadowLabel struct {
	StartMs int64
	EndMs   int64
	// Advertising is true when he said this is an ad -- the notification button, or marking a
	// break the detector found as right. False when he said it is the programme.
	Advertising bool
	// EndMeasured is false when the end is wherever the jump landed rather than where he heard
	// the advertising stop. See [GuessedEndSeedMs].
	EndMeasured bool
}

// ShadowEpisode is one episode of a batch, what detection made of it, and what the listener
// said about it.
type ShadowEpisode struct {
	ID      string
	FeedURL string
	Breaks  []ShadowBreak
	Labels  []ShadowLabel
}

// ShadowBatch is one detection pass over one show.
type ShadowBatch struct {
	// Occasion identifies the pass. Everything in it counts as a single sighting, because the
	// episodes were mirrored together and all carry whatever campaign was running that minute.
	Occasion string
	AtUnix   int64
	Episodes []ShadowEpisode
}

// Proposal is a stretch the library would cut.
type Proposal struct {
	EpisodeID string
	StartMs   int64
	EndMs     int64
	Entry     *Entry
	Votes     int
	// Novel is true when detection did not already publish a break covering this. Those are
	// the interesting ones: the library earning its place rather than agreeing with what is
	// already known.
	Novel bool
}

// GuessedEndSeedMs is how much of a reported advertisement is kept when its end was never
// measured.
//
// An uncorrected end is wherever the 25-second jump happened to land, not where the advertising
// stopped, so the tail of that range may be programme -- and a signature carrying programme is
// the one thing this must never build. One advertising slot from the start is far more audio
// than a landmark fingerprint needs to be distinctive, and it is the part most certainly
// advertising.
const GuessedEndSeedMs = advertising.UnitMs

// Report is what one shadow run saw.
type Report struct {
	Harvested  int
	Reinforced int
	// Disowned counts signatures dropped because the listener said that audio is the programme.
	Disowned int
	WouldCut []Proposal
	// Skipped counts episodes whose audio could not be read. Reported rather than failing the
	// run: one unreadable file must not cost the whole batch's learning.
	Skipped int
}

// Shadow learns from a detection pass and reports what the library would do, without doing it.
//
// Two jobs, deliberately in one place. It harvests signatures from what detection found, and it
// matches what it already holds against the same audio. Running those together is what makes the
// log honest: the proposals in a report were made by a library that did not yet know about this
// batch.
type Shadow struct {
	library *Library
	audio   Audio
	// MinSightings is how many separate occasions a signature needs before it is matched
	// against. Never one: a batch is mirrored in a minute and shares one campaign.
	MinSightings int
}

// NewShadow returns a shadow runner over the given library.
func NewShadow(library *Library, audio Audio) *Shadow {
	return &Shadow{library: library, audio: audio, MinSightings: 2}
}

// Run matches first, then harvests.
//
// The order is the point. Harvesting first would let this batch's own signatures match the batch
// they came from, and every run would report itself as a triumph.
func (s *Shadow) Run(ctx context.Context, batch ShadowBatch) (Report, error) {
	var report Report
	// Counted per episode, not per failure: an episode that could not be read fails both the
	// matching pass and the harvesting pass, and reporting it twice would overstate the damage.
	unreadable := map[string]bool{}
	for _, ep := range batch.Episodes {
		if ctx.Err() != nil {
			report.Skipped = len(unreadable)
			return report, ctx.Err()
		}
		samples, err := s.audio.Episode(ctx, ep.ID)
		if err != nil || len(samples) == 0 {
			unreadable[ep.ID] = true
			continue
		}
		for _, m := range s.library.Match(Take(samples), s.MinSightings) {
			report.WouldCut = append(report.WouldCut, Proposal{
				EpisodeID: ep.ID,
				StartMs:   m.StartMs,
				EndMs:     m.EndMs,
				Entry:     m.Entry,
				Votes:     m.Votes,
				Novel:     !covered(ep.Breaks, m.StartMs, m.EndMs),
			})
		}
	}

	for _, ep := range batch.Episodes {
		if ctx.Err() != nil {
			report.Skipped = len(unreadable)
			return report, ctx.Err()
		}
		// His "no" first, and it does two things: it stops the harvest below from storing
		// audio he says is the programme, and it takes back anything already held that matches
		// it. A signature built from programme would cut the programme in every episode
		// carrying it, until somebody noticed.
		for _, label := range ep.Labels {
			if label.Advertising {
				continue
			}
			if !s.disown(ctx, ep, label, &report) {
				unreadable[ep.ID] = true
			}
		}
		for _, br := range ep.Breaks {
			if disowned(ep.Labels, br.StartMs, br.EndMs) {
				continue
			}
			// A break this cannot read is counted and stepped over. Every other per-item loop
			// in this server isolates a single failure the same way, and the first real run
			// lost a whole show's learning to one episode whose path was wrong.
			if !s.harvest(ctx, batch, ep, seed{
				startMs:   br.StartMs,
				endMs:     br.EndMs,
				kind:      kindFor(br.Kind),
				partners:  br.Partners,
				confirmed: vouchedFor(ep.Labels, br.StartMs, br.EndMs),
			}, &report) {
				unreadable[ep.ID] = true
				break
			}
		}
		// Then what he pointed at himself. Advertising detection missed is the gap the library
		// exists to close, so a report is harvested whether or not a break was found there.
		for _, label := range ep.Labels {
			if !label.Advertising || coveredByBreak(ep.Breaks, label) {
				continue
			}
			if !s.harvest(ctx, batch, ep, seedFromLabel(label), &report) {
				unreadable[ep.ID] = true
				break
			}
		}
	}
	report.Skipped = len(unreadable)
	return report, nil
}

// seed is one stretch about to become a signature.
type seed struct {
	startMs   int64
	endMs     int64
	kind      string
	partners  int
	confirmed bool
}

func seedFromLabel(label ShadowLabel) seed {
	end := label.EndMs
	if !label.EndMeasured && end-label.StartMs > GuessedEndSeedMs {
		end = label.StartMs + GuessedEndSeedMs
	}
	// Always a copy: he presses the button when he hears an advertisement, and a show's own
	// furniture is not what that button is for.
	return seed{startMs: label.StartMs, endMs: end, kind: KindCopy, confirmed: true}
}

func overlaps(aStart, aEnd, bStart, bEnd int64) bool { return aStart < bEnd && aEnd > bStart }

// disowned says whether the listener called this stretch the programme.
func disowned(labels []ShadowLabel, startMs, endMs int64) bool {
	for _, l := range labels {
		if !l.Advertising && overlaps(startMs, endMs, l.StartMs, l.EndMs) {
			return true
		}
	}
	return false
}

// vouchedFor says whether the listener marked this break right.
func vouchedFor(labels []ShadowLabel, startMs, endMs int64) bool {
	for _, l := range labels {
		if l.Advertising && overlaps(startMs, endMs, l.StartMs, l.EndMs) {
			return true
		}
	}
	return false
}

// coveredByBreak says whether detection already found this label's audio, in which case
// harvesting the break covered it and the label only had to vouch for it.
func coveredByBreak(breaks []ShadowBreak, label ShadowLabel) bool {
	for _, br := range breaks {
		if overlaps(label.StartMs, label.EndMs, br.StartMs, br.EndMs) {
			return true
		}
	}
	return false
}

// disown forgets every signature matching audio the listener says is the programme.
func (s *Shadow) disown(ctx context.Context, ep ShadowEpisode, label ShadowLabel, report *Report) bool {
	samples, err := s.audio.Range(ctx, ep.ID, label.StartMs, label.EndMs-label.StartMs)
	if err != nil {
		return false
	}
	report.Disowned += s.library.Disown(Take(samples))
	return true
}

// harvest stores one stretch of audio as a signature, and reports whether the episode could be
// read at all. A false means give up on this episode, not on the batch.
func (s *Shadow) harvest(ctx context.Context, batch ShadowBatch, ep ShadowEpisode, sd seed, report *Report) bool {
	duration := sd.endMs - sd.startMs
	if duration <= 0 {
		return true
	}
	samples, err := s.audio.Range(ctx, ep.ID, sd.startMs, duration)
	if err != nil {
		return false
	}
	fp := Take(samples)
	if len(fp.Hashes) == 0 {
		return true
	}
	_, isNew := s.library.Observe(Observation{
		Fingerprint: fp,
		Kind:        sd.kind,
		FeedURL:     ep.FeedURL,
		DurationMs:  duration,
		Partners:    sd.partners,
		Confirmed:   sd.confirmed,
		Occasion:    batch.Occasion,
	}, batch.AtUnix)
	if isNew {
		report.Harvested++
	} else {
		report.Reinforced++
	}
	return true
}

// kindFor maps what detection called a break onto what the library stores.
//
// Advertising belongs to nobody and expires; everything else is the show's own audio and does
// not. Anything unrecognised is treated as advertising, matching the app's rule that a kind a
// newer server invented must not quietly become permanent furniture.
func kindFor(breakKind string) string {
	switch breakKind {
	case "intro", "cue", "outro":
		return KindCue
	default:
		return KindCopy
	}
}

// covered says whether detection already published a break overlapping this stretch.
func covered(breaks []ShadowBreak, startMs, endMs int64) bool {
	for _, br := range breaks {
		if startMs < br.EndMs && endMs > br.StartMs {
			return true
		}
	}
	return false
}