summaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/cmd/listend/main.go152
-rw-r--r--server/internal/store/store.go18
-rw-r--r--server/internal/store/store_test.go50
-rw-r--r--server/internal/transcript/fits.go10
-rw-r--r--server/internal/transcript/store.go15
5 files changed, 241 insertions, 4 deletions
diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go
index 9914284..d8c9d0c 100644
--- a/server/cmd/listend/main.go
+++ b/server/cmd/listend/main.go
@@ -54,6 +54,8 @@ func main() {
whisperThreads = flag.Int("whisper-threads", 6, "cores one recognition run may use")
transcribeBudget = flag.Duration("transcribe-budget", 45*time.Minute,
"how long each cycle may spend recognising episodes")
+ refetchBudget = flag.Duration("refetch-budget", 20*time.Minute,
+ "how long each cycle may spend fetching and recognising second copies")
)
flag.Parse()
@@ -100,6 +102,9 @@ func main() {
WorkDir: filepath.Join(*dataDir, "work"),
},
transcribeBudget: *transcribeBudget,
+ refetchMirror: &mirror.Mirror{AudioDir: filepath.Join(*dataDir, "work", "refetch"), Probe: decoder},
+ refetchDir: filepath.Join(*dataDir, "work", "refetch"),
+ refetchBudget: *refetchBudget,
requests: make(chan request, 64),
wakeups: make(chan struct{}, 1),
}
@@ -134,6 +139,7 @@ func main() {
// Recognition too, so a deployment check exercises the expensive path rather than
// discovering it is misconfigured hours later in a log nobody is reading.
worker.transcribeSome(ctx)
+ worker.refetchSome(ctx)
if err := state.Save(statePath); err != nil {
log.Fatalf("save: %v", err)
}
@@ -194,8 +200,13 @@ type worker struct {
// transcribeBudget is how long one cycle may spend recognising. Bounded so the backlog is
// worked through steadily rather than monopolising the machine -- see transcribeSome.
transcribeBudget time.Duration
- requests chan request
- wakeups chan struct{}
+ // refetchMirror downloads a second copy of an episode into its own directory, so the one
+ // the phone plays is never touched. Its files are deleted as soon as they are recognised.
+ refetchMirror *mirror.Mirror
+ refetchDir string
+ refetchBudget time.Duration
+ requests chan request
+ wakeups chan struct{}
}
// wake asks for a poll now. Coalescing, so a burst of subscription updates is one poll.
@@ -222,6 +233,7 @@ func (w *worker) run(ctx context.Context, every time.Duration) {
w.pollAll(ctx)
w.save()
w.transcribeSome(ctx)
+ w.refetchSome(ctx)
ticker := time.NewTicker(every)
defer ticker.Stop()
@@ -236,6 +248,7 @@ func (w *worker) run(ctx context.Context, every time.Duration) {
// wanting that show polled now; following it with three quarters of an hour of
// recognition would make a new subscription feel broken.
w.transcribeSome(ctx)
+ w.refetchSome(ctx)
case <-w.wakeups:
w.pollAll(ctx)
w.save()
@@ -633,7 +646,10 @@ func adAccounting(e *store.Episode, found []transcript.Candidate) string {
total += c.EndMs - c.StartMs
}
how := "read from the ad copy"
- if e.MasterCues > 0 {
+ switch {
+ case e.MasterSource == string(transcript.SourceRefetch):
+ how = "by subtracting a second fetch"
+ case e.MasterCues > 0:
how = "by subtracting the publisher's master"
}
gap := e.DurationMs - e.DeclaredDurationMs
@@ -660,6 +676,136 @@ func adAccounting(e *store.Episode, found []transcript.Candidate) string {
}
/*
+refetchSome fetches a second copy of episodes that have no master, and recognises it.
+
+The extension of subtraction to the rest of the library. Only 38 of 336 mirrored episodes are
+offered a publisher's transcript, and only 18 of those are the ad-carrying kind subtraction
+works on -- so the method that measures itself at 92-104% against the length gap covers about
+five per cent of what Peter listens to.
+
+A second fetch stands in for the master. Advertising is inserted per request, which this project
+already measured the hard way: the same Radiolab episode came back 7:29 of advertising on one
+fetch and 5:34 on another. So two copies agree on the programme and differ on the advertising,
+and subtracting one from the other says where the advertising in the other one is. What it
+cannot see is a campaign both copies happened to carry -- the exact opposite blind spot to the
+audio scan, whose whole method is finding what a batch shares.
+
+The second copy's audio is deleted the moment it has been recognised. The phone plays the
+mirrored bytes and nothing else may claim that slot; this is evidence, not a recording.
+*/
+func (w *worker) refetchSome(ctx context.Context) {
+ if !w.recogniser.Available() || w.refetchBudget <= 0 {
+ return
+ }
+ deadline := time.Now().Add(w.refetchBudget)
+ done, failed := 0, 0
+ var breaker transcribe.Breaker
+ var failing []*store.Episode
+
+ for _, e := range w.pendingRefetch() {
+ if ctx.Err() != nil || time.Now().After(deadline) {
+ break
+ }
+ found, err := w.refetchOne(ctx, e)
+ if ctx.Err() != nil {
+ return
+ }
+ e.RefetchAttemptedAtUnix = time.Now().Unix()
+ if err != nil {
+ log.Printf("refetching %q: %v", e.Title, err)
+ failed++
+ failing = append(failing, e)
+ if breaker.Failure() {
+ for _, given := range failing {
+ given.RefetchAttemptedAtUnix = 0
+ }
+ log.Printf("refetching abandoned after %d failures in a row -- giving those "+
+ "attempts back, since this is the machine and not the episodes", breaker.Run())
+ w.save()
+ return
+ }
+ w.save()
+ continue
+ }
+ breaker.Success()
+ failing = nil
+ done++
+ log.Printf("second copy of %q: %d lines, %s", e.Title, e.MasterCues, adAccounting(e, found))
+ w.save()
+ }
+ if done > 0 || failed > 0 {
+ log.Printf("second copies -- %d done, %d failed, %d still waiting",
+ done, failed, len(w.pendingRefetch()))
+ }
+}
+
+// refetchOne downloads one episode again, recognises it, keeps the words and throws the audio
+// away.
+func (w *worker) refetchOne(ctx context.Context, e *store.Episode) ([]transcript.Candidate, error) {
+ // Its own id and its own directory, so nothing can mistake this for the copy the phone
+ // plays even for the moment it exists.
+ second := &store.Episode{ID: e.ID + "-second", EnclosureURL: e.EnclosureURL}
+ if err := w.refetchMirror.Fetch(ctx, second); err != nil {
+ return nil, err
+ }
+ defer func() { _ = w.refetchMirror.Prune(second) }()
+
+ result, err := w.recogniser.Transcribe(ctx, filepath.Join(w.refetchDir, second.Path))
+ if err != nil {
+ return nil, err
+ }
+ if err := w.transcripts.PutMaster(transcript.Transcript{
+ EpisodeID: e.ID,
+ Source: transcript.SourceRefetch,
+ LanguageCode: result.LanguageCode,
+ Cues: result.Cues,
+ }); err != nil {
+ return nil, err
+ }
+ e.MasterCues = len(result.Cues)
+ e.MasterSource = string(transcript.SourceRefetch)
+ e.MasterNote = ""
+
+ // Re-derive this episode's advertising now that there is something to subtract it from.
+ held, err := w.transcripts.Get(e.ID)
+ if err != nil {
+ return nil, err
+ }
+ held.Ads = w.adsIn(e, held.Cues)
+ if err := w.transcripts.Put(held); err != nil {
+ return nil, err
+ }
+ return held.Ads, nil
+}
+
+/*
+pendingRefetch is every episode with its own words, no master, and advertising to find.
+
+Newest first: an episode about to be played is worth the work before one from the back of a
+catalogue.
+*/
+func (w *worker) pendingRefetch() []*store.Episode {
+ var pending []*store.Episode
+ for _, show := range w.state.Shows() {
+ for _, e := range show.Episodes {
+ if !e.NeedsRefetch() {
+ continue
+ }
+ // Without a length gap nothing was inserted, so a second fetch would be the same
+ // recording and there would be nothing to subtract.
+ if !transcript.HasInsertedAdvertising(e.DurationMs, e.DeclaredDurationMs) {
+ continue
+ }
+ pending = append(pending, e)
+ }
+ }
+ sort.Slice(pending, func(i, j int) bool {
+ return pending[i].PublishedUnix > pending[j].PublishedUnix
+ })
+ return pending
+}
+
+/*
pendingRecognition is every mirrored episode still without words, best first.
An episode holding the publisher's ad-free master comes first, whatever its date. Recognising
diff --git a/server/internal/store/store.go b/server/internal/store/store.go
index 7f81bd5..2a6992b 100644
--- a/server/internal/store/store.go
+++ b/server/internal/store/store.go
@@ -73,6 +73,14 @@ type Episode struct {
// states as everywhere else: words, a reason, or nobody has looked.
MasterCues int `json:"masterCues,omitempty"`
MasterNote string `json:"masterNote,omitempty"`
+ // MasterSource says what kind of master it is: "published" for the publisher's own ad-free
+ // transcript, "refetch" for a recognised second fetch of the same episode. They are not
+ // equally good and the readout should not pretend otherwise -- a refetch carries its own
+ // advertising, so anything both copies happen to share is invisible to it.
+ MasterSource string `json:"masterSource,omitempty"`
+ // RefetchAttemptedAtUnix is when a second copy was last fetched for this episode, so a
+ // failure is not retried every cycle. Recorded only when an attempt finished.
+ RefetchAttemptedAtUnix int64 `json:"refetchAttemptedAtUnix,omitempty"`
// TranscribeAttemptedAtUnix is when speech recognition last ran over this episode, whether
// it worked or not, so a failure is not retried on every poll for ever. Recorded only when
@@ -110,6 +118,16 @@ func (e *Episode) NeedsMaster() bool {
e.MasterCues == 0 && e.MasterNote == ""
}
+// NeedsRefetch reports whether a second copy of this episode is worth fetching.
+//
+// Only for an episode that has its own words but no master to subtract them from, and only
+// where the lengths say advertising was inserted: without a gap the two fetches would be the
+// same recording and there would be nothing to find.
+func (e *Episode) NeedsRefetch() bool {
+ return e.Mirrored() && e.HasTranscript() && e.EnclosureURL != "" &&
+ e.MasterCues == 0 && e.MasterNote == "" && e.RefetchAttemptedAtUnix == 0
+}
+
// NeedsRecognising reports whether speech recognition should be run over this episode.
//
// Deliberately not the negation of [Episode.TranscriptExamined]. An episode whose publisher's
diff --git a/server/internal/store/store_test.go b/server/internal/store/store_test.go
index c04dc29..9020b17 100644
--- a/server/internal/store/store_test.go
+++ b/server/internal/store/store_test.go
@@ -435,3 +435,53 @@ func TestAFinishedAttemptIsNotRepeated(t *testing.T) {
t.Error("an episode already attempted would be attempted again")
}
}
+
+/*
+Which episodes are worth fetching a second time.
+
+Advertising is inserted per request, so two fetches of the same episode carry different
+campaigns and agree on the programme. Subtracting one from the other therefore says where the
+advertising is -- the same arithmetic as a publisher's master, with a second recording standing
+in for one.
+
+Only worth doing where there is advertising to find. An episode whose served length matches what
+the feed declares has none inserted, and the second fetch would be the same recording.
+*/
+
+func TestAnEpisodeWithItsOwnWordsAndNoMasterIsWorthRefetching(t *testing.T) {
+ e := Episode{
+ Path: "abc.mp3", EnclosureURL: "https://cdn/1.mp3", TranscriptCues: 400,
+ }
+ if !e.NeedsRefetch() {
+ t.Error("an episode with words and nothing to subtract them from was passed over")
+ }
+}
+
+func TestAnEpisodeThatAlreadyHasAMasterIsNotRefetched(t *testing.T) {
+ e := Episode{
+ Path: "abc.mp3", EnclosureURL: "https://cdn/1.mp3", TranscriptCues: 400, MasterCues: 300,
+ }
+ if e.NeedsRefetch() {
+ t.Error("would fetch a second copy for an episode that already has a master")
+ }
+}
+
+// Nothing to subtract from until its own words exist.
+func TestAnEpisodeWithNoWordsYetIsNotRefetched(t *testing.T) {
+ e := Episode{Path: "abc.mp3", EnclosureURL: "https://cdn/1.mp3"}
+ if e.NeedsRefetch() {
+ t.Error("would fetch a second copy before there was a first to compare it against")
+ }
+}
+
+// A finished attempt is not repeated: a publisher that will not serve it again will not serve
+// it again next cycle either.
+func TestAFinishedRefetchIsNotRepeated(t *testing.T) {
+ e := Episode{
+ Path: "abc.mp3", EnclosureURL: "https://cdn/1.mp3", TranscriptCues: 400,
+ RefetchAttemptedAtUnix: 1_700_000_000,
+ }
+ if e.NeedsRefetch() {
+ t.Error("would fetch a second copy again")
+ }
+}
diff --git a/server/internal/transcript/fits.go b/server/internal/transcript/fits.go
index b0495d0..c22958b 100644
--- a/server/internal/transcript/fits.go
+++ b/server/internal/transcript/fits.go
@@ -76,6 +76,16 @@ func Fits(lastCueEndMs, servedMs, declaredMs int64) Verdict {
return Verdict{OK: true}
}
+// HasInsertedAdvertising reports whether this copy is longer than the feed says the programme
+// is, by more than the feed could be approximate about.
+//
+// The one fact about an episode that comes from neither a detector nor a transcript. An episode
+// without it has nothing inserted to find, so there is no point fetching a second copy to
+// compare against -- the two would be the same recording.
+func HasInsertedAdvertising(servedMs, declaredMs int64) bool {
+ return declaredMs > 0 && servedMs-declaredMs > insertionMs
+}
+
// LastCueEndMs is where a transcript stops describing the audio.
func LastCueEndMs(cues []Cue) int64 {
if len(cues) == 0 {
diff --git a/server/internal/transcript/store.go b/server/internal/transcript/store.go
index 9450b3a..50feae4 100644
--- a/server/internal/transcript/store.go
+++ b/server/internal/transcript/store.go
@@ -19,6 +19,15 @@ const (
// SourceASR is speech recognition over the mirrored bytes: approximate words, of exactly
// the audio the phone will play.
SourceASR Source = "asr"
+ // SourceRefetch is speech recognition over a *second* fetch of the same episode, kept as a
+ // master for subtraction. It is not ad-free -- it carries whatever campaign was running
+ // when it was fetched -- but advertising is inserted per request, so the two copies differ
+ // in exactly the places the advertising sits. What they share is the programme.
+ //
+ // Subtracting it therefore reports the mirrored copy's advertising minus anything both
+ // copies happened to carry, which is the one thing it cannot see. That is the opposite
+ // blind spot to the audio scan, which only finds what a batch of episodes shares.
+ SourceRefetch Source = "refetch"
)
// Transcript is one episode's words and when they were said.
@@ -110,9 +119,13 @@ func (s Store) Put(t Transcript) error {
// inserted into, and subtracting it from a recognised reading of the served file is what says
// where. See Inserted.
func (s Store) PutMaster(t Transcript) error {
+ source := t.Source
+ if source == "" {
+ source = SourcePublished
+ }
return s.Put(Transcript{
EpisodeID: t.EpisodeID + masterSuffix,
- Source: SourcePublished,
+ Source: source,
LanguageCode: t.LanguageCode,
Cues: t.Cues,
})