diff options
| -rw-r--r-- | server/cmd/listend/main.go | 34 | ||||
| -rw-r--r-- | server/cmd/listend/shadow.go | 127 | ||||
| -rw-r--r-- | server/internal/signature/library.go | 42 | ||||
| -rw-r--r-- | server/internal/signature/library_test.go | 69 | ||||
| -rw-r--r-- | server/internal/signature/shadow.go | 177 | ||||
| -rw-r--r-- | server/internal/signature/shadow_test.go | 183 |
6 files changed, 624 insertions, 8 deletions
diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go index c500a63..a556f5e 100644 --- a/server/cmd/listend/main.go +++ b/server/cmd/listend/main.go @@ -31,6 +31,7 @@ import ( "github.com/terst/listen/server/internal/decode" "github.com/terst/listen/server/internal/feed" "github.com/terst/listen/server/internal/mirror" + "github.com/terst/listen/server/internal/signature" "github.com/terst/listen/server/internal/skips" "github.com/terst/listen/server/internal/store" ) @@ -58,15 +59,27 @@ func main() { log.Fatalf("state: %v", err) } + signaturePath := filepath.Join(*dataDir, "signatures.json") + // A damaged library is not worth stopping for: it is evidence that can be gathered again, + // unlike the state file, which holds the only record of where mirrored audio is. + signatures, err := signature.Load(signaturePath) + if err != nil { + log.Printf("signatures: %v -- starting an empty library", err) + signatures = signature.NewLibrary() + } + decoder := decode.Decoder{} worker := &worker{ - state: state, - statePath: statePath, - keep: *keep, - mirror: &mirror.Mirror{AudioDir: audioDir, Probe: decoder}, - decoder: decoder, - requests: make(chan request, 64), - wakeups: make(chan struct{}, 1), + state: state, + statePath: statePath, + signatures: signatures, + signaturePath: signaturePath, + keep: *keep, + mirror: &mirror.Mirror{AudioDir: audioDir, Probe: decoder}, + decoder: decoder, + audioDir: audioDir, + requests: make(chan request, 64), + wakeups: make(chan struct{}, 1), } server := api.New(state, worker, api.Options{ @@ -138,9 +151,13 @@ type request struct { type worker struct { state *store.State statePath string + // signatures is the library the shadow run learns into. Nothing it holds is published yet. + signatures *signature.Library + signaturePath string keep int mirror *mirror.Mirror decoder decode.Decoder + audioDir string requests chan request wakeups chan struct{} } @@ -358,6 +375,9 @@ func (w *worker) detect(ctx context.Context, show *store.Show, candidates []*sto if w.detectAds(ctx, show, episodes, byID) { show.RecordDetected(ids) + // After the batch is recorded, so the shadow run's occasion names the batch it + // learned from. It publishes nothing -- see runShadow. + w.runShadow(ctx, show, candidates) } } diff --git a/server/cmd/listend/shadow.go b/server/cmd/listend/shadow.go new file mode 100644 index 0000000..e9376f1 --- /dev/null +++ b/server/cmd/listend/shadow.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "fmt" + "log" + "sort" + "time" + + "github.com/terst/listen/server/internal/decode" + "github.com/terst/listen/server/internal/signature" + "github.com/terst/listen/server/internal/store" +) + +// episodeAudio decodes episodes for fingerprinting, at the rate the signature package wants. +type episodeAudio struct { + decoder decode.Decoder + pathOf func(id string) string +} + +func (a episodeAudio) Episode(ctx context.Context, id string) ([]float64, error) { + return a.decode(ctx, id, 0, 0) +} + +func (a episodeAudio) Range(ctx context.Context, id string, startMs, durationMs int64) ([]float64, error) { + return a.decode(ctx, id, startMs, durationMs) +} + +func (a episodeAudio) decode(ctx context.Context, id string, startMs, durationMs int64) ([]float64, error) { + path := a.pathOf(id) + if path == "" { + return nil, nil + } + clip, err := a.decoder.RangeAt(ctx, path, startMs, durationMs, signature.SampleRate) + if err != nil { + return nil, err + } + return clip.Samples, nil +} + +// runShadow learns from a finished detection pass and logs what the library would have cut. +// +// It publishes nothing. The library is new and its proposals have never been checked against +// what a listener actually hears, so for now it watches a few weeks of real batches and says +// what it would have done. The log is the deliverable. +func (w *worker) runShadow(ctx context.Context, show *store.Show, episodes []*store.Episode) { + if w.signatures == nil || len(episodes) == 0 { + return + } + started := time.Now() + + batch := signature.ShadowBatch{ + // The detection batch is the occasion: these episodes were mirrored within minutes of + // each other and carry whatever campaign was running then, so they are one observation. + Occasion: show.FeedURL + "@" + show.DetectedBatch, + AtUnix: time.Now().Unix(), + } + for _, e := range episodes { + ep := signature.ShadowEpisode{ID: e.ID, FeedURL: show.FeedURL} + for _, br := range e.Breaks { + ep.Breaks = append(ep.Breaks, signature.ShadowBreak{ + StartMs: br.StartMs, + EndMs: br.EndMs, + Kind: br.Kind, + Partners: br.Partners, + }) + } + batch.Episodes = append(batch.Episodes, ep) + } + + report, err := signature.NewShadow(w.signatures, audioFor(w.decoder, episodes)).Run(ctx, batch) + if err != nil { + log.Printf("%s: shadow run failed: %v", show.Title, err) + return + } + + novel := 0 + for _, p := range report.WouldCut { + if p.Novel { + novel++ + } + } + log.Printf("%s: shadow -- learned %d, reinforced %d, library %d (%d worth matching); "+ + "would cut %d, %d of them new%s, took %s", + show.Title, report.Harvested, report.Reinforced, w.signatures.Len(), + len(w.signatures.Trusted(shadowMinOccasions)), len(report.WouldCut), novel, + skippedNote(report.Skipped), time.Since(started).Round(time.Second)) + + // The novel ones are the whole point: what the library would add to what detection already + // publishes. Logged individually so they can be listened to and judged. + sort.Slice(report.WouldCut, func(i, j int) bool { return report.WouldCut[i].StartMs < report.WouldCut[j].StartMs }) + for _, p := range report.WouldCut { + if !p.Novel { + continue + } + log.Printf(" would cut %s %s-%s (%s, %d votes, seen on %d occasion(s), %d partners)", + p.EpisodeID, clock(p.StartMs), clock(p.EndMs), p.Entry.Kind, p.Votes, + p.Entry.Confidence(), p.Entry.Partners) + } + + if err := w.signatures.Save(w.signaturePath); err != nil { + log.Printf("save signatures: %v", err) + } +} + +func skippedNote(n int) string { + if n == 0 { + return "" + } + return fmt.Sprintf(", %d unreadable", n) +} + +// shadowMinOccasions is how many separate weeks a signature needs before the library would act +// on it, except for advertising detection has already corroborated. See signature.Entry.Trusted. +const shadowMinOccasions = 2 + +// audioFor supplies decoded episodes to the shadow run. +// +// The paths come from the batch rather than from a lookup: these are the episodes detection +// just finished with, so their files are the ones it read and are certainly still there. +func audioFor(decoder decode.Decoder, episodes []*store.Episode) signature.Audio { + paths := make(map[string]string, len(episodes)) + for _, e := range episodes { + paths[e.ID] = e.Path + } + return episodeAudio{decoder: decoder, pathOf: func(id string) string { return paths[id] }} +} diff --git a/server/internal/signature/library.go b/server/internal/signature/library.go index 065c049..92bf77b 100644 --- a/server/internal/signature/library.go +++ b/server/internal/signature/library.go @@ -39,6 +39,9 @@ type Observation struct { // FeedURL is the show a cue belongs to. Ignored for a copy. FeedURL string DurationMs int64 + // Partners is how many episodes in this one batch carried the audio, as detection + // measured it. Within-batch corroboration, which is not the same thing as independence. + Partners int // Occasion is what makes two sightings independent. A whole batch of episodes mirrored in // one minute is one occasion, however many of them carried the audio -- they all ran // whatever campaign was running that minute, which is exactly why eight agreeing episodes @@ -52,6 +55,8 @@ type Entry struct { Kind string `json:"kind"` FeedURL string `json:"feedUrl,omitempty"` DurationMs int64 `json:"durationMs"` + // Partners is the most episodes ever seen sharing this in a single batch. + Partners int `json:"partners,omitempty"` Fingerprint Fingerprint `json:"fingerprint"` @@ -64,6 +69,26 @@ type Entry struct { // Confidence is how many separate occasions this signature has been seen on. func (e *Entry) Confidence() int { return len(e.Occasions) } +// Trusted says whether this is worth matching against, and the rule differs by kind. +// +// A cue needs [minOccasions] separate occasions, no exceptions. That is the independence test +// the withheld intro and outro offsets have been waiting for: eight episodes mirrored in one +// minute all carry whatever campaign is running, so agreement across them says nothing about +// whether audio is the show's own. Only agreement across *weeks* does. +// +// An advertisement is different, and the difference is not a loophole. Detection publishes a +// break only because the audio recurred across several episodes, verified by correlating them +// against each other -- so a copy that arrived with partners is already a confirmed repeat, not +// a single unsupported sighting. Holding it back for a second week would mean the library could +// only ever propose advertising that detection had already found twice, which is precisely when +// it has nothing to add. +func (e *Entry) Trusted(minOccasions int) bool { + if e.Confidence() >= minOccasions { + return true + } + return e.Kind == KindCopy && e.Partners >= 2 +} + func (e *Entry) seenOn(occasion string) bool { for _, o := range e.Occasions { if o == occasion { @@ -98,12 +123,16 @@ func (l *Library) Observe(o Observation, atUnix int64) (*Entry, bool) { if atUnix > existing.LastSeenUnix { existing.LastSeenUnix = atUnix } + if o.Partners > existing.Partners { + existing.Partners = o.Partners + } return existing, false } e := &Entry{ ID: fingerprintID(o.Fingerprint), Kind: o.Kind, DurationMs: o.DurationMs, + Partners: o.Partners, Fingerprint: o.Fingerprint, Occasions: []string{o.Occasion}, FirstSeenUnix: atUnix, @@ -152,6 +181,17 @@ func (l *Library) Confident(minSightings int) []*Entry { return out } +// Trusted returns the signatures worth matching against; see [Entry.Trusted]. +func (l *Library) Trusted(minOccasions int) []*Entry { + var out []*Entry + for _, e := range l.entries { + if e.Trusted(minOccasions) { + out = append(out, e) + } + } + return out +} + // Age forgets advertisements nobody has heard in a long time, and returns how many went. // // Cues are never aged out: a show's break stinger is not a campaign, and a show that goes quiet @@ -207,7 +247,7 @@ type Match struct { // is cut from what somebody is listening to. func (l *Library) Match(fp Fingerprint, minSightings int) []Match { trusted := map[string]*Entry{} - for _, e := range l.Confident(minSightings) { + for _, e := range l.Trusted(minSightings) { trusted[e.ID] = e } if len(trusted) == 0 { diff --git a/server/internal/signature/library_test.go b/server/internal/signature/library_test.go index 9231485..8290b19 100644 --- a/server/internal/signature/library_test.go +++ b/server/internal/signature/library_test.go @@ -200,3 +200,72 @@ func TestAnEpisodeCarryingNothingStoredMatchesNothing(t *testing.T) { t.Fatalf("got %+v", m) } } + +// --- what is worth matching against --------------------------------------------------------- + +func observe(l *signature.Library, o signature.Observation, atUnix int64) *signature.Entry { + e, _ := l.Observe(o, atUnix) + return e +} + +func TestAnAdvertisementDetectionConfirmedIsWorthMatchingAtOnce(t *testing.T) { + l := signature.NewLibrary() + + e := observe(l, signature.Observation{ + Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000, + Occasion: "batch-1", Partners: 4, + }, day) + + if !e.Trusted(2) { + t.Fatal("a break is only published because the audio recurred across episodes and was " + + "correlated to prove it; holding that back a week means only ever proposing " + + "advertising that was already found twice") + } +} + +func TestAnAdvertisementNothingCorroboratedWaitsForAnotherWeek(t *testing.T) { + l := signature.NewLibrary() + + e := observe(l, signature.Observation{ + Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000, + Occasion: "batch-1", + }, day) + + if e.Trusted(2) { + t.Fatal("one sighting with nothing backing it is not evidence") + } +} + +// The independence test the withheld intro and outro offsets are waiting for. Eight episodes +// mirrored in one minute all carry whatever campaign is running, so agreement across them says +// nothing about whether audio is the show's own -- only agreement across weeks does. +func TestFurnitureGetsNoShortcutHoweverManyEpisodesAgreed(t *testing.T) { + l := signature.NewLibrary() + + e := observe(l, signature.Observation{ + Fingerprint: fpOf(7, 8), Kind: signature.KindCue, FeedURL: "https://x/f.xml", + DurationMs: 8_000, Occasion: "batch-1", Partners: 8, + }, day) + + if e.Trusted(2) { + t.Fatal("eight episodes mirrored together are one observation, and a sting shared by " + + "all of them was an advertisement once already") + } +} + +func TestTheMostEpisodesEverSharingItIsWhatCounts(t *testing.T) { + l := signature.NewLibrary() + observe(l, signature.Observation{ + Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000, + Occasion: "batch-1", Partners: 6, + }, day) + + e := observe(l, signature.Observation{ + Fingerprint: fpOf(42, 20), Kind: signature.KindCopy, DurationMs: 20_000, + Occasion: "batch-2", Partners: 1, + }, 8*day) + + if e.Partners != 6 { + t.Fatalf("partners %d -- a quieter week does not unmake what was measured before", e.Partners) + } +} diff --git a/server/internal/signature/shadow.go b/server/internal/signature/shadow.go new file mode 100644 index 0000000..9c2fe6b --- /dev/null +++ b/server/internal/signature/shadow.go @@ -0,0 +1,177 @@ +package signature + +import ( + "context" + "fmt" +) + +// 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 +} + +// ShadowEpisode is one episode of a batch and what detection made of it. +type ShadowEpisode struct { + ID string + FeedURL string + Breaks []ShadowBreak +} + +// 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 +} + +// Report is what one shadow run saw. +type Report struct { + Harvested int + Reinforced 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 + for _, ep := range batch.Episodes { + if ctx.Err() != nil { + return report, ctx.Err() + } + samples, err := s.audio.Episode(ctx, ep.ID) + if err != nil || len(samples) == 0 { + report.Skipped++ + 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 { + return report, ctx.Err() + } + for _, br := range ep.Breaks { + if err := s.harvest(ctx, batch, ep, br, &report); err != nil { + return report, err + } + } + } + return report, nil +} + +func (s *Shadow) harvest(ctx context.Context, batch ShadowBatch, ep ShadowEpisode, br ShadowBreak, report *Report) error { + duration := br.EndMs - br.StartMs + if duration <= 0 { + return nil + } + samples, err := s.audio.Range(ctx, ep.ID, br.StartMs, duration) + if err != nil { + return fmt.Errorf("decode %s %d-%dms: %w", ep.ID, br.StartMs, br.EndMs, err) + } + fp := Take(samples) + if len(fp.Hashes) == 0 { + return nil + } + _, isNew := s.library.Observe(Observation{ + Fingerprint: fp, + Kind: kindFor(br.Kind), + FeedURL: ep.FeedURL, + DurationMs: duration, + Partners: br.Partners, + Occasion: batch.Occasion, + }, batch.AtUnix) + if isNew { + report.Harvested++ + } else { + report.Reinforced++ + } + return nil +} + +// 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 +} diff --git a/server/internal/signature/shadow_test.go b/server/internal/signature/shadow_test.go new file mode 100644 index 0000000..3e6336f --- /dev/null +++ b/server/internal/signature/shadow_test.go @@ -0,0 +1,183 @@ +package signature_test + +import ( + "context" + "testing" + + "github.com/terst/listen/server/internal/signature" +) + +// fakeAudio serves audio the test planted, so a shadow run can be exercised without ffmpeg. +type fakeAudio map[string][]float64 + +func (f fakeAudio) Episode(_ context.Context, id string) ([]float64, error) { + return f[id], nil +} + +func (f fakeAudio) Range(_ context.Context, id string, startMs, durMs int64) ([]float64, error) { + all := f[id] + from := int(startMs) * signature.SampleRate / 1000 + to := from + int(durMs)*signature.SampleRate/1000 + if from > len(all) { + return nil, nil + } + if to > len(all) { + to = len(all) + } + return all[from:to], nil +} + +func batch(episodes ...signature.ShadowEpisode) signature.ShadowBatch { + return signature.ShadowBatch{Occasion: "batch-1", AtUnix: day, Episodes: episodes} +} + +func TestHarvestsAnAdvertisementAsACopyAndFurnitureAsACue(t *testing.T) { + ad := tone(42, seconds(20)) + cue := tone(7, seconds(8)) + audio := fakeAudio{ + "e1": planted(1, ad, 300, 60), + "e2": planted(2, cue, 300, 30), + } + l := signature.NewLibrary() + shadow := signature.NewShadow(l, audio) + + report, err := shadow.Run(context.Background(), batch( + signature.ShadowEpisode{ID: "e1", Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad"}}}, + signature.ShadowEpisode{ID: "e2", FeedURL: "https://x/f.xml", + Breaks: []signature.ShadowBreak{{StartMs: 30_000, EndMs: 38_000, Kind: "cue"}}}, + )) + if err != nil { + t.Fatalf("run: %v", err) + } + + if report.Harvested != 2 { + t.Fatalf("harvested %d, want 2", report.Harvested) + } + kinds := map[string]string{} + for _, e := range l.Confident(1) { + kinds[e.Kind] = e.FeedURL + } + if _, ok := kinds[signature.KindCopy]; !ok { + t.Fatal("an advertising break should be stored as a copy") + } + if kinds[signature.KindCue] != "https://x/f.xml" { + t.Fatalf("a cue should carry its show, got %q", kinds[signature.KindCue]) + } +} + +func TestOneBatchIsOneSightingHoweverManyEpisodesCarriedIt(t *testing.T) { + ad := tone(42, seconds(20)) + audio := fakeAudio{"e1": planted(1, ad, 300, 60), "e2": planted(2, ad, 300, 90)} + l := signature.NewLibrary() + shadow := signature.NewShadow(l, audio) + + _, err := shadow.Run(context.Background(), batch( + signature.ShadowEpisode{ID: "e1", Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad"}}}, + signature.ShadowEpisode{ID: "e2", Breaks: []signature.ShadowBreak{{StartMs: 90_000, EndMs: 110_000, Kind: "ad"}}}, + )) + if err != nil { + t.Fatalf("run: %v", err) + } + + for _, e := range l.Confident(1) { + if e.Confidence() != 1 { + t.Fatalf("confidence %d from a single batch -- eight episodes mirrored together are one observation", e.Confidence()) + } + } +} + +func TestASecondPassFindsWhatTheFirstOnlyLearned(t *testing.T) { + ad := tone(42, seconds(20)) + l := signature.NewLibrary() + + // Week one: the detector finds the advertisement in e1, and the library learns it. + first := fakeAudio{"e1": planted(1, ad, 300, 60)} + _, err := signature.NewShadow(l, first).Run(context.Background(), signature.ShadowBatch{ + Occasion: "week-1", AtUnix: day, + Episodes: []signature.ShadowEpisode{{ID: "e1", + Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad", Partners: 4}}}}, + }) + if err != nil { + t.Fatalf("week one: %v", err) + } + + // Week two: the same advertisement is in a different episode, and detection missed it. + second := fakeAudio{"e9": planted(5, ad, 300, 200)} + report, err := signature.NewShadow(l, second).Run(context.Background(), signature.ShadowBatch{ + Occasion: "week-2", AtUnix: 8 * day, + Episodes: []signature.ShadowEpisode{{ID: "e9"}}, + }) + if err != nil { + t.Fatalf("week two: %v", err) + } + + var novel []signature.Proposal + for _, p := range report.WouldCut { + if p.Novel { + novel = append(novel, p) + } + } + if len(novel) != 1 { + t.Fatalf("got %d novel proposals from %+v, want the advertisement detection missed", len(novel), report.WouldCut) + } + if novel[0].StartMs < 199_000 || novel[0].StartMs > 201_000 { + t.Fatalf("placed at %dms, planted at 200000", novel[0].StartMs) + } +} + +func TestAProposalDetectionAlreadyFoundIsNotNews(t *testing.T) { + ad := tone(42, seconds(20)) + l := signature.NewLibrary() + first := fakeAudio{"e1": planted(1, ad, 300, 60)} + signature.NewShadow(l, first).Run(context.Background(), signature.ShadowBatch{ + Occasion: "week-1", AtUnix: day, + Episodes: []signature.ShadowEpisode{{ID: "e1", + Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad"}}}}, + }) + + // Week two: same advertisement, same place, and this time detection found it too. + second := fakeAudio{"e2": planted(3, ad, 300, 120)} + report, _ := signature.NewShadow(l, second).Run(context.Background(), signature.ShadowBatch{ + Occasion: "week-2", AtUnix: 8 * day, + Episodes: []signature.ShadowEpisode{{ID: "e2", + Breaks: []signature.ShadowBreak{{StartMs: 120_000, EndMs: 140_000, Kind: "ad"}}}}, + }) + + for _, p := range report.WouldCut { + if p.Novel { + t.Fatalf("called a break the detector already published novel: %+v", p) + } + } +} + +func TestAnEpisodeCarryingNothingKnownProposesNothing(t *testing.T) { + ad := tone(42, seconds(20)) + l := signature.NewLibrary() + signature.NewShadow(l, fakeAudio{"e1": planted(1, ad, 300, 60)}).Run(context.Background(), + signature.ShadowBatch{Occasion: "week-1", AtUnix: day, + Episodes: []signature.ShadowEpisode{{ID: "e1", + Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad"}}}}}) + + report, _ := signature.NewShadow(l, fakeAudio{"e2": tone(99, seconds(300))}).Run(context.Background(), + signature.ShadowBatch{Occasion: "week-2", AtUnix: 8 * day, + Episodes: []signature.ShadowEpisode{{ID: "e2"}}}) + + if len(report.WouldCut) != 0 { + t.Fatalf("proposed %+v against an episode with none of it", report.WouldCut) + } +} + +// The whole point of a shadow run: it watches, it does not act. +func TestAShadowRunPublishesNothing(t *testing.T) { + ad := tone(42, seconds(20)) + l := signature.NewLibrary() + episode := signature.ShadowEpisode{ID: "e1", + Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad"}}} + shadow := signature.NewShadow(l, fakeAudio{"e1": planted(1, ad, 300, 60)}) + + shadow.Run(context.Background(), batch(episode)) + + if len(episode.Breaks) != 1 || episode.Breaks[0].StartMs != 60_000 { + t.Fatal("the shadow run changed the breaks it was given") + } +} |
