diff --git a/plan.md b/plan.md index 8122ce4..05ffe5e 100644 --- a/plan.md +++ b/plan.md @@ -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 0–4 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 1–2, 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 | diff --git a/tests/test_web.py b/tests/test_web.py index 2bdb307..5cc65c8 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -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"] = '' + html = templates.pending_page(pending=items, sources=[_source()], + csrf="tok").decode() + assert " 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: + self._form_cache = {} + else: + raw = self.rfile.read(length).decode("utf-8", "replace") + self._form_cache = urllib.parse.parse_qs(raw, keep_blank_values=True) + return self._form_cache + def _form(self) -> dict[str, str]: - length = int(self.headers.get("Content-Length") or 0) - if length <= 0 or length > MAX_BODY: - return {} - 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() - } + """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", "

Not found

")) 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): diff --git a/ytstream/web/templates.py b/ytstream/web/templates.py index 32d0de1..74d35df 100644 --- a/ytstream/web/templates.py +++ b/ytstream/web/templates.py @@ -251,3 +251,93 @@ def index_page( """ 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 = 'never synced' + elif source["last_sync_ok"]: + state = 'ok' + else: + state = (f'failing ' + f'({source["consecutive_failures"]})') + + 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'

{_e(source["last_error"])}

' + + return f""" +
+ {_e(source['label'])} {state} +

{_e(source['channel_id'])} · last sync + {_e(source['last_sync_at'] or 'never')} + · first import {'done' if source['imported'] else 'pending'}

+ {error} +
""" + + +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 ( + '

No sources yet.

' + ) + + if pending: + rows = "".join(f""" + + + + {_e(item['channel_id'])} +""" for item in pending) + queue = f""" +
+ +

{len(pending)} channel(s) waiting. Approving a channel + backfills its recent videos and starts polling it; rejecting one means it is + never offered again.

+ + + {rows} +
ChannelChannel id
+
+ + +
+
""" + else: + queue = '

Nothing awaiting approval.

' + + body = f""" +

ytstream

+ + +

Mirrored accounts

+{source_html} +
+ + + +
+
+ + +
+ +

Awaiting approval

+{queue} +""" + return page("Subscriptions — ytstream", body)