package main import ( "context" "fmt" "log" "path/filepath" "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(), } // What the listener has said, indexed so each episode can pick up its own. Labels outlive // the episodes they were given on, so most of these belong to batches long since rotated // out; only the ones matching this batch's guids are of any use here. labels := map[string][]signature.ShadowLabel{} for _, l := range w.state.Labels() { if l.FeedURL != show.FeedURL { continue } labels[l.GUID] = append(labels[l.GUID], signature.ShadowLabel{ StartMs: l.StartMs, EndMs: l.EndMs, Advertising: l.Verdict == store.LabelAd, EndMeasured: l.EndMeasured, }) } for _, e := range episodes { ep := signature.ShadowEpisode{ID: e.ID, FeedURL: show.FeedURL, Labels: labels[e.GUID]} 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, w.mirror.AudioDir, 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%s, library %d (%d worth matching); "+ "would cut %d, %d of them new%s, took %s", show.Title, report.Harvested, report.Reinforced, disownedNote(report.Disowned), 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) } } // disownedNote reports signatures taken back because the listener said that audio is the // programme. Always logged when it happens: it is the library being told it was wrong. func disownedNote(n int) string { if n == 0 { return "" } return fmt.Sprintf(", disowned %d", n) } 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. // // store.Episode.Path is relative to the audio directory, which is what detection joins it with // before handing it to the decoder. Passing it raw sent ffmpeg looking in the working directory, // and the first real shadow run died on it. func audioFor(decoder decode.Decoder, audioDir string, episodes []*store.Episode) signature.Audio { paths := make(map[string]string, len(episodes)) for _, e := range episodes { if e.Path != "" { paths[e.ID] = filepath.Join(audioDir, e.Path) } } return episodeAudio{decoder: decoder, pathOf: func(id string) string { return paths[id] }} }