summaryrefslogtreecommitdiff
path: root/server/internal
diff options
context:
space:
mode:
Diffstat (limited to 'server/internal')
-rw-r--r--server/internal/signature/shadow.go33
-rw-r--r--server/internal/signature/shadow_test.go52
2 files changed, 73 insertions, 12 deletions
diff --git a/server/internal/signature/shadow.go b/server/internal/signature/shadow.go
index 9c2fe6b..412597c 100644
--- a/server/internal/signature/shadow.go
+++ b/server/internal/signature/shadow.go
@@ -1,9 +1,6 @@
package signature
-import (
- "context"
- "fmt"
-)
+import "context"
// Audio supplies decoded audio at [SampleRate].
type Audio interface {
@@ -89,13 +86,17 @@ func NewShadow(library *Library, audio Audio) *Shadow {
// 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
+ // Counted per episode, not per failure: an episode that could not be read fails both the
+ // matching pass and the harvesting pass, and reporting it twice would overstate the damage.
+ unreadable := map[string]bool{}
for _, ep := range batch.Episodes {
if ctx.Err() != nil {
+ report.Skipped = len(unreadable)
return report, ctx.Err()
}
samples, err := s.audio.Episode(ctx, ep.ID)
if err != nil || len(samples) == 0 {
- report.Skipped++
+ unreadable[ep.ID] = true
continue
}
for _, m := range s.library.Match(Take(samples), s.MinSightings) {
@@ -112,29 +113,37 @@ func (s *Shadow) Run(ctx context.Context, batch ShadowBatch) (Report, error) {
for _, ep := range batch.Episodes {
if ctx.Err() != nil {
+ report.Skipped = len(unreadable)
return report, ctx.Err()
}
for _, br := range ep.Breaks {
- if err := s.harvest(ctx, batch, ep, br, &report); err != nil {
- return report, err
+ // A break this cannot read is counted and stepped over. Every other per-item loop
+ // in this server isolates a single failure the same way, and the first real run
+ // lost a whole show's learning to one episode whose path was wrong.
+ if !s.harvest(ctx, batch, ep, br, &report) {
+ unreadable[ep.ID] = true
+ break
}
}
}
+ report.Skipped = len(unreadable)
return report, nil
}
-func (s *Shadow) harvest(ctx context.Context, batch ShadowBatch, ep ShadowEpisode, br ShadowBreak, report *Report) error {
+// harvest stores one break's audio as a signature, and reports whether the episode could be
+// read at all. A false means give up on this episode, not on the batch.
+func (s *Shadow) harvest(ctx context.Context, batch ShadowBatch, ep ShadowEpisode, br ShadowBreak, report *Report) bool {
duration := br.EndMs - br.StartMs
if duration <= 0 {
- return nil
+ return true
}
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)
+ return false
}
fp := Take(samples)
if len(fp.Hashes) == 0 {
- return nil
+ return true
}
_, isNew := s.library.Observe(Observation{
Fingerprint: fp,
@@ -149,7 +158,7 @@ func (s *Shadow) harvest(ctx context.Context, batch ShadowBatch, ep ShadowEpisod
} else {
report.Reinforced++
}
- return nil
+ return true
}
// kindFor maps what detection called a break onto what the library stores.
diff --git a/server/internal/signature/shadow_test.go b/server/internal/signature/shadow_test.go
index 3e6336f..f6c6676 100644
--- a/server/internal/signature/shadow_test.go
+++ b/server/internal/signature/shadow_test.go
@@ -2,6 +2,7 @@ package signature_test
import (
"context"
+ "errors"
"testing"
"github.com/terst/listen/server/internal/signature"
@@ -14,6 +15,27 @@ func (f fakeAudio) Episode(_ context.Context, id string) ([]float64, error) {
return f[id], nil
}
+// failingAudio serves one episode and refuses the rest, which is what an unreadable mirrored
+// file looks like.
+type failingAudio struct {
+ fakeAudio
+ refuse map[string]bool
+}
+
+func (f failingAudio) Episode(ctx context.Context, id string) ([]float64, error) {
+ if f.refuse[id] {
+ return nil, errors.New("no such file")
+ }
+ return f.fakeAudio.Episode(ctx, id)
+}
+
+func (f failingAudio) Range(ctx context.Context, id string, startMs, durMs int64) ([]float64, error) {
+ if f.refuse[id] {
+ return nil, errors.New("no such file")
+ }
+ return f.fakeAudio.Range(ctx, id, startMs, durMs)
+}
+
func (f fakeAudio) Range(_ context.Context, id string, startMs, durMs int64) ([]float64, error) {
all := f[id]
from := int(startMs) * signature.SampleRate / 1000
@@ -181,3 +203,33 @@ func TestAShadowRunPublishesNothing(t *testing.T) {
t.Fatal("the shadow run changed the breaks it was given")
}
}
+
+// One unreadable file must not cost the batch its learning. Every other per-item loop in this
+// server isolates a single failure, and the first real shadow run aborted a whole show's pass
+// because one episode's path was wrong.
+func TestOneUnreadableEpisodeDoesNotCostTheBatchItsLearning(t *testing.T) {
+ ad := tone(42, seconds(20))
+ cue := tone(7, seconds(8))
+ audio := failingAudio{
+ fakeAudio: fakeAudio{"good": planted(1, ad, 300, 60), "bad": planted(2, cue, 300, 30)},
+ refuse: map[string]bool{"bad": true},
+ }
+ l := signature.NewLibrary()
+
+ report, err := signature.NewShadow(l, audio).Run(context.Background(), batch(
+ signature.ShadowEpisode{ID: "bad",
+ Breaks: []signature.ShadowBreak{{StartMs: 30_000, EndMs: 38_000, Kind: "ad", Partners: 3}}},
+ signature.ShadowEpisode{ID: "good",
+ Breaks: []signature.ShadowBreak{{StartMs: 60_000, EndMs: 80_000, Kind: "ad", Partners: 4}}},
+ ))
+
+ if err != nil {
+ t.Fatalf("one bad file failed the whole run: %v", err)
+ }
+ if report.Harvested != 1 {
+ t.Fatalf("harvested %d, want the one episode that could be read", report.Harvested)
+ }
+ if report.Skipped != 1 {
+ t.Fatalf("skipped %d, want 1 -- an unreadable file should be counted, not silent", report.Skipped)
+ }
+}