Skip to content

feat: Podcasting 2.0 transcript support - #2181

Merged
SamTV12345 merged 32 commits into
mainfrom
worktree-podcast-transcripts
Jul 16, 2026
Merged

feat: Podcasting 2.0 transcript support#2181
SamTV12345 merged 32 commits into
mainfrom
worktree-podcast-transcripts

Conversation

@SamTV12345

Copy link
Copy Markdown
Owner

Summary

End-to-end support for Podcasting 2.0 transcripts:

  • Feed 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).
  • Full-text search: segments are indexed natively (SQLite FTS5 / Postgres tsvector); new Transcripts mode on the episode search page jumps straight to the matching playback position.
  • Player: new Transcript tab in the detailed audio player with active-segment highlighting, auto-scroll and click-to-seek.
  • Generated transcripts: episodes without a feed transcript can be transcribed via any OpenAI-compatible Whisper API (e.g. speaches/faster-whisper) — manually per episode (with live status badge via SocketIO) or automatically after download (auto_transcribe podcast setting). DB-backed job queue with retries, driven by a dedicated blocking worker thread.
  • RSS re-export: archived transcripts are emitted as <podcast:transcript> tags (apiKey-in-path file route for feed clients that never authenticate).
  • HTTP API: list/preferred/file/search/transcribe/reparse endpoints under /api/v1; admin-only reparse; regenerated ui/schema.d.ts.
  • Docs: docs/src/transcripts.md incl. 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.md

Test 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 warnings and --features postgresql — clean
  • cd ui && npm run build — clean
  • Live end-to-end smoke against a local fixture feed: feed transcript extract → archive → parse → player/preferred endpoint → FTS search hit → RSS tag; Whisper flow against a mock server: enqueue (200/409), worker retries against a dead endpoint → failed + badge, retry-after-failed, generated transcript parsed + searchable, feed archive file left intact
  • Postgres runtime (CI container tests cover this; only compile-checked locally)
  • Manual browser pass over the new UI (transcript tab, search mode, transcribe action)

🤖 Generated with Claude Code

SamTV1998 and others added 30 commits July 16, 2026 13:33
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>
SamTV1998 and others added 2 commits July 16, 2026 21:13
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>
@SamTV12345
SamTV12345 merged commit 776247e into main Jul 16, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant