summaryrefslogtreecommitdiff
path: root/server/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'server/cmd')
-rw-r--r--server/cmd/listend/main.go34
-rw-r--r--server/cmd/listend/shadow.go127
2 files changed, 154 insertions, 7 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] }}
+}