# `ytstream` — results on susan Date: 2026-08-12. Answers every open question in the `ytstream` handover: §3, §6, and §7 steps 1–4. Both Jellyfin gates pass (§6 below). §7 step 5 (integrating with youtube-automate) is **not** done and `/opt/youtube-automate` is unmodified. Environment note that matters: the PoC calls bare `yt-dlp`. On susan, `PATH` resolves that to `/usr/local/bin/yt-dlp`, which is **2023.11.16** and has no POT plugin. The working install is the automation's venv: ``` export PATH=/var/lib/youtube-automate/venv/bin:$PATH # yt-dlp 2026.07.04 # + bgutil-ytdlp-pot-provider 1.3.1 ``` That venv's plugin matches the running `bgutil-provider` container (1.3.1) and retrieves gvs PO tokens correctly. Before this was noticed the container had been up 15 hours with nothing able to talk to it — no yt-dlp plugin directory existed. --- ## 1. Verified against real videos Four videos, 2 to 46 minutes. Two are from the live `subs.db`, so they are representative of what the automation actually handles. | video | length | TTFB (`--growing`) | ready (default mode) | duration served | truth | |---|---|---|---|---|---| | `D6ocsyQLKy8` | 2 min | 7.1s | ~7s | 120.62s | 121s | | `dQw4w9WgXcQ` | 3.5 min | 7.1s | ~9s | 213.13s | 213s | | `J9O3sxoMs5U` | 21 min | 6.9s | **20s measured** | 1259.47s | 1259s | | `NH2MhBFQm9w` | 46 min | 6.5s | ~50s | 2789.45s | 2789s | Full `ffmpeg -f null -` decode exits 0 in every case, with both system ffmpeg 5.1.7 and Jellyfin's bundled 7.1.3. Codecs are always `h264 1280x720` + `aac`. "ready" figures other than the 21-minute one are derived from the measured ~8 MB/s pull rate, not separately timed. **`--load-info-json` reuses the extraction.** This was the load-bearing unknown. Verbose output shows zero re-extraction markers and no second POT fetch — it goes straight to `[info] Downloading 1 format(s): 136`. The one-extraction design holds. **Concurrency is not a problem.** 5/5 clean trials of the exact PoC pattern (one fresh extraction, two concurrent fetches). §3's worry about the POT provider under concurrent access looks unfounded. **Throughput ~8 MB/s**, roughly 60× realtime. 368 MB for 46 minutes of 720p avc1 ≈ **480 MB/hour**, about half the handover's 1 GB/hour estimate. --- ## 2. Two bugs found in the format picker — both would have shipped Both came from the same root cause: YouTube advertises variants with **identical `abr`**, so `max()` tie-broke on list order. | video | picked | consequence | |---|---|---| | `NH2MhBFQm9w` | `140-drc` | HTTP 403 — DRC variants are advertised but not served | | `J9O3sxoMs5U` | `140-0` | **German audio on an English podcast** (9-language upload) | The second is the dangerous one: silent, and it would have played. Fixed by ranking `language_preference` first (original track = 10, dubs = -1), then non-DRC, then codec, then bitrate. Language correctness deliberately outranks the DRC check: a 403 is a loud failure, wrong-language audio is a silent one. `pick_formats` now also logs the chosen language and warns if a non-original track is selected on a multi-language upload. Verified: `140-drc` and `140-0` reproducibly 403 while `140`, `140-7`, `139`, `251` and `136` all download fine from the same `info.json`. **Later correction — both bugs had a common upstream cause.** The DRC and dubbed variants existed *only* because `mweb` was in `player_client`. With `player_client=default` alone, extraction returns four audio formats (139, 249, 140, 251), no DRC variants and no language variants at all — even on the nine-language video. So "DRC formats 403" was really "mweb formats 403", and the German-audio bug could only ever have happened via mweb. `mweb` is now dropped (§9). The `language_preference` and DRC rules stay in the picker as cheap insurance if a yt-dlp update changes what `default` returns. --- ## 3. Two defects not mentioned in the handover — both now fixed ### Duration collapses while the file grows Jellyfin's own ffprobe 7.1.3 on the 46-minute video: ``` during growth : format.duration = 5.92 <- wrong after complete: format.duration = 2789.45 <- correct ``` §5 says scrubbing won't work while growing. It is worse than that: the **duration itself** is wrong, which would break Jellyfin's runtime display, seek bar, resume point and watched state. This cannot be fixed at the container level. Both attempts failed: - Patching `mvhd.duration` in place — ignored. - Inserting an `mehd` box with the correct `fragment_duration` — the box is written correctly (`mehd v0 fragment_duration=60000`, file still decodes clean), and ffmpeg **still** ignores it and recomputes from the fragments present. Also confirmed: ffmpeg writes `mvhd.duration=0` and **no `mehd`** regardless of whether it knows the input duration — a mux from regular files and one from FIFOs produce byte-identical headers. `mp4boxes.py` and `mehd_patch.py` in this directory reproduce both results. **Fix:** waiting for the mux is now the default. Correct duration, `Accept-Ranges` and working seeks on the very first request. Costs TTFB — 20s for a 21-minute video instead of 6.9s. `--growing` keeps the old low-latency behaviour and now prints a warning. Given yt-dlp runs at ~60× realtime the wait is bounded and predictable, and it is the same order of magnitude as the 30-second non-seekable window §5 already accepted. The Jellyfin gate has since settled this: Jellyfin takes its runtime **only** from its own probe of the stream, never from the NFO (§6). So `--growing` would hand it ~6 seconds and there is no way to claw the TTFB back. Wait-for-complete is mandatory, not a preference. ### Range end was ignored `bytes=1000000-1000999` returned **367 MB** instead of 1000 bytes — the server parsed only the start offset and always streamed to EOF. This directly undermines §6's mitigation #2, which depends on serving small bounded probe ranges. Now parses both ends, honours `bytes=N-M`, `bytes=N-` and the suffix form `bytes=-N`, clamps an end past EOF, and returns `416` with `Content-Range: */total` for an unsatisfiable start. `test_range.py` covers all of it — 25 assertions, no network required. --- ## 4. One correction to §6's premise The `` blocks in the existing episode NFOs were written by **Jellyfin**, not by the automation — ``, ``, `micodec` and absolute-path `` are Jellyfin's own NFO output, produced after it probed the real `.mp4` files. So there was no evidence that pre-seeding `streamdetails` suppresses probing — mitigation #1 was **untested**, not partly proven. It has since been tested with a control group and it does nothing at all (§6). --- ## 5. Intermittent 403s — found, and now retried Two 403s were observed across roughly ten real runs, and they **moved between formats** — once on video `136`, once on audio `140`, on the same video that had succeeded minutes earlier. The first guess was transient rate limiting, because the failures clustered after ~1.5 GB had been pulled from one video. **That guess is wrong.** A retry two seconds later with a fresh extraction succeeded every single time, and no rate limit clears in two seconds. See §9 for what was actually measured. What matters is the consequence: a single 403 on either stream killed the whole playback with no recovery. It then struck again during the Jellyfin test itself, so it is now **fixed in `ytstream.py`**: up to `--max-retries` attempts (default 2), each with a fresh extraction, retried only while nothing has been served yet. That recovered every occurrence seen — including the one during the gate, where attempt 1 failed and attempt 2 succeeded. Retry is a mitigation, not a root-cause fix. `ytstream_poc.py` still has no retry; it is kept as the record of what was originally verified. --- ## 6. Jellyfin: both gates PASS §7 step 3 (playback) and §6 (scan probing) are now answered against the live Jellyfin 10.11.4 on susan, using the two channels already subscribed in `subs.db` — 7 episodes, 2 shows, real metadata. Library `YouTube (stream)` → `/disks/Plex/_ytstream`, built by `make_strm_tree.py`. `SaveLocalMetadata=False` on purpose: the real YouTube library has it **True**, which is why Jellyfin rewrote those NFOs, and leaving it on here would have overwritten the test NFOs and destroyed the control group. ### §6 scan-time probing — the open risk does not materialise on a normal scan | operation | probes | pipelines started | |---|---|---| | first scan of a new library | **0** | 0 | | repeat scan, `replaceAllMetadata=false` | **0** | 0 | | refresh with `replaceAllMetadata=true` | **yes, immediately** | would be all of them | A normal scan ingests all 7 `.strm` episodes — correct show, season, episode numbers, aired dates — **without a single request to the proxy**. No scan storm. Episode `RunTimeTicks` stays null until something plays the item. The dangerous path is a **full metadata replace**, which a user can trigger from the UI. Jellyfin probes the `.strm` target immediately. Two corrections to §6's assumptions while doing so: - Probes are **open-ended `Range: bytes=0-`**, not the "small ranges from offset 0" §6 assumed. You cannot identify a probe from its Range header, so mitigation #2 cannot key off request shape. It can still work by serving the first few MB from a permanent cache — the probe takes what it needs and hangs up — but the discriminator has to be something else. - **Refusing a probe causes a retry storm.** Returning 503 produced 9 retries in ~2 seconds from `Lavf/61.7.100`, after which the refresh aborted and left six of seven episodes with their episode numbers wiped. Whatever the mitigation is, it must answer, not refuse. `--max-pipelines` (default 2) is the backstop: a full replace throttles to two concurrent fetches instead of seven. ### §6 mitigation #1 — pre-seeded streamdetails do nothing The `alternate` control group settles it. Episodes carrying full `` with `` behaved **identically** to those without: `RunTimeTicks` null after a scan in both groups. Jellyfin took `ProviderIds` and `PremiereDate` from the same NFOs, so they were definitely being read — it simply does not use NFO stream details or runtime for a `.strm` item. Mitigation #1 is a dead end, and it is also unnecessary given a normal scan does not probe at all. This also settles the `--growing` question from §3: Jellyfin has **no** duration until it probes the stream itself, so a growing file would hand it ~6 seconds. **Stay in the default wait-for-complete mode.** ### §7 step 3 playback — passes, and direct-plays Driven through Jellyfin's own API (`PlaybackInfo`, then `/Videos/{id}/stream`): | | warm cache | cold, 46-minute episode | |---|---|---| | `PlaybackInfo` latency | 0s | **55s — Jellyfin waited, no timeout** | | `Container` | `mp4` | `mp4` | | `RunTimeTicks` | 120.62s (truth 120.62) | **2789.45s (truth 2789)** | | `SupportsDirectPlay` | **True** | **True** | | streams | h264 1280x720 + aac | h264 1280x720 + aac | Fetching the episode through Jellyfin's streaming endpoint returned 10,654,544 bytes **byte-identical** to what the proxy produced, decoding cleanly. So Jellyfin direct-plays it with no transcode — which was the whole point of preferring avc1+mp4a. Two things worth knowing: - A cold 46-minute episode makes the user wait ~55s at the play button. That is the price of correct duration and seeking. It is bounded and predictable. - One ffprobe of a 368 MB file generated **481 range requests** to the proxy. All local and cheap, but it makes the access log noisy and is worth a look if throughput ever matters. ### Artwork works, and costs nothing extra Episode thumbnails need **no API call**: the URL is derivable from the video id, which is already in the filename. `add_thumbnails.py` fetches `i.ytimg.com/vi//maxresdefault.jpg` (~200 KB, 1280x720) with `hqdefault.jpg` (~22 KB) as a fallback, writing `-thumb.jpg`. Channel avatar and banner become `poster.jpg` / `fanart.jpg` and do need one yt-dlp call per channel. Verified in Jellyfin: all 7 episodes carry a 16:9 `Primary` image, both series have a poster and a backdrop, and an image refresh triggers **0 probes**. Artwork is entirely independent of the streaming path. Two things learned while doing it: - **Jellyfin re-reads an NFO when its mtime changes.** Episode names were filename-derived until the NFOs were rewritten, after which they picked up the real titles from `` — full parity with the .mp4 library. So correcting metadata does not need a full replace (which would probe); just rewrite the NFO and run a `Default` refresh. - A `title` collision bug shipped briefly: the tree generator joins `video` and `channel`, **both of which have a `title` column**, so `row["title"]` was the video title and both shows got renamed after whichever episode sorted first. Fixed by passing show fields explicitly. Worth remembering for §7 step 5, where the same join exists. ### Catalogue size — the current library is small only because of retention The 7 episodes are not a limit of the design, they are what `subs.db` holds: `backfill_days = 7` and `retention_days = 9`, both driven by disk. A streaming library has no such constraint, so it should carry the whole back catalogue. Actual sizes, from one `--flat-playlist` call per channel: | channel | videos in catalogue | in subs.db | |---|---|---| | Pitch Side | **1249** | 15 | | The Pyramid Podcast | 3 | 3 | At 1249 episodes the cost is trivial for `.strm` + `.nfo` (a few MB) and ~250 MB of thumbnails at maxres. But two things get harder: 1. **`upload_date` is not in a flat listing.** Season and episode numbers are `year` and `MMDD*10 + ordinal`, so dates are required. `--extractor-args youtubetab:approximate_date` supplies timestamps in the same single request, but they are **wrong by up to 2 days** (measured against subs.db: `J9O3sxoMs5U` reported 08-12, actually 08-10). That yields wrong episode numbers and, worse, *unstable* ones — a regeneration would renumber episodes and Jellyfin would lose watched state. If approximate dates are used they must be persisted on first sight and never recomputed, exactly as specs.md already mandates for `dir_name`. Accurate dates cost one metadata extraction per video: ~1249 requests per channel, one-time. 2. **The full-metadata-replace footgun scales with the catalogue.** At 7 episodes it is an annoyance; at 1249 it would try to fetch every video in the channel. `--max-pipelines 2` turns a stampede into a slow grind but does not stop it. A cold-start rate limit should land before any full catalogue does. ### Running it for real The proxy must outlive a shell. `ytstream.service` is written but **not installed** — that needs sudo: ``` sudo cp /home/susan/ytstream/ytstream.service /etc/systemd/system/ sudo systemctl daemon-reload && sudo systemctl enable --now ytstream ``` The unit pins `PATH` to the automation venv, which is load-bearing: bare `yt-dlp` would otherwise resolve to the 2023.11.16 binary in `/usr/local/bin`. To remove everything: delete the `YouTube (stream)` library in the Jellyfin UI, then `rm -rf /disks/Plex/_ytstream`. --- ## 7. Files | file | purpose | |---|---| | `ytstream.py` | **the proxy.** Multi-video: `/watch/<video_id>`, `/healthz`. Retry, pipeline cap, LRU cache, `--no-fetch` | | `ytstream.service` | systemd unit, not installed (needs sudo) | | `make_strm_tree.py` | builds the .strm/.nfo library from subs.db | | `add_thumbnails.py` | episode thumbnails + channel poster/fanart, idempotent | | `ytstream_poc.py` | the original single-video PoC, kept as the record of what was verified | | `test_range.py` | 25 range-server assertions, no network | | `test_limits.py` | 18 assertions for the rate limiter, pipeline cap and --no-fetch | | `run_test.sh` | one end-to-end acceptance run; prints TTFB, codecs, duration | | `trial_403.sh` | measures the intermittent-403 rate for the PoC's access pattern | | `mp4boxes.py` | dumps fMP4 boxes and the duration fields | | `mehd_patch.py` | the `mehd` insertion — kept because the negative result matters | ## 8. Where this stands The handover's §8 advice was to build the batch downloader first. That is already done — `/opt/youtube-automate` is live — so this proxy is the second-phase experiment it was meant to be. Every unknown in §3 and §7 resolves in the design's favour and both Jellyfin gates pass. The architecture holds: ffmpeg never touches googlevideo, the mux is a pure copy, and Jellyfin direct-plays the result with the correct duration. Three things are known-imperfect rather than unknown: 1. **Cold start costs ~55s on a 46-minute episode** (~21s for 22 minutes). Inherent to wait-for-complete, and wait-for-complete is mandatory because Jellyfin takes duration only from its own probe. 2. **A full metadata replace probes every episode.** Now bounded by `--max-starts` as well as `--max-pipelines` (§9), but still don't run one over a large library. 3. **The intermittent 403 is still unexplained.** Several plausible causes are ruled out (§9) and retry recovers it every time, but that is a mitigation. Not done, and the honest next steps: - `ytstream.service` is written but **not installed** — that needs sudo. Until then the library only plays while the proxy runs in a shell. - Nothing writes `.strm` automatically. `make_strm_tree.py` is a one-shot from `subs.db`; §7 step 5 is untouched and `/opt/youtube-automate` is unmodified. - The library covers 7 episodes because that is what `subs.db` holds. A real deployment should carry the full catalogue — see the catalogue section in §6 for the two things that get harder at 1249 episodes. - Two libraries coexist by choice: `YouTube` (.mp4) and `YouTube (stream)` (.strm), same 7 episodes, for comparison. - tmpfs LRU eviction is implemented but has never fired; the cap was never reached in testing. --- ## 9. Hardening pass (2026-08-12, after the gates passed) ### The 403 investigation — the obvious fix would have made it far worse `player_client` was `default,mweb`. Inspecting the `c=` and `pot=` parameters on the chosen format URLs showed something that looked like an open-and-shut cause: | `player_client` | video/audio served from | PO token in URL | |---|---|---| | `default,mweb` | `ANDROID_VR` | **no** | | `mweb` | `MWEB` | **yes** | The obvious conclusion — "we are using token-less URLs, pin the client that gets a token" — is **wrong**, and testing it was worth the ten minutes: ``` player_client=mweb -> 0 ok / 6 fail (403 on both streams, every trial) player_client=default -> works ``` mweb fails **both** as a single process and via `--load-info-json`, so it is not a round-trip problem — mweb URLs are simply rejected right now, PO token and all. `ANDROID_VR` *without* a token works; `mweb` *with* one does not. Had this been "fixed" by pinning mweb, every playback would have broken. Full client sweep on the same video: | client | result | |---|---| | `default` (→ android_vr) | **works** | | `android_vr` | works | | `mweb` | 403, every time | | `web`, `ios`, `web_safari`, `tv` | no usable formats — SABR forced | So there is no client-level fix available: `default` is the only thing that works, and it is already what was in use. What changed: - **`mweb` dropped from `player_client`.** It contributed nothing usable, and it was the sole source of the DRC and dubbed-language formats behind both original picker bugs (§2). Removing it eliminates those hazards before the picker sees them, and cut extraction time from ~5s to **2.3s** — one fewer client API call, which comes straight off cold-start latency. - Left as `default` rather than pinned to `android_vr`, so a yt-dlp update can follow YouTube if android_vr stops working. **The 403 remains unexplained and still occurs** — one retry fired during the post-hardening Jellyfin test (`retried: 1, failed: 0`). Retry with re-extraction recovers it every time observed, so it stays the mitigation. What is now ruled out: rate limiting (a 2s retry succeeds), `--load-info-json` (fails identically without it), concurrency (5/5 clean), and PO token absence (token-less URLs are the ones that work). ### Cold-start rate limiter `--max-pipelines` caps concurrency, which only slows a runaway metadata refresh down — over 1249 episodes it would still fetch the whole catalogue, just two at a time. `--max-starts` (default 20 per `--starts-window`, default 3600s) bounds the total instead. The discriminator is that it counts **cold starts only**. Cache hits are never limited, so re-watching and resuming keep working with the budget exhausted. A person starts a handful of new videos an hour; a refresh storm hits 20 in seconds. When the budget is spent the proxy refuses with 503 and logs loudly. From the §6 measurements, a refused probe produces a short libavformat retry burst and then aborts the refresh — which is the point: an aborted refresh costs some re-derivable metadata, a runaway one costs the entire catalogue in YouTube traffic. `test_limits.py` covers the limiter, the concurrency cap and `--no-fetch` with the pipeline runner stubbed out — 18 assertions, no network. ### Known gap Sessions live in memory, so a proxy restart orphans whatever is already on tmpfs and the next request re-fetches. Harmless on this host (tmpfs is cleared on reboot anyway) but worth knowing if a service restart ever looks like a stall.