Files
ytstream/plan.md
T
Claude d3616e7532 Bound time-to-first-byte so a first play actually plays
Playback failed in Jellyfin the day after deployment, for uncached videos
only. The handler blocked on the full download-and-mux before sending
anything at all -- not even response headers -- so a 46-minute upload sat
silent for 79 seconds and the client gave up. The proxy counted it a
success, which is why /healthz and doctor both looked fine.

Yesterday's "DirectPlay verified" only ever ran against videos already
pulled during testing, so the first-play path was never exercised.

The output is already a fragmented MP4, so it is readable while being
written; a finished file only buys a correct duration and working seeks.
Cap the wait at --first-byte-grace (12s, explicit in the unit) and stream
whatever has not muxed by then. Measured: 156s -> 12.0s TTFB on a
66-minute upload, with ranges on a complete file unchanged.

The cost is a first play with no seek bar when the grace is missed. Every
later play of that video is perfect.

Also fixes a hang found while testing this: the streaming loop waited on
"finished and good" rather than "finished", so a producer that died after
writing some bytes held the connection for the full 45s stall timeout and
then dropped it.

The stale yt-dlp warning pointed at youtube-automate's venv, the tree we
are decommissioning; it now names ytstream's. /healthz reports the serving
mode and grace. Eight new proxy tests, 353 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 10:31:47 +01:00

64 KiB
Raw Blame History

ytstream — implementation plan

Target machine: susan Status: deployed and running. Both systemd units are installed and active, 10 of the 119 mirrored channels are approved, and 251 episodes are live in Jellyfin with correct metadata and verified DirectPlay. 345 tests pass. What remains is the cron entries, curating the rest of the subscription list, 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 changed is in §17; what deployment changed is in §18.

Repo: /opt/ytstream, pushed to /disks/git-repos/ytstream.git, branch main. The PoC scaffolding under /home/susan/ytstream is superseded; the proxy now lives in proxy/ and the PoC-era media tree was moved to /disks/Plex/_cache/ytstream-poc-tree-backup.

Relationship to youtube-automate: ytstream replaces it. The two are entirely separate trees, databases, services and Jellyfin libraries, and they will run side by side only for as long as it takes to satisfy §13. youtube-automate is then decommissioned (§12). Nothing in /opt/youtube-automate is modified by this work.


1. What we're building

A DVR-shaped YouTube library for Jellyfin that stores no video bytes.

youtube-automate downloads each video to disk and points Jellyfin at the file. ytstream writes a ~50-byte .strm file containing a URL, and materialises the actual video only when somebody presses play — fetched on demand by a local proxy, held on tmpfs, dropped when the cache fills.

Explicitly in scope

  • Mirroring one YouTube account's subscriptions as Jellyfin TV series, one episode per video — that account is the source of truth for what exists (§4)
  • Automatic subscription pickup from a YouTube account's public subscription list (§4) — the new requirement
  • A rolling 30-day window per channel: videos older than that are removed from disk, so the library stays small and bounded forever (§5)
  • Just-in-time streaming via the proxy, with per-video caching
  • NFO metadata, episode thumbnails, channel poster/fanart
  • An admin UI for subscriptions, status and settings
  • Running as real systemd services with real logs and real alerting

Explicitly out of scope — do not build these

  • Any form of transcoding. susan is a dual Westmere Xeon with no AVX; software transcode is off the table. The proxy produces -c copy fMP4 that Jellyfin direct-plays, and that is the only supported path.
  • Downloading and keeping video files. If we want a permanent copy of something, that is a different tool.
  • Playlists, Shorts, livestreams, comments, community posts, memberships.
  • Anything that runs on victoria (the Linode). susan's residential IP is a load-bearing part of not getting flagged by YouTube.
  • A YouTube account login / cookies. PO tokens only, exactly as youtube-automate does it.

2. This is a fork, not a green-field rewrite

youtube-automate is 3,543 lines and most of it is correct, verified, and has nothing to do with downloading. Rewriting it from scratch would mean re-deriving the RSS filtering, the episode numbering, the NFO schema and the auth code — all of which were validated on the live machine on 2026-08-11 and are documented in /opt/youtube-automate/specs.md.

So: copy the tree, then add, delete and replace.

youtube_automate module Fate in ytstream Note
discovery.py Lift, then extend UULF-feed polling is the cheapest correct incremental source and stays — and at 119 channels it still completes in 8 s (§16). Gains an API-backed 30-day backfill (§3).
naming.py Lift unchanged Season = upload year, episode = MMDD*10 + ordinal. Keep byte-identical so both trees sort the same during the overlap.
nfo.py Lift, small change Drop <fileinfo><streamdetails> — measured to accomplish nothing (FINDINGS §6). Keep <durationinseconds>.
channels.py Lift, minus artwork fetch Artwork moves to the API/i.ytimg.com path already built in add_thumbnails.py.
db.py, settings.py, util.py, config.py Lift New DB file and new schema version (§7).
web/ (auth, server, templates) Lift 838 lines of working scrypt auth + admin UI. New routes for subscription sources.
jellyfin.py Lift, harden Library refresh must never be replaceAllMetadata (§5).
doctor.py Lift, extend Add checks for API key validity, proxy health, .strm orphan count.
ytdlp.py Lift, narrow Only the proxy calls yt-dlp now.
download.py (330 lines) Delete Replaced by strm.py, which writes a text file.
reap.py (150 lines) Lift, simplify Originally slated for deletion — the 30-day window (§5) brings it back. Same aging-out logic and tombstone semantics, minus the disk-cap machinery, and deleting a .strm instead of a 500 MB mp4.
runner.py Rewrite The run loop changes shape: sync subscriptions → poll → materialise → refresh.
New: strm.py Writes .strm + .nfo + thumbnail for one video.
New: subsync.py The brother-subscription puller (§4).
New: api.py YouTube Data API v3 client (§3).
New: proxy/ /home/susan/ytstream/ytstream.py, moved in and split up.

Everything lifted keeps its tests. youtube-automate has 12 test modules; they come across too.


3. The pivot: the YouTube Data API becomes the metadata source

The subscription feature (§4) forces us to get a Google API key. Once we have one, it is worth noticing what else it buys, because it resolves the two problems that would otherwise make the full back catalogue impractical.

Verified quota costs (developers.google.com/youtube/v3/determine_quota_cost): a new project gets 10,000 units/day shared across everything except search.list and videos.insert, which have their own 100-call/day buckets. subscriptions.list, playlistItems.list, videos.list and channels.list are 1 unit each, and each returns up to 50 items.

That changes the arithmetic completely. All figures below use the measured shape of the real account: 119 subscriptions, 441 long-form videos in a 30-day window (§16).

Job Method Calls Units
Sync subscriptions, hourly subscriptions.list 3 pages × 24/day 72/day
Initial backfill, all 119 channels playlistItems.list @50 119 119 once
Durations for those 441 videos videos.list @50 ids 9 9 once
Steady-state incremental discovery RSS feed 119/hour 0
Durations for the day's ~15 new videos videos.list @50 ids 1 1/day

Initial build: ~130 units. Steady state: ~75 units/day, nearly all of it the hourly subscription poll — which is 3 pages rather than 1 precisely because there are 119 subscriptions. Against 10,000/day that is under 1%, so quota is not a constraint worth designing around, provided we never touch search.list (100 calls/day, and we have no use for it).

If it ever mattered, the lever is obvious: the subscription list changes daily at most, so polling it hourly is already generous and dropping to every 4 hours would cut the bill by 75%.

Why this matters more than the quota

It takes yt-dlp out of the cataloguing path entirely. youtube-automate needs a yt-dlp extraction per video to learn an upload date whenever it backfills past the RSS feed, at roughly 2.3s each. That is the "scan storm" failure mode the original handover warned about: requests to YouTube from a residential IP, in bulk, for videos nobody asked to watch.

Be honest about the size of the win now that the window is 30 days: at 441 videos, the yt-dlp route would be ~441 extractions ≈ 17 minutes, not the hours a 90-day window implied. The API is still the right answer — it is documented, keyed, quota-metered, indifferent to PO tokens and SABR, and cannot be rate-limited by YouTube's anti-bot heuristics — but it is now buying correctness (exact dates, real durations) far more than it is buying safety.

So the split becomes:

  • Cataloguing (what exists, when, how long, what it's called) → YouTube Data API + RSS. No yt-dlp, no PO token, no IP-flagging risk.
  • Playback (actual media bytes) → yt-dlp inside the proxy, one video at a time, only when a human pressed play.

That is a much better boundary than the current one, and it is the main reason to build ytstream as a new service rather than patch youtube-automate.

It also fixes exact upload dates

Measured during the PoC: --flat-playlist reports timestamp: None, and youtubetab:approximate_date is wrong by up to 2 days. Since season/episode is derived from the upload date, an approximate date means episodes numbered into the wrong day — and Jellyfin caches episode numbers, so fixing it later is a metadata-wipe operation. playlistItems.list returns contentDetails.videoPublishedAt as an exact RFC-3339 timestamp. Use it.

UULF works through the API — verified

youtube-automate polls the UULF playlist (UU with LF spliced in), which is undocumented but excludes Shorts and livestreams at the cheapest possible point (specs.md §4). Whether playlistItems.list would accept an undocumented playlist id was the plan's main open technical question. It does — verified 2026-08-12 against Pitch Side, returning exact contentDetails.videoPublishedAt timestamps:

Playlist totalResults
UULFjCJ2LaOIsPzOoXUTMDI3wg 1,249 long-form only
UUjCJ2LaOIsPzOoXUTMDI3wg 2,724 everything

So UULF excludes 54% of that catalogue, and the existing Shorts/livestream filtering carries over unchanged with no fallback needed. The UU-plus-duration-filter path in earlier drafts is not required — though it remains the correct contingency if YouTube ever retires UULF, since UU is the documented one.

Corroborating, from the videos.list check: of 50 consecutive UU uploads, 38 were ≤120 s. The min_duration_seconds = 120 default is filtering a real majority, not an edge case.


4. Subscription mirroring — the new requirement

Goal: the brother subscribes to a channel on YouTube, and it appears in Jellyfin without anyone touching an admin page.

The account to mirror — resolved

Handle @cflux1030
Channel id UCPcTWaLV8zwx4WP4QExHj4Q
Display name C Flux

Resolved via yt-dlp on 2026-08-12, so no channels.list call is needed at build time — seed it as the single source row.

This account is the sole source of truth. There is no second source and no parallel manual subscription workflow: what he follows on YouTube is what exists in Jellyfin. Tom's own subscriptions are explicitly not part of this. That is a simplification (§4.3) but it moves all the weight onto the removal safeguards (§4.4), because nothing else protects a channel any more.

It works, with one condition on his side

Confirmed against the API reference: subscriptions.list accepts a channelId filter — "The API will only return that channel's subscriptions" — and unlike mine, mySubscribers and myRecentSubscribers it is not documented as requiring an authorized request. So a plain API key is enough. The condition is that his subscriptions must be public: the implementation guide states the API returns 403 if the channel "does not publicly expose its subscriptions and the request is not authorized by the channel's owner", and the errors table lists subscriptionForbidden (403) — "The requester is not allowed to access the requested subscriptions."

This is a good failure mode: 403, not an empty list. We can tell "he made his subscriptions private again" apart from "he has no subscriptions", which matters a lot for §4.4.

There is no scraping fallback — verified. https://www.youtube.com/@cflux1030/channels returns 200 but silently serves the Home tab: the rendered ytInitialData lists exactly four tabs (Home, Videos, Playlists, Search) and contains zero channel ids. YouTube retired the public subscriptions tab, so the API is the only route to this list. That makes the privacy checkbox genuinely mandatory rather than merely the convenient path, and it means a 403 has no workaround short of §4.2.

4.1 Setup — what has to happen once

Google side (Tom, ~5 minutes, free, no billing account required):

  1. console.cloud.google.com → new project, e.g. ytstream.
  2. APIs & Services → Library → YouTube Data API v3 → Enable.
  3. Credentials → Create credentials → API key.
  4. Restrict the key: Application restrictions → None (it is called from a server, so referrer and Android/iOS restrictions do not apply; an IP restriction is optional and breaks if susan's residential IP rotates). API restrictions → YouTube Data API v3 only. Do step 2 before this step. YouTube Data API v3 does not appear in the API-restriction picker until it is enabled on the project, so restricting first produces a key that blocks the one API it exists for — see §16, which is exactly what happened.
  5. Paste it into the ytstream admin UI. It is stored in the setting table like jellyfin_api_key already is — never in the repo, never in a systemd unit.

No OAuth consent screen. No app verification. No user-facing consent flow.

Brother's side (one checkbox, and it is the only thing he has to do):

  • youtube.com → Settings → Privacy → uncheck "Keep all my subscriptions private".

Verify immediately — before writing any of subsync.py — that his account actually returns data:

curl -s "https://www.googleapis.com/youtube/v3/subscriptions?part=snippet\
&channelId=UCPcTWaLV8zwx4WP4QExHj4Q&maxResults=50&key=$KEY" \
  | python3 -c 'import json,sys; d=json.load(sys.stdin); \
      print(d.get("pageInfo")); \
      [print(i["snippet"]["resourceId"]["channelId"], i["snippet"]["title"]) \
       for i in d.get("items",[])]'

A 200 with items[] confirms the design on the real account. A 403 means the privacy checkbox has not taken effect — and per the finding above, there is no way around it.

Verified 2026-08-12: 200 with items. The feature works as designed.

119 subscriptions actually returned, across 3 pages. pageInfo.totalResults claims 127 — see §15; trust the fetched list, not the count. → subsync_max_new = 25.

4.2 Fallback if channelId turns out not to work

Ranked, if and only if the day-one curl fails. Note that all three require him to do something — there is no silent workaround, because the public HTML route is gone.

  1. OAuth with mine=true. Robust and privacy-setting-independent, but carries a trap: confirmed in Google's OAuth 2.0 docs, a project whose consent screen publishing status is "Testing" is "issued a refresh token expiring in 7 days" unless the only scopes are basic profile ones. youtube.readonly is not, so the token would die weekly. Avoiding that means moving the app to "In production", which for a sensitive scope means Google verification — a disproportionate amount of process for one brother. Only do this if option 3 is unacceptable.
  2. Google Takeout subscription CSV, dropped into the admin UI. Manual, but a 30-second job whenever he adds channels, and zero moving parts.
  3. Just tell him to use the admin UI. It already exists and it is one text box.

Design subsync.py so the source of a channel list is pluggable (§4.3 makes this fall out naturally), so swapping between these is a small change rather than a rewrite.

4.3 One source of truth — the sync is authoritative

His subscription list is the subscription list. So the model is simply "make the DB match the API response", and it needs no reconciliation machinery:

  • A channel in his list is subscribed. A channel that leaves it is unsubscribed, subject to §4.4.
  • Two columns on channel carry what the sync needs: source (provenance) and missing_syncs (the §4.4 counter). No join table.

An earlier draft modelled this as multi-source claims, so that a manually-added channel could not be deleted by someone else's list. With one source that machinery is dead weight, and speculative generality in the part of the system that deletes things is the wrong place to spend it. If a second account is ever mirrored, source is already there to key on and the claims model can come back then.

One escape hatch survives: source = 'manual' marks a channel the sync will never remove. It is for pinning something during debugging, not a workflow, and it is not exposed as "subscribe to a channel" in the UI — the way to add a channel is to subscribe to it on YouTube.

The cost of this choice, stated plainly: if he unsubscribes from a channel, its series stops updating and disappears from the library view. That is the correct behaviour for a mirror, and it is exactly why §4.4 refuses to act on a single bad response.

4.4 Removals must be slow and loud; additions must be capped

Two failure modes here are genuinely destructive, and both are cheap to defend against.

Runaway additions. If he has 400 subscriptions, the first sync queues 400 channels — ~8,000 episodes at the measured rate in §5, and 400 backfills' worth of API calls and file writes in one run. The 30-day window makes this survivable where the earlier 90-day draft did not, but it is still not something to discover by accident. So:

  • A sync that would add more than subsync_max_new channels adds none of them. It records them as pending_approval, alerts, and waits for a click in the admin UI.
  • The cap applies per sync run, so ordinary drip-feed additions never trip it.

Separate the first sync from steady state. Otherwise the cap always trips on day one, whatever it is set to, and the guard trains everyone to ignore it. This is now certain rather than hypothetical: the measured list is 119 channels, so the first sync trips any sane cap. So a source's first sync is an explicit bulk import — the admin UI shows all 119 with per-channel checkboxes and the episode count each implies, and nothing is subscribed until someone confirms. It is also the natural place to not take all 119: a personal subscription list accumulated over years is not automatically a list of things worth putting in a media library.

From the second sync onwards the cap is a runaway guard. subsync_max_new = 25, from max(10, ceil(119 × 0.2)) — a genuine burst gets through, an order-of-magnitude jump does not.

Runaway removals. A transient 403, a network blip, or him re-ticking the privacy box all look like "he unsubscribed from everything". Deleting 119 channels' worth of Jellyfin metadata on that basis would be unrecoverable in any pleasant way. So:

  • A 403, a 5xx, a timeout, or a zero-item 200 is never treated as a removal. It increments the source's failure counter, alerts, and changes nothing. A genuinely empty list is indistinguishable from a broken one in consequence, and we prefer the harmless reading.
  • A channel missing from an otherwise-healthy response increments missing_syncs. Only at subsync_missing_threshold (default 3 consecutive syncs, so ~3 hours) is it unsubscribed.
  • Unsubscribing deletes the channel — its directory, its .strm files, its NFOs and artwork, and its rows. Re-subscribing rebuilds it from scratch, which under the 30-day window (§5) is one playlistItems.list page and ~20 files: cheap enough that keeping a dormant copy around would be the more complicated choice. The Jellyfin series disappears, taking its watch state with it.
  • source = 'manual' channels are exempt from all of the above.

Note how the two halves interact: because the action is now destructive and irreversible-ish, the detection rules above are what stand between a transient 403 and a wiped library. They are not belt-and-braces. Do not weaken the three-sync threshold to make testing more convenient — make the threshold configurable and set it to 1 in tests.

Ordering: the sync runs before the poll in the same run, so a channel added at 14:00 has its catalogue built in the same pass.

4.5 Alerting

Existing convention on susan is runitor + healthchecks.io at hc.jihakuz.xyz, one UUID per job. Follow it. A sync that hits 403, trips the add cap, or accumulates failures must exit non-zero so the check goes red — a silently-broken subscription mirror is the worst possible outcome, because nothing appears to be wrong until someone asks why a channel never showed up.

4.6 The brother gets admin access, on the existing shared password

He gets access, which is right — the approval queue (§4.4) and the "channel went missing" alerts are about his subscriptions, so they should be his to action rather than landing on Tom every time.

Auth does not change. One shared admin_password_hash in the setting table, exactly as youtube-automate already does it, matching how every other service on susan is shared. An earlier draft proposed a two-row user table; it is not worth the code. The two things given up are worth naming once and then forgetting: neither party's access can be revoked without changing the other's password, and the approval log records that something was approved rather than by whom.

web/auth.py therefore carries over unchanged, and it is adequate for an internet-facing login: scrypt hashing and per-address failed-login throttling that reads X-Forwarded-For (which nginx must set — it already does, see §12 step 4).


5. Scale — a rolling 30-day window

retention_days = 30. A video older than 30 days has its .strm, .nfo and thumbnail deleted and its DB row tombstoned. Backfill on subscribe reaches back the same 30 days. One number governs both ends, so the library cannot grow.

This supersedes the earlier "3 months or 300 videos" answer, which was about backfill depth while this is about retention; keeping both would mean backfilling 90 days and then immediately deleting two thirds of it. If the intent was really "go back 3 months, keep everything", set retention_days = 90 and this section still holds — only the numbers move.

What this actually costs — measured across the real subscription list

Not extrapolated. On 2026-08-12 I pulled all 119 subscriptions and probed every one of their UULF feeds:

Channels subscribed 119 (117 with working feeds; 2 return HTTP errors)
Long-form videos in the last 30 days, all channels 441
Median per channel 1
Channels with zero long-form uploads in 30 days 52 of 117
Busiest penguinz0 57, Daggerwin 30, GothamChess 30, FORMULA 1 28
Poll cycle for all 119 feeds 7.9 s wall, 8 threads (15 feeds/s)

So the whole library is ~441 episodes, ~1,300 files — smaller than the 20-channel guess this section previously carried, despite six times as many channels, because the distribution is wildly skewed. Jellyfin will not notice this. Neither will the network: an hourly poll of all 119 feeds costs eight seconds.

UULF is doing substantial work. For Pitch Side, playlistItems.list reports 1,249 items in UULF against 2,724 in UU — it excludes 54% of the catalogue — and of 50 consecutive UU uploads, 38 were ≤120 s. The Shorts majority is real, and it is exactly what nobody wants as Jellyfin episodes.

The skew creates a problem the window alone does not solve

52 of 117 channels would produce an empty series. A channel that uploads every six weeks has nothing inside a 30-day window, so it appears in Jellyfin as a show with no episodes — and worse, it would flicker in and out of existence as its one video ages past the line. With a median of 1 video per channel per month, this is the common case, not an edge case.

Recommended fix, and it is a small one: make retention max(30 days, the N most recent videos) via a min_keep_videos setting, default 5.

  • A channel uploading twice a year keeps its last 5 videos permanently visible instead of showing an empty shelf.
  • A channel uploading daily is unaffected — 30 days already exceeds 5 videos.
  • Cost: 441 + (52 × 5) ≈ 700 episodes. Still nothing.
  • It removes the flicker, because a video only leaves once 5 newer ones exist.

The alternative — per-channel retention_days overrides — is already in the schema but means hand- tuning 119 channels, which nobody will do. Also materialise a channel's directory only once it has at least one in-window video, so a genuinely dead channel does not create an empty shelf at all.

A useful side effect: RSS nearly covers the whole window

The UULF feed returns 15 entries, and for Pitch Side those 15 span 23.3 days — because the feed holds 15 long-form items, not 15 uploads. At these rates a single free RSS fetch covers most of a 30-day window on its own.

That narrows what the API is for (§3): subscription reading, durations, and topping up the few days RSS does not reach on a fresh subscribe. It does not eliminate it — RSS carries no duration at all, and a busier channel would truncate sooner — but nobody should be surprised when the API turns out to be handling a few dozen calls a day rather than thousands.

Aging out has two consequences worth stating

  1. Tombstones are mandatory, not optional. Delete a .strm and the next poll finds the video in the feed again and re-materialises it, forever. youtube-automate already solves this: the DB row survives deletion in a terminal state (aged_out) and discovery skips it. Lift that behaviour exactly; it is the single most likely thing to get wrong here.

  2. Jellyfin loses watch state for aged-out episodes. Removing the media file removes the item, and with it play counts and resume positions. For a 30-day window on a subscription feed that is acceptable — this is a "what's new" library, not an archive — but it should be a deliberate choice rather than a surprise. Anything worth keeping permanently wants a different tool (§1).

  3. Jellyfin scan cost is now a non-issue, but still worth one measurement. Every episode is a .strm + a .nfo + a thumbnail. Normal scans were measured to make 0 media probes (FINDINGS §6), which is what makes this viable at all. At ~1,200 files the stat-and-parse cost will not be noticeable. Record it in Phase 3 anyway, as seconds-per-1,000-episodes, because it is the number that would let anyone judge a future change to retention_days without re-deriving it.

  4. replaceAllMetadata remains the one real hazard. It is the one operation verified to probe media, and at ~400 items it means ~400 cold starts. The cold-start rate limiter (20/hour) contains the damage to YouTube's side, but the library-side result is items whose metadata got wiped and not re-derived. Defences, all of them:

    • the proxy 503s past the budget (built, tested — test_limits.py)
    • ytstream never itself issues a refresh with replaceAllMetadata=true
    • the admin UI documents "do not click Replace all metadata" in the place where someone would be tempted to
    • NFOs are the source of truth, so recovery is a re-materialise pass over the tree, not a re-fetch from YouTube. Make sure that pass exists and is one CLI command.
  5. Episode numbering holds, and tombstones are what make it hold. MMDD*10 + ordinal clamps at 10 uploads/channel/day and is computed against the DB, not the batch. Since aged-out rows stay in the DB, the ordinal for a given day never shifts as videos are deleted — which is the second reason the tombstones are load-bearing. Season = upload year means a 30-day window usually spans one season and occasionally two, which needs no special handling.

  6. The .strm URL is baked into every file. Changing the proxy's host or port means rewriting all of them. Cheap (it is a tree walk) but it must be a supported CLI command, not a sed someone invents under pressure. Settle the URL now: http://127.0.0.1:8099/watch/<id>. Jellyfin is the only client that ever reads it, it runs on the same host, and 127.0.0.1 means the proxy is unreachable from the network by construction.

  7. Backfill stays resumable (channel.backfill_cursor, commit per page of 50) even though a 30-day backfill is now one or two pages. It costs a column and it is the difference between a crashed first sync resuming and starting over across 119 channels.


6. Components

Four processes. Two are long-running services, two are cron jobs.

6.1 ytstream-proxy — systemd service

The PoC, productionised. /home/susan/ytstream/ytstream.py, 31 KB, currently running by hand.

Already built and verified: /watch/<id> and /healthz, single extraction reused by both yt-dlp legs via --load-info-json, FIFO→ffmpeg -c copy fMP4 on tmpfs, correct HTTP range handling (25 assertions), LRU cache, concurrency cap, retry-on-403, cold-start rate limiter (18 assertions), counters.

Outstanding before it is a service:

  • Install the unit. ytstream.service is written but not installed; susan has no passwordless sudo, so this goes in deploy/deploy.sh for the operator to run once. The unit pins Environment=PATH=/var/lib/ytstream/venv/bin:... (load-bearing — the venv yt-dlp is the only one with the POT plugin), User=susan Group=mediaserver UMask=0002, and Requires=docker.service for the POT provider container.
  • Startup sweep of the work dir. Sessions live in memory; a restart orphans whatever is in /dev/shm/ytstream. Wipe it on start.
  • Prove LRU eviction. It has never fired in testing. Add a test with a tiny --cache-gb.
  • Decide the tmpfs budget. /dev/shm is 24 GB of 47 GB RAM; throughput measured at ~480 MB/hour of video. --cache-gb 8 holds ~16 hours of content, which is ample. Do not raise it without thinking about what else on susan wants RAM.
  • Log to journald rather than a scratch file, and keep the access log behind a flag.

Unresolved, and staying that way: the intermittent 403 has no known cause. Ruled out during the hardening pass: rate limiting, --load-info-json round-tripping, concurrency, and — the plausible-sounding one — missing PO tokens. Pinning player_client=mweb to get token-bearing URLs measured 0 ok / 6 fail, while token-less ANDROID_VR URLs work; had that "fix" shipped it would have broken every playback. Retry with a fresh extraction is the mitigation, it works, and it fires roughly once per dozen cold starts. Keep the retried counter visible so a change in that rate is noticeable.

6.2 ytstream-admin — systemd service

Lifted from youtube_automate/web/ unchanged: shared-password scrypt auth (§4.6), per-address failed-login throttling reading X-Forwarded-For, behind nginx on 127.0.0.1:8086 (8085 is youtube-automate's during the overlap).

New routes beyond what exists: subscription sources (add/remove a mirrored YouTube account, show its last sync, its failure count, its pending_approval queue with an approve/reject action), the first-sync bulk-import screen, per-channel backfill-depth controls, account management for the two users, and the API key field.

Two people use this, so the copy matters more than it did with a single operator: the "do not click Replace all metadata" warning (§5) has to be where someone would be tempted, and the approval screen has to show what a channel implies — an episode count — before it is approved.

6.3 ytstream run — hourly cron

sync sources → poll feeds → materialise → refresh Jellyfin. Under flock on /var/lib/ytstream/run.lock, under runitor with its own healthchecks UUID, via sg mediaserver "..." to match the existing convention.

6.4 ytstream backfill — manual / one-off per channel

The expensive path (§3, §5). Deliberately not on a timer: it runs when a channel is first subscribed, and when someone explicitly asks for a deeper catalogue.


7. Data model

/var/lib/ytstream/ytstream.db. Start from youtube-automate's schema — channel, video, setting are the right shape — with these changes:

channel — add backfill_cursor TEXT (resumable backfill) and uploads_playlist TEXT (whether UULF or UU won, §3). Keep retention_days as a per-channel override of the 30-day window (NULL = use the global setting) — youtube-automate already has this column and the rescan semantics that go with it. No active column: unsubscribing now deletes rather than deactivating (§4.4).

video — drop size_bytes, downloaded_at, attempts, last_error; none of them mean anything when there is no download. Keep deleted_at — it is the aging-out timestamp now (§5). Keep duration (from videos.list; it feeds <durationinseconds>). Add published_at TEXT (exact RFC-3339 from the API) alongside the date-only upload_date that naming uses.

state becomes {listed, materialised, skipped_short, skipped_live, skipped_old, aged_out}. No pending/downloading/failed, because materialising a .strm cannot meaningfully fail. The two terminal states matter and are not the same thing:

  • skipped_old — discovered already outside the window; never materialised. youtube-automate's rescan can revive these if retention_days is raised.
  • aged_out — was materialised, then deleted by the sweep. Never revived, or raising retention_days would resurrect months of episodes into Jellyfin as "new". These are tombstones and discovery must skip them unconditionally (§5).

channel, continued — two more columns instead of the join table an earlier draft had (§4.3): source TEXT NOT NULL ('youtube' for synced, 'manual' for the pinning escape hatch) and missing_syncs INTEGER NOT NULL DEFAULT 0 (the §4.4 counter).

source — new, and in practice exactly one row: key, label, channel_id, enabled, last_sync_at, last_sync_ok, consecutive_failures, last_error. A one-row table rather than six setting keys, because these are fields of one thing and they change together.

pending_approval — new, the §4.4 add-cap and first-sync import queue: source, channel_id, title, seen_at, resolved_at, resolution (approved/rejected).

setting — carry over what still applies (jellyfin_url, jellyfin_api_key, pot_provider_url, max_height, min_duration_seconds, admin_password_hash, session_secret, last_run_at), including retention_days — now 30, and governing both the aging-out sweep and the backfill reach (§5). Drop disk_cap_gb, write_subs, sub_langs, sponsorblock_mark, max_attempts, backfill_days — download-era concepts. Add youtube_api_key, subsync_max_new (25, derived in §4.4), subsync_missing_threshold (3), proxy_base_url (http://127.0.0.1:8099), backfill_max_videos (300) purely as a runaway guard on a channel that turns out to upload 50 times a day, and min_keep_videos (5) — the fix for the 52 empty series measured in §5.

No migration from subs.db. It holds 2 channels and 18 videos. Re-subscribe by hand and let the backfill do the rest; a migration script would be more code than the data is worth.


8. On-disk layout and the .strm contract

Media root: /disks/Plex/_ytstream/ — already created and already wired to the YouTube (stream) Jellyfin library, with 2 channels in it from the PoC. Keep it. (The name has a leading underscore matching _ingest/_cache on that volume; /disks/Plex/YouTube stays with youtube-automate until §12 retires it.)

/disks/Plex/_ytstream/
└── Pitch Side/
    ├── tvshow.nfo
    ├── poster.jpg                                  # channel avatar
    ├── fanart.jpg                                  # channel banner
    └── Season 2026/
        ├── Pitch Side - S2026E8120 - Title [dQw4w9WgXcQ].strm
        ├── Pitch Side - S2026E8120 - Title [dQw4w9WgXcQ].nfo
        └── Pitch Side - S2026E8120 - Title [dQw4w9WgXcQ]-thumb.jpg

Naming, sanitisation and dir_name stability rules are youtube-automate's, unchanged (specs.md §5) — identical rules in both trees is what makes the overlap period sane.

.strm contents: one line, no trailing newline required:

http://127.0.0.1:8099/watch/dQw4w9WgXcQ

NFO: episodedetails with title, season, episode, aired, plot, runtime, durationinseconds, uniqueid type="youtube". No <fileinfo><streamdetails> — pre-seeding it was measured to change nothing about whether Jellyfin probes (FINDINGS §6), so it is dead weight.

No .work/ dir under the media root. The proxy's scratch space is /dev/shm/ytstream, outside the library entirely, which is strictly better than the dot-dir-plus-.ignore belt-and-braces that youtube-automate needs.


9. Environment and paths

Verified on the machine, 2026-08-12.

Purpose Path
Source (bare repo) /disks/git-repos/ytstream.gitexists but is not yet git init --bare
Checkout /opt/ytstream — exists, susan:automation 0770
Entry point /usr/local/bin/ytstream (needs root, → deploy.sh)
Virtualenv /var/lib/ytstream/venv
State DB /var/lib/ytstream/ytstream.db
Lock file /var/lib/ytstream/run.lock
Media root /disks/Plex/_ytstream/
Proxy scratch /dev/shm/ytstream (tmpfs, 24 GB available)
Admin UI 127.0.0.1:8086, nginx → tube.jihakuz.xyz (§12)
Proxy 127.0.0.1:8099, not exposed

Facts that constrain the design:

  • Ownership: the media tree convention is susan:mediaserver, dirs 0770, files 0664. /disks/Plex is 0770 with no setgid; Jellyfin reaches the tree only through its mediaserver supplementary group. Both units run User=susan Group=mediaserver UMask=0002; cron goes through sg mediaserver. /disks/Plex/_ytstream already has the setgid bit — keep it.
  • No passwordless sudo. Everything touching /usr/local/bin, /etc/systemd/system or /etc/nginx belongs in deploy/deploy.sh.
  • yt-dlp: /usr/local/bin/yt-dlp is a 2023.11.16 binary, far too old to work. Nothing references it; leave it alone. ytstream gets its own venv with yt-dlp[default] (the extra that ships yt-dlp-ejs, mandatory for JS challenge solving), bgutil-ytdlp-pot-provider==1.3.1 matching the container tag, and curl-cffi<0.16. Note that during the overlap two venvs will hold yt-dlp; keep both current, and do not let a bare yt-dlp on PATH be what either service resolves.
  • POT provider: container on 127.0.0.1:4416, --restart unless-stopped, Docker enabled at boot. Shared with youtube-automate during the overlap and inherited afterwards. It is the reason the proxy unit declares Requires=docker.service.
  • Client selection is settled — do not re-litigate it. CLIENT_ARGS = "youtube:player_client=default". mweb is deliberately absent: its formats 403 on every attempt, and it was the sole source of the DRC and dubbed-language variants behind both original format-picker bugs. web/ios/web_safari/tv are SABR-only and yield nothing usable. Left as default rather than pinned to android_vr so a yt-dlp update can follow YouTube.
  • Jellyfin 10.11.4, native systemd, 0.0.0.0:8096, API key already in the DB.

10. Playback UX — state it plainly

Measured: wait-for-complete is ready in 20 s for a 21-minute video and ~55 s for a 46-minute one, at ~8 MB/s (~60× realtime). --growing mode gives TTFB of 6.57.1 s but ffmpeg writes mvhd.duration=0 and no mehd box regardless of what it knows about the input — verified, including an attempt to patch the boxes afterwards, which ffmpeg ignores — so the player sees a video of unknown, growing length. Seeking and progress bars misbehave.

Ship wait-for-complete. A ~2055 s spinner that then behaves like a normal file beats instant playback with a broken timeline. Keep --growing as a flag for experimentation. Revisit only if someone complains, and if they do, the fix is a pre-warm on library browse rather than a change to the mux.


11. Configuration and secrets

Secrets live in /var/lib/ytstream/ytstream.db, mode 0640 susan:automation, and are entered through the admin UI. Never in the repo, never in a systemd unit, never in a cron line. This matches youtube-automate, where jellyfin_api_key and session_secret already live that way.

Four keys, all in setting: youtube_api_key (new), jellyfin_api_key, session_secret, and admin_password_hash — one shared admin password, as everywhere else on susan (§4.6).

The YouTube API key is restricted to the YouTube Data API v3 and has read-only reach over public data. Worst case on leak is quota exhaustion; rotation is a two-minute job in the console.

The Phase 0 key is treated as disposable and rotated once the service is built (Tom's call, and the right one — it has been pasted into a terminal and a chat transcript while debugging). That makes one thing a requirement rather than a nicety: rotating the key must not need a restart or a code change. Read it from the setting table at the point of use, not once at process start, so the admin UI's "save" is the whole rotation procedure.


12. Decommissioning youtube-automate

Not until §13 phase 5 passes. Order matters — the point is that every step is reversible until the last one.

  1. Stop new work. Comment out the 17 * * * * cron entry. Leave the service running so the admin UI still answers.
  2. Watch for a week with both libraries live in Jellyfin. This is the real acceptance test: does anyone reach for the old library?
  3. Retire the Jellyfin library. Remove YouTube (/disks/Plex/YouTube) via /Library/VirtualFolders. Rename YouTube (stream)YouTube. Note from the PoC: deleting and recreating a library at the same path returns the same ItemId and reuses the old items — if a clean slate is ever needed, use a fresh path, as _ytstream already is.
  4. Free the hostname. tube.jihakuz.xyz is served by a leftover TubeArchivist server block inside sites-available/jihakuz.xyz, which owns the Let's Encrypt cert and wins because nginx takes the first matching block. Repoint it at 8086 the same way deploy/fix-nginx-tube.sh did for 8085; do not install a competing vhost file.
  5. Disable the service. systemctl disable --now youtube-automate.service; remove the unit.
  6. Reclaim the bytes. Estimated 510 GB under /disks/Plex/YouTube. Delete only after step 2 has actually elapsed.
  7. Keep, do not delete: /var/lib/youtube-automate/subs.db (copy it aside — it is the only record of what was subscribed and when), /opt/youtube-automate and its bare repo, and specs.md, which remains the reference for every rule ytstream inherited.

update-ytdlp.sh (Mondays 04:40) must be repointed, not removed — it becomes ytstream's, and it is the thing that keeps playback working as YouTube changes.


13. Build order

Each phase ends in something checkable. Do not start the next one until it does.

Phase 0 — verify the assumptions the plan rests on. COMPLETE 2026-08-12. All three API checks pass; tools/verify_api.py reproduces them in one command. Results and the numbers they changed are in §16.

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. 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. 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. 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.

Phase 6 — decommission. §12 steps 37.


14. Decisions — answered 2026-08-12

  1. Account to mirror: @cflux1030UCPcTWaLV8zwx4WP4QExHj4Q ("C Flux"), resolved and recorded in §4. Sole source of truth — Tom's own subscriptions are not part of this at all, which removed the multi-source claim model from §4.3 and put the weight on §4.4 instead.
  2. Retention: a rolling 30-day window — anything older is deleted from disk and tombstoned (§5). This supersedes the earlier "3 months or 300 videos" answer, which was about backfill depth; holding both would mean backfilling 90 days and deleting two thirds of it immediately. If the intent was "reach back 3 months and keep it", retention_days = 90 is a one-line change and §5 still holds. Measured consequence: 441 episodes total across 119 channels, not 20,000.
  3. subsync_max_new = 25 — closed by Phase 0. Measured 119 subscriptions, and max(10, ceil(119 × 0.2)) = 25 (§4.4).
  4. Brother gets admin access: yes, on the existing shared password — no user table, no per-account credentials (§4.6). web/auth.py carries over untouched.
  5. Unsubscribe deletes the channel rather than deactivating it, since re-subscribing is one API page and ~20 files under the 30-day window (§4.4). This makes the removal detection safeguards load-bearing rather than precautionary.

15. Gotchas carried forward

Things already paid for once. All of these are verified.

  • A normal Jellyfin scan makes 0 media probes, which is the single fact this design depends on. replaceAllMetadata=true does probe. Pre-seeded streamdetails do not prevent it.
  • --load-info-json reuses the extraction — confirmed twice, once during the original PoC and again while hunting the 403. Both yt-dlp legs run from one -J.
  • ffmpeg will not write a duration into a fragmented MP4, and will not accept one patched in afterwards. Hence wait-for-complete (§10).
  • Two picker bugs, both silent, both from mweb: 140-drc was chosen over 140 on an abr tie and 403'd; 140-0 was chosen on a 9-language video and produced German audio. The audio sort key ranks language_preference first, deliberately above the DRC check — a 403 is a loud failure, wrong-language audio is a silent one that would have shipped.
  • row["title"] ambiguity renamed every series once. The channel/video join has title on both sides. Pass show fields explicitly, never row["title"]. Caught only by accident.
  • Jellyfin's /Items/{id} needs user context — use the /Items?ids= form.
  • youtubetab:approximate_date is wrong by up to 2 days. Do not derive episode numbers from it; use playlistItems.list (§3).
  • YouTube's public subscriptions tab is gone. /@handle/channels returns 200 and silently serves Home — four tabs, zero channel ids in ytInitialData. Verified 2026-08-12. Nobody should spend an afternoon trying to scrape it; the API is the only route (§4).
  • pkill -f 'ytstream.py' kills the shell that runs it, because the command string contains its own pattern. Bracket it: pkill -f 'ytstrea[m].py'.
  • Google returns 403 forbidden for two unrelated setup mistakes, and the useful signal is in error.details[].reason, not error.errors[].reason. SERVICE_DISABLED means the API is not enabled on the project (and carries an activationUrl naming the project number); API_KEY_SERVICE_BLOCKED means the API is enabled but this key's restrictions exclude it. They are fixed on different console screens. tools/verify_api.py distinguishes them and prints the fix.
  • Console changes take up to ~2 minutes to propagate. Measured: a key restriction change was still returning API_KEY_SERVICE_BLOCKED at 0 s, 30 s and 60 s, and succeeded at 90 s. Do not conclude a setting is wrong until a couple of minutes have passed — retry before re-editing.
  • subscriptions.list pageInfo.totalResults overcounts. It reported 127 while full pagination returned 119 distinct channels, and 2 of those 119 have feeds that HTTP-error — terminated or private channels still counted as subscriptions. Never treat totalResults as the list length; page to exhaustion and count what arrives. Anything sized off it (a progress bar, a cap, an "is the list complete" check) will be wrong.

16. Phase 0 results — COMPLETE, 2026-08-12

All three assumptions the plan rested on are verified against the live API. Nothing in the design had to change; several numbers did.

The three API checks

# Assumption Result
1 subscriptions.list?channelId= returns his subscriptions with only an API key PASS. 200, 119 channels across 3 pages. The feature is possible exactly as designed.
2 playlistItems.list accepts an undocumented UULF… playlist id PASS. 1,249 items with exact videoPublishedAt. No UU fallback needed (§3).
3 videos.list returns parseable contentDetails.duration PASS. 50 ids in one call, 50 returned, 0 unparseable.

Reproduce with python3 tools/verify_api.py --key <key> (~5 quota units).

The numbers that came out of it

  • 119 subscriptions, not the ~20 the plan assumed — and pageInfo.totalResults claims 127 (§15).
  • 441 long-form videos across all 119 channels in the last 30 days. That is the library size.
  • Median 1 video per channel per 30 days, and 52 of 117 channels uploaded nothing — the distribution is severely skewed, which is what produced the min_keep_videos recommendation in §5.
  • UULF excludes 54% of Pitch Side's catalogue (1,249 of 2,724), and 38 of 50 consecutive UU uploads were ≤120 s. The Shorts filter earns its place.
  • All 119 feeds poll in 7.9 s (8 threads). Hourly polling is free.
  • subsync_max_new = 25.

Also done

  • Bare repo initialised. /disks/git-repos/ytstream.git was an empty directory; it is now a real --bare --shared=group repo with HEADrefs/heads/main, matching youtube-automate.git. /opt/ytstream is a checkout with origin pointed at it.
  • The mirrored channel is resolved: @cflux1030UCPcTWaLV8zwx4WP4QExHj4Q ("C Flux"). No channels.list call needed at build time.
  • There is no scraping fallback/@cflux1030/channels serves the Home tab with zero channel ids (§15), so the privacy setting is mandatory. It is now confirmed to be off, since the API returns his list.
  • tools/verify_api.py written, self-tested, and hardened against the two setup failures below.

Getting the key working took three attempts, and the errors were misleading

Recorded because the next person to set up a Google Cloud project will hit the same wall. All three states return HTTP 403 forbidden, and the distinguishing signal is error.details[].reason:

  1. SERVICE_DISABLED — YouTube Data API v3 not enabled on project 510818173753. Carries an activationUrl naming the project.
  2. API_KEY_SERVICE_BLOCKED — API enabled, but the key's own API restrictions exclude it. A different console screen entirely. The ordering is the trap: YouTube Data API v3 is absent from the key's restriction picker until the API is enabled on the project, so restricting first yields a key that blocks the only API it exists for. §4.1 step 4 now says enable-then-restrict.
  3. Propagation delay — after the restriction was fixed, calls still failed at 0 s, 30 s and 60 s, and succeeded at 90 s. Nothing was wrong at that point except impatience.

What remains before Phase 1

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

18. What deployment changed — 2026-08-12

Installing it found six things the tests could not. Two were my bugs in the deploy scripts, one was a missing dependency, and three were Jellyfin behaviours that only appear against a real library.

The deploy scripts had a circular dependency

deploy.sh installed and started the units, then told the operator to run bootstrap.sh — but bootstrap.sh refused to run until /var/lib/ytstream existed, and only deploy.sh creates it. Neither could go first. The units started against a venv that did not exist, failed 203/EXEC, and restart-looped 17 and 21 times until the venv appeared.

deploy.sh now creates the state directory, calls bootstrap.sh itself via runuser so the venv is not left root-owned, and refuses to start the units at all if the venv is still missing. One command, correct order.

Deno was missing, and doctor caught it

Mandatory, not optional. Without a JS runtime yt-dlp cannot solve the n challenge; youtube-automate measured the consequence on this machine as 22 formats instead of 29 and throttled downloads. The new venv had yt-dlp-ejs but no deno, and nothing else would have noticed until playback quietly degraded. bootstrap.sh now installs it — preferring a copy from the youtube-automate venv while that still exists, falling back to the GitHub release — and verifies yt-dlp reports JS runtimes: deno.

Jellyfin ignores <runtime> and <durationinseconds> for episodes

It reads the rest of the NFO — aired and the youtube provider id both arrive — but runtime comes only from a media probe, so a .strm episode shows no duration until it has been played once. Not fixable from our side: the only way to supply one is to probe, which means fetching every episode, which is the one thing this design exists to avoid. Accepted limitation, stated here so nobody re-litigates it.

Episodes had no plot at all

strm.materialise passed plot=None, so every synopsis was empty — while both sources hand us descriptions for free. Now plumbed through: RSS carries media:group/media:description, and videos.list carries snippet.description in the call already being made for durations, so the ~40% of episodes older than RSS reaches still get one. That needed a schema v2 migration; v1 was left exactly as it shipped so a fresh install and a migrated one end up identical, which is asserted by a test.

materialise --all created duplicates instead of repairing

The documented recovery path from a metadata wipe was itself broken. Episode numbers were re-derived on every run, and next_episode() excludes the row it is numbering, so re-materialising a day's videos in a different order renumbered them — new filenames, old files left behind. One run left 102 orphaned NFOs against 251 episodes.

Two fixes: an episode number, once assigned, is now permanent and reused from the row; and materialising a video that already has a different rel_path removes the old files first. Running materialise --all twice in a row is now a no-op, verified on the live tree and pinned by tests.

There is a safe metadata refresh, and this is it

§5 says never to use replaceAllMetadata=true, and that stands. But a plain /Library/Refresh does not reliably re-read a rewritten NFO — after rewriting all 251, fifty kept their old empty metadata. The middle ground works:

POST /Items/{id}/Refresh?metadataRefreshMode=Default&imageRefreshMode=Default
                        &replaceAllMetadata=false&recursive=true

Measured across the whole 251-episode library: plots and aired dates went from 201 to 251, and the proxy served 0 requests. The safety is entirely in replaceAllMetadata=false — with it true, Jellyfin discards what it has and re-derives from the media. Exposed as ytstream refresh-metadata, and run automatically after materialise --all.

Measured on the deployed service

Channels approved (of 119 queued) 10
Episodes materialised 251 in 70 s
Filtered as Shorts / livestreams 2 / 4
Tree size 49 MB for 753 files
Jellyfin full scan 251 episodes in ~119 s (~8 min per 1,000)
Media probes during that scan 0
Playback via Jellyfin PlaybackInfo DirectPlay h264 720p + aac, 2049 s runtime
Episodes with plot / aired after refresh 251 / 251
doctor all fatal checks pass

19. First play was broken, and how — 2026-08-13

The day after deployment, playback failed in Jellyfin. The service was healthy the whole time: both units active, doctor green, /healthz reporting failed: 0.

Cached videos played; uncached ones did not. Yesterday's "DirectPlay verified" was measured only on videos already pulled during testing, so the first-play path had never actually been exercised end to end. That is the hole in §18's evidence.

The mechanism

In wait-for-complete mode the handler blocked on sess.final.wait(wait_timeout) before sending anything — not the body, not even response headers. The wait is the whole download and mux:

upload cold time to first byte
8 min ~10 s
6.8 min (one transient failure + retry) >12 s
22 min 47 s
46 min 79 s ← what the user hit
66 min 156 s

No player waits that long, so the socket sat silent until the client gave up. The proxy counted it a success, which is why nothing looked wrong from inside.

The fix: bound the wait, then stream

The output is already a fragmented MP4 (frag_keyframe+empty_moov), so it is readable while being written. The only thing a finished file buys is a correct duration and working seeks — ffmpeg patches the real duration into the moov on close, and ignores both mvhd.duration and an injected mehd before then (confirmed: a growing file probes 197 s → 1185 s → 2378 s → 4127 s against a true 4128 s).

So the wait is now capped by --first-byte-grace (default 12 s, explicit in the unit). Whatever has not muxed by then is streamed as it is written.

before after
TTFB, 66-min upload cold 156 s (silent) 12.0 s
TTFB, same video cached ~0 ~0
Range request on a complete file 206 + Content-Range unchanged, 1.7 ms

--first-byte-grace is a cap, not a prediction. Completion time ranged 10156 s and does not track duration closely: YouTube's per-format throttling varies, and one transient failure plus a retry costs ~5 s of extraction before any byte is pulled. Raising it to --wait-timeout restores the old finished-file-or-504 behaviour, which is the bug. The unit carries a comment saying so.

Cost, stated plainly

A first play that misses the grace has no seek bar and no duration for that watch. The stream is chunked with no Content-Length, so the client cannot seek even after the mux lands mid-play; it has to re-request, which happens on the next play. Every subsequent play of that video is perfect and instant. This is a real regression against a hypothetical fast first play, and a large improvement over an error.

A second bug found while fixing the first

The streaming loop waited on sess.complete (finished and good) rather than sess.final (finished). A producer that died after writing some bytes therefore never satisfied the wait: the loop sat in _wait_for_bytes for the full 45 s STALL_TIMEOUT and then dropped the connection. Now split into finished for every wait and sess.complete only for the ranges decision. Caught by a new test, not by inspection.

Also corrected

The too-old-yt-dlp warning pointed at /var/lib/youtube-automate/venv/bin — the tree §12 decommissions. It now names ytstream's venv, and says the unit pins PATH so a manual run has to as well. That warning is what a future debugger reads at 2am.

The /healthz payload gained mode (wait-then-stream / growing) and first_byte_grace_s, because the difference between "plays" and "playback error" was invisible without reading the unit file.

Eight new tests in test_proxy.py cover: a slow mux streaming instead of blocking, a fast mux keeping ranges and seeking, --first-byte-grace at --wait-timeout restoring strict mode, a failed producer with bytes being served but 502 in strict mode, a failed producer with no bytes always 502, --growing meaning no wait, and /healthz reporting the mode. 353 pass.