Files
youtube-automate/specs.handover-original.md
T
Tom FluxandClaude Opus 5 18bb2e420b Initial implementation of youtube-automate
A DVR for YouTube subscriptions, laid out so Jellyfin presents each channel
as a show and each video as an episode. Cron-driven, idempotent, with a
public admin UI for a non-operator.

Verified end to end on susan against three real channels: PO tokens, h264
downloads, Jellyfin resolution from local NFOs with all providers disabled,
retention and tombstones.

Corrections to the original design handover (specs.md documents each with
the evidence, and specs.handover-original.md preserves the original):

- The format sort selected 360p. Ranking acodec above res makes `bv*` prefer
  the combined 360p stream, which carries AAC, over the 720p video-only
  stream whose acodec is none. vcodec now leads, so a video without h264 at
  720p yields h264 lower down rather than VP9 this hardware cannot transcode.
- yt-dlp now requires a JS runtime and the yt-dlp-ejs solver scripts, which
  only ship with the [default] extra. Without them the n challenge fails and
  the mweb formats disappear entirely.
- --flat-playlist carries no upload dates, so the specced client-side date
  filter for backfill was impossible. Backfill is RSS-first.
- skipped_old was terminal, so raising a channel's retention appeared to do
  nothing. Added an explicit rescan.
- is_upcoming premieres now defer and retry instead of being skipped forever.
- TubeArchivist is gone, so the media root and the tube.jihakuz.xyz vhost
  were both reclaimed; the latter still pointed at its dead port.

240 offline tests, no network and no real yt-dlp invocation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:42:48 +01:00

657 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `ytsubs` — implementation handover
**Target machine:** `susan`
**Audience:** a Claude Code agent implementing this from scratch
**Status:** design agreed, not started
---
## 1. What we're building
A self-hosted pipeline that keeps a rolling 14-day window of recent YouTube uploads from a
handful of subscribed channels, laid out on disk so Jellyfin presents each channel as a TV
show and each video as an episode. A small web UI lets a non-operator (the requester's
brother) subscribe, unsubscribe, and tweak settings.
The mental model is **a DVR for YouTube subscriptions**, not an archiver. Videos are
disposable. If a video ages out or a channel is unsubscribed, it goes away permanently.
### Explicitly in scope
- Poll subscribed channels for new uploads
- Download at ≤720p, h264/AAC, into a Jellyfin-friendly layout
- Generate Kodi-style `.nfo` metadata + artwork (no Jellyfin plugin)
- Delete videos older than the retention window
- Web admin: add/remove channels, edit settings
- Cron-driven, idempotent, monitored via Healthchecks
### Explicitly out of scope — do not build these
- **Cookie handling of any kind.** No browser extension, no cookie-receiving API, no
`cookies.txt`. See §3.
- Members-only, age-restricted, or private content (follows from the above)
- Migration from the existing TubeArchivist install
- Multi-user support, authentication beyond a single shared credential
- Transcoding of any kind (see §6 — this matters on this hardware)
---
## 2. Environment facts you need
- **susan**: Linux rack server, dual Xeon X58, 12 threads, **no AVX**. Old and slow.
Software video transcoding is effectively off the table.
- Already running on susan: Jellyfin, Docker, nginx (Let's Encrypt), Netdata,
Healthchecks (`hc.jihakuz.xyz`), `runitor` for cron wrapping.
- susan is on a **residential IP**. This matters — YouTube flags datacenter IPs far more
aggressively. Do not move any part of this to `victoria` (the Linode VPS).
- Tailnet: Headscale at `headscale.jihakuz.xyz`. The admin UI should bind to the tailnet
interface rather than being published publicly (see §9).
- `/disks/Plex/YouTube` is **already in use by TubeArchivist**. Do not touch it. Use a
fresh root (§5).
- Existing conventions to match: state under `/var/lib/<service>/`, deployed executables
under `/usr/local/bin/`, bare git repos under `/disks/git-repos/`.
### Proposed paths
| Purpose | Path |
|---|---|
| Source (bare repo) | `/disks/git-repos/youtube-automate.git` |
| Checkout | `/opt/youtube-automate` |
| Entry point | `/usr/local/bin/youtube-automate` |
| State DB | `/var/lib/youtube-automate/subs.db` |
| Lock file | `/var/lib/youtube-automate/run.lock` |
| Media root | `/disks/Plex/YouTube/` |
| Scratch/work dir | `/disks/Plex/YouTube/.ingest/` |
The work dir **must** be on the same filesystem as the media root so finished downloads
move into place with an atomic `rename()` rather than a copy. Jellyfin should be
configured to ignore `.ingest` (it's dot-prefixed, which Jellyfin skips by default —
verify).
---
## 3. YouTube access: PO tokens, not cookies
This was the main open question in design and the answer is that it collapses.
yt-dlp's current recommended setup for reliable downloads is the **mweb client plus a PO
Token Provider plugin** supplying tokens for GVS (Google Video Server) requests. No
account, no cookies, no browser. Cookies only unlock age-restricted / members-only /
private content, which is out of scope, and carry a real risk of the Google account being
banned.
### Setup
1. Run the provider as a Docker sidecar:
!! tom: needs a way of starting up when system reboots
```
docker run --name bgutil-provider -d --restart unless-stopped --init \
-p 127.0.0.1:4416:4416 brainicism/bgutil-ytdlp-pot-provider
```
2. Install the matching plugin into the same Python environment as yt-dlp:
!! tom: do this in uv venv
```
pip install bgutil-ytdlp-pot-provider
```
The plugin and server versions should match.
3. Verify before writing any other code:
```
yt-dlp -v 'https://www.youtube.com/watch?v=<some_id>' -F
```
You must see a line like
`[debug] [youtube] [pot] PO Token Providers: bgutil:http-1.x.x (external), ...`
If you don't, stop and fix this first — everything downstream depends on it.
4. `ytsubs` should health-check the provider before each download batch (a plain HTTP
GET against `http://127.0.0.1:4416/ping`) and fail loudly if it's down, rather than
silently accumulating 403s.
### Caveats to be aware of
- The bgutil README carries a warning that PO tokens no longer bypass the "Sign in to
confirm you're not a bot" interstitial in most cases. That is a **different failure
mode** (IP reputation) from the 403s on format URLs that this solves. Residential IP +
low request volume + sleep intervals should keep us clear of it. If it does start
firing, that's the point at which cookies get reconsidered — not before.
- **yt-dlp's YouTube handling churns every few weeks.** Client recommendations,
extractor-arg names, and which clients need tokens all move. Before implementing, read
the current `PO Token Guide` and `Extractors` pages on the yt-dlp wiki and adjust the
flags in §6 accordingly. Do not treat the flags in this document as authoritative —
treat them as the shape of the answer.
- Keep yt-dlp itself updated (weekly `pip install -U yt-dlp` under the same runitor
wrapper). A stale yt-dlp is the single most likely cause of "everything broke".
---
## 4. Discovery: RSS feeds
YouTube publishes an unauthenticated Atom feed per channel. Use the **undocumented
`UULF` playlist variant**, which returns long-form videos only — no Shorts, no
livestreams:
```
https://www.youtube.com/feeds/videos.xml?playlist_id=UULF<channel_id without the UC prefix>
```
So `UCabc123...` → `https://www.youtube.com/feeds/videos.xml?playlist_id=UULFabc123...`
This does most of the Shorts/livestream filtering for free, at the cheapest possible
point in the pipeline.
Related prefixes, for reference: `UU` all uploads, `UUSH` shorts, `UULV` livestreams,
`UUMF`/`UUMO` members-only.
### Robustness
These prefixes are undocumented and there have been reports through 2026 of intermittent
failures and missing entries in YouTube's native feeds. So:
- If the `UULF` feed 404s or returns zero entries, **fall back** to
`?channel_id=UC...` and record `discovery_source = 'uc_feed'` on the resulting rows.
- Rows discovered via the fallback path get the duration/live filter applied at download
time (§6). Rows from `UULF` don't need it.
- Track consecutive poll failures per channel and surface the count in the admin UI. Two
channels quietly failing for a month is the realistic failure mode here.
### Parsing
`xml.etree.ElementTree`, no dependencies. Namespaces:
```python
NS = {
"atom": "http://www.w3.org/2005/Atom",
"yt": "http://www.youtube.com/xml/schemas/2015",
"media": "http://search.yahoo.com/mrss/",
}
```
Per `atom:entry`: `yt:videoId`, `atom:title`, `atom:published`,
`media:group/media:description`. The feed does **not** carry duration.
### Backfill on subscribe
New subscriptions pull the last **7 days**. The RSS feed only returns ~15 items, which
can be under 7 days for a prolific channel. For the initial backfill only, use:
```
yt-dlp --flat-playlist --playlist-end 50 -J \
'https://www.youtube.com/playlist?list=UULF<...>'
```
and date-filter the entries client-side. Steady-state polling uses the RSS feed.
### Getting the subscription list in
There is no need for a Google account or a subscriptions RSS feed. Either the brother
pastes channel URLs into the admin page one at a time, or he does a one-off Google
Takeout export (`subscriptions.csv`) and we bulk-insert. With single-digit channels,
manual entry is fine — do not build a CSV importer unless asked.
---
## 5. On-disk layout
Media root: `/disks/Plex/YouTubeSubs/`
```
/disks/Plex/YouTubeSubs/
├── .work/ # scratch, ignored by Jellyfin
└── Some Channel/
├── tvshow.nfo
├── poster.jpg
├── fanart.jpg
└── Season 2026/
├── Some Channel - S2026E08110 - Video Title [dQw4w9WgXcQ].mp4
├── Some Channel - S2026E08110 - Video Title [dQw4w9WgXcQ].nfo
├── Some Channel - S2026E08110 - Video Title [dQw4w9WgXcQ]-thumb.jpg
├── Some Channel - S2026E08110 - Video Title [dQw4w9WgXcQ].en.srt
└── Some Channel - S2026E08110 - Video Title [dQw4w9WgXcQ].info.json
```
### Season / episode numbering
- **Season** = upload year (`2026`)
- **Episode** = `MMDD * 10 + ordinal_within_day`
So the first video uploaded on 11 August is `8110`, the second that day is `8111`, and
the first on 12 August is `8120`. This scheme sorts correctly across the whole year
(unlike appending a suffix to a bare `MMDD`) and tolerates up to 10 uploads per channel
per day. Clamp the ordinal at 9 and log a warning if exceeded.
The ordinal must be computed against **what's already in the DB for that channel+date**,
not against the current batch, so it's stable across runs.
### Filename sanitisation
Strip `/ \ : * ? " < > |`, collapse runs of whitespace, strip leading/trailing dots and
spaces, truncate the title component to 120 chars on a word boundary. The `[videoid]`
suffix guarantees uniqueness regardless.
Store the channel directory name in the DB (`channel.dir_name`) at subscribe time and
never recompute it — channels rename themselves and we don't want orphaned directories.
---
## 6. Download
### Format selection — read this bit carefully
susan has no AVX and cannot realistically transcode. YouTube serves 720p as VP9 or AV1 by
default. If a client can't direct-play those, Jellyfin will try to transcode and it will
be miserable. So we force h264 + AAC in mp4 at download time:
```
-f "bv*[height<=720]+ba/b[height<=720]"
-S "vcodec:h264,acodec:aac,res:720"
--merge-output-format mp4
```
If a video genuinely has no h264 rendition at ≤720p, `-S` will fall back to VP9 rather
than fail. Log those cases; they should be rare.
Do **not** use `--sponsorblock-remove` — it requires cutting and re-encoding. Use
`--sponsorblock-mark all` with `--embed-chapters`, which writes chapter markers only and
is free.
### Full invocation
```
yt-dlp \
--extractor-args "youtube:player_client=default,mweb" \
--extractor-args "youtubepot-bgutilhttp:base_url=http://127.0.0.1:4416" \
-f "bv*[height<=720]+ba/b[height<=720]" \
-S "vcodec:h264,acodec:aac,res:720" \
--merge-output-format mp4 \
--no-playlist \
--write-info-json \
--write-thumbnail --convert-thumbnails jpg \
--write-auto-subs --sub-langs "en.*" --convert-subs srt \
--sponsorblock-mark all --embed-chapters \
--retries 3 --fragment-retries 10 \
--sleep-requests 2 --sleep-interval 5 --max-sleep-interval 15 \
-P "/disks/Plex/YouTubeSubs/.work" \
-o "%(id)s.%(ext)s" \
"https://www.youtube.com/watch?v=<VIDEO_ID>"
```
Verify the `youtubepot-bgutilhttp:base_url` arg name against the current plugin README —
this is the kind of thing that gets renamed.
### Match filter (fallback-discovered videos only)
For rows with `discovery_source = 'uc_feed'`, append:
```
--match-filter "duration>?{min_duration_seconds} & live_status!=is_live & live_status!=is_upcoming & !was_live"
```
The `>?` form allows videos with unknown duration through rather than rejecting them.
`min_duration_seconds` defaults to 120 (Shorts can now run to 3 minutes, but so can
legitimate short videos — this is the tradeoff, and it's why `UULF` is the primary path).
A video rejected by the match filter should be recorded as `skipped_short` /
`skipped_live` and **never retried**.
### Concurrency and politeness
One download at a time. Single-digit channels over a 14-day window is a small workload;
there is no reason to be aggressive and every reason not to be.
### After a successful download
1. Read the `.info.json` from `.work/`
2. Compute season/episode/filename
3. Generate the episode `.nfo` (§7)
4. `os.rename()` all artefacts into the season directory
5. Update the DB row: `state='downloaded'`, `rel_path`, `downloaded_at`
6. After the whole batch, trigger a Jellyfin library refresh:
`POST {jellyfin_url}/Library/Refresh` with header `X-Emby-Token: {api_key}`
If any step 14 fails, clean up `.work/` for that video ID and mark `failed` with
`attempts += 1`. Give up after 5 attempts and surface it in the UI.
---
## 7. Metadata — NFO files, no Jellyfin plugin
Jellyfin reads Kodi-style NFO sidecars natively. Configure the library as **Shows**,
disable all internet metadata providers, enable *Prefer local metadata* and *Save
artwork/metadata into media folders*. Do not write or install a metadata provider plugin.
### `tvshow.nfo` (per channel, written at subscribe time, refreshed on title change)
```xml
<?xml version="1.0" encoding="utf-8"?>
<tvshow>
<title>Some Channel</title>
<plot>Channel description from yt-dlp.</plot>
<studio>YouTube</studio>
<uniqueid type="youtube" default="true">UCabc123...</uniqueid>
</tvshow>
```
### Episode `.nfo` (one per video, filename matches the media file)
```xml
<?xml version="1.0" encoding="utf-8"?>
<episodedetails>
<title>Video Title</title>
<showtitle>Some Channel</showtitle>
<season>2026</season>
<episode>8110</episode>
<plot>Video description.</plot>
<aired>2026-08-11</aired>
<runtime>12</runtime>
<studio>YouTube</studio>
<uniqueid type="youtube" default="true">dQw4w9WgXcQ</uniqueid>
</episodedetails>
```
`runtime` is in **minutes**. Escape all text content properly — video descriptions
contain everything.
### Artwork
At subscribe time, `yt-dlp --flat-playlist --playlist-items 0 -J <channel_url>` returns a
`thumbnails` array containing entries with `id` values like `avatar_uncropped` and
`banner_uncropped`. Download the avatar to `poster.jpg` and the banner to `fanart.jpg`.
Treat both as best-effort — if they're missing, carry on without them.
Per-episode thumbnails come from `--write-thumbnail`; rename to `<basename>-thumb.jpg`.
---
## 8. Data model
SQLite at `/var/lib/ytsubs/subs.db`. **Enable WAL mode** — the cron job and the web
server both write.
```sql
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE channel (
id INTEGER PRIMARY KEY,
channel_id TEXT NOT NULL UNIQUE, -- UC...
handle TEXT, -- @handle, informational
title TEXT NOT NULL,
description TEXT,
dir_name TEXT NOT NULL UNIQUE, -- sanitised, immutable after creation
added_at TEXT NOT NULL,
backfilled INTEGER NOT NULL DEFAULT 0,
last_polled_at TEXT,
last_poll_ok INTEGER,
consecutive_poll_failures INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE video (
id INTEGER PRIMARY KEY,
video_id TEXT NOT NULL UNIQUE,
channel_pk INTEGER NOT NULL REFERENCES channel(id) ON DELETE CASCADE,
title TEXT,
upload_date TEXT, -- YYYY-MM-DD
duration INTEGER,
season INTEGER,
episode INTEGER,
state TEXT NOT NULL,
discovery_source TEXT NOT NULL, -- 'uulf_feed' | 'uc_feed' | 'backfill'
rel_path TEXT, -- relative to media root, NULL unless downloaded
size_bytes INTEGER,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
discovered_at TEXT NOT NULL,
downloaded_at TEXT,
deleted_at TEXT
);
CREATE INDEX idx_video_state ON video(state);
CREATE INDEX idx_video_upload_date ON video(upload_date);
CREATE TABLE setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
```
### `video.state` values
| State | Meaning | Retried? |
|---|---|---|
| `pending` | discovered, queued | — |
| `downloading` | claimed by a worker | recovered on startup → `pending` |
| `downloaded` | on disk, `rel_path` set | — |
| `deleted` | aged out; tombstone prevents re-download | never |
| `skipped_short` | rejected by match filter | never |
| `skipped_live` | rejected by match filter | never |
| `skipped_old` | discovered but already outside the window | never |
| `failed` | download error | yes, up to 5 attempts |
The tombstone behaviour is the important part: a `deleted` row must never be
re-discovered and re-downloaded. This is why we keep our own state table rather than
using yt-dlp's `--download-archive`.
### Settings (all editable from the admin page)
Store as strings; provide typed accessors with defaults so a missing key never crashes.
| Key | Default | Notes |
|---|---|---|
| `retention_days` | `14` | delete videos with `upload_date` older than this |
| `backfill_days` | `7` | window pulled when a channel is added |
| `max_height` | `720` | |
| `min_duration_seconds` | `120` | fallback-path Shorts filter |
| `sponsorblock_mark` | `true` | |
| `write_subs` | `true` | |
| `sub_langs` | `en.*` | |
| `respect_jellyfin_watch_state` | `true` | §10 |
| `jellyfin_url` | `http://127.0.0.1:8096` | |
| `jellyfin_api_key` | *(empty)* | masked in the UI |
| `jellyfin_user_id` | *(empty)* | |
| `pot_provider_url` | `http://127.0.0.1:4416` | |
| `max_attempts` | `5` | |
| `disk_cap_gb` | `0` | `0` = disabled; if set, evict oldest downloaded videos |
Validate on save (integers parse, URLs well-formed) and re-render the form with an inline
error rather than 500-ing.
---
## 9. Entry point and scheduling
Single executable `ytsubs` with subcommands:
| Command | Purpose |
|---|---|
| `ytsubs run` | poll → download → reap. This is what cron calls. |
| `ytsubs poll [--channel ID]` | discovery only |
| `ytsubs download` | drain the pending queue |
| `ytsubs reap` | retention pass |
| `ytsubs serve` | admin HTTP server (systemd unit) |
| `ytsubs subscribe <url>` / `unsubscribe <id>` | CLI equivalents, useful for debugging |
| `ytsubs doctor` | check yt-dlp version, POT provider reachable, DB writable, media root writable, Jellyfin reachable |
`run` takes a **non-blocking `flock`** on `/var/lib/ytsubs/run.lock` and exits 0 silently
if already held. On startup, reset any `downloading` rows to `pending` (crash recovery).
### Cron
```
17 * * * * runitor -uuid <hc-uuid> -- /usr/local/bin/ytsubs run
```
Hourly is ample for single-digit channels. Register the check in Healthchecks with a
generous grace period.
### systemd unit for the admin server
Standard `simple` unit, `Restart=always`, running as the same unprivileged user that owns
the DB and media root. When the user subscribes to a channel via the web UI, the handler
should spawn `ytsubs run --channel <id>` detached so the backfill starts immediately
rather than waiting up to an hour.
### nginx
Bind the admin server to `127.0.0.1:8085`. Front it with nginx on the **tailnet
interface only** — `yt.jihakuz.xyz` resolving inside Headscale, HTTP basic auth on top.
Given the brother will be on the tailnet anyway, there's no reason to expose this
publicly, and it removes the need for a Let's Encrypt cert and any real CSRF story.
Confirm this before implementing; if it does need to be public, add proper CSRF tokens.
---
## 10. Retention and deletion
### Aging out (`ytsubs reap`)
Candidates: `state = 'downloaded'` and `upload_date < today - retention_days`.
Before deleting, if `respect_jellyfin_watch_state` is on:
1. `GET {jellyfin_url}/Items?userId={user_id}&recursive=true&includeItemTypes=Episode&fields=Path,UserData`
with header `X-Emby-Token: {api_key}` — one call per reap, build a `path → UserData` map.
2. **Skip** any candidate where `UserData.PlaybackPositionTicks > 0 and not UserData.Played`
(part-watched) or `UserData.IsFavorite` is true.
3. Apply a hard backstop: delete anyway once `upload_date` is older than
`retention_days * 2`, so a half-watched video doesn't live forever.
Nothing wrecks "set and forget" like a video disappearing at the 20-minute mark. This
check is worth the complexity.
Deletion removes the media file plus its `.nfo`, `-thumb.jpg`, `.srt`, and `.info.json`
siblings, then prunes the season directory if empty. Set `state='deleted'`,
`deleted_at`, and `rel_path=NULL`. **Keep the row** — it's the tombstone.
If the Jellyfin API is unreachable, skip the whole reap for that run and log it. Do not
delete blindly.
### Unsubscribe
Hard delete, as agreed: `shutil.rmtree()` the channel directory, then `DELETE FROM
channel` (cascades to `video`). Re-subscribing later starts from scratch and re-downloads
the last 7 days. Put a confirmation step in the UI — this is destructive and irreversible.
### Disk cap (optional, `disk_cap_gb`)
If enabled, after each reap, sum `size_bytes` over `downloaded` rows and evict oldest-by-
`upload_date` until under the cap, honouring the same watch-state protection.
---
## 11. Admin UI
Stdlib only — `http.server.ThreadingHTTPServer` + `BaseHTTPRequestHandler`. No Flask, no
FastAPI, no npm. Server-rendered HTML with a single embedded `<style>` block. The only
JavaScript should be a `confirm()` on the unsubscribe button.
### Routes
| Method | Path | Behaviour |
|---|---|---|
| `GET` | `/` | Channel list + add form + settings form |
| `POST` | `/channels` | Resolve URL → insert → spawn backfill → redirect to `/` |
| `POST` | `/channels/<id>/delete` | Confirm-guarded hard delete → redirect |
| `POST` | `/settings` | Validate + persist → redirect |
| `GET` | `/health` | JSON: yt-dlp version, POT provider up, last run time, queue depth |
All POSTs redirect (303) so refresh doesn't resubmit.
### Channel list should show, per channel
Title, `@handle`, video count on disk, disk usage, most recent upload date, last poll
time, and a warning badge if `consecutive_poll_failures > 2`.
### Channel URL resolution
Accept: `https://www.youtube.com/@handle`, `/channel/UC...`, `/c/name`, `/user/name`,
a bare `@handle`, or a bare `UC...` ID.
```
yt-dlp --flat-playlist --playlist-items 0 -J <url>
```
`--playlist-items 0` fetches channel metadata **without enumerating the uploads**, which
is the cheap way to do this. Pull `channel_id`, `channel`, `description`, `thumbnails`
from the result. If resolution fails or returns no `channel_id`, re-render the form with
an error — don't insert a half-formed row.
Reject duplicates on `channel_id` (not on the submitted URL — the same channel has many
URL forms).
---
## 12. Known gotchas
- **yt-dlp churn is the top operational risk.** Extractor args, client names, and PO
token requirements change frequently. Weekly `pip install -U yt-dlp` under runitor.
When something breaks, check the yt-dlp issue tracker before debugging our code.
- **The `UULF` prefix is undocumented** and could vanish. The `channel_id` fallback path
is not optional.
- **RSS feeds cap at ~15 entries.** Fine for hourly polling, insufficient for backfill —
hence the separate `--flat-playlist` path in §4.
- **`.work` must share a filesystem with the media root**, or every completed download
becomes a full copy.
- **Don't transcode.** If Jellyfin is transcoding these files, the format selection in §6
is wrong — fix it there, not by throwing hardware at it.
- **Descriptions are hostile input** for XML generation. Use `xml.etree`'s serialiser
rather than string-formatting the NFO by hand.
- **WAL mode is required** — two writers.
- Don't reuse `/disks/Plex/YouTube` (TubeArchivist's tree).
---
## 13. Acceptance criteria
Work through these in order; each is a real check, not a code-reading exercise.
1. `ytsubs doctor` passes on a clean install.
2. Subscribing to a real, active channel creates the directory, `tvshow.nfo`,
`poster.jpg`, and queues the last 7 days of videos.
3. At least one real video downloads end to end and lands in the right season directory
with a correct filename, NFO, thumbnail, and subtitle sidecar.
4. `ffprobe` on the downloaded file shows **h264 video and AAC audio**.
5. Jellyfin, after a library scan, shows the channel as a show, the year as a season, and
the video as an episode with the correct title, description, and air date — with all
internet metadata providers disabled.
6. The video **direct-plays** on a client with no transcoding (check the Jellyfin
dashboard's active-streams panel).
7. Re-running `ytsubs run` immediately downloads nothing and errors on nothing
(idempotency).
8. Manually backdating a video's `upload_date` past the retention window causes `reap` to
delete it and its sidecars, prune the empty directory, and leave a `deleted` tombstone.
9. Re-running `poll` after that does **not** re-download the deleted video.
10. Marking a video part-watched in Jellyfin protects it from reap (with
`respect_jellyfin_watch_state` on).
11. Unsubscribing removes the directory and all rows.
12. Killing `ytsubs run` mid-download leaves no orphan in `.work`, and the next run
recovers the `downloading` row to `pending`.
13. The admin page renders, adds, removes, and persists settings; a bad settings value
produces an inline error rather than a traceback.
14. Editing `retention_days` in the UI visibly changes reap behaviour on the next run.
---
## 14. Suggested build order
!! tom: we also wants tests through this
1. Skeleton: DB schema, migrations, settings accessors, `doctor`
2. POT provider + a hardcoded single-video download proving §3 and §6 work — **do this
before writing anything else of substance**
3. Channel resolution + `subscribe`/`unsubscribe` on the CLI
4. Discovery (`poll`), both feed paths
5. Download worker + NFO/artwork generation + move-into-place
6. Jellyfin library verification (acceptance criteria 5 and 6) — a checkpoint, not a step
7. `reap`, including the watch-state check
8. `run` orchestration, flock, crash recovery, cron + runitor + Healthchecks
9. Admin server
10. systemd unit, nginx on the tailnet, deploy scripts
Steps 2 and 6 are the two places where this design could turn out to be wrong. Hit them
early and report back rather than building on top of an unverified assumption.