summaryrefslogtreecommitdiff
path: root/server/cmd
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-10-28 07:50:54 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-10-28 07:50:54 +0000
commitf2b5c8f471096ce467c0b606dfaaa88ebf46648a (patch)
tree8ae2a5b9bc365f2baaf5806d244229e257b5229a /server/cmd
parentbe2402198cc77e799a54ab25ec5384b675a4ba1d (diff)
Take publishers' transcripts, where they describe the copy we mirrored
Eleven of the 44 subscribed feeds carry <podcast:transcript>, in SRT or WebVTT. Those are the publisher's exact words with the times they were said, which is free and better than anything recognition would produce. **They are only usable when the show does not insert advertising**, and that turns out to be exact rather than approximate. A published transcript describes the publisher's master. Measured against the live library on 2026-10-28: Darknet Diaries "Conti" declared 3557s served 3813s last cue 3544s Wards Way "A Family Bar" declared 1216s served 1217s last cue 1213s LOW "Afterword" declared 610s served 610s last cue 603s LOW "Part 5: Bane" declared 2822s served 2823s last cue 2807s The transcript tracks the *declared* length every time. So it aligns precisely when served and declared agree, and is minutes out of step precisely when they do not -- which is the case where a transcript would be worth most. Reading along with one of those would put every line in the wrong place, and a break placed from it would seek into the programme. transcript.Fits makes that call from the two lengths rather than from the words, because the lengths are independent of the transcript: the feed declares the ad-free programme and the file measures what was served, so their difference is this copy's advertising. It is the same figure and the same fifteen-second unit the ad scan already validates itself against. A refusal is recorded, not retried. It is a judgement about this copy, so re-fetching every poll would be work done for ever to reach the same answer -- and the refusal is the record of which episodes speech recognition will have to do. Refused, taken and never-looked-at are three states, kept apart here and in the API, because conflating the first and third is the readout mistake this project has already made twice. Run against the live library before committing: 342 mirrored episodes, 57 offered a timestamped transcript, 38 taken, 19 declined -- and every decline is a show with dynamic insertion (Heavyweight 10:03, Incels 11:54, Darknet Diaries 4:23). Transcripts live one file per episode beside the audio, not in state.json: that file is the only record of where mirrored audio is and is rewritten whole on every label and every poll, and Darknet Diaries' "Conti" alone is 1784 cues. They are pruned with the audio they describe. Both spellings of the Podcasting 2.0 namespace are read. Every feed here declares https:// today, but the specification was published under http:// and Go matches a namespace by exact string -- so the wrong one would mean episodes silently never getting words, with nothing failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'server/cmd')
-rw-r--r--server/cmd/listend/main.go76
1 files changed, 72 insertions, 4 deletions
diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go
index a4400d2..3ca2727 100644
--- a/server/cmd/listend/main.go
+++ b/server/cmd/listend/main.go
@@ -34,6 +34,7 @@ import (
"github.com/terst/listen/server/internal/signature"
"github.com/terst/listen/server/internal/skips"
"github.com/terst/listen/server/internal/store"
+ "github.com/terst/listen/server/internal/transcript"
)
func main() {
@@ -69,6 +70,8 @@ func main() {
}
decoder := decode.Decoder{}
+ transcriptDir := filepath.Join(*dataDir, "transcripts")
+ transcripts := transcript.Store{Dir: transcriptDir}
worker := &worker{
state: state,
statePath: statePath,
@@ -77,13 +80,16 @@ func main() {
keep: *keep,
mirror: &mirror.Mirror{AudioDir: audioDir, Probe: decoder},
decoder: decoder,
+ transcripts: transcripts,
+ harvester: transcript.Harvester{Store: transcripts},
requests: make(chan request, 64),
wakeups: make(chan struct{}, 1),
}
server := api.New(state, worker, api.Options{
- Token: token,
- AudioDir: audioDir,
+ Token: token,
+ AudioDir: audioDir,
+ TranscriptDir: transcriptDir,
// Persist the list the moment it arrives, then poll.
//
// Both halves were learned the hard way. The list is the one thing the server cannot
@@ -156,8 +162,12 @@ type worker struct {
keep int
mirror *mirror.Mirror
decoder decode.Decoder
- requests chan request
- wakeups chan struct{}
+ // transcripts holds each episode's words, and harvester takes the publishers' own where
+ // they describe the copy that was mirrored. See transcript.Fits for when they do not.
+ transcripts transcript.Store
+ harvester transcript.Harvester
+ requests chan request
+ wakeups chan struct{}
}
// wake asks for a poll now. Coalescing, so a burst of subscription updates is one poll.
@@ -291,6 +301,14 @@ func (w *worker) pollOne(ctx context.Context, feedURL string) {
if e.Mirrored() {
_ = w.mirror.Prune(e)
}
+ // The words go with the audio. A transcript of a file nobody holds any more is of no
+ // use to the phone, which plays the server's bytes, and the directory would otherwise
+ // grow for ever.
+ if e.TranscriptExamined() {
+ _ = w.transcripts.Prune(e.ID)
+ e.TranscriptSource, e.TranscriptCues, e.TranscriptNote = "", 0, ""
+ e.TranscriptAtUnix = 0
+ }
}
fetched := 0
@@ -311,9 +329,59 @@ func (w *worker) pollOne(ctx context.Context, feedURL string) {
if fetched > 0 {
log.Printf("%s: mirrored %d new episode(s)", channel.Title, fetched)
}
+ w.harvestTranscripts(ctx, show, kept)
w.detect(ctx, show, kept)
}
+// harvestTranscripts takes the publishers' own transcripts for a show's mirrored episodes.
+//
+// Only where the publisher's master is what was mirrored, which transcript.Fits decides from the
+// declared-versus-served length gap rather than from the words. A show using dynamic ad
+// insertion publishes a transcript of the ad-free programme, and reading along with it would be
+// minutes out of step with the audio the phone plays.
+//
+// Each episode is examined once. A refusal is a judgement about this copy, so re-fetching it on
+// every poll would be work done for ever to reach the same answer -- and the refusal is itself
+// the record of which episodes speech recognition would have to do.
+func (w *worker) harvestTranscripts(ctx context.Context, show *store.Show, candidates []*store.Episode) {
+ taken, declined := 0, 0
+ for _, e := range candidates {
+ if ctx.Err() != nil {
+ return
+ }
+ if !e.Mirrored() || e.TranscriptExamined() || e.TranscriptURL == "" {
+ continue
+ }
+ result, err := w.harvester.Harvest(ctx, transcript.Offer{
+ EpisodeID: e.ID,
+ URL: e.TranscriptURL,
+ Type: e.TranscriptType,
+ ServedMs: e.DurationMs,
+ DeclaredMs: e.DeclaredDurationMs,
+ })
+ if err != nil {
+ // The download failed, so nothing has been decided and the next poll tries again.
+ log.Printf("%s: transcript for %q: %v", show.Title, e.Title, err)
+ continue
+ }
+ switch {
+ case result.Stored:
+ e.TranscriptSource = string(transcript.SourcePublished)
+ e.TranscriptCues = result.Cues
+ e.TranscriptAtUnix = time.Now().Unix()
+ e.TranscriptNote = ""
+ taken++
+ case result.Note != "":
+ e.TranscriptNote = result.Note
+ e.TranscriptAtUnix = time.Now().Unix()
+ declined++
+ }
+ }
+ if taken > 0 || declined > 0 {
+ log.Printf("%s: transcripts -- took %d, declined %d", show.Title, taken, declined)
+ }
+}
+
// detect runs the intro/outro search over a show's mirrored episodes and records the result.
//
// Every mirrored episode at once, which is the whole reason this is a server: the phone could