Ran Phase 0 against a live key and hit both of the ways a fresh Google Cloud
project can be wrong, in sequence. Google reports them as the same 403
`forbidden`, so the first version of this script printed reason='forbidden'
three times and buried the one sentence that said what to do.
The distinguishing signal is in error.details[].reason, not
error.errors[].reason:
SERVICE_DISABLED YouTube Data API v3 is not enabled on the project.
Carries an activationUrl naming the project number.
API_KEY_SERVICE_BLOCKED The API is enabled, but this key's API restrictions
exclude it.
They are fixed on different console screens, so they are now separate exception
types with separate advice, and a one-call preflight reports either before the
three real checks run and fail identically.
The ordering between them is a trap worth writing down: YouTube Data API v3 does
not appear in a key's API-restriction picker until the API is enabled on the
project, so creating the key and restricting it first yields a key that blocks
the only API it exists for. That is precisely what happened here. §4.1 step 4
now says to enable before restricting.
Nothing has yet reached YouTube's own privacy check, so whether the brother's
subscriptions are readable is still untested — every call so far failed at the
key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
49 KiB
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.
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
retires that directory.
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 copyfMP4 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-automatedoes 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. Gains an API-backed full-catalogue 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:
| Job | Method | Calls | Units |
|---|---|---|---|
| Sync subscriptions, hourly | subscriptions.list |
24/day (1 page) | 24 |
| Backfill one channel's 30-day window (~20 videos) | playlistItems.list @50 |
1 | 1 |
| Durations for those videos | videos.list @50 ids |
1 | 1 |
| Steady-state incremental discovery | RSS feed | — | 0 |
| Durations for the day's new videos | videos.list @50 ids |
1 | 1 |
A 20-channel initial build costs about 40 units. Steady state is ~25 units/day, nearly all of
it the hourly subscription poll. Against 10,000/day this is not a constraint worth thinking about,
provided we never touch search.list (100 calls/day, and we have no use for it).
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 ~20 videos per channel, the yt-dlp route would be ~400 extractions ≈ 15 minutes, not the 4 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.
One thing to verify before relying on it
youtube-automate polls the UULF playlist (UU with LF spliced in), which is undocumented
but excludes Shorts and livestreams at the cheapest possible point — verified in specs.md §4.
Whether playlistItems.list accepts a UULF id is unverified; only UU is documented.
- If UULF works: use it, and the existing filtering carries over unchanged.
- If it 404s: fall back to
UU(definitely works, includes Shorts and livestreams) and filter with thevideos.listcall we are making anyway —contentDetails.duration < min_duration_secondsdrops Shorts, and the presence ofliveStreamingDetailsdrops streams.
Either way it is one extra unit per 50 videos. Verify with a single curl on day one:
curl -s "https://www.googleapis.com/youtube/v3/playlistItems?part=contentDetails\
&playlistId=UULF2EvK7nHUOEw1IvWFpTourQ&maxResults=5&key=$KEY" | head -40
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):
console.cloud.google.com→ new project, e.g.ytstream.- APIs & Services → Library → YouTube Data API v3 → Enable.
- Credentials → Create credentials → API key.
- 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.
- Paste it into the ytstream admin UI. It is stored in the
settingtable likejellyfin_api_keyalready 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.
This call also produces the number that sets subsync_max_new (§4.4): pageInfo.totalResults
is how many channels he is subscribed to today, which is the one input the cap needs and which
cannot be discovered any other way. Record it here when known:
totalResults= TBD — Phase 0
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.
- 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.readonlyis 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. - Google Takeout subscription CSV, dropped into the admin UI. Manual, but a 30-second job whenever he adds channels, and zero moving parts.
- 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
channelcarry what the sync needs:source(provenance) andmissing_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_newchannels adds none of them. It records them aspending_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. So a source's first sync is an
explicit bulk import: the admin UI shows the whole list with per-channel checkboxes and a count of
the episodes it implies, and nothing is subscribed until someone confirms. From the second sync
onwards the cap is a runaway guard, and subsync_max_new is set from the day-one totalResults
(§4.1) — a sensible rule is max(10, ceil(totalResults × 0.2)), so a genuine burst of activity
gets through but an order-of-magnitude jump does not. Provisional default 25 until Phase 0
produces the real number.
Runaway removals. A transient 403, a network blip, or him re-ticking the privacy box all look like "he unsubscribed from everything". Deleting 20 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 atsubsync_missing_threshold(default 3 consecutive syncs, so ~3 hours) is it unsubscribed. - Unsubscribing deletes the channel — its directory, its
.strmfiles, its NFOs and artwork, and its rows. Re-subscribing rebuilds it from scratch, which under the 30-day window (§5) is oneplaylistItems.listpage 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,.nfoand 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
The upload rates matter more than the total catalogue size, so I measured them from the live feeds on 2026-08-12 rather than guessing:
| Channel | UULF (long-form) | UC feed (everything) | UULF filters |
|---|---|---|---|
| Pitch Side | 0.60/day → ~18 per 30d | 2.33/day → ~70 per 30d | 74% |
| The Pyramid Podcast | 0.77/day → ~23 per 30d | 1.91/day → ~57 per 30d | 60% |
So a 30-day window is roughly 20 episodes per channel, and 20 channels is ~400 episodes, ~1,200 files. That is two orders of magnitude below the 20,000 the previous draft was braced for, and it makes the entire scale section boring — which is the point. Pitch Side's 1,249-video back catalogue simply never enters the library.
It also confirms the UULF feed is doing real work: it filters 60–74% of what the channel publishes, and that Shorts-and-livestreams majority is exactly what nobody wants as Jellyfin episodes.
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
-
Tombstones are mandatory, not optional. Delete a
.strmand the next poll finds the video in the feed again and re-materialises it, forever.youtube-automatealready 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. -
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).
-
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 toretention_dayswithout re-deriving it. -
replaceAllMetadataremains 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) ytstreamnever itself issues a refresh withreplaceAllMetadata=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.
- the proxy 503s past the budget (built, tested —
-
Episode numbering holds, and tombstones are what make it hold.
MMDD*10 + ordinalclamps 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. -
The
.strmURL 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 asedsomeone 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. -
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 20 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.serviceis written but not installed; susan has no passwordless sudo, so this goes indeploy/deploy.shfor the operator to run once. The unit pinsEnvironment=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, andRequires=docker.servicefor 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/shmis 24 GB of 47 GB RAM; throughput measured at ~480 MB/hour of video.--cache-gb 8holds ~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'srescancan revive these ifretention_daysis raised.aged_out— was materialised, then deleted by the sweep. Never revived, or raisingretention_dayswould 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, provisional — §4.4), subsync_missing_threshold (3), proxy_base_url
(http://127.0.0.1:8099), and backfill_max_videos (300) purely as a runaway guard on a channel
that turns out to upload 50 times a day.
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.git — exists 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, dirs0770, files0664./disks/Plexis0770with no setgid; Jellyfin reaches the tree only through itsmediaserversupplementary group. Both units runUser=susan Group=mediaserver UMask=0002; cron goes throughsg mediaserver./disks/Plex/_ytstreamalready has the setgid bit — keep it. - No passwordless sudo. Everything touching
/usr/local/bin,/etc/systemd/systemor/etc/nginxbelongs indeploy/deploy.sh. - yt-dlp:
/usr/local/bin/yt-dlpis a 2023.11.16 binary, far too old to work. Nothing references it; leave it alone. ytstream gets its own venv withyt-dlp[default](the extra that shipsyt-dlp-ejs, mandatory for JS challenge solving),bgutil-ytdlp-pot-provider==1.3.1matching the container tag, andcurl-cffi<0.16. Note that during the overlap two venvs will hold yt-dlp; keep both current, and do not let a bareyt-dlponPATHbe what either service resolves. - POT provider: container on
127.0.0.1:4416,--restart unless-stopped, Docker enabled at boot. Shared withyoutube-automateduring the overlap and inherited afterwards. It is the reason the proxy unit declaresRequires=docker.service. - Client selection is settled — do not re-litigate it.
CLIENT_ARGS = "youtube:player_client=default".mwebis 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/tvare SABR-only and yield nothing usable. Left asdefaultrather than pinned toandroid_vrso 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.5–7.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 ~20–55 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.
12. Decommissioning youtube-automate
Not until §13 phase 5 passes. Order matters — the point is that every step is reversible until the last one.
- Stop new work. Comment out the
17 * * * *cron entry. Leave the service running so the admin UI still answers. - Watch for a week with both libraries live in Jellyfin. This is the real acceptance test: does anyone reach for the old library?
- 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 sameItemIdand reuses the old items — if a clean slate is ever needed, use a fresh path, as_ytstreamalready is. - Free the hostname.
tube.jihakuz.xyzis served by a leftover TubeArchivistserverblock insidesites-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 waydeploy/fix-nginx-tube.shdid for 8085; do not install a competing vhost file. - Disable the service.
systemctl disable --now youtube-automate.service; remove the unit. - Reclaim the bytes. Estimated 5–10 GB under
/disks/Plex/YouTube. Delete only after step 2 has actually elapsed. - 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-automateand its bare repo, andspecs.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. Mostly done, 2026-08-12 — see §16 for what was verified and what is still blocked. Remaining: paste the API key in and run
python3 /opt/ytstream/tools/verify_api.py --key AIza...
which performs all three API checks, costs ~5 quota units, prints the subsync_max_new value, and
exits non-zero if anything fails.
→ Done when: verify_api.py reports ALL PASS and its numbers are written into §4.1 and §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 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 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 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 5 — cut over. §12 steps 1–2, run for a week. → Done when: nothing has broken and nobody has used the old library.
Phase 6 — decommission. §12 steps 3–7.
14. Decisions — answered 2026-08-12
- Account to mirror:
@cflux1030→UCPcTWaLV8zwx4WP4QExHj4Q("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. - 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 = 90is a one-line change and §5 still holds. Measured consequence: ~400 episodes total, not 20,000. subsync_max_new: still open — set frompageInfo.totalResultson the firstsubscriptions.listcall, whichtools/verify_api.pyprints. Provisional 25; rule of thumbmax(10, ceil(totalResults × 0.2))(§4.4).- Brother gets admin access: yes, on the existing shared password — no
usertable, no per-account credentials (§4.6).web/auth.pycarries over untouched. - 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=truedoes probe. Pre-seededstreamdetailsdo not prevent it. --load-info-jsonreuses 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-drcwas chosen over140on anabrtie and 403'd;140-0was chosen on a 9-language video and produced German audio. The audio sort key rankslanguage_preferencefirst, 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 hastitleon both sides. Pass show fields explicitly, neverrow["title"]. Caught only by accident.- Jellyfin's
/Items/{id}needs user context — use the/Items?ids=form. youtubetab:approximate_dateis wrong by up to 2 days. Do not derive episode numbers from it; useplaylistItems.list(§3).- YouTube's public subscriptions tab is gone.
/@handle/channelsreturns 200 and silently serves Home — four tabs, zero channel ids inytInitialData. 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
forbiddenfor two unrelated setup mistakes, and the useful signal is inerror.details[].reason, noterror.errors[].reason.SERVICE_DISABLEDmeans the API is not enabled on the project (and carries anactivationUrlnaming the project number);API_KEY_SERVICE_BLOCKEDmeans the API is enabled but this key's restrictions exclude it. They are fixed on different console screens.tools/verify_api.pydistinguishes them and prints the fix.
16. Phase 0 results — 2026-08-12
Done
- Bare repo initialised.
/disks/git-repos/ytstream.gitexisted as an empty directory; it is now a real--bare --shared=grouprepo withHEAD→refs/heads/main, matchingyoutube-automate.git./opt/ytstreamis a checkout withoriginpointed at it. - The mirrored channel is resolved.
@cflux1030→UCPcTWaLV8zwx4WP4QExHj4Q("C Flux", 55 subscribers). Nochannels.listcall needed at build time. - There is no scraping fallback —
/@cflux1030/channelsserves the Home tab with zero channel ids (§15). The privacy checkbox is mandatory. - UULF feeds work for both existing channels, and the filtering is substantial — 60–74% of what these channels publish is Shorts or livestreams that UULF correctly excludes.
- Upload rates measured, which is what sized §5: ~18–23 long-form videos per channel per 30 days, so ~400 episodes for a 20-channel library. Pitch Side's UULF feed spans 23.3 days in 15 entries, meaning RSS alone nearly covers the retention window.
tools/verify_api.pywritten and self-tested (ISO-8601 duration parser unit-checked against six cases; argparse and import verified). It runs all three API checks in one command.
Key created, project 510818173753 — two setup steps deep, one to go
Progress on the key itself, all of it diagnosed from the error bodies:
- Key created and reaching Google — it authenticates, so the key string is good.
SERVICE_DISABLED— YouTube Data API v3 was not enabled on project510818173753. Fixed by enabling it.API_KEY_SERVICE_BLOCKED← current state. The API is now enabled, but the key's own API restrictions exclude it, so every method returns "Requests to this API youtube method … are blocked". Fix athttps://console.cloud.google.com/apis/credentials?project=510818173753→ the key → API restrictions → Don't restrict key, or tick YouTube Data API v3.
The ordering trap is worth remembering rather than rediscovering: the API must be enabled before the key can be restricted to it, because it is absent from the picker until then. §4.1 step 4 now says so.
Until the key answers, these three assumptions remain unverified:
| # | Assumption | Consequence if it fails |
|---|---|---|
| 1 | subscriptions.list?channelId= returns his subscriptions |
Feature is impossible as designed. Falls back to §4.2, all of which need him to do something. |
| 2 | playlistItems.list accepts a UULF… playlist id |
Take the documented UU fallback and filter Shorts/livestreams via videos.list duration + liveStreamingDetails. Costs one extra unit per 50. |
| 3 | videos.list returns parseable contentDetails.duration |
No <durationinseconds> in NFOs without a yt-dlp extraction per video. Degrades, does not block. |
Only #1 is a genuine blocker, and it also carries the number that sets subsync_max_new. Two
external prerequisites remain: the key's API restriction (above), and his brother unchecking
"Keep all my subscriptions private" — note that nothing so far has tested the second, because every
call has failed at the key before reaching YouTube's privacy check. Then one command closes Phase 0:
python3 /opt/ytstream/tools/verify_api.py --key AIza...