Stats page, mark-don't-cut chapters mode, OPML export, search genre filter (0.35.0)
- /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:
@@ -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
@@ -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,3 +1,3 @@
|
||||
"""hark — cross-podcast topic index and discovery service."""
|
||||
|
||||
__version__ = "0.34.1"
|
||||
__version__ = "0.35.0"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user