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>
844 lines
49 KiB
Markdown
844 lines
49 KiB
Markdown
# `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 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. 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
|
||
the `videos.list` call we are making anyway — `contentDetails.duration < min_duration_seconds`
|
||
drops Shorts, and the presence of `liveStreamingDetails` drops streams.
|
||
|
||
Either way it is one extra unit per 50 videos. Verify with a single curl on day one:
|
||
|
||
```sh
|
||
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):
|
||
|
||
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:
|
||
|
||
```sh
|
||
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.
|
||
|
||
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. 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 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
|
||
|
||
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
|
||
|
||
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).
|
||
|
||
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 to `retention_days` without re-deriving it.
|
||
2. **`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.
|
||
3. **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.
|
||
4. **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.
|
||
5. **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.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, 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`, 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.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.
|
||
|
||
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 5–10 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.** Mostly **done, 2026-08-12** — see §16 for
|
||
what was verified and what is still blocked. Remaining: paste the API key in and run
|
||
|
||
```sh
|
||
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
|
||
|
||
1. **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.
|
||
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: **~400 episodes total**, not 20,000.
|
||
3. **`subsync_max_new`:** still open — set from `pageInfo.totalResults` on the first
|
||
`subscriptions.list` call, which `tools/verify_api.py` prints. Provisional 25; rule of thumb
|
||
`max(10, ceil(totalResults × 0.2))` (§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.
|
||
|
||
---
|
||
|
||
## 16. Phase 0 results — 2026-08-12
|
||
|
||
### Done
|
||
|
||
- **Bare repo initialised.** `/disks/git-repos/ytstream.git` existed as an empty directory; it is now
|
||
a real `--bare --shared=group` repo with `HEAD` → `refs/heads/main`, matching
|
||
`youtube-automate.git`. `/opt/ytstream` is a checkout with `origin` pointed at it.
|
||
- **The mirrored channel is resolved.** `@cflux1030` → `UCPcTWaLV8zwx4WP4QExHj4Q` ("C Flux", 55
|
||
subscribers). No `channels.list` call needed at build time.
|
||
- **There is no scraping fallback** — `/@cflux1030/channels` serves 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.py` written 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:
|
||
|
||
1. **Key created** and reaching Google — it authenticates, so the key string is good.
|
||
2. **`SERVICE_DISABLED`** — YouTube Data API v3 was not enabled on project `510818173753`. Fixed by
|
||
enabling it.
|
||
3. **`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 at
|
||
`https://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:
|
||
|
||
```sh
|
||
python3 /opt/ytstream/tools/verify_api.py --key AIza...
|
||
```
|