diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-11-03 08:22:11 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-11-03 08:22:11 +0000 |
| commit | 87b8cfa942b995d1c013827ec7014cf6ef4138ca (patch) | |
| tree | 86ca78d7dc93193dcc39aa6114576f9e8a314965 /server/internal | |
| parent | 5fc97b66bda97d3af14da6d3de69e84f29299773 (diff) | |
Keep what the listener asked for, and publish ads only from a publisher's master
Two changes to the server, both measured against the live data rather than reasoned about.
**Subtraction publishes only from a publisher's transcript.** Subtracting a publisher's transcript
recovers 94-104% of the advertising the file lengths imply; subtracting a refetch -- a second
download of the same assembled file, carrying its own ads in its own places -- recovers a median of
33%. That tier had already been tried and rejected at 21% and 0%, but nothing in the publish path
was told which master it had, so it reached the phone anyway on seventeen ad spans.
Written as a denylist first, and measuring it against a copy of the live data showed why that was
the wrong shape: sixteen episodes carry a master whose label is blank -- the field postdates the
first masters written, and Reclaim restored those files after the library was destroyed with the
blank intact -- and they published on the strength of not being the one named bad kind. They
happened to be publisher transcripts, so the answer was right by luck. It is an allowlist now, and
the blank labels are repaired from the master files, which have said "published" all along.
Measured on a copy, not predicted:
before 49 ad spans (17 refetch, 24 unlabelled, 8 published) + 604 cue, 245 episodes
after 62 ad spans (all publisher-master) + 611 cue, 245 episodes
No episode loses its skips; the refetch ones trade advertising nobody could measure for the show's
own recurring audio, which is a preference rather than an unconditional skip. Measuring also caught
the recurring pass blanking the refusal subtraction had just written -- both passes answer "why is
nothing published" through one field and recurring runs second, so all thirty-two episodes that now
carry a reason were losing it a moment later.
**Being asked for keeps an episode mirrored.** Retention was publication date alone, and pruning
calls words.Forget, which deletes the transcript *file*. So an episode outside the newest eight was
fetched on demand, recognised, served, and deleted on the next poll -- and asking again paid the
whole cost over. From the server's own journal over twenty-one days: 397 transcription runs
covering 309 distinct episodes, with 7.7 of the 33.4 hours of whisper time spent on episodes it had
already recognised and thrown away. 23% of the budget, re-doing work it had discarded.
Working through a back catalogue -- the archive tier, the slow-drip shows, the listening this was
built for -- therefore paid full recognition for every episode and again to resume one. The window
still decides what is worth fetching; RequestedAtUnix now decides what is worth keeping, stamped on
every /api/episode call including for episodes already mirrored, because resuming something played
last month must not let it fall out mid-listen. Kept for 28 days after the last ask, the same span
refetchAfterSeconds uses, being the same kind of judgement about how long something stays
interesting.
Disk points the same way: transcripts are 36MB for 445 files, audio is 17GB for 336 episodes, and
the volume has 365GB free. The expensive artefact is the small one.
374 server tests pass. Not yet deployed; the phone's half of this -- a pending reply is retried
only on a feed refresh -- is still to come.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'server/internal')
| -rw-r--r-- | server/internal/api/api.go | 17 | ||||
| -rw-r--r-- | server/internal/api/api_test.go | 23 | ||||
| -rw-r--r-- | server/internal/store/store.go | 36 | ||||
| -rw-r--r-- | server/internal/store/store_test.go | 70 | ||||
| -rw-r--r-- | server/internal/transcript/publish.go | 22 | ||||
| -rw-r--r-- | server/internal/transcript/publish_test.go | 79 | ||||
| -rw-r--r-- | server/internal/words/pipeline.go | 29 | ||||
| -rw-r--r-- | server/internal/words/pipeline_test.go | 47 |
8 files changed, 310 insertions, 13 deletions
diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 6114c1b..d716bb4 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/terst/listen/server/internal/store" "github.com/terst/listen/server/internal/transcript" @@ -46,6 +47,8 @@ type Server struct { state *store.State fetcher Fetcher opts Options + // clock is the time source, left nil outside tests. + clock func() int64 } // New builds the server. @@ -53,6 +56,14 @@ func New(state *store.State, fetcher Fetcher, opts Options) *Server { return &Server{state: state, fetcher: fetcher, opts: opts} } +// now is the clock, overridable in tests the same way the words pipeline's is. +func (s *Server) now() int64 { + if s.clock != nil { + return s.clock() + } + return time.Now().Unix() +} + type episodeResponse struct { // Status is "ready" when the audio can be played now, or "pending" when the server has // been asked to fetch it. There is deliberately no "unknown": an episode the phone knows @@ -211,6 +222,12 @@ func (s *Server) episode(w http.ResponseWriter, r *http.Request) { } found := s.state.Match(feedURL, guid, enclosure, title) + if found != nil { + // The only moment the server learns anybody wants this episode. Recorded whether or not + // it is mirrored yet, and on every ask, so resuming something played last month keeps + // it from falling out of the mirror while it is being listened to. + found.Requested(s.now()) + } if found == nil || !found.Mirrored() { // Ask for it rather than refusing. Everything Peter actually listens to should end up // with offsets, including an episode older than the ones kept by default. diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index 94f7ef4..8a29b33 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -418,3 +418,26 @@ func TestATierForARemovedFeedIsDropped(t *testing.T) { t.Errorf("got %q, want it dropped with the feed", got) } } + +/* +Asking for an episode is what keeps it. + +Retention used to be publication date alone, so an episode outside the newest few was fetched on +demand, served, and deleted on the next poll together with its transcript -- 23% of the server's +whisper time over three weeks went on episodes it had already recognised and thrown away. The +window still decides what is worth fetching; this is what decides what is worth keeping, and this +request is the only moment the server learns that anybody wants it. + +Recorded for an episode already mirrored too, not only a missing one: resuming something played +last month must not let it fall out while it is being listened to. +*/ +func TestAskingForAnEpisodeMarksItWanted(t *testing.T) { + s, st, _, audioDir := newServer(t) + seedMirrored(t, st, audioDir, "audio-bytes") + + get(t, s, "/api/episode?feed=https://f&guid=g1") + + if got := st.Match("https://f", "g1", "", "").RequestedAtUnix; got == 0 { + t.Error("asking for a mirrored episode left it unwanted, so a poll may still drop it") + } +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 2e20f2e..de763ed 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -78,6 +78,10 @@ type Episode struct { TranscriptCues int `json:"transcriptCues,omitempty"` // TranscriptAtUnix is when the transcript landed. TranscriptAtUnix int64 `json:"transcriptAtUnix,omitempty"` + // RequestedAtUnix is when the phone last asked for this episode, which keeps it mirrored + // past the newest-N window.Zero for an episode nobody has reached for. + RequestedAtUnix int64 `json:"requestedAtUnix,omitempty"` + // MasterCues is how many cues the publisher's ad-free transcript holds, kept for episodes // whose served copy carries inserted advertising. MasterNote is why there is none. Three // states as everywhere else: words, a reason, or nobody has looked. @@ -128,6 +132,23 @@ func (e *Episode) NeedsMaster() bool { e.MasterCues == 0 && e.MasterNote == "" } +// keepRequestedSeconds is how long an episode stays mirrored after the phone last asked for it, +// regardless of how far outside the newest-N window it has fallen. +// +// Four weeks, the same span refetchAfterSeconds uses, because it is the same kind of judgement +// about how long something stays interesting. Long enough to work through a back catalogue +// without re-fetching what was played last week; short enough that a binge does not pin a +// show's whole archive to the disk for ever. +const keepRequestedSeconds = 28 * 24 * 60 * 60 + +// Requested records that the phone asked for this episode, which is what keeps it mirrored. +func (e *Episode) Requested(now int64) { e.RequestedAtUnix = now } + +// wanted reports whether the phone has asked for this episode recently enough to keep it. +func (e *Episode) wanted(now int64) bool { + return e.RequestedAtUnix > 0 && now-e.RequestedAtUnix < keepRequestedSeconds +} + // refetchAfterSeconds is how old an episode must be before a second fetch is worth making. // Four weeks: long enough that whatever campaign was running at mirroring has given way, and // short enough that the episode is usually still being served. @@ -256,7 +277,7 @@ type Show struct { // // By publish date rather than feed order, because feeds are not reliably sorted and the whole // point is to mirror what is current. -func (s *Show) Partition(keep int) (kept, pruned []*Episode) { +func (s *Show) Partition(keep int, now int64) (kept, pruned []*Episode) { ordered := make([]*Episode, len(s.Episodes)) copy(ordered, s.Episodes) sort.SliceStable(ordered, func(i, j int) bool { @@ -265,7 +286,18 @@ func (s *Show) Partition(keep int) (kept, pruned []*Episode) { if keep >= len(ordered) { return ordered, nil } - return ordered[:keep], ordered[keep:] + // The window decides what is worth fetching; being asked for decides what is worth keeping. + // Without this an episode outside the newest N was fetched on demand, served, and deleted + // again on the next poll -- with its transcript, which is the expensive half. + kept, pruned = ordered[:keep:keep], nil + for _, e := range ordered[keep:] { + if e.wanted(now) { + kept = append(kept, e) + continue + } + pruned = append(pruned, e) + } + return kept, pruned } // State is the server's whole persistent world. diff --git a/server/internal/store/store_test.go b/server/internal/store/store_test.go index a88fdd4..657e7b1 100644 --- a/server/internal/store/store_test.go +++ b/server/internal/store/store_test.go @@ -136,7 +136,7 @@ func TestKeepNewestSelectsByPublishDateNotFeedOrder(t *testing.T) { ep("g2", "Middle", "https://cdn/2.mp3", 200), }) - keep, prune := s.Show("https://f").Partition(2) + keep, prune := s.Show("https://f").Partition(2, 0) if len(keep) != 2 || len(prune) != 1 { t.Fatalf("keep %d prune %d, want 2 and 1", len(keep), len(prune)) } @@ -152,7 +152,7 @@ func TestKeepNewestKeepsEverythingWhenThereAreFewerThanTheLimit(t *testing.T) { s := New() s.Merge("https://f", "A Show", []feed.Episode{ep("g1", "One", "https://cdn/1.mp3", 100)}) - keep, prune := s.Show("https://f").Partition(8) + keep, prune := s.Show("https://f").Partition(8, 0) if len(keep) != 1 || len(prune) != 0 { t.Errorf("keep %d prune %d, want 1 and 0", len(keep), len(prune)) } @@ -745,3 +745,69 @@ func TestTheGuardNamesWhatItRefused(t *testing.T) { t.Errorf("error %q does not say how much it would have lost", err) } } + +/* +An episode the phone asked for survives the keep window. + +Retention was publication date and nothing else, so anything outside the newest few was fetched +on demand, served, and deleted again within one poll -- audio, transcript and all, because +words.Forget removes the transcript file too. Playing a back catalogue therefore paid the full +cost of recognition every time, and paid it again to resume. + +Measured on the live server over twenty-one days: 397 transcription runs covering 309 distinct +episodes, 7.7 hours of the 33.4 hours of whisper time spent on episodes already transcribed once. +23% of the budget, re-doing work that had been thrown away. + +So the window decides what to fetch, and being wanted decides what to keep. +*/ +func TestAnEpisodeTheListenerAskedForIsNotPruned(t *testing.T) { + const now = 1_800_000_000 + s := New() + s.Merge("https://f", "A Show", []feed.Episode{ + ep("g1", "Oldest", "https://cdn/1.mp3", 100), + ep("g2", "Middle", "https://cdn/2.mp3", 200), + ep("g3", "Newest", "https://cdn/3.mp3", 300), + }) + show := s.Show("https://f") + // The oldest, which the window would drop, asked for a moment ago. + for _, e := range show.Episodes { + if e.GUID == "g1" { + e.Requested(now) + } + } + + keep, prune := show.Partition(1, now) + + if len(prune) != 1 || prune[0].GUID != "g2" { + t.Fatalf("pruned %v, want only g2", guids(prune)) + } + if len(keep) != 2 { + t.Fatalf("kept %v, want the newest and the one asked for", guids(keep)) + } +} + +// Wanted once is not wanted for ever, or a back-catalogue binge would keep every episode's audio. +func TestAnEpisodeAskedForLongAgoIsPrunedAgain(t *testing.T) { + const now = 1_800_000_000 + s := New() + s.Merge("https://f", "A Show", []feed.Episode{ + ep("g1", "Oldest", "https://cdn/1.mp3", 100), + ep("g2", "Newest", "https://cdn/2.mp3", 200), + }) + show := s.Show("https://f") + show.Episodes[0].Requested(now - keepRequestedSeconds - 1) + + _, prune := show.Partition(1, now) + + if len(prune) != 1 || prune[0].GUID != "g1" { + t.Errorf("pruned %v, want g1 back in the window's hands", guids(prune)) + } +} + +func guids(episodes []*Episode) []string { + out := make([]string, 0, len(episodes)) + for _, e := range episodes { + out = append(out, e.GUID) + } + return out +} diff --git a/server/internal/transcript/publish.go b/server/internal/transcript/publish.go index b9856c3..f79b61c 100644 --- a/server/internal/transcript/publish.go +++ b/server/internal/transcript/publish.go @@ -40,7 +40,7 @@ const overshootLimit = 150 // // A refusal is a different state from having found nothing, and the caller keeps them apart -- // the same distinction the ad scan's own refusals have needed twice before. -func Publishable(found []Candidate, servedMs, declaredMs int64) ([]Candidate, string) { +func Publishable(found []Candidate, servedMs, declaredMs int64, master Source) ([]Candidate, string) { if len(found) == 0 { return nil, "" } @@ -55,6 +55,26 @@ func Publishable(found []Candidate, servedMs, declaredMs int64) ([]Candidate, st return nil, "found by reading the ad copy, which has never agreed with the lengths" } + // What was subtracted decides whether this is a measurement or a guess. A publisher's + // transcript describes the programme once; a refetch is another copy of the same assembled + // file, with its own advertising in its own places, so the difference between them is two ad + // loads rather than one. Measured on the live corpus: 94-104% of the length gap from a + // published master, a median of 33% from a refetch. + // + // An allowlist, not a "not refetch" test. Sixteen episodes carry a master whose label is + // blank -- the field postdates the first masters written -- and a denylist published them on + // the strength of not being the one named bad kind. + if master != SourcePublished { + kind := string(master) + if kind == "" { + kind = "a master that does not say what it is" + } else { + kind = "a " + kind + } + return nil, fmt.Sprintf("subtracted from %s rather than the publisher's transcript, "+ + "which is the only master measured accurate enough to skip on", kind) + } + gap := servedMs - declaredMs if !HasInsertedAdvertising(servedMs, declaredMs) { return nil, "the lengths say nothing was inserted into this copy, so there is " + diff --git a/server/internal/transcript/publish_test.go b/server/internal/transcript/publish_test.go index 8c98a8f..92153f8 100644 --- a/server/internal/transcript/publish_test.go +++ b/server/internal/transcript/publish_test.go @@ -29,7 +29,7 @@ func TestSubtractedAdvertisingIsPublished(t *testing.T) { found := candidates(SignalDifference, [2]int64{0, 57_000}, [2]int64{164_000, 304_000}, [2]int64{1_928_000, 1_992_000}) - got, why := Publishable(found, 3_813_000, 3_557_000) + got, why := Publishable(found, 3_813_000, 3_557_000, SourcePublished) if why != "" { t.Fatalf("refused: %s", why) } @@ -48,7 +48,7 @@ hint beside the words; not something to seek past. func TestAdvertisingReadFromTheCopyIsNotPublished(t *testing.T) { found := candidates("sponsor", [2]int64{60_000, 120_000}) - got, why := Publishable(found, 1_480_000, 1_216_000) + got, why := Publishable(found, 1_480_000, 1_216_000, SourcePublished) if len(got) != 0 { t.Errorf("published %d breaks found only by their wording", len(got)) } @@ -67,7 +67,7 @@ finding it is what reading the copy is for. Acting on it is a different matter. func TestNothingIsPublishedWithoutALengthGapToCheckAgainst(t *testing.T) { found := candidates(SignalDifference, [2]int64{60_000, 120_000}) - got, why := Publishable(found, 1_216_500, 1_216_000) + got, why := Publishable(found, 1_216_500, 1_216_000, SourcePublished) if len(got) != 0 { t.Errorf("published %d breaks with nothing to check them against", len(got)) } @@ -86,7 +86,7 @@ func TestClaimingHalfAgainAsMuchAsWasInsertedIsRefused(t *testing.T) { // 4:15 inserted, but the candidates come to 9:00. found := candidates(SignalDifference, [2]int64{0, 540_000}) - got, why := Publishable(found, 3_813_000, 3_557_000) + got, why := Publishable(found, 3_813_000, 3_557_000, SourcePublished) if len(got) != 0 { t.Errorf("published %d breaks claiming more than twice what was inserted", len(got)) } @@ -101,14 +101,14 @@ func TestAModestOvershootIsStillPublished(t *testing.T) { // 4:15 inserted, 4:40 found -- 110%. found := candidates(SignalDifference, [2]int64{0, 280_000}) - got, why := Publishable(found, 3_813_000, 3_557_000) + got, why := Publishable(found, 3_813_000, 3_557_000, SourcePublished) if len(got) != 1 || why != "" { t.Errorf("got %d breaks, refused %q", len(got), why) } } func TestNothingFoundPublishesNothingAndSaysNothing(t *testing.T) { - got, why := Publishable(nil, 3_813_000, 3_557_000) + got, why := Publishable(nil, 3_813_000, 3_557_000, SourcePublished) if len(got) != 0 { t.Errorf("got %d breaks from nothing", len(got)) } @@ -185,3 +185,70 @@ func TestNoRecurringTextPublishesNothing(t *testing.T) { t.Errorf("got %d breaks, refused %q", len(got), why) } } + +/* +Subtraction against a second fetch is not fit to skip. + +The master this subtracts from decides whether the result is a measurement or a guess, and until +now nothing here asked which it was. A publisher's transcript is a second *description* of the +programme, written once: subtracting it recovered 94% to 104% of the length gap across nine +episodes in the live corpus. A refetch is a second *copy of the same dynamically-assembled file*, +carrying its own advertising at its own positions, so the diff compares two ad loads instead of +finding one -- a median of 33% across fifteen episodes, range 20% to 146%. + +That 33% is the same tier that measured 21% and 0% when the refetch detector was first tried and +rejected, and it was reaching the phone anyway because this function was never told the +difference. Measured 2026-11-02; see docs/detection-findings.md. +*/ +func TestSubtractionFromARefetchIsNotPublished(t *testing.T) { + // Reply All #188: the published span claimed 146% of a length gap that is itself noise. + found := candidates(SignalDifference, [2]int64{1_154_200, 1_243_000}) + + got, why := Publishable(found, 2_616_000, 2_555_000, SourceRefetch) + + if len(got) != 0 { + t.Errorf("published %d breaks from a refetch master, want none", len(got)) + } + if why == "" { + t.Error("refused silently; a refusal has to say why, the same as every other one here") + } +} + +/* The same candidates, from a publisher's master, still publish. */ +func TestSubtractionFromAPublishedMasterStillPublishes(t *testing.T) { + found := candidates(SignalDifference, [2]int64{0, 57_000}, [2]int64{164_000, 304_000}) + + got, why := Publishable(found, 3_813_000, 3_557_000, SourcePublished) + if why != "" { + t.Fatalf("refused: %s", why) + } + if len(got) != 2 { + t.Errorf("got %d breaks, want 2", len(got)) + } +} + +/* +A master that will not say what it is does not publish either. + +The refetch check started as a denylist and that was the wrong shape. Sixteen episodes in the live +corpus carry a master whose state-side label is blank -- the field postdates the first masters +written, and Reclaim restored those files faithfully, empty label and all -- so they passed a +"not refetch" test and published. They happened to be publisher transcripts, so the outcome was +right by luck. + +An allowlist is the shape that does not depend on luck: subtraction publishes when the master is +known to be the publisher's, and says so otherwise. Same reasoning as safefetch's, where the +denylist missed a proxy and a whole family of address ranges. +*/ +func TestSubtractionFromAnUnlabelledMasterIsNotPublished(t *testing.T) { + found := candidates(SignalDifference, [2]int64{0, 57_000}, [2]int64{164_000, 304_000}) + + got, why := Publishable(found, 3_813_000, 3_557_000, Source("")) + + if len(got) != 0 { + t.Errorf("published %d breaks from an unlabelled master, want none", len(got)) + } + if why == "" { + t.Error("refused silently") + } +} diff --git a/server/internal/words/pipeline.go b/server/internal/words/pipeline.go index 2ae2c4e..8cd18ed 100644 --- a/server/internal/words/pipeline.go +++ b/server/internal/words/pipeline.go @@ -406,7 +406,13 @@ func (p *Pipeline) Recurring(show *store.Show) { } found := transcript.Recurring(target.cues, others) fit, why := transcript.PublishableRecurring(found, target.episode.DurationMs) - target.episode.WordAdsRefused = why + // Both passes answer "why is nothing published" through one field, and this one runs + // second. Overwriting unconditionally discarded whatever subtraction had just said -- + // which, once subtraction began refusing every master that is not the publisher's, is + // the only explanation thirty-two episodes had. + if why != "" { + target.episode.WordAdsRefused = why + } target.episode.WordBreaks = nil for _, c := range fit { target.episode.WordBreaks = append(target.episode.WordBreaks, store.Break{ @@ -441,7 +447,7 @@ Kept in their own field so the ad scan, which rewrites its own list from scratch detection pass, cannot wipe them -- and so that which detector found a break stays answerable. */ func (p *Pipeline) publishWordBreaks(e *store.Episode, found []transcript.Candidate) { - fit, why := transcript.Publishable(found, e.DurationMs, e.DeclaredDurationMs) + fit, why := transcript.Publishable(found, e.DurationMs, e.DeclaredDurationMs, p.masterSource(e)) e.WordAdsRefused = why e.WordBreaks = nil for _, c := range fit { @@ -454,6 +460,25 @@ func (p *Pipeline) publishWordBreaks(e *store.Episode, found []transcript.Candid } /* +masterSource is what kind of master an episode has, repaired from the master file when the state +has no label for it. + +Publishing depends on this now, and the state's copy of it is not reliably present: the field +postdates the first masters written, and Reclaim -- which rebuilt the words from the transcript +files after the library was destroyed -- restored those files faithfully, blank label and all. +Eighteen masters in the live corpus describe themselves correctly on disk and said nothing in the +state. The file is the record; this copies it back. +*/ +func (p *Pipeline) masterSource(e *store.Episode) transcript.Source { + if e.MasterSource == "" && e.MasterCues > 0 { + if master, err := p.Transcripts.Master(e.ID); err == nil && master.Source != "" { + e.MasterSource = string(master.Source) + } + } + return transcript.Source(e.MasterSource) +} + +/* adsIn works out which stretches of a recognised episode are advertising. By subtraction where the publisher's ad-free master is held, and by reading the ad copy where it diff --git a/server/internal/words/pipeline_test.go b/server/internal/words/pipeline_test.go index c010c8e..9982a10 100644 --- a/server/internal/words/pipeline_test.go +++ b/server/internal/words/pipeline_test.go @@ -204,3 +204,50 @@ func TestReclaimLeavesWhatIsAlreadyThere(t *testing.T) { t.Error("overwrote words that were already there") } } + +/* +The recurring pass does not wipe the reason subtraction gave. + +Both passes write the same WordAdsRefused field, for two different judgements, and the recurring +one runs second. It blanked the field on every episode it succeeded on, so an episode whose +advertising was refused -- and then given the show's own cues instead -- arrived at the phone +saying nothing about the advertising. + +That was survivable while refusals were rare. Making subtraction publish only from a publisher's +master put a reason on thirty-two episodes in the live corpus, and measuring the change on a copy +of that data is what showed all thirty-two being discarded a moment later. +*/ +func TestRecurringKeepsSubtractionsRefusal(t *testing.T) { + dir := t.TempDir() + held := transcript.Store{Dir: dir} + // Three episodes with words, which clears the corpus floor, and nothing shared between them + // so the pass publishes no cues -- the case where it previously still blanked the field. + for _, id := range []string{"e1", "e2", "e3"} { + if err := held.Put(transcript.Transcript{ + EpisodeID: id, Source: transcript.SourceASR, + Cues: []transcript.Cue{{StartMs: 0, EndMs: 1000, Text: "a line only " + id + " says"}}, + }); err != nil { + t.Fatalf("Put %s: %v", id, err) + } + } + + state := store.New() + state.Merge("https://f", "A Show", nil) + show := state.Show("https://f") + show.Episodes = []*store.Episode{ + {ID: "e1", Path: "a.mp3", TranscriptCues: 1, DurationMs: 600_000, WordAdsRefused: "subtracted from a refetch"}, + {ID: "e2", Path: "b.mp3", TranscriptCues: 1, DurationMs: 600_000, WordAdsRefused: "subtracted from a refetch"}, + {ID: "e3", Path: "c.mp3", TranscriptCues: 1, DurationMs: 600_000, WordAdsRefused: "subtracted from a refetch"}, + } + + (&Pipeline{State: state, Transcripts: held}).Recurring(show) + + for _, e := range show.Episodes { + if len(e.WordBreaks) != 0 { + t.Fatalf("%s published cues; the fixture meant to share nothing", e.ID) + } + if e.WordAdsRefused == "" { + t.Errorf("%s lost the reason no advertising was published", e.ID) + } + } +} |
