Stats page, mark-don't-cut chapters mode, OPML export, search genre filter (0.35.0)
Lint Python / lint (push) Canceled after 0s
Test Python / test (push) Canceled after 0s
Typecheck Python / typecheck (push) Canceled after 0s

- /stats: corpus ad-stripping impact + per-account listening summary
- Per-show "chapters" mode: mark ads as Podcasting-2.0 <podcast:chapters>
  instead of cutting; honored in the recommendation feed as well
- /feeds.opml: bulk export of all subscribable feed URLs
- /search genre filter across topics, episode titles, and transcripts
- Share the episode->genre predicate (queries.episode_in_genre) and the
  cut-vs-chapters enclosure resolution across both feed builders
This commit is contained in:
flan
2026-07-24 20:44:47 +00:00
parent 9c520c216f
commit 4bb4713ee0
14 changed files with 501 additions and 65 deletions
+27
View File
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.35.0] - 2026-07-24
### Added
- **Stats page (`/stats`).** Ad-stripping impact across the whole corpus — hours of ads removed,
episodes cut, cuts held for review, and a per-tier breakdown of ad spans — plus this account's
own listening summary (plays, shows listened to, top genres). Corpus counts are exact; the
personal reads are guarded, so an account with no synced listen history just shows an empty state.
- **"Mark, don't cut" mode (per show).** A show can now be set to *mark* its ads as skippable
Podcasting-2.0 `<podcast:chapters>` rather than hard-cutting them: the feed then serves the
original audio with a chapters link per episode that has ad spans
(`/chapters/<episode>/<token>.json`), so a player (AntennaPod, Overcast) shows ad boundaries the
listener can skip. Toggle it from the show page. Respected in the personalized recommendation
feed too, not only the show's own feed.
- **OPML export (`/feeds.opml`).** The feeds hub gains an "Export all as OPML" link that downloads
every feed the account can subscribe to — the recommendation feed and each subscribed show's
ad-stripped feed — for bulk-importing into a podcast app instead of adding each URL by hand.
- **Genre filter on search.** `/search` gains a genre dropdown that scopes results — topics,
episode-title matches, and full-text transcript matches alike — to a single genre.
### Changed
- The episode→genre filter behind search now lives in one shared helper
(`queries.episode_in_genre`) instead of being duplicated across the title and transcript paths,
and cut-vs-chapters enclosure resolution is shared between the show and recommendation feed
builders rather than reimplemented in each.
## [0.34.1] - 2026-07-24
### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hark"
version = "0.34.1"
version = "0.35.0"
description = "Cross-podcast topic index and discovery service"
readme = "README.md"
requires-python = ">=3.12"
+1 -1
View File
@@ -1,3 +1,3 @@
"""hark — cross-podcast topic index and discovery service."""
__version__ = "0.34.1"
__version__ = "0.35.0"
+2
View File
@@ -52,6 +52,7 @@ CREATE TABLE IF NOT EXISTS shows (
feed_token TEXT,
ad_stripping_enabled INTEGER NOT NULL DEFAULT 1,
topic_index_enabled INTEGER NOT NULL DEFAULT 1,
cut_mode TEXT NOT NULL DEFAULT 'cut', -- 'cut' removes ads, 'chapters' marks them instead
hosting_platform TEXT,
last_fetched_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
@@ -253,6 +254,7 @@ _MIGRATIONS = (
"ALTER TABLE shows ADD COLUMN ad_stripping_enabled INTEGER NOT NULL DEFAULT 1"),
("shows", "topic_index_enabled",
"ALTER TABLE shows ADD COLUMN topic_index_enabled INTEGER NOT NULL DEFAULT 1"),
("shows", "cut_mode", "ALTER TABLE shows ADD COLUMN cut_mode TEXT NOT NULL DEFAULT 'cut'"),
("shows", "hosting_platform", "ALTER TABLE shows ADD COLUMN hosting_platform TEXT"),
("listen_actions", "started", "ALTER TABLE listen_actions ADD COLUMN started INTEGER"),
("subscription_changes", "user_id",
+99 -23
View File
@@ -18,11 +18,48 @@ direct hark dependency).
from __future__ import annotations
import sqlite3
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from pathlib import Path
from feedgen.feed import FeedGenerator
from adscrub.cut import CUT_SOURCES
# Podcasting 2.0 namespace — for <podcast:chapters> in "mark, don't cut" mode.
_PODCAST_NS = "https://podcastindex.org/namespace/1.0"
def chapters_json(conn: sqlite3.Connection, episode_id: int) -> dict:
"""A Podcasting-2.0 chapters document marking this episode's ad spans, so a player (AntennaPod)
shows ad boundaries the listener can skip — the alternative to hard-cutting. Only cuttable
tiers are marked (the same spans `cut` would remove)."""
ph = ",".join("?" * len(CUT_SOURCES))
spans = conn.execute(
f"SELECT start_second, end_second FROM ad_segments WHERE episode_id = ? "
f"AND source IN ({ph}) ORDER BY start_second", (episode_id, *CUT_SOURCES)).fetchall()
chapters: list[dict] = []
for s in spans:
chapters.append({"startTime": round(s["start_second"], 1), "title": "Advertisement"})
chapters.append({"startTime": round(s["end_second"], 1), "title": "Content"})
return {"version": "1.2.0", "chapters": chapters}
def _add_chapters_links(rss: bytes, chapters_url_by_guid: dict[str, str]) -> bytes:
"""Add a <podcast:chapters> link to each <item> whose guid is in the map. Done by parsing the
feedgen output rather than string-splicing, so the result stays well-formed."""
ET.register_namespace("podcast", _PODCAST_NS)
root = ET.fromstring(rss)
for item in root.iter("item"):
guid_el = item.find("guid")
guid = guid_el.text if guid_el is not None else None
url = chapters_url_by_guid.get(guid) if guid else None
if url:
ch = ET.SubElement(item, f"{{{_PODCAST_NS}}}chapters")
ch.set("url", url)
ch.set("type", "application/json+chapters")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def _parse_pubdate(value: str | None) -> datetime | None:
if not value:
@@ -37,6 +74,39 @@ def feed_url(show: sqlite3.Row, base_url: str) -> str:
return f"{base_url}/feed/{show['id']}/{show['feed_token']}"
def _episodes_with_cuttable_ads(conn: sqlite3.Connection, episode_ids: list[int]) -> set[int]:
"""Of the given episodes, those with at least one ad span in a cuttable tier — i.e. the ones
'chapters' mode would mark. Empty in/empty out (no `IN ()`)."""
if not episode_ids:
return set()
eph = ",".join("?" * len(episode_ids))
sph = ",".join("?" * len(CUT_SOURCES))
return {r[0] for r in conn.execute(
f"SELECT DISTINCT episode_id FROM ad_segments WHERE episode_id IN ({eph}) "
f"AND source IN ({sph})", (*episode_ids, *CUT_SOURCES))}
def _entry_media(ep: sqlite3.Row, feed_token: str | None, base_url: str, chapters_mode: bool,
with_ads: set[int]) -> tuple[str | None, int, str | None]:
"""Resolve one episode's enclosure for a feed, honoring the show's cut vs chapters mode. In
'cut' mode a locally-cut file (if present) is served; in 'chapters' mode — or when no cut
exists — the original enclosure is served and, if the episode has cuttable ads, a
<podcast:chapters> URL is returned so the player can skip them. Returns
(audio_url, enclosure_length, chapters_url); chapters_url is None unless in chapters mode."""
length = 0
if not chapters_mode and ep["cut_path"] and feed_token:
cut_path = Path(ep["cut_path"])
audio_url = f"{base_url}/audio/{ep['id']}/{feed_token}{cut_path.suffix}"
if cut_path.is_file():
length = cut_path.stat().st_size
else:
audio_url = ep["audio_url"]
chapters_url = None
if chapters_mode and feed_token and ep["id"] in with_ads:
chapters_url = f"{base_url}/chapters/{ep['id']}/{feed_token}.json"
return audio_url, length, chapters_url
def build_feed(conn: sqlite3.Connection, show: sqlite3.Row, base_url: str) -> bytes:
fg = FeedGenerator()
fg.title(show["title"] or show["query"])
@@ -45,20 +115,22 @@ def build_feed(conn: sqlite3.Connection, show: sqlite3.Row, base_url: str) -> by
if show["image_url"]:
fg.image(show["image_url"])
# "chapters" mode marks ads instead of removing them: serve the ORIGINAL audio and attach a
# <podcast:chapters> link per episode that has ad spans, so the listener can skip them.
chapters_mode = show["cut_mode"] == "chapters"
episodes = conn.execute(
"SELECT * FROM episodes WHERE show_id = ? ORDER BY pubdate DESC", (show["id"],)
).fetchall()
with_ads = (_episodes_with_cuttable_ads(conn, [e["id"] for e in episodes])
if chapters_mode else set())
chapters_url_by_guid: dict[str, str] = {}
for ep in episodes:
length = 0
if ep["cut_path"]:
cut_path = Path(ep["cut_path"])
audio_url = f"{base_url}/audio/{ep['id']}/{show['feed_token']}{cut_path.suffix}"
if cut_path.is_file():
length = cut_path.stat().st_size
else:
audio_url = ep["audio_url"]
audio_url, length, chapters_url = _entry_media(
ep, show["feed_token"], base_url, chapters_mode, with_ads)
if not audio_url:
continue # nothing playable to link — skip rather than emit a dead enclosure
if chapters_url and ep["guid"]:
chapters_url_by_guid[ep["guid"]] = chapters_url
fe = fg.add_entry()
fe.id(ep["guid"])
fe.title(ep["title"] or "(untitled)")
@@ -68,15 +140,17 @@ def build_feed(conn: sqlite3.Connection, show: sqlite3.Row, base_url: str) -> by
fe.pubDate(pubdate)
fe.enclosure(audio_url, length, "audio/mpeg")
return fg.rss_str(pretty=True)
rss = fg.rss_str(pretty=True)
return _add_chapters_links(rss, chapters_url_by_guid) if chapters_url_by_guid else rss
def build_recommendation_feed(conn: sqlite3.Connection, username: str, base_url: str,
episode_ids: list[int], feed_token: str) -> bytes:
"""A personalized 'recommended for you' RSS feed (from scoring.py's ranking), subscribable in
a podcast app like any other. Episodes are served AD-STRIPPED where a cut exists (via the
show's own feed_token), else the original enclosure — so recommendations get the same
ad-removal as a subscribed show. Order follows the ranking, not pubdate."""
a podcast app like any other. Each episode is served the same way its own show's feed is: cut
where a cut exists, or — if the show is in 'chapters' mode — the original audio with a
<podcast:chapters> link, so a recommendation respects the show's mark-don't-cut choice. Order
follows the ranking, not pubdate."""
fg = FeedGenerator()
fg.title(f"hark — recommended for {username}")
fg.link(href=f"{base_url}/recommended/{feed_token}", rel="self")
@@ -86,30 +160,32 @@ def build_recommendation_feed(conn: sqlite3.Connection, username: str, base_url:
placeholders = ",".join("?" * len(episode_ids))
rows = conn.execute(
f"SELECT e.id, e.guid, e.title, e.description, e.pubdate, e.audio_url, e.cut_path, "
f" s.feed_token, COALESCE(s.title, s.query) AS show "
f" s.feed_token, s.cut_mode, COALESCE(s.title, s.query) AS show "
f"FROM episodes e JOIN shows s ON s.id = e.show_id WHERE e.id IN ({placeholders})",
tuple(episode_ids)).fetchall()
by_id = {r["id"]: r for r in rows}
with_ads = _episodes_with_cuttable_ads(
conn, [r["id"] for r in rows if r["cut_mode"] == "chapters"])
chapters_url_by_guid: dict[str, str] = {}
for eid in episode_ids: # preserve the recommendation ranking order
ep = by_id.get(eid)
if ep is None:
continue
length = 0
if ep["cut_path"] and ep["feed_token"]:
cut_path = Path(ep["cut_path"])
audio_url = f"{base_url}/audio/{ep['id']}/{ep['feed_token']}{cut_path.suffix}"
if cut_path.is_file():
length = cut_path.stat().st_size
else:
audio_url = ep["audio_url"]
chapters_mode = ep["cut_mode"] == "chapters"
audio_url, length, chapters_url = _entry_media(
ep, ep["feed_token"], base_url, chapters_mode, with_ads)
if not audio_url:
continue
guid = ep["guid"] or f"hark-rec-{ep['id']}"
if chapters_url:
chapters_url_by_guid[guid] = chapters_url
fe = fg.add_entry()
fe.id(ep["guid"] or f"hark-rec-{ep['id']}")
fe.id(guid)
fe.title(f"{ep['title'] or '(untitled)'}{ep['show']}")
fe.description(ep["description"] or "")
pubdate = _parse_pubdate(ep["pubdate"])
if pubdate:
fe.pubDate(pubdate)
fe.enclosure(audio_url, length, "audio/mpeg")
return fg.rss_str(pretty=True)
rss = fg.rss_str(pretty=True)
return _add_chapters_links(rss, chapters_url_by_guid) if chapters_url_by_guid else rss
+14
View File
@@ -20,6 +20,20 @@ def _topics_filter(genre: str, q: str) -> tuple[str, list]:
return "", []
def episode_in_genre(genre: str, col: str = "e.id") -> tuple[str, tuple]:
"""A predicate scoping to episodes whose topics fall in `genre`, e.g. `<col> IN (…)`.
Shared by /search's title and transcript filters so the join lives in one place (the
topic-scoped `_topics_filter` above is a different shape — topic rows, not episodes).
Returns ("", ()) when no genre is given, so callers can splice unconditionally.
`col` is interpolated into SQL, so it must only ever be a hardcoded column name — never
user input; `genre` (the only user value) is always bound via `?`."""
if not genre:
return "", ()
return (f"{col} IN (SELECT et.episode_id FROM episode_topics et "
"JOIN topic_genres tg ON tg.topic_id = et.topic_id WHERE tg.genre = ?)"), (genre,)
# Whitelisted sort keys only — never interpolate the raw `sort` query param
# into SQL. Each maps to a full ORDER BY clause; "shows" is the original
# (and still default) ordering.
+2
View File
@@ -32,6 +32,7 @@ th { color:var(--dim); font-weight:normal; font-size:0.85rem; text-transform:upp
.pill { display:inline-block; border:1px solid var(--line); border-radius:9px; padding:0 0.5rem; margin:0 0.2rem 0.2rem 0; font-size:0.78rem; color:var(--dim); }
form.search { display:flex; gap:0.5rem; margin:1rem 0; }
input[type=text], input[type=password] { background:var(--panel); border:1px solid var(--line); color:var(--ink); padding:0.45rem 0.6rem; font:inherit; flex:1; }
select { background:var(--panel); border:1px solid var(--line); color:var(--ink); padding:0.45rem 0.6rem; font:inherit; }
button { background:var(--acc); border:0; color:#151007; padding:0.45rem 1rem; font:inherit; cursor:pointer; }
button.ghost { background:transparent; border:1px solid var(--line); color:var(--ink); }
.cards { display:grid; grid-template-columns:repeat(auto-fit, minmax(10rem,1fr)); gap:0.8rem; margin:1.2rem 0; }
@@ -109,6 +110,7 @@ NAV_ITEMS = [
("notable", "/notable", "notable"),
("search", "/search", "search"),
("feeds", "/feeds", "feeds"),
("stats", "/stats", "stats"),
("pipeline", "/pipeline", "pipeline"),
]
+17 -13
View File
@@ -15,6 +15,8 @@ import json
import sqlite3
from pathlib import Path
from . import queries
# episode_id UNINDEXED: stored for retrieval but not tokenised (we match on `text` only).
_CREATE = "CREATE VIRTUAL TABLE IF NOT EXISTS transcript_fts USING fts5(text, episode_id UNINDEXED)"
@@ -75,28 +77,30 @@ def index_transcripts(conn: sqlite3.Connection, limit: int | None = None) -> tup
return (indexed, len(pending) - len(todo))
def search(conn: sqlite3.Connection, query: str, limit: int = 25) -> list[sqlite3.Row]:
def search(conn: sqlite3.Connection, query: str, limit: int = 25,
genre: str = "") -> list[sqlite3.Row]:
"""Episodes whose transcript matches `query`, with a context snippet. Read-only-safe: guarded
so a database without the FTS table (fresh deploy) yields no matches instead of erroring. The
query is matched as a quoted PHRASE, which both does the intuitive thing and sidesteps FTS5's
own query-operator syntax erroring on stray punctuation."""
own query-operator syntax erroring on stray punctuation. `genre`, if given, scopes hits to
episodes whose topics fall in that genre — same filter /search applies to topics and titles."""
q = query.strip()
if not q:
return []
phrase = '"' + q.replace('"', '""') + '"'
genre_clause, genre_params = queries.episode_in_genre(genre)
if genre_clause:
genre_clause = "AND " + genre_clause + " "
try:
return conn.execute(
"""
SELECT e.id, e.title, s.id AS show_id, COALESCE(s.title, s.query) AS show,
snippet(transcript_fts, 0, '', '', '', 14) AS snip
FROM transcript_fts f
JOIN episodes e ON e.id = f.episode_id
JOIN shows s ON s.id = e.show_id
WHERE transcript_fts MATCH ?
ORDER BY rank
LIMIT ?
""",
(phrase, limit),
"SELECT e.id, e.title, s.id AS show_id, COALESCE(s.title, s.query) AS show, "
" snippet(transcript_fts, 0, '', '', '', 14) AS snip "
"FROM transcript_fts f "
"JOIN episodes e ON e.id = f.episode_id "
"JOIN shows s ON s.id = e.show_id "
"WHERE transcript_fts MATCH ? " + genre_clause
+ "ORDER BY rank LIMIT ?",
(phrase, *genre_params, limit),
).fetchall()
except sqlite3.OperationalError:
return []
+168 -23
View File
@@ -6,8 +6,10 @@ and HTTP routing.
from __future__ import annotations
import os
import secrets
import sqlite3
import urllib.parse
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from pathlib import Path
@@ -20,6 +22,7 @@ from .extract import GENRES as GENRES_FILTER
from .queries import (
PAGE_SIZE,
contested_topics,
episode_in_genre,
paginate,
pipeline_status,
rare_genre_episodes,
@@ -123,6 +126,38 @@ class App:
def toggle_ad_stripping(self, show_id: int) -> bool | None:
return self._toggle_show_flag(show_id, "ad_stripping_enabled")
def toggle_cut_mode(self, show_id: int) -> str | None:
"""Flip a show between 'cut' (remove ads) and 'chapters' (mark them for the player to
skip). Admin-only (gated at the route). Returns the new mode, or None if unknown show."""
conn = sqlite3.connect(self.db_path)
try:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute("SELECT cut_mode FROM shows WHERE id = ?", (show_id,)).fetchone()
if row is None:
conn.rollback()
return None
new_mode = "chapters" if row[0] == "cut" else "cut"
conn.execute("UPDATE shows SET cut_mode = ? WHERE id = ?", (new_mode, show_id))
conn.commit()
return new_mode
finally:
conn.close()
def chapters_feed(self, episode_id: int, token: str) -> dict | None:
"""Podcasting-2.0 chapters JSON for one episode, gated by its show's feed_token (same
unauthenticated model as /feed and /audio). None if the episode/token doesn't match."""
conn = self.db()
try:
row = conn.execute(
"SELECT s.feed_token FROM episodes e JOIN shows s ON s.id = e.show_id "
"WHERE e.id = ?", (episode_id,)).fetchone()
if row is None or not row["feed_token"] or not secrets.compare_digest(
row["feed_token"], token):
return None
return podcast_feed.chapters_json(conn, episode_id)
finally:
conn.close()
def toggle_topic_index(self, show_id: int) -> bool | None:
return self._toggle_show_flag(show_id, "topic_index_enabled")
@@ -255,6 +290,32 @@ class App:
finally:
conn.close()
def feeds_opml(self, user) -> bytes:
"""An OPML file of every feed this account can subscribe to — the recommendation feed and
each subscribed show's ad-stripped feed — for bulk-importing into a podcast app. Built with
ElementTree so URLs/titles are escaped, not string-spliced."""
rec_token = self.auth.feed_token_for(user["id"])
conn = self.db()
try:
shows = conn.execute(
"SELECT s.id, COALESCE(s.title, s.query) AS show, s.feed_token "
"FROM user_shows us JOIN shows s ON s.id = us.show_id "
"WHERE us.user_id = ? AND s.feed_token IS NOT NULL ORDER BY show",
(user["id"],)).fetchall()
finally:
conn.close()
opml = ET.Element("opml", version="2.0")
head = ET.SubElement(opml, "head")
ET.SubElement(head, "title").text = f"hark feeds for {user['username']}"
body = ET.SubElement(opml, "body")
if rec_token:
ET.SubElement(body, "outline", text="Recommended for you (hark)", type="rss",
xmlUrl=f"{self.base_url}/recommended/{rec_token}")
for s in shows:
ET.SubElement(body, "outline", text=f"{s['show']} (ad-stripped)", type="rss",
xmlUrl=podcast_feed.feed_url(s, self.base_url))
return ET.tostring(opml, encoding="utf-8", xml_declaration=True)
def api_episode(self, episode_id: int) -> dict | None:
"""Read-only JSON view of an episode: its ad spans and topics. None if it doesn't exist."""
conn = self.db()
@@ -612,7 +673,8 @@ class App:
body = ("<h1>Your feeds</h1>"
"<p class=\"dim\">Add these URLs in your podcast app (AntennaPod, Overcast, …) — "
"each is gated by a private token, so keep them to yourself.</p>"
"each is gated by a private token, so keep them to yourself. "
"<a href=\"/feeds.opml\">Export all as OPML</a>.</p>"
"<h2>Recommended for you</h2>"
"<p class=\"dim\">Episodes ranked from your own listening, ad-stripped where a cut "
"exists. Updates as you listen.</p>"
@@ -635,6 +697,73 @@ class App:
+ feed_row(f"show-{s['id']}", f"{self.base_url}/feed/{s['id']}/{s['feed_token']}"))
return page("feeds", body, user["username"], bool(user["is_admin"]), section="feeds")
def view_stats(self, user) -> str:
"""What hark has done: ad-stripping impact across the corpus, plus this account's own
listening summary. Personal reads are guarded (listen_actions may be empty/absent)."""
from adscrub.cut import CUT_SOURCES
ph = ",".join("?" * len(CUT_SOURCES))
conn = self.db()
try:
# COUNT(col) skips NULLs, so both corpus counters come from one scan of episodes.
episodes_cut, held = conn.execute(
"SELECT COUNT(cut_path), COUNT(cut_held_at) FROM episodes").fetchone()
ad_removed = conn.execute(
f"SELECT COALESCE(SUM(a.end_second - a.start_second), 0) FROM ad_segments a "
f"JOIN episodes e ON e.id = a.episode_id "
f"WHERE e.cut_path IS NOT NULL AND a.source IN ({ph})", CUT_SOURCES).fetchone()[0]
tiers = conn.execute(
"SELECT source, COUNT(*) AS n, COALESCE(SUM(end_second - start_second), 0) AS secs "
"FROM ad_segments GROUP BY source ORDER BY n DESC").fetchall()
plays, shows_listened, top_genres = 0, 0, []
try:
plays = conn.execute(
"SELECT COUNT(*) FROM listen_actions WHERE user_id = ? AND LOWER(action) = 'play'",
(user["id"],)).fetchone()[0]
shows_listened = conn.execute(
"SELECT COUNT(DISTINCT podcast_url) FROM listen_actions WHERE user_id = ?",
(user["id"],)).fetchone()[0]
top_genres = conn.execute(
"SELECT tg.genre, COUNT(*) AS n FROM listen_actions la "
"JOIN episodes e ON e.audio_url = la.episode_url "
"JOIN episode_topics et ON et.episode_id = e.id "
"JOIN topic_genres tg ON tg.topic_id = et.topic_id "
"WHERE la.user_id = ? AND LOWER(la.action) = 'play' "
"GROUP BY tg.genre ORDER BY n DESC LIMIT 6", (user["id"],)).fetchall()
except sqlite3.OperationalError:
pass
finally:
conn.close()
hours = ad_removed / 3600.0
cards = f"""
<div class="cards">
<div class="card"><div class="big">{hours:.1f}h</div>of ads removed</div>
<div class="card"><div class="big">{episodes_cut}</div>episodes cut</div>
<div class="card"><div class="big">{plays}</div>episodes you've played</div>
<div class="card"><div class="big">{held}</div>cuts held for review</div>
</div>"""
tier_rows = "".join(
f"<tr><td><code>{esc(r['source'])}</code></td><td class='num'>{r['n']}</td>"
f"<td class='num dim'>{mmss(r['secs'])}</td></tr>" for r in tiers
) or '<tr><td class="dim" colspan="3">No ad spans found yet.</td></tr>'
genre_pills = " ".join(
f'<a class="pill" href="/topics?genre={esc(g["genre"])}">{esc(g["genre"])} '
f'({g["n"]})</a>' for g in top_genres)
if plays:
listening = f"<p>{plays} plays across {plural(shows_listened, 'show')}.</p>"
if genre_pills:
listening += f"<p>Top genres: {genre_pills}</p>"
else:
listening = '<p class="dim">No listening history synced for this account yet.</p>'
body = (
"<h1>Stats</h1>" + cards
+ "<h2>Ad spans by tier</h2>"
+ "<table><tr><th>tier</th><th class='num'>spans</th><th class='num'>total time</th>"
+ f"</tr>{tier_rows}</table>"
+ "<h2>Your listening</h2>" + listening
)
return page("stats", body, user["username"], bool(user["is_admin"]), section="stats")
def view_topics(self, user, params) -> str:
genre = params.get("genre", [""])[0]
if genre not in GENRES_FILTER:
@@ -905,43 +1034,50 @@ class App:
def view_search(self, user, params) -> str:
q = params.get("q", [""])[0].strip()
genre = params.get("genre", [""])[0].strip()
if genre not in GENRES_FILTER:
genre = "" # whitelist — only a real genre filters
page_num = paginate(params)
topics, episodes, topic_total, episode_total = [], [], 0, 0
transcript_hits: list = []
if q:
like = f"%{q}%"
# episode-title match, optionally scoped to a genre via the episode's topics
ep_where = "e.title LIKE ? COLLATE NOCASE"
ep_params: tuple = (like,)
genre_clause, genre_params = episode_in_genre(genre)
if genre_clause:
ep_where += " AND " + genre_clause
ep_params += genre_params
conn = self.db()
try:
topic_total = conn.execute(*topics_count(q=q)).fetchone()[0]
topic_total = conn.execute(*topics_count(q=q, genre=genre)).fetchone()[0]
topics = conn.execute(
*topics_query(q=q, limit=PAGE_SIZE, offset=(page_num - 1) * PAGE_SIZE)
*topics_query(q=q, genre=genre, limit=PAGE_SIZE, offset=(page_num - 1) * PAGE_SIZE)
).fetchall()
episode_total = conn.execute(
"""
SELECT COUNT(*) FROM episodes e WHERE e.title LIKE ? COLLATE NOCASE
""",
(like,),
).fetchone()[0]
f"SELECT COUNT(*) FROM episodes e WHERE {ep_where}", ep_params).fetchone()[0]
episodes = conn.execute(
"""
SELECT e.id, e.title, e.pubdate, s.id AS show_id,
COALESCE(s.title, s.query) AS show
FROM episodes e JOIN shows s ON s.id = e.show_id
WHERE e.title LIKE ? COLLATE NOCASE
ORDER BY e.pubdate DESC LIMIT ? OFFSET ?
""",
(like, PAGE_SIZE, (page_num - 1) * PAGE_SIZE),
).fetchall()
transcript_hits = transcript_search.search(conn, q, limit=25)
f"SELECT e.id, e.title, e.pubdate, s.id AS show_id, "
f" COALESCE(s.title, s.query) AS show "
f"FROM episodes e JOIN shows s ON s.id = e.show_id WHERE {ep_where} "
f"ORDER BY e.pubdate DESC LIMIT ? OFFSET ?",
(*ep_params, PAGE_SIZE, (page_num - 1) * PAGE_SIZE)).fetchall()
transcript_hits = transcript_search.search(conn, q, limit=25, genre=genre)
finally:
conn.close()
pager_q = {"q": q, "genre": genre} if genre else {"q": q}
genre_opts = '<option value="">all genres</option>' + "".join(
f'<option value="{esc(g)}"{" selected" if g == genre else ""}>{esc(g)}</option>'
for g in sorted(GENRES_FILTER))
body = (
"<h1>search</h1>"
'<form class="search" action="/search" method="get">'
f'<input type="text" name="q" value="{esc(q)}" autofocus><button>Search</button></form>'
f'<input type="text" name="q" value="{esc(q)}" autofocus>'
f'<select name="genre">{genre_opts}</select><button>Search</button></form>'
)
if q:
topics_pager = pagination_html("/search", {"q": q}, page_num, topic_total, "topics")
topics_pager = pagination_html("/search", pager_q, page_num, topic_total, "topics")
no_match = f"No topics match “{q}”."
body += (f"<h2>{plural(topic_total, 'topic')}</h2>"
+ topic_table(topics, empty=no_match) + topics_pager)
@@ -955,7 +1091,7 @@ class App:
eps_table = f"<table><tr><th>show</th><th>episode</th><th>date</th></tr>{eps}</table>"
else:
eps_table = f'<p class="dim">No episode titles match “{q}”.</p>'
episodes_pager = pagination_html("/search", {"q": q}, page_num, episode_total,
episodes_pager = pagination_html("/search", pager_q, page_num, episode_total,
"episode title matches")
body += (f"<h2>{plural(episode_total, 'episode title match', 'episode title matches')}"
f"</h2>{eps_table}{episodes_pager}")
@@ -1046,7 +1182,7 @@ class App:
show = conn.execute(
"""
SELECT id, COALESCE(title, query) AS name, feed_token,
ad_stripping_enabled, topic_index_enabled
ad_stripping_enabled, topic_index_enabled, cut_mode
FROM shows WHERE id = ?
""",
(show_id,),
@@ -1140,6 +1276,10 @@ class App:
)
enabled = bool(show["ad_stripping_enabled"])
toggle_label = "Disable ad-stripping" if enabled else "Enable ad-stripping"
chapters_mode = show["cut_mode"] == "chapters"
mode_desc = ("marks ad boundaries as skippable chapters — original audio, nothing removed"
if chapters_mode else "removes the ads from the audio")
mode_toggle = "Switch to cutting" if chapters_mode else "Switch to chapter markers"
adblock_section = (
'<div class="status">'
f'<p>Ad-stripped feed URL — subscribe to this in AntennaPod instead of the '
@@ -1151,9 +1291,14 @@ class App:
f'<p class="dim">{ad_stripping_progress["transcribed"]}/{total_episodes} transcribed, '
f'{ad_stripping_progress["detected"]}/{total_episodes} ad-scanned, '
f'{ad_stripping_progress["cut"]}/{total_episodes} cut.</p>'
f'<p>Mode: <strong>{"chapter markers" if chapters_mode else "cut"}</strong> — '
f'{mode_desc}.</p>'
'<div class="confirm-row">'
f'<form method="post" action="/show/{show_id}/adblock">'
f'<button class="ghost">{toggle_label}</button></form>'
"</div>"
f'<form method="post" action="/show/{show_id}/cut-mode">'
f'<button class="ghost">{mode_toggle}</button></form>'
"</div></div>"
)
topic_index_on = bool(show["topic_index_enabled"])
topic_toggle_label = "Remove from topic index" if topic_index_on else "Add to topic index"
+27
View File
@@ -413,6 +413,17 @@ class Handler(BaseHTTPRequestHandler):
return self.respond_bytes(200, rss, "application/rss+xml; charset=utf-8")
if route.startswith("/audio/"):
return self._serve_audio(route)
if route.startswith("/chapters/"):
parts = route.split("/") # ['', 'chapters', '<episode_id>', '<token>.json']
data = None
if len(parts) == 4:
try:
data = app.chapters_feed(int(parts[2]), parts[3].removesuffix(".json"))
except ValueError:
data = None
if data is None:
return self.respond(404, "not found", "text/plain; charset=utf-8")
return self.respond_json(200, data)
if route == "/index.php/apps/gpoddersync/subscriptions":
return self._gpodder_get_subscriptions(params)
if route == "/index.php/apps/gpoddersync/episode_action":
@@ -452,6 +463,12 @@ class Handler(BaseHTTPRequestHandler):
return self.respond(200, app.view_pipeline(user))
if route == "/feeds":
return self.respond(200, app.view_feeds(user))
if route == "/feeds.opml":
return self.respond_bytes(
200, app.feeds_opml(user), "text/x-opml; charset=utf-8",
{"Content-Disposition": 'attachment; filename="hark-feeds.opml"'})
if route == "/stats":
return self.respond(200, app.view_stats(user))
if route.startswith("/topic/"):
try:
topic_id = int(route.rsplit("/", 1)[1])
@@ -632,6 +649,16 @@ class Handler(BaseHTTPRequestHandler):
if app.toggle_topic_index(show_id) is None:
return self.not_found(user)
return self.redirect(f"/show/{show_id}")
if route.startswith("/show/") and route.endswith("/cut-mode"):
if not user["is_admin"]:
return self.forbidden(user)
try:
show_id = int(route.removeprefix("/show/").removesuffix("/cut-mode"))
except ValueError:
return self.not_found(user)
if app.toggle_cut_mode(show_id) is None:
return self.not_found(user)
return self.redirect(f"/show/{show_id}")
if route.startswith("/show/") and route.endswith("/subscribe"):
try:
show_id = int(route.removeprefix("/show/").removesuffix("/subscribe"))
+49
View File
@@ -0,0 +1,49 @@
"""Function-level tests for the feed builders — specifically cut vs 'chapters' (mark, don't cut)
mode. The token-gated HTTP routes are covered in test_podcast_feed_routes.py; these exercise
build_recommendation_feed directly, since driving it through the route would require the full
scoring pipeline to pick the episode_ids.
"""
import xml.etree.ElementTree as ET
from hark import db, podcast_feed
TOKEN = "tok-rec-123"
_CHAPTERS_TAG = "{https://podcastindex.org/namespace/1.0}chapters"
def _enclosure_urls(rss: bytes) -> list[str | None]:
return [e.get("url") for e in ET.fromstring(rss).iter("enclosure")]
def _chapters_urls(rss: bytes) -> list[str | None]:
return [c.get("url") for c in ET.fromstring(rss).iter(_CHAPTERS_TAG)]
def _setup(tmp_path, cut_mode):
conn = db.connect(tmp_path / "hark.db")
conn.execute("INSERT INTO shows (query, title, feed_token, cut_mode) VALUES ('q','Show A',?,?)",
(TOKEN, cut_mode))
cut = tmp_path / "cut2.mp3"
cut.write_bytes(b"cut-bytes")
conn.execute("INSERT INTO episodes (show_id, guid, title, audio_url, cut_path) "
"VALUES (1,'ep-2','Ep 2','http://orig/ep2.mp3',?)", (str(cut),))
conn.execute("INSERT INTO ad_segments (episode_id, start_second, end_second, source) "
"VALUES (1, 30, 60, 'llm')") # episode id 1, one cuttable ad span
conn.commit()
return conn
def test_recommendation_feed_cut_mode_serves_the_cut(tmp_path):
conn = _setup(tmp_path, "cut")
rss = podcast_feed.build_recommendation_feed(conn, "holden", "http://h:8710", [1], "rec-token")
assert f"http://h:8710/audio/1/{TOKEN}.mp3" in _enclosure_urls(rss) # local cut
assert _chapters_urls(rss) == [] # no chapters in cut mode
def test_recommendation_feed_honors_chapters_mode(tmp_path):
# the regression this fixes: a show set to "mark, don't cut" was still hard-cut in the
# cross-show recommendation feed. Now it serves the ORIGINAL audio + a chapters link.
conn = _setup(tmp_path, "chapters")
rss = podcast_feed.build_recommendation_feed(conn, "holden", "http://h:8710", [1], "rec-token")
assert "http://orig/ep2.mp3" in _enclosure_urls(rss) # original, not the cut
assert f"http://h:8710/chapters/1/{TOKEN}.json" in _chapters_urls(rss)
+24
View File
@@ -89,6 +89,30 @@ def test_feed_route_points_cut_episode_at_local_audio(server):
assert f'url="http://myhost:8710/audio/2/{TOKEN}.mp3"'.encode() in data
def test_chapters_mode_serves_original_audio_with_chapter_links(server, tmp_path):
import json
c = db.connect(tmp_path / "hark.db")
c.execute("INSERT INTO ad_segments (episode_id, start_second, end_second, source) "
"VALUES (2, 30, 60, 'llm')")
c.execute("UPDATE shows SET cut_mode = 'chapters' WHERE id = 1")
c.commit()
c.close()
resp, data = request(server, f"/feed/1/{TOKEN}")
assert resp.status == 200
assert b"podcast:chapters" in data
assert b'url="http://original/ep2.mp3"' in data # ORIGINAL audio, not the cut file
assert f"/chapters/2/{TOKEN}.json".encode() in data
resp, data = request(server, f"/chapters/2/{TOKEN}.json")
assert resp.status == 200
doc = json.loads(data)
assert doc["version"] == "1.2.0"
assert {"startTime": 30.0, "title": "Advertisement"} in doc["chapters"]
assert {"startTime": 60.0, "title": "Content"} in doc["chapters"]
assert request(server, f"/chapters/2/wrong.json")[0].status == 404 # wrong token
def test_feed_route_uses_admin_base_url_override(server, tmp_path):
# App.base_url re-reads auth.db's settings row on every access (see its
# own docstring) — an admin-set override must be picked up by an
+12
View File
@@ -43,6 +43,18 @@ def test_search_finds_the_right_episode_with_a_snippet(conn):
assert transcript_search.search(conn, "nonexistentword") == []
def test_search_scopes_to_a_genre(conn):
transcript_search.index_transcripts(conn)
conn.execute("INSERT INTO topics (id, label) VALUES (1, 'Dyatlov Pass')")
conn.execute("INSERT INTO topic_genres (topic_id, genre) VALUES (1, 'history')")
conn.execute("INSERT INTO episode_topics (episode_id, topic_id) VALUES (1, 1)")
conn.commit()
# ep 1 matches the phrase AND falls in 'history' — kept under that filter, dropped under another
assert [h["id"] for h in transcript_search.search(conn, "Dyatlov", genre="history")] == [1]
assert transcript_search.search(conn, "Dyatlov", genre="disaster") == []
assert [h["id"] for h in transcript_search.search(conn, "Dyatlov")] == [1] # no filter, unchanged
def test_search_is_guarded_before_the_index_exists(tmp_path):
c = db.connect(tmp_path / "empty.db") # no transcript_fts table ever created
assert transcript_search.search(c, "anything") == [] # guarded, not a crash
+58 -4
View File
@@ -86,8 +86,8 @@ def login(srv, password="letmein"):
def test_everything_gated_except_allowlist(server):
for path in ("/", "/topics", "/topic/1", "/notable", "/pipeline", "/feeds", "/search", "/shows",
"/show/1", "/episode/1", "/account"):
for path in ("/", "/topics", "/topic/1", "/notable", "/pipeline", "/feeds", "/stats", "/search",
"/shows", "/show/1", "/episode/1", "/account"):
resp, _ = request(server, "GET", path)
assert resp.status == 303, path
assert resp.getheader("Location") == "/login"
@@ -110,12 +110,56 @@ def test_no_inline_styles(server):
# is silently no-op'd by the browser rather than erroring — easy to miss
# without actually rendering the page. Guard against it creeping back in.
cookie = login(server)
for path in ("/login", "/", "/topics", "/topic/1", "/notable", "/pipeline", "/feeds", "/shows",
"/show/1", "/search", "/account"):
for path in ("/login", "/", "/topics", "/topic/1", "/notable", "/pipeline", "/feeds", "/stats",
"/shows", "/show/1", "/search", "/account"):
_, body = request(server, "GET", path, cookie=cookie)
assert 'style="' not in body, path
def test_search_genre_filter(server):
cookie = login(server)
_, body = request(server, "GET", "/search?q=Somerton&genre=mystery", cookie=cookie)
assert "Somerton Man" in body # topic is in 'mystery'
assert 'value="mystery" selected' in body # dropdown reflects the filter
_, body = request(server, "GET", "/search?q=Somerton&genre=history", cookie=cookie)
assert "Case 1: Somerton" not in body # filtered out of a genre it isn't in
assert "Somerton Man" not in body
def test_cut_mode_toggle(server):
cookie = login(server)
_, body = request(server, "GET", "/show/1", cookie=cookie)
assert "Switch to chapter markers" in body # show starts in 'cut' mode
resp, _ = request(server, "POST", "/show/1/cut-mode", cookie=cookie)
assert resp.status in (302, 303)
_, body = request(server, "GET", "/show/1", cookie=cookie)
assert "chapter markers" in body and "Switch to cutting" in body # flipped
def test_stats_page_shows_ad_savings(tmp_path):
conn = db.connect(tmp_path / "hark.db")
conn.execute("INSERT INTO shows (query, title, feed_url) VALUES ('q', 'Show A', 'http://x')")
conn.execute("INSERT INTO episodes (show_id, guid, title, cut_path) VALUES (1, 'g1', 'Ep', 'cut/1.mp3')")
conn.execute("INSERT INTO ad_segments (episode_id, start_second, end_second, source) "
"VALUES (1, 0, 3720, 'llm')") # 62 min of ads on a cut episode
conn.commit()
conn.close()
srv = web.make_server(tmp_path / "hark.db", tmp_path / "auth.db",
bind="127.0.0.1:0", admin_token="t")
thread = threading.Thread(target=srv.serve_forever, daemon=True)
thread.start()
try:
resp, _ = request(srv, "POST", "/login", body={"username": "admin", "password": "t"})
cookie = resp.getheader("Set-Cookie").split(";")[0]
resp, body = request(srv, "GET", "/stats", cookie=cookie)
assert resp.status == 200
assert "1.0h" in body and "of ads removed" in body # 3720s cut from a cut episode
assert "1" in body and "episodes cut" in body
assert "llm" in body # tier breakdown
finally:
srv.shutdown()
def test_feeds_hub_lists_recommendation_and_show_feeds(server):
cookie = login(server)
resp, body = request(server, "GET", "/feeds", cookie=cookie)
@@ -126,6 +170,16 @@ def test_feeds_hub_lists_recommendation_and_show_feeds(server):
assert "genre=mystery" in body # a per-genre filtered feed URL (ep 1 is 'mystery')
def test_feeds_opml_export(server):
cookie = login(server)
resp, body = request(server, "GET", "/feeds.opml", cookie=cookie)
assert resp.status == 200
assert "<opml" in body and "xmlUrl=" in body
assert "/recommended/" in body # recommendation feed outline
assert "/feed/1/" in body and "Show A" in body # subscribed show's ad-stripped feed
assert "attachment" in (resp.getheader("Content-Disposition") or "")
def test_recommendation_feed_genre_filter(server, tmp_path):
from hark.auth import Auth
login(server)