Commit Graph
3 Commits
Author SHA1 Message Date
Tom FluxandClaude Opus 5 d3bf8d6f19 Deploy it, and fix the six things installation found
Both units are installed and running, 10 of 119 channels approved, 251 episodes
live in Jellyfin with verified DirectPlay. 345 tests. Six problems surfaced that no
test could have, and two of them were mine in the deploy scripts.

deploy.sh had a circular dependency with bootstrap.sh: deploy started the units and
told the operator to run bootstrap, but bootstrap refused to run until the state
directory existed, which only deploy creates. The units started against a
non-existent venv, failed 203/EXEC and restart-looped 17 and 21 times. deploy.sh now
creates the directory, calls bootstrap itself through runuser so the venv is not left
root-owned, and refuses to start units when the venv is still missing.

Deno was absent, and `doctor` is the only reason we know. It is mandatory rather than
nice-to-have — without a JS runtime yt-dlp cannot solve the n challenge, which
youtube-automate measured on this machine as 22 formats instead of 29 plus
throttling. Nothing else would have complained; playback would just have quietly
degraded. bootstrap.sh now installs it and asserts yt-dlp reports it.

Episodes had no synopsis at all, because materialise passed plot=None while both
sources hand us descriptions for free. Now plumbed through from RSS
(media:group/media:description) and from videos.list, which carries
snippet.description in the call already being made for durations — so the ~40% of
episodes older than RSS reaches get one too. That needed a schema v2 migration; v1
was left exactly as shipped so a fresh install and a migrated one are identical, and
a test asserts it.

`materialise --all` — the documented recovery from a Jellyfin metadata wipe — was
itself creating duplicates. Episode numbers were re-derived each run, and
next_episode() excludes the row being numbered, so re-materialising a day's videos in
a different order renumbered them and orphaned the old files. One run left 102
orphaned NFOs against 251 episodes. An episode number is now permanent once assigned,
and a video whose rel_path changes has its old files removed first. Running it twice
is now a no-op.

Two Jellyfin behaviours worth having in writing. It ignores <runtime> and
<durationinseconds> for episodes while reading the rest of the NFO happily, so a
.strm shows no duration until first played — not fixable without probing, which is
the one thing this design exists to avoid. And a plain /Library/Refresh does not
reliably re-read a rewritten NFO: after rewriting all 251, fifty kept their old empty
metadata. The fix is metadataRefreshMode=Default with replaceAllMetadata=false, which
took plots from 201 to 251 while the proxy served zero requests. §5's prohibition on
replaceAllMetadata=true still stands — that one probes. Exposed as
`ytstream refresh-metadata` and run automatically after `materialise --all`.

The measurement §5 has been waiting for: a full Jellyfin scan of 251 .strm files
took ~119 s, about 8 minutes per 1,000 episodes, and made zero media probes. That
last number is the fact the whole design rests on, now confirmed at scale on the real
library rather than on seven PoC files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:21:35 +01:00
Tom FluxandClaude Opus 5 61cc1672ec 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>
2026-08-12 16:41:47 +01:00
Tom FluxandClaude Opus 5 155f05773d Build ytstream: catalogue, retention, subscription mirror, proxy
Phases 1-4 of plan.md §13. Forked from youtube-automate as planned rather than
written from scratch: naming, NFO, auth, the admin UI, settings and the DB layer
came across largely unchanged, download.py is gone, and the pieces that only make
sense for a streaming library are new.

  api.py       YouTube Data API v3 client. The whole metadata path.
  strm.py      Materialising: a .strm, an .nfo and a thumbnail. Replaces the
               330-line download.py, because the job is writing a URL to a file.
  subsync.py   The subscription mirror, most of which is refusals.
  reap.py      Retention, rewritten around the 30-day window and min_keep_videos.
  discovery.py RSS polling plus an API-backed, resumable, bounded backfill.
  proxy/       The verified PoC, moved in with a systemd unit.

330 tests, all passing, no network and no yt-dlp in any of them. The suite leans
towards the failure paths, because that is where this design can actually hurt
someone: a 403 that looks like an unsubscribe, a video that ages out and comes
back, a title that never arrives. tests/test_proxy.py replaces the two standalone
scripts under proxy/ and now drives the real make_handler(mgr, ...) rather than the
PoC's make_handler(path, done), so routing and video-id validation are covered too.

Ran it end to end against the live API and it found three real bugs.

The first was mine and the tests caught it: 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 the season. It only looked safe because
tvshow.nfo normally stops the walk. The prune boundary is now the channel
directory explicitly.

The other two only showed up against real data, and they compounded. The backfill
inserted rows with no title and left the RSS poll to fill them in — but RSS returns
15 entries, which for Pitch Side spans 23 days against a 30-day window, so five of
twenty episodes were named after their video ids. Worse, strm.materialise wrote
that fallback back to the database as the title, which made the row look titled and
permanently disabled the repair path. Both fixed: playlistItems.list now requests
snippet as well as contentDetails, which costs the same single quota unit and
carries the title alongside the exact publish date, and the fallback is used for the
filename without being persisted. A title that does arrive late now also removes the
badly-named files and re-queues, so the episode is rewritten rather than keeping its
video-id name forever. Verified against the live API: all twenty Pitch Side episodes
now carry real titles.

Measured on the real account: 119 subscriptions queued for approval and none added
on the first sync, then a two-channel run backfilled and materialised 26 episodes in
under seven seconds.

Two deliberate departures from plan.md, both recorded there:

min_keep_videos defaults to 5 rather than being left as an open question. Without
it 52 of 117 measured channels are empty Jellyfin series that flicker in and out as
their single video crosses the retention line, and the plan already recommended it.

The Jellyfin refresh is a bare /Library/Refresh with a comment explaining why it
must stay that way. A normal scan makes zero media probes; FullRefresh does probe,
and at 400 episodes that is 400 cold starts.

Not yet done: no systemd units are installed (needs root — deploy/deploy.sh), the
admin UI has no routes for sources or the approval queue yet, and nothing has been
pointed at the real media root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:35:23 +01:00