diff options
Diffstat (limited to 'server/internal')
| -rw-r--r-- | server/internal/api/api.go | 52 | ||||
| -rw-r--r-- | server/internal/api/api_test.go | 3 | ||||
| -rw-r--r-- | server/internal/api/transcript_test.go | 182 | ||||
| -rw-r--r-- | server/internal/feed/feed.go | 58 | ||||
| -rw-r--r-- | server/internal/feed/feed_test.go | 125 | ||||
| -rw-r--r-- | server/internal/store/store.go | 34 | ||||
| -rw-r--r-- | server/internal/store/store_test.go | 68 | ||||
| -rw-r--r-- | server/internal/transcript/fits.go | 73 | ||||
| -rw-r--r-- | server/internal/transcript/fits_test.go | 94 | ||||
| -rw-r--r-- | server/internal/transcript/harvest.go | 101 | ||||
| -rw-r--r-- | server/internal/transcript/harvest_test.go | 145 | ||||
| -rw-r--r-- | server/internal/transcript/parse.go | 138 | ||||
| -rw-r--r-- | server/internal/transcript/parse_test.go | 158 | ||||
| -rw-r--r-- | server/internal/transcript/store.go | 131 | ||||
| -rw-r--r-- | server/internal/transcript/store_test.go | 101 |
15 files changed, 1462 insertions, 1 deletions
diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 969e69d..bcfe6a2 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/terst/listen/server/internal/store" + "github.com/terst/listen/server/internal/transcript" ) // Fetcher is asked to mirror an episode the phone wants and the server does not have. @@ -27,6 +28,10 @@ type Options struct { Token string // AudioDir is where mirrored episodes live. AudioDir string + // TranscriptDir is where episodes' words live, one file per episode. Separate from the + // state file because a transcript is a thousand cues and state.json is rewritten whole + // every time anything at all changes. + TranscriptDir string // OnSubscriptions is called when the phone sends its list, so a newly-subscribed show is // polled at once rather than at the next interval. Optional. OnSubscriptions func(feeds []string) @@ -71,6 +76,22 @@ type episodeResponse struct { // AdsRefused: matches were found but were too extensive to be advertising, so none were // published. Distinct from nothing having matched. AdsRefused bool `json:"adsRefused,omitempty"` + + // Transcript says whether this episode has words and where they came from, or why it has + // none. Absent entirely when nobody has looked yet, which is a third state again. + Transcript *transcriptNote `json:"transcript,omitempty"` +} + +// transcriptNote is the line the phone needs on every episode: is there a transcript, whose +// words are they, and if there is none, why not. +// +// The source travels with it because the two kinds are not the same thing to read along with. +// A published transcript is the publisher's exact words; a recognised one is a machine's guess +// at the same audio, and somebody reading it should know which they are looking at. +type transcriptNote struct { + Source string `json:"source,omitempty"` + Cues int `json:"cues,omitempty"` + Note string `json:"note,omitempty"` } type breakRange struct { @@ -90,6 +111,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/api/subscriptions", s.authed(s.subscriptions)) mux.HandleFunc("/api/episode", s.authed(s.episode)) mux.HandleFunc("/api/labels", s.authed(s.labels)) + mux.HandleFunc("/api/transcript", s.authed(s.transcript)) mux.HandleFunc("/audio/", s.authed(s.audio)) return mux } @@ -190,9 +212,39 @@ func (s *Server) episode(w http.ResponseWriter, r *http.Request) { FoundAdMs: found.FoundAdMs, InsertedAdMs: found.InsertedAdMs, AdsRefused: found.AdsRefused, + Transcript: transcriptNoteFor(found), }) } +func transcriptNoteFor(e *store.Episode) *transcriptNote { + if !e.TranscriptExamined() { + return nil + } + return &transcriptNote{ + Source: e.TranscriptSource, + Cues: e.TranscriptCues, + Note: e.TranscriptNote, + } +} + +// transcript serves one episode's words, which are fetched only when somebody opens them -- +// a thousand cues is not something to attach to every episode lookup. +func (s *Server) transcript(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("episode") + // Same rule as the audio route: the id decides a path, so anything with a separator or a + // dot segment in it is an attempt to read elsewhere. + if id == "" || strings.ContainsAny(id, "/\\") || strings.Contains(id, "..") { + http.NotFound(w, r) + return + } + held, err := (transcript.Store{Dir: s.opts.TranscriptDir}).Get(id) + if err != nil { + http.NotFound(w, r) + return + } + writeJSON(w, http.StatusOK, held) +} + func (s *Server) audio(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/audio/") // No separators: an id is a flat hex string, and anything else is an attempt to read diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index d4d3ee6..19e40db 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -26,7 +26,8 @@ func newServer(t *testing.T) (*Server, *store.State, *fakeFetcher, string) { audioDir := t.TempDir() st := store.New() fetcher := &fakeFetcher{} - return New(st, fetcher, Options{Token: token, AudioDir: audioDir}), st, fetcher, audioDir + opts := Options{Token: token, AudioDir: audioDir, TranscriptDir: t.TempDir()} + return New(st, fetcher, opts), st, fetcher, audioDir } func get(t *testing.T, s *Server, path string) *httptest.ResponseRecorder { diff --git a/server/internal/api/transcript_test.go b/server/internal/api/transcript_test.go new file mode 100644 index 0000000..b208f7f --- /dev/null +++ b/server/internal/api/transcript_test.go @@ -0,0 +1,182 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/terst/listen/server/internal/feed" + "github.com/terst/listen/server/internal/store" + "github.com/terst/listen/server/internal/transcript" +) + +/* +Serving an episode's words. + +Two pieces, deliberately separate. The episode response says whether there are words and where +they came from, which is a line the phone needs on every episode it asks about; the transcript +itself is a thousand cues and is fetched only when somebody opens it. + +Where they came from is not decoration. A published transcript is the publisher's exact words; +a recognised one is a machine's guess at the same audio, and reading along with it means seeing +the difference. The app says which. +*/ + +func writeAudio(t *testing.T, audioDir, name string) { + t.Helper() + if err := os.WriteFile(filepath.Join(audioDir, name), []byte("audio"), 0o644); err != nil { + t.Fatalf("write audio: %v", err) + } +} + +func recorder() *httptest.ResponseRecorder { return httptest.NewRecorder() } + +func seedTranscribedEpisode(t *testing.T, s *Server, st *store.State, audioDir string) *store.Episode { + t.Helper() + st.Merge("https://f", "A Show", []feed.Episode{{ + GUID: "g1", Title: "One", EnclosureURL: "https://cdn/1.mp3", PublishedUnix: 100, + }}) + e := st.Match("https://f", "g1", "", "") + e.Path = e.ID + ".mp3" + e.DurationMs = 1_217_000 + writeAudio(t, audioDir, e.Path) + + held := transcript.Store{Dir: s.opts.TranscriptDir} + if err := held.Put(transcript.Transcript{ + EpisodeID: e.ID, + Source: transcript.SourcePublished, + Cues: []transcript.Cue{ + {StartMs: 0, EndMs: 3120, Text: "Welcome back."}, + {StartMs: 3500, EndMs: 7000, Text: "This week's sponsor is a mattress."}, + }, + }); err != nil { + t.Fatalf("seed transcript: %v", err) + } + e.TranscriptSource = string(transcript.SourcePublished) + e.TranscriptCues = 2 + return e +} + +func TestTheEpisodeResponseSaysWhetherThereAreWords(t *testing.T) { + s, st, _, audioDir := newServer(t) + seedTranscribedEpisode(t, s, st, audioDir) + + rec := get(t, s, "/api/episode?feed=https://f&guid=g1") + var got struct { + Transcript *struct { + Source string `json:"source"` + Cues int `json:"cues"` + } `json:"transcript"` + } + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Transcript == nil { + t.Fatal("the episode has a transcript and the response does not mention it") + } + if got.Transcript.Source != "published" || got.Transcript.Cues != 2 { + t.Errorf("got %+v", *got.Transcript) + } +} + +// Refused is not the same as absent, and the phone shows the difference: an episode whose +// publisher's transcript does not describe the copy being played has been looked at. +func TestTheEpisodeResponseCarriesARefusal(t *testing.T) { + s, st, _, audioDir := newServer(t) + st.Merge("https://f", "A Show", []feed.Episode{{ + GUID: "g1", Title: "One", EnclosureURL: "https://cdn/1.mp3", PublishedUnix: 100, + }}) + e := st.Match("https://f", "g1", "", "") + e.Path = e.ID + ".mp3" + writeAudio(t, audioDir, e.Path) + e.TranscriptNote = "this copy carries 4:16 of inserted advertising" + + rec := get(t, s, "/api/episode?feed=https://f&guid=g1") + var got struct { + Transcript *struct { + Cues int `json:"cues"` + Note string `json:"note"` + } `json:"transcript"` + } + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Transcript == nil || got.Transcript.Note == "" || got.Transcript.Cues != 0 { + t.Errorf("got %+v, want a refusal with no cues", got.Transcript) + } +} + +func TestAnEpisodeNobodyHasLookedAtMentionsNoTranscript(t *testing.T) { + s, st, _, audioDir := newServer(t) + st.Merge("https://f", "A Show", []feed.Episode{{ + GUID: "g1", Title: "One", EnclosureURL: "https://cdn/1.mp3", PublishedUnix: 100, + }}) + e := st.Match("https://f", "g1", "", "") + e.Path = e.ID + ".mp3" + writeAudio(t, audioDir, e.Path) + + rec := get(t, s, "/api/episode?feed=https://f&guid=g1") + var got struct { + Transcript *json.RawMessage `json:"transcript"` + } + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Transcript != nil { + t.Errorf("invented a transcript field: %s", *got.Transcript) + } +} + +func TestTheTranscriptItselfIsServedByEpisodeId(t *testing.T) { + s, st, _, audioDir := newServer(t) + e := seedTranscribedEpisode(t, s, st, audioDir) + + rec := get(t, s, "/api/transcript?episode="+e.ID) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + var got transcript.Transcript + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Cues) != 2 || got.Cues[1].Text != "This week's sponsor is a mattress." { + t.Errorf("got %+v", got) + } +} + +func TestAskingForATranscriptThatDoesNotExistIsNotFound(t *testing.T) { + s, st, _, audioDir := newServer(t) + st.Merge("https://f", "A Show", []feed.Episode{{ + GUID: "g1", Title: "One", EnclosureURL: "https://cdn/1.mp3", PublishedUnix: 100, + }}) + e := st.Match("https://f", "g1", "", "") + e.Path = e.ID + ".mp3" + writeAudio(t, audioDir, e.Path) + + if rec := get(t, s, "/api/transcript?episode="+e.ID); rec.Code != http.StatusNotFound { + t.Errorf("status %d, want 404", rec.Code) + } +} + +// Same rule as the audio route: an id decides a path, so it is checked rather than trusted. +func TestATranscriptIdCannotReachOutsideTheDirectory(t *testing.T) { + s, _, _, _ := newServer(t) + for _, id := range []string{"../state", "a/b", ""} { + if rec := get(t, s, "/api/transcript?episode="+id); rec.Code == http.StatusOK { + t.Errorf("%q: served something", id) + } + } +} + +func TestTheTranscriptRouteNeedsTheToken(t *testing.T) { + s, _, _, _ := newServer(t) + req, _ := http.NewRequest(http.MethodGet, "/api/transcript?episode=abc", nil) + rec := recorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status %d, want 401", rec.Code) + } +} diff --git a/server/internal/feed/feed.go b/server/internal/feed/feed.go index edc58be..493fbdd 100644 --- a/server/internal/feed/feed.go +++ b/server/internal/feed/feed.go @@ -21,6 +21,12 @@ type Episode struct { // DeclaredDurationMs is the feed's claim, which is routinely wrong -- a show using dynamic // ad insertion declares the ad-free length. Useful for ordering, not for arithmetic. DeclaredDurationMs int64 + // TranscriptURL is a Podcasting 2.0 transcript in a format that carries timestamps, or + // empty. The publisher's own words, which is exact where speech recognition is a guess -- + // but it describes the publisher's master, so whether it describes the file the server + // mirrored is a separate question. See transcript.Fits. + TranscriptURL string + TranscriptType string } // Channel is a show and its audio episodes, newest first. @@ -41,6 +47,12 @@ type rss struct { URL string `xml:"url,attr"` Type string `xml:"type,attr"` } `xml:"enclosure"` + // Both spellings of the Podcasting 2.0 namespace. The specification was published + // under http:// and the feeds in the wild now declare https://; Go matches a + // namespace by exact string, so taking only one silently drops the other's + // transcripts rather than failing. + Transcripts []transcriptTag `xml:"https://podcastindex.org/namespace/1.0 transcript"` + OldNamespace []transcriptTag `xml:"http://podcastindex.org/namespace/1.0 transcript"` } `xml:"item"` } `xml:"channel"` } @@ -64,6 +76,7 @@ func Parse(raw []byte) (*Channel, error) { if !isAudio(item.Enclosure.Type, item.Enclosure.URL) { continue } + transcriptURL, transcriptType := bestTranscript(append(item.Transcripts, item.OldNamespace...)) out.Episodes = append(out.Episodes, Episode{ GUID: strings.TrimSpace(item.GUID), Title: strings.TrimSpace(item.Title), @@ -71,6 +84,8 @@ func Parse(raw []byte) (*Channel, error) { EnclosureType: strings.TrimSpace(item.Enclosure.Type), PublishedUnix: parseDate(item.PubDate), DeclaredDurationMs: parseDuration(item.Duration), + TranscriptURL: transcriptURL, + TranscriptType: transcriptType, }) } @@ -81,6 +96,49 @@ func Parse(raw []byte) (*Channel, error) { return out, nil } +// transcriptRank orders the transcript formats a publisher may offer. An item routinely carries +// several at once, and the MIME types in the wild disagree about the same format: both +// application/srt and application/x-subrip are SRT. +// +// WebVTT first only because it is the format with a specification that says what a cue is; SRT +// is equally usable and ranks next. text/plain, text/html and application/json are absent +// deliberately -- they carry no timestamps, so a cue cannot be seeked to or placed, and a +// transcript that can do neither is worse than none at all because storing it stops the episode +// being looked at again. +var transcriptRank = map[string]int{ + "text/vtt": 1, + "text/webvtt": 1, + "application/srt": 2, + "application/x-subrip": 2, + "text/srt": 2, +} + +// transcriptTag is one <podcast:transcript> element. +type transcriptTag struct { + URL string `xml:"url,attr"` + Type string `xml:"type,attr"` +} + +func bestTranscript(offered []transcriptTag) (string, string) { + bestRank := 0 + var bestURL, bestType string + for _, t := range offered { + url := strings.TrimSpace(t.URL) + mediaType := strings.ToLower(strings.TrimSpace(t.Type)) + if i := strings.IndexByte(mediaType, ';'); i >= 0 { + mediaType = strings.TrimSpace(mediaType[:i]) + } + rank, ok := transcriptRank[mediaType] + if !ok || url == "" { + continue + } + if bestRank == 0 || rank < bestRank { + bestRank, bestURL, bestType = rank, url, mediaType + } + } + return bestURL, bestType +} + func isAudio(mediaType, url string) bool { if url == "" { return false diff --git a/server/internal/feed/feed_test.go b/server/internal/feed/feed_test.go index e62558f..ce68404 100644 --- a/server/internal/feed/feed_test.go +++ b/server/internal/feed/feed_test.go @@ -143,3 +143,128 @@ func TestAFeedWithNoItemsIsNotAnError(t *testing.T) { t.Errorf("got %d episodes", len(ch.Episodes)) } } + +/* +Podcasting 2.0 transcripts. + +Eleven of Peter's 44 feeds carry <podcast:transcript>, and the shapes below are the ones they +actually use (surveyed 2026-10-28). An item routinely carries the same transcript in several +formats at once, and the MIME types disagree with each other: application/srt and +application/x-subrip are both SRT, and text/plain, text/html and application/json carry no +timestamps at all. + +Only a timestamped one is worth taking: the app seeks to a cue and the detector places a break +at one, and neither can be done with a wall of text. +*/ + +const withTranscripts = `<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0"> +<channel> + <title>A Show</title> + <item> + <title>Every Format At Once</title> + <guid>https://example.com/1</guid> + <pubDate>Fri, 03 Oct 2026 10:00:00 -0000</pubDate> + <enclosure url="https://cdn.example/1.mp3" type="audio/mpeg"/> + <podcast:transcript url="https://cdn.example/1.txt" type="text/plain"/> + <podcast:transcript url="https://cdn.example/1.srt" type="application/srt"/> + <podcast:transcript url="https://cdn.example/1.vtt" type="text/vtt"/> + </item> + <item> + <title>Subrip Spelling</title> + <guid>https://example.com/2</guid> + <pubDate>Fri, 26 Sep 2026 10:00:00 -0000</pubDate> + <enclosure url="https://cdn.example/2.mp3" type="audio/mpeg"/> + <podcast:transcript url="https://cdn.example/2.srt" type="application/x-subrip"/> + </item> + <item> + <title>Nothing Timestamped</title> + <guid>https://example.com/3</guid> + <pubDate>Fri, 19 Sep 2026 10:00:00 -0000</pubDate> + <enclosure url="https://cdn.example/3.mp3" type="audio/mpeg"/> + <podcast:transcript url="https://cdn.example/3.html" type="text/html"/> + <podcast:transcript url="https://cdn.example/3.json" type="application/json"/> + </item> + <item> + <title>None At All</title> + <guid>https://example.com/4</guid> + <pubDate>Fri, 12 Sep 2026 10:00:00 -0000</pubDate> + <enclosure url="https://cdn.example/4.mp3" type="audio/mpeg"/> + </item> +</channel> +</rss>` + +func TestPrefersAWebVTTTranscriptWhenSeveralAreOffered(t *testing.T) { + channel, err := Parse([]byte(withTranscripts)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + got := channel.Episodes[0] + if got.TranscriptURL != "https://cdn.example/1.vtt" || got.TranscriptType != "text/vtt" { + t.Errorf("got %q (%q), want the WebVTT one", got.TranscriptURL, got.TranscriptType) + } +} + +func TestTakesSRTUnderEitherSpelling(t *testing.T) { + channel, err := Parse([]byte(withTranscripts)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + got := channel.Episodes[1] + if got.TranscriptURL != "https://cdn.example/2.srt" { + t.Errorf("got %q, want the x-subrip one", got.TranscriptURL) + } +} + +func TestIgnoresTranscriptsWithNoTimestamps(t *testing.T) { + channel, err := Parse([]byte(withTranscripts)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got := channel.Episodes[2]; got.TranscriptURL != "" { + t.Errorf("took %q (%q), which has no times in it", got.TranscriptURL, got.TranscriptType) + } +} + +func TestAnEpisodeWithNoTranscriptSaysSo(t *testing.T) { + channel, err := Parse([]byte(withTranscripts)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got := channel.Episodes[3]; got.TranscriptURL != "" { + t.Errorf("invented %q", got.TranscriptURL) + } +} + +/* +The namespace URI is spelled two ways in the wild. + +Every one of Peter's eleven transcript-carrying feeds declares the https:// form today +(surveyed 2026-10-28), but the Podcasting 2.0 specification was published under http:// and +older generators still emit it. Go matches a namespace by exact string, so a feed using the +other spelling would parse as having no transcripts at all -- silently, which is the failure +worth pinning: nothing errors, the episodes simply never get words. +*/ +func TestTranscriptsAreFoundUnderEitherNamespaceSpelling(t *testing.T) { + const httpNamespace = `<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0" xmlns:podcast="http://podcastindex.org/namespace/1.0"> +<channel> + <title>A Show</title> + <item> + <title>Older Generator</title> + <guid>https://example.com/1</guid> + <pubDate>Fri, 03 Oct 2026 10:00:00 -0000</pubDate> + <enclosure url="https://cdn.example/1.mp3" type="audio/mpeg"/> + <podcast:transcript url="https://cdn.example/1.vtt" type="text/vtt"/> + </item> +</channel> +</rss>` + + channel, err := Parse([]byte(httpNamespace)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if got := channel.Episodes[0].TranscriptURL; got != "https://cdn.example/1.vtt" { + t.Errorf("got %q, want the transcript -- the http:// namespace was dropped", got) + } +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 7f04fa0..26dab82 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -52,6 +52,36 @@ type Episode struct { // DetectedAtUnix records when detection last ran over the show this belongs to, so a // result can be told apart from never having looked. DetectedAtUnix int64 `json:"detectedAtUnix,omitempty"` + + // TranscriptURL and TranscriptType are where the publisher says its own transcript is, in + // a format carrying timestamps. Feed-owned, like the enclosure. + TranscriptURL string `json:"transcriptUrl,omitempty"` + TranscriptType string `json:"transcriptType,omitempty"` + + // TranscriptSource says where the words the server actually holds came from: "published" + // for the publisher's own, "asr" for speech recognition over the mirrored bytes. They are + // not interchangeable -- a published transcript is exact but describes the publisher's + // master, and recognition is approximate but describes the file the phone will play. + TranscriptSource string `json:"transcriptSource,omitempty"` + // TranscriptCues is how many cues the stored transcript has, so "does this episode have + // words" is answerable without opening the file they live in. + TranscriptCues int `json:"transcriptCues,omitempty"` + // TranscriptAtUnix is when the transcript landed. + TranscriptAtUnix int64 `json:"transcriptAtUnix,omitempty"` + // TranscriptNote is why a transcript that was offered was not taken -- most often that this + // copy carries inserted advertising the publisher's master does not describe. Kept because + // refused and never-looked-at are different states, and because a refusal here is the + // signal that this episode is one recognition would have to do. + TranscriptNote string `json:"transcriptNote,omitempty"` +} + +// HasTranscript reports whether the server holds words for this episode. +func (e *Episode) HasTranscript() bool { return e.TranscriptCues > 0 } + +// TranscriptExamined reports whether anything has been decided about this episode's words, so a +// refusal is not re-read as an episode nobody has looked at yet. +func (e *Episode) TranscriptExamined() bool { + return e.HasTranscript() || e.TranscriptNote != "" } // Break is one stretch of an episode to skip. @@ -275,6 +305,8 @@ func (s *State) Merge(feedURL, title string, episodes []feed.Episode) *Show { EnclosureURL: parsed.EnclosureURL, PublishedUnix: parsed.PublishedUnix, DeclaredDurationMs: parsed.DeclaredDurationMs, + TranscriptURL: parsed.TranscriptURL, + TranscriptType: parsed.TranscriptType, }) continue } @@ -286,6 +318,8 @@ func (s *State) Merge(feedURL, title string, episodes []feed.Episode) *Show { existing.PublishedUnix = parsed.PublishedUnix } existing.DeclaredDurationMs = parsed.DeclaredDurationMs + existing.TranscriptURL = parsed.TranscriptURL + existing.TranscriptType = parsed.TranscriptType } return show } diff --git a/server/internal/store/store_test.go b/server/internal/store/store_test.go index 0540fbf..61cbb30 100644 --- a/server/internal/store/store_test.go +++ b/server/internal/store/store_test.go @@ -319,3 +319,71 @@ func TestWhatHasBeenExaminedSurvivesARestart(t *testing.T) { t.Fatal("a restart would redo every show") } } + +/* +The feed owns where a transcript is; the server owns whether it took it. + +Same split as every other field here, and for the same reason the duration pair exists: a +refresh must not overwrite what the server measured. A transcript that was refused because this +copy of the episode carries inserted advertising would otherwise look unexamined again on every +poll, and be re-fetched and re-refused for ever. +*/ + +func TestARefreshUpdatesWhereTheTranscriptIsWithoutForgettingTheVerdict(t *testing.T) { + state := New() + state.Merge("https://f", "A Show", []feed.Episode{{ + GUID: "g1", + Title: "One", + EnclosureURL: "https://cdn/1.mp3", + PublishedUnix: 100, + TranscriptURL: "https://cdn/1.vtt", TranscriptType: "text/vtt", + }}) + episode := state.Match("https://f", "g1", "", "") + episode.TranscriptSource = "published" + episode.TranscriptCues = 412 + episode.TranscriptAtUnix = 1_700_000_000 + + // The publisher moves the file, as publishers do. + state.Merge("https://f", "A Show", []feed.Episode{{ + GUID: "g1", + Title: "One", + EnclosureURL: "https://cdn/1.mp3", + PublishedUnix: 100, + TranscriptURL: "https://cdn/1-v2.vtt", TranscriptType: "text/vtt", + }}) + + got := state.Match("https://f", "g1", "", "") + if got.TranscriptURL != "https://cdn/1-v2.vtt" { + t.Errorf("transcript url = %q, want the feed's new one", got.TranscriptURL) + } + if got.TranscriptSource != "published" || got.TranscriptCues != 412 { + t.Errorf("a refresh forgot what the server stored: %+v", got) + } +} + +func TestAnEpisodeKnowsWhetherItHasWords(t *testing.T) { + var e Episode + if e.HasTranscript() { + t.Error("an episode with no cues claims a transcript") + } + e.TranscriptCues = 1 + if !e.HasTranscript() { + t.Error("an episode with cues denies having a transcript") + } +} + +// Refused and never-looked-at are different states, which is the readout mistake this codebase +// has now made twice. +func TestARefusedTranscriptIsNotTheSameAsNoneFound(t *testing.T) { + e := Episode{TranscriptNote: "this copy carries 4:16 of inserted advertising"} + if e.HasTranscript() { + t.Error("a refusal reads as a transcript") + } + if !e.TranscriptExamined() { + t.Error("an episode that was examined and refused reads as never examined") + } + empty := Episode{} + if empty.TranscriptExamined() { + t.Error("an episode nobody has looked at reads as examined") + } +} diff --git a/server/internal/transcript/fits.go b/server/internal/transcript/fits.go new file mode 100644 index 0000000..7597039 --- /dev/null +++ b/server/internal/transcript/fits.go @@ -0,0 +1,73 @@ +package transcript + +import "fmt" + +const ( + // insertionMs is how far a served file may exceed its declared length before the excess is + // read as inserted advertising rather than as the feed being approximate. Fifteen seconds, + // the same unit skips.Classify uses and for the same reason: advertising is sold in units of + // roughly that length, so a smaller gap cannot be a real insertion. + insertionMs = 15_000 + // tailMs is how much of a file may follow the last spoken word before the silence has to be + // explained. Closing music runs to fifteen seconds in this corpus (LOW's "Part 5: Bane" + // ends 15.2s before its file does); the shortest advertisement does not fit in sixty. + tailMs = 60_000 +) + +// Verdict is whether a published transcript may be used for a particular mirrored file, and if +// not, why not. +// +// Why, not just no. A transcript refused because the file carries inserted advertising is a +// different thing from one refused because it describes another recording: the first is an +// episode that needs speech recognition, and the second is a publisher's mistake. +type Verdict struct { + OK bool + Why string +} + +// Fits reports whether a transcript ending at lastCueEndMs describes a mirrored file of +// servedMs, given the declaredMs the feed claims. +// +// The decision is made on the lengths rather than on the words because the lengths are +// independent of the transcript: the feed declares the ad-free programme's length and the file +// measures what was served, so their difference is the advertising inserted into this copy. +// A publisher's transcript always describes the ad-free master, so it aligns exactly when that +// difference is nothing. +func Fits(lastCueEndMs, servedMs, declaredMs int64) Verdict { + if lastCueEndMs <= 0 { + return Verdict{Why: "the transcript has no cues"} + } + if lastCueEndMs > servedMs { + return Verdict{Why: fmt.Sprintf( + "the transcript runs %s past the end of the file, so it describes another recording", + clock(lastCueEndMs-servedMs))} + } + if declaredMs > 0 { + if gap := servedMs - declaredMs; gap > insertionMs { + return Verdict{Why: fmt.Sprintf( + "this copy carries %s of inserted advertising the transcript does not describe", + clock(gap))} + } + return Verdict{OK: true} + } + // No declared length to compare against, so the transcript answers for its own gap. + if gap := servedMs - lastCueEndMs; gap > tailMs { + return Verdict{Why: fmt.Sprintf( + "the transcript stops %s before the file does, and the feed declares no length", + clock(gap))} + } + return Verdict{OK: true} +} + +// LastCueEndMs is where a transcript stops describing the audio. +func LastCueEndMs(cues []Cue) int64 { + if len(cues) == 0 { + return 0 + } + return cues[len(cues)-1].EndMs +} + +func clock(ms int64) string { + s := ms / 1000 + return fmt.Sprintf("%d:%02d", s/60, s%60) +} diff --git a/server/internal/transcript/fits_test.go b/server/internal/transcript/fits_test.go new file mode 100644 index 0000000..4df5c5a --- /dev/null +++ b/server/internal/transcript/fits_test.go @@ -0,0 +1,94 @@ +package transcript + +import "testing" + +/* +Whether a publisher's transcript describes the file the server actually mirrored. + +Measured, not assumed, across the four mirrored shows in Peter's library that publish SRT or +WebVTT (2026-10-28), comparing each transcript's last cue against the declared length and the +length the mirrored file measures: + + 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 pattern is exact. A published transcript describes the publisher's master, which is the +ad-free programme -- so it aligns precisely when the show does not use dynamic ad insertion, and +is useless precisely when it does, which is the case where a transcript would be worth most. The +declared-versus-served gap says which case an episode is in without reading the transcript at +all, and it is the same figure the ad scan already validates itself against. + +A transcript refused here is not wasted: its text is the ad-free programme, and the served file +is that programme plus advertising, so the two together say where the advertising is. That needs +speech recognition over the served bytes to compare against, which is a separate piece of work. +*/ + +func TestATranscriptFitsAnEpisodeWithNoInsertedAdvertising(t *testing.T) { + // Wards Way: served and declared agree, so the master is what was mirrored. + got := Fits(1_213_400, 1_217_000, 1_216_000) + if !got.OK { + t.Errorf("refused a transcript for an episode with no insertion: %s", got.Why) + } +} + +func TestATranscriptIsRefusedForAnEpisodeCarryingInsertedAdvertising(t *testing.T) { + // Darknet Diaries "Conti": 4:16 of advertising the transcript knows nothing about. + got := Fits(3_544_300, 3_813_000, 3_557_000) + if got.OK { + t.Error("accepted an ad-free transcript for an ad-inserted file") + } + if got.Why == "" { + t.Error("a refusal has to say why -- it is the signal that this show needs recognition") + } +} + +// LOW's Part 5 ends 15 seconds before the file does, which is closing music, not an ad break. +func TestTrailingMusicDoesNotRefuseATranscript(t *testing.T) { + got := Fits(2_807_300, 2_822_500, 2_822_000) + if !got.OK { + t.Errorf("refused over %s of trailing music: %s", "15s", got.Why) + } +} + +// A transcript running past the end of the file describes a different recording -- a publisher +// pointing at the wrong episode, or a master that was later trimmed. +func TestATranscriptLongerThanTheFileIsRefused(t *testing.T) { + got := Fits(1_300_000, 1_217_000, 1_216_000) + if got.OK { + t.Error("accepted a transcript that outlasts the audio") + } +} + +/* +Without a declared length there is nothing to compare the file against, so the transcript itself +has to answer for the gap. A whole ad break's worth of silence at the end of a file is not +closing music. +*/ +func TestWithNoDeclaredLengthTheTranscriptsOwnGapDecides(t *testing.T) { + if got := Fits(1_213_400, 1_217_000, 0); !got.OK { + t.Errorf("refused a transcript that reaches the end of the file: %s", got.Why) + } + if got := Fits(3_544_300, 3_813_000, 0); got.OK { + t.Error("accepted a transcript ending 4:29 before the file does") + } +} + +func TestATranscriptWithNoCuesFitsNothing(t *testing.T) { + if got := Fits(0, 1_217_000, 1_216_000); got.OK { + t.Error("accepted an empty transcript") + } +} + +// The published length is what Fits judges, so it has to come from the cues rather than from a +// caller's arithmetic. +func TestLastCueEndIsTheEndOfTheLastCue(t *testing.T) { + cues := []Cue{{StartMs: 0, EndMs: 1000}, {StartMs: 2000, EndMs: 3120}} + if got := LastCueEndMs(cues); got != 3120 { + t.Errorf("got %d, want 3120", got) + } + if got := LastCueEndMs(nil); got != 0 { + t.Errorf("got %d, want 0", got) + } +} diff --git a/server/internal/transcript/harvest.go b/server/internal/transcript/harvest.go new file mode 100644 index 0000000..ed149e3 --- /dev/null +++ b/server/internal/transcript/harvest.go @@ -0,0 +1,101 @@ +package transcript + +import ( + "context" + "fmt" + "io" + "net/http" + "time" +) + +// maxTranscriptBytes caps a download. The largest in this corpus is Darknet Diaries' 1784-cue +// SRT at well under a megabyte; eight is room for an outlier and a refusal for a mistake. +const maxTranscriptBytes = 8 << 20 + +// Offer is a publisher's transcript and the file it would have to describe. +type Offer struct { + EpisodeID string + URL string + Type string + // ServedMs is the mirrored file's measured length, DeclaredMs the feed's claim. Their + // difference is the advertising inserted into this copy, which is what decides whether the + // publisher's master is what was mirrored. + ServedMs int64 + DeclaredMs int64 +} + +// Result is what came of an offer: a transcript stored, or a recorded reason it was not. +type Result struct { + Stored bool + Cues int + // Note is why nothing was stored, and is empty when something was. It is a verdict about + // this copy of the episode, so it is recorded rather than retried -- and for an episode + // carrying inserted advertising it is also the signal that this is one recognition has to do. + Note string +} + +// Harvester takes publishers' transcripts, when they describe the file the server mirrored. +type Harvester struct { + Store Store + Client *http.Client +} + +func (h Harvester) client() *http.Client { + if h.Client != nil { + return h.Client + } + return &http.Client{Timeout: 60 * time.Second} +} + +// Harvest fetches an offered transcript and stores it if it describes the mirrored file. +// +// An error means try again -- the download failed, so nothing has been decided. A Result with a +// Note means do not try again: the transcript arrived and was judged. +func (h Harvester) Harvest(ctx context.Context, offer Offer) (Result, error) { + if offer.URL == "" { + return Result{}, nil + } + + body, err := h.download(ctx, offer.URL) + if err != nil { + return Result{}, err + } + + cues, err := Parse(offer.Type, body) + if err != nil { + // The feed said this URL is the transcript and it is not one. Nothing about fetching it + // again changes that, so it is a verdict. + return Result{Note: err.Error()}, nil + } + + if verdict := Fits(LastCueEndMs(cues), offer.ServedMs, offer.DeclaredMs); !verdict.OK { + return Result{Note: verdict.Why}, nil + } + + stored := Transcript{EpisodeID: offer.EpisodeID, Source: SourcePublished, Cues: cues} + if err := h.Store.Put(stored); err != nil { + return Result{}, err + } + return Result{Stored: true, Cues: len(cues)}, nil +} + +func (h Harvester) download(ctx context.Context, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("transcript: %w", err) + } + req.Header.Set("User-Agent", "listend/1.0") + resp, err := h.client().Do(req) + if err != nil { + return "", fmt.Errorf("transcript: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("transcript: %s returned %s", url, resp.Status) + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxTranscriptBytes)) + if err != nil { + return "", fmt.Errorf("transcript: %w", err) + } + return string(raw), nil +} diff --git a/server/internal/transcript/harvest_test.go b/server/internal/transcript/harvest_test.go new file mode 100644 index 0000000..ec55681 --- /dev/null +++ b/server/internal/transcript/harvest_test.go @@ -0,0 +1,145 @@ +package transcript + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +/* +Taking a publisher's transcript, or declining to. + +The decision is the point. A published transcript is free and exact, and it describes the +publisher's master -- so for an episode whose served copy carries inserted advertising it is +minutes out of step with the audio the phone will play, and using it would put the app's +follow-along and any break placed from it in the wrong place. + +A refusal is recorded rather than retried. It is a judgement about this copy of the episode, not +a transient failure, and an episode re-fetched and re-refused on every poll would be work done +for ever to reach the same answer. +*/ + +const harvestSRT = `1 +00:00:00,000 --> 00:00:03,120 +Welcome back. + +2 +00:20:10,000 --> 00:20:16,000 +That's all for this week. +` + +func serving(t *testing.T, status int, body, contentType string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + return server +} + +func TestHarvestStoresAPublishedTranscriptThatFitsTheFile(t *testing.T) { + server := serving(t, 200, harvestSRT, "application/srt") + store := Store{Dir: t.TempDir()} + harvester := Harvester{Store: store} + + got, err := harvester.Harvest(context.Background(), Offer{ + EpisodeID: "ep1", + URL: server.URL, + Type: "application/srt", + ServedMs: 1_217_000, + DeclaredMs: 1_216_000, + }) + if err != nil { + t.Fatalf("Harvest: %v", err) + } + if !got.Stored || got.Cues != 2 { + t.Fatalf("got %+v, want it stored with 2 cues", got) + } + held, err := store.Get("ep1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if held.Source != SourcePublished { + t.Errorf("source = %q, want %q", held.Source, SourcePublished) + } +} + +func TestHarvestDeclinesATranscriptForAnAdInsertedCopy(t *testing.T) { + server := serving(t, 200, harvestSRT, "application/srt") + store := Store{Dir: t.TempDir()} + harvester := Harvester{Store: store} + + // Darknet Diaries' shape: the file is minutes longer than the feed declares. + got, err := harvester.Harvest(context.Background(), Offer{ + EpisodeID: "ep1", + URL: server.URL, + Type: "application/srt", + ServedMs: 1_480_000, + DeclaredMs: 1_216_000, + }) + if err != nil { + t.Fatalf("Harvest returned an error for a decision: %v", err) + } + if got.Stored { + t.Error("stored an ad-free transcript against an ad-inserted file") + } + if !strings.Contains(got.Note, "advertising") { + t.Errorf("note = %q, want it to say why", got.Note) + } + if store.Has("ep1") { + t.Error("a declined transcript was written anyway") + } +} + +// A 404 is the publisher's file having moved, and the next poll gets the feed's new URL. That +// is worth another attempt; a refusal is not. +func TestAFailedDownloadIsAnErrorAndNotAVerdict(t *testing.T) { + server := serving(t, 404, "nope", "text/plain") + harvester := Harvester{Store: Store{Dir: t.TempDir()}} + + got, err := harvester.Harvest(context.Background(), Offer{ + EpisodeID: "ep1", URL: server.URL, Type: "application/srt", + ServedMs: 1_217_000, DeclaredMs: 1_216_000, + }) + if err == nil { + t.Fatal("a 404 returned no error, so it would be recorded as a decision") + } + if got.Note != "" { + t.Errorf("note = %q, want nothing -- this is not a verdict", got.Note) + } +} + +// A publisher that serves HTML from a URL it labelled as SRT has given us nothing usable, and +// that is a verdict: the feed says this is the transcript, and it is not one. +func TestSomethingThatIsNotCuesIsDeclinedNotRetried(t *testing.T) { + server := serving(t, 200, "<html><body>Transcript coming soon</body></html>", "text/html") + harvester := Harvester{Store: Store{Dir: t.TempDir()}} + + got, err := harvester.Harvest(context.Background(), Offer{ + EpisodeID: "ep1", URL: server.URL, Type: "application/srt", + ServedMs: 1_217_000, DeclaredMs: 1_216_000, + }) + if err != nil { + t.Fatalf("Harvest: %v", err) + } + if got.Stored || got.Note == "" { + t.Errorf("got %+v, want a recorded refusal", got) + } +} + +func TestAnEpisodeOfferingNoTranscriptIsNotFetched(t *testing.T) { + harvester := Harvester{Store: Store{Dir: t.TempDir()}} + got, err := harvester.Harvest(context.Background(), Offer{EpisodeID: "ep1", ServedMs: 1_000}) + if err != nil { + t.Fatalf("Harvest: %v", err) + } + if got.Stored || got.Note != "" { + t.Errorf("got %+v, want nothing done and nothing said", got) + } +} diff --git a/server/internal/transcript/parse.go b/server/internal/transcript/parse.go new file mode 100644 index 0000000..e5c39cb --- /dev/null +++ b/server/internal/transcript/parse.go @@ -0,0 +1,138 @@ +// Package transcript reads an episode's words with the times they were said at, from whatever +// shape the words arrived in. +// +// Two sources, and they are not interchangeable. A publisher's own transcript is exact and free +// but describes the publisher's master -- which for a show using dynamic ad insertion is the +// ad-free programme, minutes shorter than the copy the server mirrored. Speech recognition over +// the mirrored bytes is slower and approximate but describes the audio the phone will actually +// play. [Fits] is what decides whether a published transcript may be used for a given file. +package transcript + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// Cue is a run of speech and when it was said, in milliseconds from the start of the file. +type Cue struct { + StartMs int64 `json:"startMs"` + EndMs int64 `json:"endMs"` + Text string `json:"text"` +} + +// Parse reads cues from a published transcript of the declared MIME type. +// +// The types in the wild disagree with each other about the same format: application/srt and +// application/x-subrip are both SRT. text/plain, text/html and application/json carry no +// timestamps in any publisher's usage seen here, so they are refused rather than stored -- a +// transcript with no times can neither be seeked to nor used to place a break, and accepting +// one would mean the episode never gets another attempt. +func Parse(mimeType, body string) ([]Cue, error) { + var ( + cues []Cue + err error + ) + switch normaliseType(mimeType) { + case "application/srt", "application/x-subrip", "text/srt": + cues, err = ParseSRT(body) + case "text/vtt", "text/webvtt": + cues, err = ParseVTT(body) + default: + return nil, fmt.Errorf("transcript: %q carries no timestamps", mimeType) + } + if err != nil { + return nil, err + } + if len(cues) == 0 { + return nil, fmt.Errorf("transcript: %q parsed to no cues", mimeType) + } + return cues, nil +} + +func normaliseType(mimeType string) string { + t := strings.ToLower(strings.TrimSpace(mimeType)) + if i := strings.IndexByte(t, ';'); i >= 0 { + t = strings.TrimSpace(t[:i]) + } + return t +} + +// timing matches both shapes of cue timing line: SRT's comma and WebVTT's full stop, with the +// hours field optional as WebVTT allows. +var timing = regexp.MustCompile( + `^(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{1,3})\s*-->\s*(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{1,3})`, +) + +// markup strips WebVTT's cue spans -- <v Speaker>, <i>, <00:00:01.000> -- which are there to be +// displayed, not to be read as something somebody said. +var markup = regexp.MustCompile(`</?[^>]*>`) + +// ParseSRT reads SubRip cues. The cue numbers are ignored: they are an index, and a publisher +// that misnumbers them has still said when the words were spoken. +func ParseSRT(body string) ([]Cue, error) { return parseCues(body) } + +// ParseVTT reads WebVTT cues, skipping the header, NOTE and STYLE blocks and cue identifiers. +// +// Everything that is not a timing line is either furniture or the text of the cue that a timing +// line just opened, which is why one reader serves both formats. +func ParseVTT(body string) ([]Cue, error) { return parseCues(body) } + +func parseCues(body string) ([]Cue, error) { + var ( + cues []Cue + current *Cue + text []string + ) + flush := func() { + if current == nil { + return + } + current.Text = strings.TrimSpace(strings.Join(text, " ")) + if current.Text != "" { + cues = append(cues, *current) + } + current, text = nil, nil + } + + for _, line := range strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") { + trimmed := strings.TrimSpace(line) + if m := timing.FindStringSubmatch(trimmed); m != nil { + flush() + current = &Cue{StartMs: millis(m[1], m[2], m[3], m[4]), EndMs: millis(m[5], m[6], m[7], m[8])} + continue + } + if trimmed == "" { + flush() + continue + } + if current == nil { + // A cue number, a cue identifier, the WEBVTT header, or a NOTE or STYLE block -- + // all of it precedes a timing line rather than following one. + continue + } + text = append(text, strings.TrimSpace(markup.ReplaceAllString(trimmed, ""))) + } + flush() + return cues, nil +} + +func millis(hours, minutes, seconds, fraction string) int64 { + h, _ := strconv.ParseInt(emptyAsZero(hours), 10, 64) + m, _ := strconv.ParseInt(minutes, 10, 64) + s, _ := strconv.ParseInt(seconds, 10, 64) + // A fraction of fewer than three digits is tenths or hundredths, not milliseconds. + f, _ := strconv.ParseInt(fraction, 10, 64) + for i := len(fraction); i < 3; i++ { + f *= 10 + } + return ((h*60+m)*60+s)*1000 + f +} + +func emptyAsZero(s string) string { + if s == "" { + return "0" + } + return s +} diff --git a/server/internal/transcript/parse_test.go b/server/internal/transcript/parse_test.go new file mode 100644 index 0000000..4dcbc9a --- /dev/null +++ b/server/internal/transcript/parse_test.go @@ -0,0 +1,158 @@ +package transcript + +import "testing" + +/* +Reading a transcript a publisher supplied. + +Eleven of Peter's 44 feeds carry <podcast:transcript>, in SRT or WebVTT. Both are cue lists with +a start, an end and some text, and the differences between them are entirely in the furniture: +SRT numbers its cues and separates the timestamp fields with a comma, WebVTT writes a header, +allows a cue identifier, uses a full stop, and may carry styling blocks that are not cues at all. + +Parsed rather than passed through because the app seeks to a cue and the detector reads the +words, and neither wants to know which of the two shapes a given publisher chose. +*/ + +const sampleSRT = `1 +00:00:00,000 --> 00:00:03,120 +Welcome back to the show. + +2 +00:00:03,500 --> 00:00:07,000 +This episode is brought to you +by a mattress company. + +3 +01:02:03,400 --> 01:02:08,000 +And we're back. +` + +func TestSRTCuesCarryTheirTimesAndText(t *testing.T) { + got, err := ParseSRT(sampleSRT) + if err != nil { + t.Fatalf("ParseSRT: %v", err) + } + if len(got) != 3 { + t.Fatalf("got %d cues, want 3", len(got)) + } + if got[0] != (Cue{StartMs: 0, EndMs: 3120, Text: "Welcome back to the show."}) { + t.Errorf("first cue = %+v", got[0]) + } +} + +// A cue's text is routinely wrapped across lines for display. The words are one sentence. +func TestAWrappedCueIsOneRunOfText(t *testing.T) { + got, err := ParseSRT(sampleSRT) + if err != nil { + t.Fatalf("ParseSRT: %v", err) + } + want := "This episode is brought to you by a mattress company." + if got[1].Text != want { + t.Errorf("got %q, want %q", got[1].Text, want) + } +} + +func TestSRTReadsHoursCorrectly(t *testing.T) { + got, err := ParseSRT(sampleSRT) + if err != nil { + t.Fatalf("ParseSRT: %v", err) + } + if got[2].StartMs != 3_723_400 { + t.Errorf("got %d ms, want 3723400 -- 01:02:03.400", got[2].StartMs) + } +} + +const sampleVTT = `WEBVTT + +NOTE this file was generated by a robot + +STYLE +::cue { color: peachpuff } + +intro-1 +00:00.000 --> 00:03.120 +Welcome back to the show. + +00:01:03.500 --> 00:01:07.000 +<v Speaker>And we're back.</v> +` + +// WebVTT's header, notes and styling blocks are not cues, and a cue may be named. +func TestVTTSkipsEverythingThatIsNotACue(t *testing.T) { + got, err := ParseVTT(sampleVTT) + if err != nil { + t.Fatalf("ParseVTT: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d cues, want 2: %+v", len(got), got) + } + if got[0].Text != "Welcome back to the show." { + t.Errorf("first cue text = %q", got[0].Text) + } +} + +// WebVTT allows mm:ss.mmm as well as hh:mm:ss.mmm, and the app seeks to these numbers. +func TestVTTAcceptsATimestampWithNoHours(t *testing.T) { + got, err := ParseVTT(sampleVTT) + if err != nil { + t.Fatalf("ParseVTT: %v", err) + } + if got[0].EndMs != 3120 { + t.Errorf("got %d ms, want 3120 -- 00:03.120", got[0].EndMs) + } + if got[1].StartMs != 63_500 { + t.Errorf("got %d ms, want 63500 -- 00:01:03.500", got[1].StartMs) + } +} + +// Speaker tags are display markup, not words anybody said. +func TestVTTStripsCueMarkup(t *testing.T) { + got, err := ParseVTT(sampleVTT) + if err != nil { + t.Fatalf("ParseVTT: %v", err) + } + if got[1].Text != "And we're back." { + t.Errorf("got %q", got[1].Text) + } +} + +// A publisher's URL says which shape it is, and the types in the wild are not consistent: +// application/srt and application/x-subrip both appear, for the same format. +func TestParseChoosesByDeclaredType(t *testing.T) { + for _, mime := range []string{"application/srt", "application/x-subrip"} { + got, err := Parse(mime, sampleSRT) + if err != nil { + t.Fatalf("%s: %v", mime, err) + } + if len(got) != 3 { + t.Errorf("%s: got %d cues, want 3", mime, len(got)) + } + } + got, err := Parse("text/vtt", sampleVTT) + if err != nil { + t.Fatalf("text/vtt: %v", err) + } + if len(got) != 2 { + t.Errorf("text/vtt: got %d cues, want 2", len(got)) + } +} + +// text/plain and text/html carry no timestamps, so there is nothing to seek to and nothing to +// place an ad break at. Refusing them is the honest answer; taking them would produce a +// transcript that silently could not do either job. +func TestParseRefusesAFormatWithNoTimestamps(t *testing.T) { + for _, mime := range []string{"text/plain", "text/html", "application/json"} { + if _, err := Parse(mime, sampleSRT); err == nil { + t.Errorf("%s: parsed without error, want a refusal", mime) + } + } +} + +// A truncated or empty download is not a transcript, and storing it would mean the episode +// never gets another attempt. +func TestParseRefusesSomethingWithNoCues(t *testing.T) { + if _, err := Parse("text/vtt", "WEBVTT\n\n"); err == nil { + t.Error("parsed an empty file without error") + } +} diff --git a/server/internal/transcript/store.go b/server/internal/transcript/store.go new file mode 100644 index 0000000..d7e86a4 --- /dev/null +++ b/server/internal/transcript/store.go @@ -0,0 +1,131 @@ +package transcript + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Source says where a transcript's words came from. The two are not interchangeable, which is +// why every stored transcript carries it. +type Source string + +const ( + // SourcePublished is the publisher's own transcript: exact words, but of the publisher's + // master, so it is only used where that master is what was mirrored. + SourcePublished Source = "published" + // SourceASR is speech recognition over the mirrored bytes: approximate words, of exactly + // the audio the phone will play. + SourceASR Source = "asr" +) + +// Transcript is one episode's words and when they were said. +type Transcript struct { + EpisodeID string `json:"episodeId"` + Source Source `json:"source"` + // LanguageCode is empty when nobody said. Recognition reports what it detected. + LanguageCode string `json:"languageCode,omitempty"` + Cues []Cue `json:"cues"` +} + +// LastEndMs is where the words stop. +func (t Transcript) LastEndMs() int64 { return LastCueEndMs(t.Cues) } + +// Store holds transcripts as one file per episode, beside the audio rather than inside the +// state file. +// +// state.json is the only record of where mirrored audio is and is rewritten atomically whenever +// anything changes -- a label, a poll, a detection result. A transcript runs to well over a +// thousand cues, so folding 342 of them into that file would mean megabytes rewritten for every +// tap the listener makes. +type Store struct{ Dir string } + +func (s Store) path(episodeID string) (string, error) { + // The id decides a filename, and it arrives from the state file, so it is checked rather + // than trusted: anything with a separator or a dot segment in it could write elsewhere. + if episodeID == "" || + strings.ContainsAny(episodeID, `/\`) || + episodeID == "." || episodeID == ".." { + return "", fmt.Errorf("transcript: %q is not an episode id", episodeID) + } + return filepath.Join(s.Dir, episodeID+".json"), nil +} + +// Put writes an episode's transcript, replacing any earlier one. +func (s Store) Put(t Transcript) error { + if len(t.Cues) == 0 { + return fmt.Errorf("transcript: %s has no cues", t.EpisodeID) + } + name, err := s.path(t.EpisodeID) + if err != nil { + return err + } + if err := os.MkdirAll(s.Dir, 0o755); err != nil { + return fmt.Errorf("transcript: %w", err) + } + raw, err := json.Marshal(t) + if err != nil { + return fmt.Errorf("transcript: %w", err) + } + // Written through a temporary file and renamed, the same way the state file is: a + // half-written transcript would read as a short one rather than as a failure. + tmp, err := os.CreateTemp(s.Dir, ".transcript-*.tmp") + if err != nil { + return fmt.Errorf("transcript: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(raw); err != nil { + tmp.Close() + return fmt.Errorf("transcript: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("transcript: %w", err) + } + if err := os.Rename(tmpName, name); err != nil { + return fmt.Errorf("transcript: %w", err) + } + return nil +} + +// Get reads an episode's transcript. +func (s Store) Get(episodeID string) (Transcript, error) { + name, err := s.path(episodeID) + if err != nil { + return Transcript{}, err + } + raw, err := os.ReadFile(name) + if err != nil { + return Transcript{}, fmt.Errorf("transcript: %w", err) + } + var t Transcript + if err := json.Unmarshal(raw, &t); err != nil { + return Transcript{}, fmt.Errorf("transcript %s: %w", episodeID, err) + } + return t, nil +} + +// Has reports whether an episode's transcript is on disk. +func (s Store) Has(episodeID string) bool { + name, err := s.path(episodeID) + if err != nil { + return false + } + _, err = os.Stat(name) + return err == nil +} + +// Prune removes an episode's transcript, which is what has to happen when its audio is +// reclaimed. Removing one that is already gone is a retry, not a failure. +func (s Store) Prune(episodeID string) error { + name, err := s.path(episodeID) + if err != nil { + return err + } + if err := os.Remove(name); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("transcript: %w", err) + } + return nil +} diff --git a/server/internal/transcript/store_test.go b/server/internal/transcript/store_test.go new file mode 100644 index 0000000..5b29d14 --- /dev/null +++ b/server/internal/transcript/store_test.go @@ -0,0 +1,101 @@ +package transcript + +import ( + "path/filepath" + "testing" +) + +/* +Where an episode's words live. + +Beside the audio rather than in state.json, which is the one file holding the only record of +where mirrored audio is. A transcript runs to well over a thousand cues -- Darknet Diaries' +"Conti" has 1784 -- and 342 mirrored episodes of that would turn a file that must be written +atomically on every label into megabytes rewritten for every change. +*/ + +func TestATranscriptSurvivesARoundTrip(t *testing.T) { + dir := t.TempDir() + store := Store{Dir: dir} + want := Transcript{ + EpisodeID: "abc123", + Source: SourcePublished, + Cues: []Cue{ + {StartMs: 0, EndMs: 3120, Text: "Welcome back to the show."}, + {StartMs: 3500, EndMs: 7000, Text: "This episode is brought to you by a mattress."}, + }, + } + if err := store.Put(want); err != nil { + t.Fatalf("Put: %v", err) + } + + got, err := store.Get("abc123") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Source != SourcePublished || len(got.Cues) != 2 || got.Cues[1].Text != want.Cues[1].Text { + t.Errorf("got %+v", got) + } +} + +func TestAnEpisodeWithNoTranscriptReportsThatPlainly(t *testing.T) { + store := Store{Dir: t.TempDir()} + if _, err := store.Get("nothing-here"); err == nil { + t.Error("read a transcript that was never written") + } + if store.Has("nothing-here") { + t.Error("claims to have a transcript it never wrote") + } +} + +// An id reaches this from the state file, so it decides a path. A traversal in one would write +// outside the data directory. +func TestAnIdCannotEscapeTheTranscriptDirectory(t *testing.T) { + store := Store{Dir: t.TempDir()} + for _, id := range []string{"../escape", "a/b", "", "."} { + if err := store.Put(Transcript{EpisodeID: id, Cues: []Cue{{Text: "x"}}}); err == nil { + t.Errorf("accepted %q as an episode id", id) + } + } +} + +// Pruning an episode's audio has to take its words with it, or the directory grows for ever +// with transcripts of files nobody holds any more. +func TestPruneRemovesATranscript(t *testing.T) { + dir := t.TempDir() + store := Store{Dir: dir} + if err := store.Put(Transcript{EpisodeID: "gone", Cues: []Cue{{Text: "x"}}}); err != nil { + t.Fatalf("Put: %v", err) + } + if err := store.Prune("gone"); err != nil { + t.Fatalf("Prune: %v", err) + } + if store.Has("gone") { + t.Error("the transcript outlived the episode") + } + // Pruning something already gone is what a retry looks like, not a failure. + if err := store.Prune("gone"); err != nil { + t.Errorf("Prune on a missing transcript: %v", err) + } +} + +func TestPutRefusesATranscriptWithNoCues(t *testing.T) { + store := Store{Dir: t.TempDir()} + if err := store.Put(Transcript{EpisodeID: "empty"}); err == nil { + t.Error("wrote a transcript with nothing in it") + } +} + +func TestTheFileIsNamedForTheEpisode(t *testing.T) { + dir := t.TempDir() + store := Store{Dir: dir} + if err := store.Put(Transcript{EpisodeID: "abc123", Cues: []Cue{{Text: "x"}}}); err != nil { + t.Fatalf("Put: %v", err) + } + if _, err := filepath.Glob(filepath.Join(dir, "abc123.json")); err != nil { + t.Fatalf("glob: %v", err) + } + if !store.Has("abc123") { + t.Error("Has disagrees with Put") + } +} |
