summaryrefslogtreecommitdiff
path: root/server/internal
diff options
context:
space:
mode:
Diffstat (limited to 'server/internal')
-rw-r--r--server/internal/signature/library.go42
-rw-r--r--server/internal/signature/library_test.go69
-rw-r--r--server/internal/signature/shadow.go177
-rw-r--r--server/internal/signature/shadow_test.go183
4 files changed, 470 insertions, 1 deletions
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")
+ }
+}