test_cut_pending_isolates_per_episode_failures matched a bare "2" against the full audio file path to target episode 2's failure — but pytest's auto-numbered tmp_path can itself contain that digit, so the test failed depending on run order. Now matches the deterministic per-episode filename. README/CLAUDE.md/PLAN.md still described the hark relationship as an open question; M5 was actually decided (library dependency, not a merge) — bring them in line with what hark's own docs already say.
6.8 KiB
6.8 KiB
adscrub — plan
Milestones. Each one ships something usable and gets a CHANGELOG version.
M0 — scaffold + ingest (done, 0.1.0)
- Project scaffold: uv/pyproject, src layout, pytest.
- SQLite schema: feeds, episodes, ad_segments.
- Feed ingest:
add-feed,ingest, idempotent upsert, captures chapters URL. - CLI stubs for transcribe/detect/cut/serve so the eventual pipeline shape is visible
from
adscrub --helpeven before it's built.
M1 — chapter-sourced ad detection (done, 0.1.0)
- Fetch a feed's
<podcast:chapters>JSON, keyword-match ad/sponsor chapter titles, store spans inad_segmentswithsource='chapter'. No transcription, no LLM call — this is the free case and should cover any feed whose creator already tags ad breaks. - Confirmed against feedparser 6.x: the chapters URL lands in
entry["podcast_chapters"]["url"]for feeds using the conventional "podcast" prefix. Not yet tested against a real-world feed (only a synthetic fixture) — validate against an actual subscribed show before trusting this path in production.
M2 — transcription (done, 0.2.0)
- Local Whisper (faster-whisper) for episodes with no usable chapters URL, or where
the chapters pass found nothing (
ad_segments.source = 'chapter'absent). - Downloads episode audio to
data/audio/<id>.mp3(cached, re-run safe), transcribes, writes segment-level timestamps todata/transcripts/<id>.json. - GPU:
codephysically has an RTX 2070 SUPER and Docker here has thenvidiaruntime + CDI device registered (confirmed 2026-07-10) — resolved, this is not CPU-only forever. Device selection is automatic at runtime viactranslate2.get_cuda_device_count()(no torch dependency needed just to check): CUDA float16 if a device is visible, CPU int8 otherwise. The interactive dev shell itself doesn't have the device nodes passed through, so plainuv run adscrub transcribehere runs CPU — that's expected, not a bug. The Docker deploy path (compose.gpu.yamloverride) requests the GPU explicitly. - GPU runtime libs (cuBLAS/cuDNN) are an optional
gpuextra (uv sync --extra gpu) rather than a base dependency, since they're large, CUDA-specific, and only needed on the deploy target.
M3 — LLM ad-span classification (done, 0.3.0)
- Send the timestamped transcript to a Claude model (reuse hark's
$ANTHROPIC_API_KEY-from-rbw pattern, not a file) via structured outputs (messages.parse, same idiom as hark'sClaudeExtractor). The model points at segment indices, not raw seconds — LLMs are unreliable at reproducing exact floating-point timestamps from memory but reliable at picking from a numbered list — and indices are mapped back to the transcript's own timestamps, so a stored span is always grounded in what Whisper actually produced. - Store spans in
ad_segmentswithsource='llm'and areason(why the model flagged it — auditability, same ethos as tiltmeter). Keep chapter-sourced spans too (dedup/precedence is a pipeline decision, not a schema one — don't drop lower-confidence sources, prefer/override at cut time). - Completion is tracked via
episodes.llm_detected_at, set even when zero spans are found — this fixed a real bug caught by the test suite: without it, ad-free episodes got silently re-sent to the LLM (and re-billed) on every run. Same fix applied to M1'schapters_scanned_atfor the same reason (a free HTTP re-fetch, not a billing bug, but the same defect class).
M4 — cut + re-hosted feed (done, 0.4.0)
ffmpegextraction of the surviving (non-ad) spans (cut.compute_keep_spansmerges overlapping ad_segments from any source, then takes the complement), concat back together via the concat demuxer (-c copy— no re-encode, no quality loss), write toepisodes.cut_path. Episode duration comes fromffprobeon the actual downloaded file, not RSS metadata (which is often wrong/missing).feedgen-based feed regeneration (adscrub serve, stdlibhttp.server— dependency -free by design, same as hark's web.py):GET /feed/<feed_id>regenerates the RSS live from the DB on every request; episodes with acut_pathpoint at/audio/<id>.<ext>(served fromdata/cut/), everything else still points straight at its originalaudio_url— an episode nobody's cut (no ads found, or not processed yet) needs no local copy at all. This is the only integration point any podcast player needs — subscribe to/feed/<id>instead of the original feed URL.- No login wall on the server (unlike hark's dashboard) — this is a machine-consumed feed on a trusted homelab network, not a browsable UI over someone's listening habits. Revisit if it ever needs to be reachable from outside a trusted network.
- Docker: default
CMDnow runsadscrub serve(long-running, port 8711, matching hark/tiltmeter's shape); pipeline stages stay one-shotdocker compose run --rmcommands.$ADSCRUB_BASE_URLmust be set to wherever the podcast player can actually reach the container —serveprints a warning if left at thelocalhostdefault.
M5 — hark module decision (resolved 2026-07-11)
- Decision: stays a separate product.
flan/harkdepends on this repo as a library (uvpath dependency, editable), not a source merge — hark's schema was shaped to match this one so adscrub's schema-coupled functions work unchanged against hark's database.hark chapters/transcribe/detect-ads/cutare thin CLI wrappers that call straight into this package. - An earlier pass in the same session fully copied this source into
src/hark/(wrong shape — two products' worth of code entangled in one), pushed to hark's main, then reverted viagit revert -m 1once caught. See hark's CHANGELOG 0.4.0 for the full story. - Practical effect on this repo: still developed and versioned independently, own CHANGELOG/SemVer, own test suite. hark's Docker build doesn't yet resolve the path dependency (build context only has hark's own files) — a packaging gap noted in hark's own docs/PLAN.md, not this repo's problem to solve.
Open questions (owner input needed, don't block on these)
M5's hark-merge decisionResolved 2026-07-11 (see above).- M3 currently defaults to
claude-opus-4-8; revisit cost vs. accuracy on ad-span boundaries once it's run against real transcripts (a cheaper model may be plenty for a fairly mechanical "find the sponsor read" task). - Real-world validation of the M1 chapters-URL parsing against an actual subscribed feed, not just the synthetic test fixture.
- Real-world end-to-end validation: this has only been tested with synthetic fixtures and mocked ffmpeg/Whisper/Claude calls — actual audio quality/timing accuracy after a real cut, and whether AntennaPod accepts the regenerated feed without complaint, are both unverified.