feat: Podcasting 2.0 transcript support - #2181
Merged
Merged
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… to English Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…db level The upsert repository method only guaranteed one generated transcript per episode via app-level SELECT-then-INSERT; NULL original_url values don't collide under the existing UNIQUE(episode_id, original_url) index, so two concurrent upserts could race past the check. Add a partial unique index scoped to source='generated' as the real DB-level backstop, wrap the upsert body in a transaction, and add a test that bypasses the repo to insert a duplicate generated row directly and asserts the DB rejects it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements search() for DieselPodcastEpisodeTranscriptRepository via diesel::sql_query, branching on the DBType enum the same way run_migrations() does. SQLite uses the transcript_segments_fts virtual table (highlight()/bm25()) with a small pure sanitizer that turns free text into quoted prefix terms so user input can never break FTS5 MATCH syntax. Postgres uses websearch_to_tsquery/ts_headline/ts_rank against the generated text_search tsvector column. Both paths share one QueryableByName row struct, mapped into TranscriptSearchHit.
…gher-is-better Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the crates/podfetch-web/src/services/transcript module with a hand-written TranscriptFormat::detect/preference_rank and parse() that turns raw JSON/WebVTT/SRT/HTML bytes into TranscriptSegment lists, per the Podcasting-2.0 transcript formats. No subtitle-parsing crate is added (declared plan deviation); HTML uses conservative regex-based tag handling since no scraper/html5ever/kuchiki dependency exists yet.
Replace plain arithmetic with checked_mul/checked_add chains in parse_timestamp() and parse_html_time() to prevent overflow panic on adversarial timestamps. Transcripts are remote/attacker-supplied content, so the module's contract (never panic) must be maintained. Overflow now correctly returns None, matching the existing malformed-time handling: - VTT/SRT: cue is skipped - HTML: start_ms is set to None Add 3 tests for overflow cases: 18-digit hour value in VTT, 99999999999:00:00 in HTML, and timestamp above i32::MAX ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ference logic Implements TranscriptService: Flow 1 feed-tag upserts, Flow 2 download + archive + parse of pending transcripts, preference recomputation (feed beats generated, format rank JSON>VTT>SRT>HTML), grouped full-text search (max 3 hits/episode), reparse_all, and needs_generated_transcript. Wired into AppState alongside the other per-feature services. Also adds PodcastEpisodeTranscriptRepository::get_all (domain + Diesel impl + adapter), a small necessary addition since reparse_all needs to walk every transcript row and no such method existed yet.
recompute_preferred now looks up the episode's podcast via PodcastService::get_podcast_by_episode_id and, among otherwise-tied feed transcripts (same source, same format rank), prefers the one whose language matches the podcast's language (compared on the primary BCP-47 subtag, case-insensitively). The lookup is best-effort: any failure or missing language on either side simply drops the tie-break, so preference recomputation stays non-fatal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds extract_transcript_tags(item: &rss::Item) -> Vec<FeedTranscriptTag>, reading url/type/language off <podcast:transcript> extensions (checking both the "transcript" and "podcast:transcript" map keys, since the rss crate's normalization is not guaranteed across feed declarations). Tags without a url are skipped; type defaults to text/plain. Wires this into insert_podcast_episodes' item loop via sync_transcript_tags_for_episode, called on both the existing-episode update path and the new-episode insert path once the episode row exists, forwarding into TranscriptService::upsert_from_feed (Task 6). Errors are only logged (tracing::error!) and never propagate, since feed refresh must not fail because of transcript bookkeeping. No HTTP fetch happens here - that is the download hook (Task 8). Adds a service-level idempotence test verifying that re-syncing the same feed tags never creates duplicate rows and never resets an already-parsed transcript back to pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… episode file process_pending_for_episode required episode.file_episode_path, but on a first-time download the in-memory entity never carries that path (it's only persisted to the DB afterwards), so every pending transcript was permanently marked failed instead of retried. Add process_pending_after_download, which derives the archive path from the just-written audio file path instead, and call it from the download hook. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds TRANSCRIPTION_API_BASE_URL/TRANSCRIPTION_API_KEY/TRANSCRIPTION_MODEL env vars and a TranscriptionConfig on EnvironmentService (None unless the base URL is set, trailing slash stripped, model defaults to whisper-1), plus a transcriptionEnabled flag on the /sys/config DTO.
Adds WhisperClient, a blocking reqwest client that POSTs a local audio
file as multipart/form-data to {base_url}/v1/audio/transcriptions
(OpenAI-compatible verbose_json), converting seconds to millisecond
TranscriptSegments and returning the detected language. Authorization:
Bearer is only sent when api_key is configured. Also adds
segments_to_vtt for archiving generated transcripts as WebVTT.
Enables the reqwest "multipart" workspace feature required to build
the multipart form.
…nqueue Wires the Whisper client (Task 10) into a DB-backed job queue: a tokio background worker drains transcription_jobs one at a time via process_one_job (spawn_blocking, since transcribe() is a blocking HTTP call), retrying up to 3 attempts before marking a job failed, and broadcasting each status change over SocketIO (transcriptionStatus). The worker resets stuck 'running' jobs to 'pending' once at startup and only starts when a transcription backend is configured. Also enqueues a generation job right after an episode download when the podcast has auto_transcribe enabled and no usable transcript is already in flight, mirroring the existing feed-transcript hook.
Exposes the transcript backend stack over /api/v1: listing an episode's transcripts, fetching the preferred one with segments, streaming a transcript's archived file (session or apiKey-in-path auth), enqueueing a generated-transcript job, full-text search grouped by episode, and an admin-only reparse-all action. Adds two thin TranscriptService getters (get_by_episode_id, get_by_id) needed to serve individual transcripts over HTTP. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion auth
The GET .../transcripts/{tid}/file/apiKey/{api_key} route was merged into
get_transcript_router(), which sits inside get_private_api() behind the
session-auth middleware (Basic/OIDC/proxy). That defeats its purpose: it
authenticates via the {api_key} path segment (checked inside the handler),
so external podcast clients consuming the generated feed (Task 13 embeds
this URL in <podcast:transcript>) must be able to hit it with no login
session at all, exactly like proxy_podcast_with_path_api_key.
Move its registration into startup::config(), alongside the other public
/api/v1 routes (get_invite, onboard_user, get_public_config, login), which
sit before the get_private_api() merge and are therefore never wrapped by
the auth layer. The handler's own apiKey validation is unchanged.
Tests updated to call .clear_headers() so they prove the route works
with zero auth headers (not just alongside the test server's default
Basic-Auth header), and renamed to make that intent explicit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Regenerates ui/schema.d.ts from the current OpenAPI spec (adds the transcript endpoints and transcriptionEnabled flag) and aligns the local PodcastSetting model with fields the schema now marks as optional. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…page Search groups now carry the full episode DTO so the UI can render episode cards and start playback without extra requests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…setting to ui The transcript list endpoint now folds the transcription job state in as a virtual generated entry so status badges survive reloads, and the 409 on duplicate enqueue carries a translatable ApiError code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…and allow retrying failed jobs
Found in end-to-end verification:
- reqwest::blocking::Client construction/drop inside the async worker
task panicked ('Cannot drop a runtime...') and silently killed the
worker at startup; the whole claim-transcribe-record loop now runs on
a single spawn_blocking thread that owns the client exclusively.
- enqueue() rejected any existing job row, so a failed job blocked an
episode's transcription forever; failed jobs are now reset in place.
- search tests seeded reused terms into the shared on-disk test DB and
accumulated across runs until fresh hits fell off the first page.
Also adds transcript feature docs (docs/src/transcripts.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found in end-to-end verification: the generated VTT was written to the same <stem>.transcript.vtt path as the archived feed transcript, silently overwriting the original that the file endpoint and generated RSS feeds keep serving. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Playwright drives the real sqlite server (fresh DB per run, built UI from ./static) plus a fixture feed server and a mock whisper API, all managed via playwright's webServer array. Covers the player transcript tab incl. click-to-seek, transcript full-text search, the transcribe action's status badge lifecycle and the auto-transcribe setting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Transcript suite now covers active-segment highlighting, auto-scroll, the no-transcript hint, search edge cases and XSS-escaping of snippets, click-to-play from search hits, RSS re-export, the whisper failure + retry path (controllable mock) and the full auto-transcribe-after- download flow with server-side settings persistence. New app suite covers podcast list/detail navigation, adding and deleting podcasts, player controls and metadata search. Fixture feed gained a second podcast and runtime-publishable episodes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous .last() span selector raced against the hidden detailed player portal's mount order and picked the invisible title span in CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
End-to-end support for Podcasting 2.0 transcripts:
<podcast:transcript>tags are recorded during feed refresh; after an episode download the best transcript (VTT > SRT > JSON > HTML, podcast-language tie-break) is downloaded, archived next to the audio file and parsed into segments (hand-written VTT/SRT/JSON/HTML parsers).auto_transcribepodcast setting). DB-backed job queue with retries, driven by a dedicated blocking worker thread.<podcast:transcript>tags (apiKey-in-path file route for feed clients that never authenticate)./api/v1; admin-only reparse; regeneratedui/schema.d.ts.docs/src/transcripts.mdincl. docker-compose example and env-var table (TRANSCRIPTION_API_BASE_URL,TRANSCRIPTION_API_KEY,TRANSCRIPTION_MODEL).Design spec:
docs/superpowers/specs/2026-07-16-podcast-transcripts-design.md· Plan:docs/superpowers/plans/2026-07-16-podcast-transcripts.mdTest plan
cargo test --no-default-features --workspace --features sqlite— all suites green (100+ new tests, TDD throughout)cargo clippy --no-default-features --features sqlite -- -D warningsand--features postgresql— cleancd ui && npm run build— cleanfailed+ badge, retry-after-failed, generated transcript parsed + searchable, feed archive file left intact🤖 Generated with Claude Code