Admin routes for the subscription queue, and record the build in plan.md

Phase 4's acceptance criterion is that my brother approves the first import
himself, which needs a UI, so /pending now carries source management, sync-now, and
multi-select approve/reject. Driven over real HTTP rather than only through the
templates: unauthenticated requests redirect to login, all four new routes reject a
missing or forged CSRF token, and the live account rendered 117 checkboxes.

Approving three at once added three channels, which is the point of the fix
underneath. _form() collapses repeated fields to the last value, which is correct
for every single-value field but silently wrong for a form of checkboxes all named
`id` — it would have approved only the last box ticked. Added _form_list(), with the
parsed body cached because rfile can only be read once and the approval path needs
both views of it.

The approval page is deliberately its own page rather than a section on the index:
the first sync of the real account queued 119 channels, and that does not belong
inline under the channel table. Source errors are shown in full rather than
truncated, because the useful ones say exactly what to do — "subscriptions are
private, uncheck Keep all my subscriptions private" — and hiding that behind a log
file defeats the purpose of surfacing it.

plan.md §13 now reflects what is actually built rather than what was intended, and a
new §17 records the three bugs the build turned up, including which of them a test
caught and which two needed real data. 337 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Flux
2026-08-12 16:41:47 +01:00
co-authored by Claude Opus 5
parent 155f05773d
commit 61cc1672ec
4 changed files with 360 additions and 33 deletions
+96 -24
View File
@@ -1,9 +1,11 @@
# `ytstream` — implementation plan
**Target machine:** `susan`
**Status:** streaming PoC verified end to end against real videos and real Jellyfin — every
measurement behind this plan is written up in **`FINDINGS.md`** alongside this file. Nothing is
installed as a service yet. This document is the plan for turning it into one.
**Status:** **built.** Phases 04 of §13 are code-complete with 337 passing tests, verified against
the live YouTube Data API and the running proxy. What remains is installation, which needs root
(`sudo deploy/deploy.sh`), and the cut-over in §12. The streaming PoC measurements this plan was
designed around are in **`FINDINGS.md`** alongside this file; what the build itself changed is in
**§17**.
**Repo:** `/opt/ytstream`, pushed to `/disks/git-repos/ytstream.git`, branch `main`. The PoC code
still lives in `/home/susan/ytstream` and is *not* under version control; Phase 2 moves it in and
@@ -739,30 +741,36 @@ Each phase ends in something checkable. Do not start the next one until it does.
pass; `tools/verify_api.py` reproduces them in one command. Results and the numbers they changed are
in §16.
**Phase 1 — skeleton and lift.** Fork the tree, new package name, new DB path, new schema (§7),
lifted modules and their tests passing. No new behaviour.
→ *Done when: `pytest` is green and `ytstream doctor` reports a healthy environment.*
**Phase 1 — skeleton and lift. ✅ COMPLETE 2026-08-12.** Forked, renamed, new schema, 337 tests
green. Lifted: `naming`, `nfo`, `db`, `settings`, `util`, `config`, `channels`, `jellyfin`, `doctor`,
`ytdlp`, `web/`. Deleted `download.py`. See §17 for what changed on the way through.
**Phase 2 — the proxy as a service.** Move it in, split it up, add the startup sweep and the LRU
test, write `deploy/deploy.sh`, operator runs it.
→ *Done when: `systemctl status ytstream-proxy` is active after a reboot, `/healthz` answers, and
Jellyfin direct-plays a cold video end to end.*
**Phase 2 — the proxy as a service. ⏳ CODE DONE, INSTALL PENDING.** Moved to
`proxy/ytstream_proxy.py`; its two standalone test scripts are now `tests/test_proxy.py`, driving the
real `make_handler(mgr, …)` so routing and video-id validation are covered too. `deploy/` carries both
systemd units, `bootstrap.sh` and `deploy.sh`.
→ *Blocked on root: the operator must run `sudo /opt/ytstream/deploy/deploy.sh`. Until then the
PoC-era proxy on 8099 is what serves playback.*
**Phase 3 — catalogue and retention.** `api.py`, `strm.py`, the 30-day bounded resumable backfill, the
aging-out sweep with its tombstones, the hourly run. Build **Pitch Side alone** (expect ~18 episodes)
and time a Jellyfin scan for the §5 record.
→ *Done when: the expected episode count is visible with correct titles, dates, durations and
thumbnails; one of them plays; scan time per 1,000 episodes is recorded in §5; and — the part most
likely to be wrong — a video forced past the window is deleted from disk, and the **next two polls do
not bring it back**.*
**Phase 3 — catalogue and retention. ✅ CODE COMPLETE, verified against the live API.** Pitch Side
backfilled to **20 episodes** (the §5 estimate was ~18), Asianometry to 6, in **6.8 s** for a full
channel. Titles, exact dates and durations all correct; a generated `.strm` fetched through the proxy
returns h264 720p + aac and honours ranges; the NFO's `durationinseconds` matches the API to the
second.
→ *Still outstanding: the Jellyfin scan-time measurement for §5, which needs the tree at the real
media root, which needs Phase 2 installed.*
**Phase 4 — subscription sync.** `subsync.py`, the first-sync bulk import, the add cap, the
missing-threshold, channel deletion on unsubscribe, the admin routes, the healthchecks UUID.
→ *Done when: he approves the first import; then he subscribes to a new channel on YouTube and within
an hour it is a series in Jellyfin with episodes that play, nobody having touched the admin UI. Then
the destructive half: he unsubscribes, and after three syncs the channel and its tree are gone. Plus
a forced 403 and a forced empty response, each of which must leave the DB untouched and turn the check
red test these before trusting the deletion path, not after.*
**Phase 4 — subscription sync. ✅ CODE COMPLETE.** `subsync.py`, the first-sync import, the add cap,
the missing-threshold, deletion on unsubscribe, and the `/pending` admin page with source management,
sync-now, and multi-select approve/reject — all four new routes CSRF-guarded, verified over real HTTP.
Against the live account the first sync queued **119 channels and added none**; approving three at
once added three.
The destructive half is covered by tests rather than by having done it to the real account: a forced
403, a forced network error and a forced empty response each leave the database untouched, absence is
counted across three healthy syncs before deletion, and `manual` channels are exempt.
→ *Still outstanding: the healthchecks UUIDs in `deploy/crontab.fragment`, and watching a real
subscribe-then-unsubscribe cycle once the services are installed.*
**Phase 5 — cut over.** §12 steps 12, run for a week.
→ *Done when: nothing has broken and nobody has used the old library.*
@@ -887,3 +895,67 @@ states return HTTP 403 `forbidden`, and the distinguishing signal is `error.deta
Nothing. Phase 0 is closed. The one open decision is §14 item 2 — whether `min_keep_videos` (§5) is
wanted, which is a five-minute change either way and does not block starting Phase 1.
---
## 17. What the build changed — 2026-08-12
Three bugs, two of which only real data would have found. Recorded because each one
is a trap the next change could walk back into.
### The prune boundary — caught by a test
`strm.remove` pruned empty directories up to the media root, so a channel directory
whose `tvshow.nfo` happened to be missing would be deleted along with its last
season. It only *looked* safe because `tvshow.nfo` normally stops the walk. The
boundary is now the channel directory explicitly, and a test asserts the channel
directory survives.
### Titles were missing on the oldest episodes of every backfill
The backfill inserted rows with no title and left the RSS poll to fill them in.
That works only if RSS reaches as far back as the retention window, and it does
not: the feed returns 15 entries, which for Pitch Side spans 23 days against a
30-day window. **Five of twenty episodes were named after their video id.**
`playlistItems.list` now requests `snippet` as well as `contentDetails`. Both parts
cost the same single quota unit together as either does alone, and `snippet.title`
arrives alongside the exact publish date. Note the trap next door:
`snippet.publishedAt` is when the video was *added to the playlist*, not when it was
published — only `contentDetails.videoPublishedAt` is the real thing.
### The fallback title was written back to the database — the worse half
`strm.materialise` used `video["title"] or video["video_id"]` for the filename and
then stored *that* as the title. So an untitled row became a row whose title was its
own video id, which is not empty, which permanently disabled the repair path that
fills titles in from a later feed poll. The two bugs compounded: the first created
badly-named episodes and the second made them permanent.
Now the fallback is used for the filename only, and a title that arrives late also
deletes the badly-named files and re-queues the video so it is rewritten under its
real name.
### Also worth knowing
- **`_form()` collapsed repeated fields to the last value.** The approval queue is a
form of checkboxes all named `id`; through `_form()` it would have silently
approved only the last box ticked. Added `_form_list()`, and verified over real
HTTP that approving three at once adds three.
- **`min_keep_videos` shipped at 5** rather than being left open (§14 item 2). Without
it, 52 of 117 measured channels are empty Jellyfin series that flicker in and out
as their single video crosses the window. Set it to 0 to get pure 30-day retention.
- **Two settings validators earn their keep**: `subsync_missing_threshold` rejects 0
at the form, and `subsync.sync_source` clamps it to 1 anyway — a stored zero would
mean "unsubscribe before any absence has been confirmed".
### Measured during the build
| | |
|---|---|
| Tests | **337**, no network, no yt-dlp, no Jellyfin |
| First sync of the real account | 119 queued, **0 added** |
| Pitch Side backfill | **20 episodes** (§5 predicted ~18) in **6.8 s** |
| Asianometry backfill | 6 episodes |
| Generated `.strm` played through the proxy | h264 720p + aac, ranges honoured |
| NFO `durationinseconds` vs API truth | 889 vs 889 |
+72
View File
@@ -144,3 +144,75 @@ def test_csrf_token_rejects_tampering():
def test_csrf_rejects_an_empty_token():
secret = auth.new_secret()
assert not auth.verify_csrf(secret, "s", "")
# --------------------------------------------------- sources / approval queue
def _source(**kw):
base = {"key": "youtube:UCbrother", "label": "C Flux",
"channel_id": "UCPcTWaLV8zwx4WP4QExHj4Q", "enabled": 1, "imported": 1,
"last_sync_at": "2026-08-12T16:00:00+00:00", "last_sync_ok": 1,
"consecutive_failures": 0, "last_error": None}
base.update(kw)
return base
def _pending(n=3):
return [{"id": i, "source": "youtube:UCbrother", "title": f"Channel {i}",
"channel_id": f"UC{i:022d}", "seen_at": "2026-08-12T16:00:00+00:00"}
for i in range(1, n + 1)]
def test_pending_page_renders_the_queue():
html = templates.pending_page(pending=_pending(3), sources=[_source()],
csrf="tok").decode()
assert "3 channel(s) waiting" in html
assert "Channel 1" in html and "Channel 3" in html
assert "Approve selected" in html
def test_every_queue_row_shares_the_id_field_name():
"""The approve handler reads a repeated `id` field; if the template numbered
them uniquely the multi-select would silently approve nothing."""
html = templates.pending_page(pending=_pending(3), sources=[_source()],
csrf="tok").decode()
assert html.count('name="id"') == 3
def test_pending_page_with_an_empty_queue():
html = templates.pending_page(pending=[], sources=[_source()], csrf="tok").decode()
assert "Nothing awaiting approval" in html
def test_pending_page_with_no_sources():
html = templates.pending_page(pending=[], sources=[], csrf="tok").decode()
assert "No sources yet" in html
def test_never_synced_source_is_labelled():
html = templates.pending_page(pending=[], sources=[_source(last_sync_ok=None)],
csrf="tok").decode()
assert "never synced" in html
def test_failing_source_shows_the_actionable_error_in_full():
"""The useful errors say exactly what to do; truncating them defeats the point."""
message = ('subscriptions are private (subscriptionForbidden) — nothing '
'changed. Fix: YouTube → Settings → Privacy → uncheck "Keep all '
'my subscriptions private".')
html = templates.pending_page(
pending=[], sources=[_source(last_sync_ok=0, consecutive_failures=3,
last_error=message)],
csrf="tok").decode()
assert "failing (3)" in html
assert "Keep all" in html
def test_hostile_pending_title_is_escaped():
items = _pending(1)
items[0]["title"] = '<img src=x onerror=alert(1)>'
html = templates.pending_page(pending=items, sources=[_source()],
csrf="tok").decode()
assert "<img src=x" not in html
assert "&lt;img" in html
+100 -7
View File
@@ -16,7 +16,18 @@ import threading
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from .. import channels, config, db, discovery, jellyfin, util, videos, ytdlp
from .. import (
api,
channels,
config,
db,
discovery,
jellyfin,
subsync,
util,
videos,
ytdlp,
)
from ..settings import EDITABLE, MASKED_KEYS, Settings, validate_all
from . import auth, templates
@@ -77,15 +88,32 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
def _form(self) -> dict[str, str]:
def _parsed_form(self) -> dict[str, list[str]]:
"""Parse the body once and cache it.
Cached because the request body can only be read from rfile once, and the
approval queue needs both the single-value and multi-value views of it.
"""
if getattr(self, "_form_cache", None) is None:
length = int(self.headers.get("Content-Length") or 0)
if length <= 0 or length > MAX_BODY:
return {}
self._form_cache = {}
else:
raw = self.rfile.read(length).decode("utf-8", "replace")
return {
key: values[-1]
for key, values in urllib.parse.parse_qs(raw, keep_blank_values=True).items()
}
self._form_cache = urllib.parse.parse_qs(raw, keep_blank_values=True)
return self._form_cache
def _form(self) -> dict[str, str]:
"""Last value wins, which is right for every single-value field."""
return {key: values[-1] for key, values in self._parsed_form().items()}
def _form_list(self, key: str) -> list[str]:
"""Every value for a repeated field.
The approval queue is a form of checkboxes all named `id`. Reading it
through _form() would silently approve only the last box ticked.
"""
return [value for value in self._parsed_form().get(key, []) if value]
def _cookie_token(self) -> str:
return auth.cookie_value(self.headers.get("Cookie") or "")
@@ -141,6 +169,10 @@ class Handler(BaseHTTPRequestHandler):
if path == "/":
return self._send(200, self._render_index(conn, settings, token))
if path == "/pending":
return self._send(
200, self._render_pending(conn, settings, token))
return self._send(404, templates.page("Not found", "<h1>Not found</h1>"))
finally:
conn.close()
@@ -204,6 +236,14 @@ class Handler(BaseHTTPRequestHandler):
return self._add_channel(conn, settings, token, form)
if path == "/settings":
return self._save_settings(conn, settings, token, form)
if path == "/sources":
return self._add_source(conn, settings, token, form)
if path == "/sync":
return self._sync_now(conn, settings)
if path == "/pending/approve":
return self._resolve_pending(conn, settings, form, "approved")
if path == "/pending/reject":
return self._resolve_pending(conn, settings, form, "rejected")
parts = path.strip("/").split("/")
if len(parts) == 3 and parts[0] == "channels" and parts[1].isdigit():
@@ -219,6 +259,59 @@ class Handler(BaseHTTPRequestHandler):
finally:
conn.close()
# ------------------------------------------------- subscription sources
def _render_pending(self, conn, settings: Settings, token: str) -> bytes:
return templates.pending_page(
pending=[dict(row) for row in subsync.pending(conn)],
sources=[dict(row) for row in subsync.all_sources(conn)],
csrf=auth.csrf_token(self._secret(settings), token),
)
def _add_source(self, conn, settings: Settings, token: str, form: dict) -> None:
reference = (form.get("channel") or "").strip()
if not reference:
return self._redirect("/pending")
client = api.Api(settings.get_str("youtube_api_key"))
try:
info = (client.channel(reference) if reference.startswith("UC")
else client.resolve_handle(reference))
except api.ApiError as exc:
log.error("could not resolve source %s: %s", reference, exc)
return self._redirect("/pending")
if not info:
return self._redirect("/pending")
subsync.add_source(conn, channel_id=info["channel_id"], label=info["title"])
return self._redirect("/pending")
def _sync_now(self, conn, settings: Settings) -> None:
"""Run a sync from the UI.
Synchronous, and that is deliberate: it is one API call per 50
subscriptions and the result is what the operator is about to look at.
A background job would mean rendering a page that does not yet reflect
the button that was just pressed.
"""
try:
subsync.sync_all(conn, settings)
except Exception as exc: # noqa: BLE001
log.error("sync from the UI failed: %s", exc)
return self._redirect("/pending")
def _resolve_pending(self, conn, settings: Settings, form: dict,
resolution: str) -> None:
ids = [int(value) for value in self._form_list("id") if value.isdigit()]
if not ids:
return self._redirect("/pending")
if resolution == "approved":
subsync.approve(conn, settings, ids)
else:
subsync.resolve(conn, ids, "rejected")
return self._redirect("/pending")
def _login(self, settings: Settings, form: dict) -> None:
key = self._client_key()
if self.server.throttle.locked(key):
+90
View File
@@ -251,3 +251,93 @@ def index_page(
<footer>Downloads run hourly. Videos are deleted once they pass the retention
window for their channel — this is a DVR, not an archive.</footer>"""
return page("ytstream", body)
# --------------------------------------------------------------------------
# subscription sources and the approval queue
def _source_panel(source: dict, csrf: str) -> str:
if source["last_sync_ok"] is None:
state = '<span class="badge">never synced</span>'
elif source["last_sync_ok"]:
state = '<span class="badge ok">ok</span>'
else:
state = (f'<span class="badge bad">failing '
f'({source["consecutive_failures"]})</span>')
error = ""
if source["last_error"]:
# Shown in full rather than truncated: the useful ones say exactly what to
# do ("subscriptions are private — uncheck Keep all my subscriptions
# private"), and hiding that behind a log file defeats the point.
error = f'<p class="muted">{_e(source["last_error"])}</p>'
return f"""
<div class="panel">
<strong>{_e(source['label'])}</strong> {state}
<p class="muted">{_e(source['channel_id'])} · last sync
{_e(source['last_sync_at'] or 'never')}
· first import {'done' if source['imported'] else 'pending'}</p>
{error}
</div>"""
def pending_page(*, pending: list[dict], sources: list[dict], csrf: str) -> bytes:
"""The approval queue.
A separate page rather than a section on the index because the first sync of a
real account queued 119 channels, and that does not belong inline underneath
the channel table.
"""
source_html = "".join(_source_panel(source, csrf) for source in sources) or (
'<p class="muted">No sources yet.</p>'
)
if pending:
rows = "".join(f"""
<tr>
<td><input type="checkbox" name="id" value="{item['id']}" id="p{item['id']}"></td>
<td><label for="p{item['id']}">{_e(item['title'])}</label></td>
<td class="muted hide">{_e(item['channel_id'])}</td>
</tr>""" for item in pending)
queue = f"""
<form method="post" id="queue">
<input type="hidden" name="csrf" value="{_e(csrf)}">
<p class="muted">{len(pending)} channel(s) waiting. Approving a channel
backfills its recent videos and starts polling it; rejecting one means it is
never offered again.</p>
<table>
<thead><tr><th></th><th>Channel</th><th class="hide">Channel id</th></tr></thead>
<tbody>{rows}</tbody>
</table>
<div style="margin-top:.8rem">
<button type="submit" formaction="/pending/approve">Approve selected</button>
<button type="submit" formaction="/pending/reject" class="danger">
Reject selected</button>
</div>
</form>"""
else:
queue = '<p class="muted">Nothing awaiting approval.</p>'
body = f"""
<h1>ytstream</h1>
<nav class="muted"><a href="/">Channels</a> · <strong>Subscriptions</strong></nav>
<h2>Mirrored accounts</h2>
{source_html}
<form method="post" action="/sources" class="row">
<input type="hidden" name="csrf" value="{_e(csrf)}">
<input type="text" name="channel" placeholder="@handle or UC... id"
style="min-width:18rem">
<button type="submit">Add account</button>
</form>
<form method="post" action="/sync" style="margin-top:.6rem">
<input type="hidden" name="csrf" value="{_e(csrf)}">
<button type="submit">Sync now</button>
</form>
<h2>Awaiting approval</h2>
{queue}
"""
return page("Subscriptions — ytstream", body)