10 KiB
10 KiB
hark
Cross-podcast topic index and discovery service. Working title "hark" — renaming is cheap, don't get attached.
What this is
A homelab web service (NOT a mobile app, NOT an AntennaPod fork) that:
- Topic index (first milestone): resolves podcast episodes in subject-per-episode genres (true crime, history, disasters, scams/fraud, biographies, espionage, cults, mysteries) to the real-world case/event/person they cover, so you can ask "who covered the Dyatlov Pass incident?" and compare treatments across shows.
- Discovery: related-show and notable-episode recommendations via topic/embedding similarity.
- Episode scoring (added 2026-07-13): personalized "recommended for you" ranking from the owner's own listening history plus external show ratings, tiltmeter-style (auditable, defined metrics — pure SQL/Python arithmetic, no LLM calls at all). Per-topic treatment-comparison scoring (depth, sensationalism) is still future work.
- Ad-stripping (added 2026-07-11): finds ad spans (chapter markers, or Whisper +
LLM classification) and cuts them out, covering every subscription, not just the
genre-curated shows #1-#3 track. This is provided by depending on
flan/adscrubas a library, not by duplicating its code — adscrub is a separate, standalone product. See "Architecture decisions" below before touching anything ad-stripping-related.
Origin: ideas #2 and #3 in the project-ideas tracker — see it for the full assessments and reasoning. The ad-stripping feature's own origin (AntennaPod's long-open feature request, why LLM-over-transcript beats fingerprinting/crowdsourcing) is in adscrub's own repo history.
Architecture decisions (already made — don't relitigate)
- Standalone service on the homelab, shaped like tiltmeter: scheduled ingest → pipeline → SQLite → API/UI. The owner's player stays AntennaPod.
- Input integration: AntennaPod syncs subscriptions + play history to Nextcloud (its gpodder
sync app); hark reads from that API. OPML import as fallback. (Not wired yet —
see M3 in docs/PLAN.md; ad-stripping still uses the manual
feeds.txt/resolveflow too.) - Output integration: hark generates custom RSS feeds (e.g. "top episodes about topics you like", "best of candidate shows", ad-stripped versions of any subscription) that get subscribed to in AntennaPod like any podcast. No app modification anywhere.
- Feed URLs resolve via the keyless iTunes Search API; Podcast Index API can be added later (needs a registered key). Episode metadata comes from plain RSS.
- Topic extraction: LLM extraction from episode title/description, canonicalized against Wikidata — these genres name their subject in the metadata, so this doesn't need transcripts even though transcription is now available (see below).
- Topics can belong to multiple genres (Titanic = history + disaster); never force one bucket.
- adscrub is a dependency, not a merge — this is deliberate and non-negotiable.
flan/adscrubis its own product: own repo, own schema, own CLI, deployable and useful standalone. hark depends on it via a git source ([tool.uv.sources], pinned commit in uv.lock — see pyproject.toml; was a local../adscrubpath dependency until 2026-07-13, changed because a path dependency made a fresh clone/CI unbuildable for anyone but the author once the repo went public) and calls its functions directly. For side-by-side local dev, override withuv pip install -e ../adscrubafteruv sync. hark'sepisodes/shows/ad_segmentsschema is deliberately shaped to match adscrub's own column names specifically so adscrub's schema-coupled functions (pending_episodes,scan_episode,transcribe_episode,detect_pending,cut_pending, ...) work unchanged against hark'sconn— call them from hark's cli.py directly. Do not copy adscrub's source files into this repo. That mistake was actually made once (2026-07-11), pushed to main, and had to be reverted viagit revertonce caught — see CHANGELOG 0.4.0. The only hark-owned ad-stripping code should be: the schema migration, cli.py's argparse wiring, andpodcast_feed.py(genuinely schema-specific — adscrub's own feed-building code targets a different schema and has no token-auth concept, so it isn't reusable as-is). - Whisper transcription (via adscrub) is cached process-wide, keyed by model size —
hark's ad-span detection and (later) M4 episode-scoring should request the same model
size and run sequentially through the cron-scheduled pipeline (they do) so exactly one
model is ever resident in VRAM. This works automatically only because hark calls
adscrub's actual
load_model()function, not a copy — don't undermine it by ever adding a second, hark-owned copy of that caching logic. GPU:codehas a real RTX 2070 SUPER, Docker'snvidiaruntime is registered;compose.gpu.yamlrequests it, hark's owngpuextra passes through toadscrub[gpu]. - Feed/audio route auth:
/feed/<show_id>/<token>and/audio/<episode_id>/<token>.<ext>are unauthenticated (no cookie login — a podcast app can't do that) but gated by a random per-showfeed_tokenembedded in the URL, compared withsecrets.compare_digest. Not the dashboard's session system, and not wide open either. - Docker build: hark's adscrub path dependency needs adscrub's source alongside this
repo in the build context, which
docker build ./docker compose buildrun from this repo alone can't provide. Resolved viascripts/build-image.sh(stages git-archive-clean copies of both repos into a temp directory, builds against that) — use it instead ofdocker build .directly. See docs/PLAN.md's ad-stripping section for the full story. - Multi-user (added 2026-07-13): shows/episodes/transcripts/ad_segments stay global,
never per-user. Only the subscription list (
user_shows) and listen history (listen_actions.user_id) are per-account — that's the whole mechanism that keeps a show two accounts both subscribe to from being transcribed/ad-detected twice. Don't add auser_idtoshows/episodes/ad_segmentsto "make it more multi-user" — that would defeat the entire point.userslives in auth.db, not hark.db (same session-survives-a-snapshot-restore reasoning as everything else in this file);user_idcolumns in hark.db are a soft cross-database reference, not an enforced FK. User management now has both a CLI (hark user add/invite/list/remove) and an admin-only/admin/usersweb page — the web page got added (2026-07-13, 0.15.0) once it turned out this project's own homelab deploy has no container shell access, so CLI-only wasn't actually usable day to day. Both call the sameAuthmethods; neither is more authoritative.hark user invite//admin/usersis the preferred onboarding path (single-use/invite/<token>link, scoped to one account) overhark user add(the shared-$HARK_ADMIN_TOKENbootstrap, still supported but hands out a credential that also works on any other passwordless row). Non-admin accounts are capped atgpodder_server.MAX_SHOWS_PER_USER(10) — enforced on both paths that can add a subscription (web.py'ssubscribe()andrecord_subscription_changes()), admin exempt. See docs/PLAN.md's multi-user and invite-links sections. - M4 episode scoring (added 2026-07-13) has zero LLM calls, deliberately.
scoring.pyis pure SQL/Python arithmetic overlisten_actions+episode_topics/topic_genres+ the newshow_ratingscache — don't reach for a model here even for something that feels judgment-shaped; the whole point was proving "interesting" could be answered with auditable arithmetic instead.show_ratings(external ratings, currently just Taddy's free-tier API viaratings.py) lives in hark.db, not auth.db — unlike server config (settings, auth.db), this genuinely is show-catalog data, not per-deployment config, so it's fine if a pipeline snapshot swap replaces it wholesale.App.db()'s read-only connection never runs schema setup, so any query touching a table this feature added needs the sametry/except sqlite3.OperationalErrorguardclaims.get_comparison()already established — seescoring._weighted_external_ratings(). Per-topic treatment-comparison scoring (depth/sensationalism) is real future work that WILL need an LLM (matchesclaims.ClaudeComparator's Protocol shape) — default it to the cheapest capable model when it's built, notextract.py/claims.py's Opus default. See docs/PLAN.md's M4 section for the full metric definitions.
Conventions
- Python 3.12+,
uv+pyproject.toml, src layout. SQLite for storage. Keep dependencies minimal (feedparser/httpx-level, no frameworks until the API milestone). pyrightruns in CI, scoped tosrc/only ([tool.pyright]in pyproject.toml) —tests/isn't included, deliberately:http.client's own stubs typegetheader()asOptional[str], and dozens of test call sites use its result without an assert, each one harmless (the test already assertedresp.status == 200first). Keepsrc/itself clean at "basic" strictness — that's where aNoneactually reaching somewhere unexpected matters.- CHANGELOG.md in Keep a Changelog format; SemVer.
- No AI/Claude attribution in commit messages (no Co-Authored-By). Disclose AI use in the README instead. Commit messages describe actual changes, concise; never reference prompts or instructions.
- Significant multi-commit features go on a feature branch; small increments can go on main while the project is pre-0.1.
- Remote: Gitea
flan/harkis canonical (origin, SSH) — always push there. Push to main is fine pre-0.2; also note the feature-branch rule above. The repo is public, andclaude-fleet'sjobs/repo-mirror.shmirrors it out togithub.com/sudolulo/harkfor visibility. GitHub is a read-only shop window: never push to it directly, and never treat it as a source of truth. Policy lives inclaude-fleet/config/repos.toml. Do not add other remotes or mirrors unprompted. - Public-facing docs must not link to
git.arch.fyi— outsiders cannot reach it. Cross-reference sibling projects by their GitHub URL.