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", "
{_e(source["last_error"])}
' + + return f""" +{_e(source['channel_id'])} · last sync + {_e(source['last_sync_at'] or 'never')} + · first import {'done' if source['imported'] else 'pending'}
+ {error} +No sources yet.
' + ) + + if pending: + rows = "".join(f""" +Nothing awaiting approval.
' + + body = f""" +