From 4ad9b9a73a0d70c8d9fd74ce32ad00706ab32f1a Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 24 Oct 2026 09:24:22 +0000 Subject: Shadow run: log what the signature library would cut, cut nothing The library now learns from every detection pass and says what it would have done, without doing any of it. The log is the deliverable: a few weeks of real batches answers whether it finds advertising or accumulates junk, before one extra second is skipped from something somebody is listening to. It matches before it harvests, deliberately. Harvesting first would let a batch's own signatures match the batch they came from, and every run would report itself a triumph. A detection pass is one occasion however many of its episodes carried the audio -- they were mirrored within minutes and all ran whatever campaign was running then. What a break's partner count does buy is different: an advertisement is published only because the audio recurred across episodes and correlation proved it, so a copy that arrives with partners is already a confirmed repeat rather than a single unsupported sighting, and is worth matching at once. Furniture gets no such shortcut -- that is exactly the independence the withheld intro and outro offsets are waiting for, and a sting shared by all eight episodes turned out to be an advertisement once already. Proposals are marked novel when detection did not already publish a break covering them. Those are the only interesting ones: the library earning its place rather than agreeing with what is already known. They are logged individually, with their votes and how many occasions and partners back them, so they can be listened to and judged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- server/cmd/listend/main.go | 34 ++++-- server/cmd/listend/shadow.go | 127 +++++++++++++++++++++ server/internal/signature/library.go | 42 ++++++- server/internal/signature/library_test.go | 69 +++++++++++ server/internal/signature/shadow.go | 177 +++++++++++++++++++++++++++++ server/internal/signature/shadow_test.go | 183 ++++++++++++++++++++++++++++++ 6 files changed, 624 insertions(+), 8 deletions(-) create mode 100644 server/cmd/listend/shadow.go create mode 100644 server/internal/signature/shadow.go create mode 100644 server/internal/signature/shadow_test.go (limited to 'server') 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") + } +} -- cgit v1.2.3