docs(playbooks): point at beta-flix for procedural docs
Some checks are pending
secret-scan / gitleaks (HEAD + history) (push) Waiting to run
secret-scan / detect-secrets (entropy + cross-tool) (push) Waiting to run
secret-scan / summary (push) Blocked by required conditions

Procedural playbooks (READMEs, helpers) moved to git.s8n.ru/s8n/beta-flix
and rewritten for stock Jellyfin 10.11.8. Per-iteration runs/ and
CHANGELOG.md stay here as history. Replace the three top-level READMEs
with pointer stubs.
This commit is contained in:
s8n 2026-05-11 16:00:12 +01:00
parent 690ea117c3
commit 4ab8c277da
3 changed files with 28 additions and 512 deletions

View file

@ -1,29 +1,15 @@
# playbooks/ — repeatable acquisition workflows
# playbooks/ — moved
Runbook-style playbooks for repeatable ARRFLIX ops — subtitles, importing
media, artwork, metadata, episode stills, and any other recurring acquisition
or maintenance procedure. Each playbook is a standalone recipe Claude Code
(or a human operator) can execute end-to-end.
The procedural playbooks (README, CHANGELOG, helper scripts) have moved to
beta-flix:
This folder holds the canonical recipes for **acquiring external content** for
the ARRFLIX library: subtitles, artwork, metadata, episode stills, etc.
Internal ops (encoding, importing, theming) stay in `bin/` and `docs/`.
<https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/>
Each playbook is its own sub-folder with three files:
Per-run logs stay here under `playbooks/<area>/runs/` — they document past
ARRFLIX work and are history, not procedure. New run logs for ARRFLIX
imports continue to land here.
| File | Purpose |
|---|---|
| `README.md` | The canonical recipe. Step-by-step, executable by Claude Code. Always reflects the latest version. |
| `CHANGELOG.md` | Why the recipe changed, version-by-version. One entry per breakage that forced a revision. |
| `runs/<show>.md` | Evidence log: what happened when this recipe was applied to a specific show. |
Recipes evolve via the **iteration model**: apply to a show, succeed or break,
amend the recipe to handle the new case + every prior case, retry. A recipe
that "just works" is one that has survived every show in the library without
amendment for a full sweep.
## Children
| Playbook | Status | Last touched |
| Sub-area | Procedure | Run logs |
|---|---|---|
| [`subtitles/`](subtitles/) | v3.5 — YouTube auto-CC added as stop-gap for shows with no community subs anywhere (verified via 3-agent research run). AD 49/58 + Sassy 5/5. v4 WhisperX planned (ROADMAP H5) | 2026-05-10 |
| Import media | [`beta-flix/playbooks/import-media/`](https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/import-media/) | [`import-media/runs/`](import-media/runs/) |
| Subtitles | [`beta-flix/playbooks/subtitles/`](https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/subtitles/) | [`subtitles/runs/`](subtitles/runs/) |

View file

@ -1,279 +1,10 @@
# Import Media Playbook
# Import-media playbook — moved
> Repeatable workflow for adding a new movie or TV show to ARRFLIX.
> Mirror the format of `playbooks/subtitles/README.md` (CHANGELOG-driven, runs/ folder for per-import logs).
The procedure has moved to beta-flix and been rewritten for stock Jellyfin
10.11.8:
Version: **v1.1** (2026-05-10)
<https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/import-media/README.md>
---
## TL;DR — five-step import
1. **Stage** on onyx — rename to canonical form, cleanup junk
2. **rsync** to nullstone `/home/user/media/<library>/<Title> (<Year>)/`
3. **Permissions**`chown user:user`, files `644`, dirs `755`
4. **Verify scan**`LibraryMonitor` auto-refreshes ~13 s after copy; `Items/Counts` should bump
5. **Subtitles** (if needed) — follow `playbooks/subtitles/`
If anything goes wrong, **don't delete the source download** until the new content is confirmed playing in the app (`ADMIN-GUIDE.md:74`).
---
## Pre-flight checklist
Before importing:
- [ ] Source file is canonical quality (4K source-permitting, AI-upscale otherwise — no 480p filler, no junk encodes per `README.md:41`)
- [ ] Title and year confirmed (TMDb / IMDb)
- [ ] No copy already in library — check via `curl -s -H "X-Emby-Token: $TOK" 'https://arrflix.s8n.ru/Items?Recursive=true&IncludeItemTypes=Movie&searchTerm=<title>'`
- [ ] Container running healthy: `ssh user@nullstone 'docker ps --filter name=jellyfin --format "{{.Names}} {{.Status}}"'`
---
## Step 1 — Stage on onyx
Source land in `/home/admin/Downloads/<release-name>/`. Stage to a clean dir before pushing.
### Movie
```bash
SRC="/home/admin/Downloads/<Original Release Name>/<file>.mkv"
DEST_DIR="/home/admin/staging-jelly/<Title> (<Year>)"
mkdir -p "$DEST_DIR"
cp "$SRC" "$DEST_DIR/<Title> (<Year>).mkv"
ls -la "$DEST_DIR"
```
### TV (single season)
```bash
SRC="/home/admin/Downloads/<Series Release>"
DEST_DIR="/home/admin/staging-jelly/<Series> (<Year>)/Season <NN>"
mkdir -p "$DEST_DIR"
# rename per docs/08 — <Series> (<Year>) - SNNEMM - <Title>.mkv
for ep in "$SRC"/*.mkv; do
cp "$ep" "$DEST_DIR/<Series> (<Year>) - S<NN>E<MM> - <Episode Title>.mkv"
done
```
### Filename rules (canonical, see `docs/05` + `docs/08`)
- Year in `()` mandatory, even when unique
- Forbidden chars in path: `< > : " / \ | ? *`
- Strip group tags: `[YIFY]`, `[RARBG]`, `[FS99 Joy]`, `-FQM`, `-AMIABLE`, etc.
- Strip resolution/codec/source/audio tokens: `1080p`, `2160p`, `BluRay`, `WEB-DL`, `x265`, `HEVC`, `10bit`, `AAC`, `DTS`, `EAC3`, `5.1`, `H264`
- Strip language tokens from video file (`.eng`, `.pl`, etc. — those go on subtitle sidecars only)
- Lowercase extension
- Movies: `<Title> (<Year>).<ext>`
- TV: `<Series> (<Year>) - S<NN>E<MM> - <Episode Title>.<ext>`, episodes inside `Season <NN>/`
### Cleanup junk
If source dir has extra files (NFO, JPG, sample, torrent, RARBG.txt, etc.) — staging copy should include ONLY the media file. NFOs that pin TMDb/IMDb ID are exception (see `docs/07`).
`bin/cleanup-import.sh` and `bin/normalize.py` are documented in `docs/07` and `docs/08` but not yet extracted as runnable scripts (ROADMAP M6). For now, manual cleanup per the rules above.
---
## Step 2 — rsync to nullstone
### Movie
```bash
rsync -a --info=progress2 --no-owner --no-group \
"/home/admin/staging-jelly/<Title> (<Year>)" \
user@nullstone:/home/user/media/movies/
```
### TV (one season)
```bash
rsync -a --info=progress2 --no-owner --no-group \
"/home/admin/staging-jelly/<Series> (<Year>)/Season <NN>" \
'user@nullstone:/home/user/media/tv/<Series> (<Year>)/'
```
`scp -r` works too but `rsync -a` preserves perms-by-mode + resumes on interrupt.
---
## Step 3 — Permissions on nullstone
Files must be `user:user` 644 (per live audit, `stat -c '%U:%G %a'`):
```bash
ssh user@nullstone '
cd "/home/user/media/movies/<Title> (<Year>)"
find . -type d -exec chmod 755 {} \;
find . -type f -exec chmod 644 {} \;
ls -la
'
```
`rsync` over ssh as `user@` already lands files as `user:user`. The chmod pass is for safety (`mv` from another path may differ).
---
## Step 4 — Trigger scan (do not rely on auto-refresh)
> **v1.1 change:** prior versions said `LibraryMonitor` auto-fires within 13 s and `POST /Library/Refresh` is the fallback. **Both proved unreliable across consecutive runs (`lilo-stitch-2002`, `archer-s02-2009`).** Always trigger the `Scan Media Library` scheduled task directly.
### 4a. Pull task ID once (cache in shell or env)
```bash
TOK=<your-admin-token>
SCAN_TASK_ID=$(ssh user@nullstone \
"docker exec jellyfin curl -sf -H 'X-Emby-Token: $TOK' http://127.0.0.1:8096/ScheduledTasks" \
| python3 -c 'import sys,json
[print(t["Id"]) for t in json.load(sys.stdin) if t["Name"]=="Scan Media Library"]')
echo "$SCAN_TASK_ID"
```
### 4b. Trigger the task (returns 204 + actually runs)
```bash
ssh user@nullstone \
"docker exec jellyfin curl -sf -X POST -H 'X-Emby-Token: $TOK' \
http://127.0.0.1:8096/ScheduledTasks/Running/$SCAN_TASK_ID -w 'HTTP:%{http_code}\n'"
```
### Known broken endpoints (do NOT use)
| Endpoint | Why broken |
|---|---|
| `LibraryMonitor` (inotify) auto-fire | Bind-mount + userns-remap inotify events do not propagate reliably; observed silent miss on every recent run. |
| `POST /Library/Refresh` | Returns HTTP 204 but the `Scan Media Library` scheduled task does NOT fire. Silent no-op. Use `/ScheduledTasks/Running/<id>` instead. |
### 4c. Confirm task actually started
```bash
ssh user@nullstone \
"docker exec jellyfin curl -sf -H 'X-Emby-Token: $TOK' \
http://127.0.0.1:8096/ScheduledTasks/$SCAN_TASK_ID" \
| python3 -c 'import sys,json;d=json.load(sys.stdin);print(d["State"], d.get("CurrentProgressPercentage","-"))'
```
Expect: `Running <pct>` while scanning, then `Idle -` when done. `LastExecutionResult.EndTimeUtc` should advance past the trigger time.
---
## Step 5 — Verify items added + metadata + playback
> **v1.1 change:** **Do NOT use `/Items/Counts` for verification.** It is scope-cached/user-default-view-cached and stays stale even when the scan completed and items were added (observed in `archer-s02-2009`: counts stayed at 230 long after 13 eps were indexed). Use the per-item or per-series query as authoritative.
### Movies — search by title
```bash
TOK=<token>
ssh user@nullstone "docker exec jellyfin curl -sf -H 'X-Emby-Token: $TOK' \
'http://127.0.0.1:8096/Items?Recursive=true&IncludeItemTypes=Movie&searchTerm=<Title>'" \
| python3 -c "import sys,json;[print(i['Id'],i['Name']) for i in json.load(sys.stdin)['Items']]"
```
### TV — list episodes for the affected series + season (authoritative)
```bash
SERIES_ID=$(ssh user@nullstone "docker exec jellyfin curl -sf -H 'X-Emby-Token: $TOK' \
'http://127.0.0.1:8096/Items?Recursive=true&IncludeItemTypes=Series&searchTerm=<Series>'" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["Items"][0]["Id"])')
ssh user@nullstone "docker exec jellyfin curl -sf -H 'X-Emby-Token: $TOK' \
'http://127.0.0.1:8096/Shows/$SERIES_ID/Episodes?Season=<NN>&fields=Path,ProviderIds,ImageTags'" \
| python3 -c 'import sys,json
d=json.load(sys.stdin)
print("COUNT:", len(d["Items"]))
for e in d["Items"]:
print(" S{}E{:02d} {} providers={} hasImg={}".format(
e["ParentIndexNumber"], e["IndexNumber"], e["Name"],
list(e.get("ProviderIds",{}).keys()),
"Primary" in e.get("ImageTags",{})))'
```
Confirm: count matches files-on-disk, every episode has at least one of `Tvdb`/`Tmdb`/`Imdb` in providers, every episode has `Primary` image. If any row is missing providers/image, run a series-level metadata refresh:
```bash
ssh user@nullstone "docker exec jellyfin curl -sf -X POST -H 'X-Emby-Token: $TOK' \
'http://127.0.0.1:8096/Items/$SERIES_ID/Refresh?MetadataRefreshMode=FullRefresh&ImageRefreshMode=FullRefresh&Recursive=true'"
```
Probe codec/streams (sanity check no transcode surprise):
```bash
ssh user@nullstone 'docker exec jellyfin /usr/lib/jellyfin-ffmpeg/ffprobe -hide_banner "/media/movies/<Title> (<Year>)/<Title> (<Year>).mkv" 2>&1 | grep -E "Stream|Duration"'
```
Open in browser → confirm artwork + title + duration look right.
If TMDb match is wrong (rare for canonical titles), force the right one by adding `[tmdbid-NNNNN]` to the folder name and re-scanning. See `docs/05:54-56`.
---
## Step 6 — Subtitles (optional, see playbook)
If the user wants English subs and source doesn't have them embedded:
- Follow `playbooks/subtitles/README.md`
- Drop `.eng.srt` next to the mkv
- After dropping subs: `POST /Items/{id}/Refresh?MetadataRefreshMode=ValidationOnly&Recursive=true`
---
## Step 7 — Document the run
Copy `runs/_template.md` to `runs/<title-slug>.md` and record:
- Source provenance (laptop path, release name, hash if you bothered)
- Target nullstone path
- Item ID
- Counts before/after
- Codec/stream summary
- Subtitle status
Commit + push: see `testing/DEPLOY.md` for git workflow.
---
## Verification checklist (from `docs/05:1082-1099`)
- [ ] Folder matches `<Title> (<Year>)` (movies) or `<Series> (<Year>)/Season <NN>` (TV)
- [ ] Filename matches canonical pattern (no group tags, no codec/resolution tokens)
- [ ] Year present, four digits, in parentheses
- [ ] No forbidden chars `< > : " / \ | ? *`
- [ ] Folder + filename match exactly (basename equals folder name for movies)
- [ ] One folder per item — no shared folders
- [ ] NFO present iff TMDb override needed (`[tmdbid-NNNN]` token)
- [ ] Subtitle sidecars correctly named (`.eng.srt`, not `.en.srt` or stripped)
- [ ] `Scan Media Library` task triggered via `/ScheduledTasks/Running/<id>` (NOT `/Library/Refresh`) and `LastExecutionResult.EndTimeUtc` advanced
- [ ] Per-series query (`/Shows/<id>/Episodes?Season=N`) shows expected count (do NOT trust `/Items/Counts`)
- [ ] `ProviderIds` populated on the new item (TMDb / TVDB / IMDb match successful)
- [ ] Image artwork populated
---
## Rollback / abort
If a partial copy fails or the wrong item gets matched:
```bash
# Remove the item folder
ssh user@nullstone 'rm -rf "/home/user/media/movies/<Title> (<Year>)"'
# Trigger scan (NOT /Library/Refresh — see Step 4 known-broken table) to drop it from index
ssh user@nullstone "docker exec jellyfin curl -sf -X POST -H 'X-Emby-Token: $TOK' \
http://127.0.0.1:8096/ScheduledTasks/Running/$SCAN_TASK_ID"
```
Source download on laptop is untouched — restage and retry per `docs/07:55-58`.
---
## Cross-references
| Topic | File |
|-------|------|
| Brand quality bar | `README.md:41` |
| Daily ops media flow | `ADMIN-GUIDE.md:3546` |
| Folder/filename rules | `docs/05`, `docs/08` |
| Pre-import cleanup logic | `docs/07` |
| Subtitle sidecar form | `docs/03` + `playbooks/subtitles/` |
| TMDb override token | `docs/05:54-56` |
| Verification checklist (canonical) | `docs/05:1082-1099` |
| LibraryMonitor proof | live `docker logs jellyfin | grep LibraryMonitor` |
| Existing run examples | `runs/` |
Per-import run logs stay here under [`runs/`](runs/) — they're history,
not procedure. [`CHANGELOG.md`](CHANGELOG.md) preserves the ARRFLIX
playbook evolution (v1.0 → v1.1) for context.

View file

@ -1,215 +1,14 @@
# Subtitle acquisition process — v1
# Subtitles playbook — moved
Last updated: 2026-05-10
Status: **v3.5** — four fetch paths (plugin / OS REST / Addic7ed / YouTube auto-CC). American Dad 49/58 + Sassy 5/5. v4 WhisperX planned (ROADMAP H5).
The procedure, STYLE.md, COVERAGE.md, STOPGAP-SUBS.md and the helper
scripts (`audit-coverage.py`, `sub-fetch.sh`, `sub-rest-fetch.py`,
`sub-a7d-fetch.py`, `sub-yt-fetch.sh`, `yt-clean.py`) have moved to
beta-flix and been rewritten / generalised for stock Jellyfin 10.11.8:
This recipe is written for Claude Code to execute. Each step lists the exact
command, what to verify, and what to do on failure. Background reference for
how Jellyfin and the OpenSubtitles plugin work together lives in
[`docs/03-subtitles.md`](../../docs/03-subtitles.md).
<https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/subtitles/>
> **Current state:** [`COVERAGE.md`](COVERAGE.md) is the live audit
> (per-show + per-movie). Regenerate at any time:
>
> ```bash
> JELLYFIN_TOKEN=<admin-token> playbooks/subtitles/lib/audit-coverage.py
> ```
>
> Run after every fetch batch so the committed file stays accurate.
>
> **Read [`STYLE.md`](STYLE.md) first.** Every fetch must hit the
> bar set there: one English `.srt` per episode, plain (no SDH / no MT / no
> AI / no Forced), best-quality release. The picker logic in v1/v2/v3
> mirrors that bar; if a step would violate it, stop and ask before
> downloading.
>
> Stop-gap exception: when the only available source is the v3.5 YouTube
> auto-CC path (lowercase, censored, mangled names), ship the sub but
> **add the show to [`STOPGAP-SUBS.md`](STOPGAP-SUBS.md)** so v4 WhisperX
> picks it up later.
---
## Prereqs (verify before running)
| Check | How |
|---|---|
| OpenSubtitles plugin v20 installed + Active | `docker exec jellyfin ls /config/plugins | grep -i opensub` |
| Plugin creds saved (`Caveman5`) | `docker exec jellyfin grep -E 'Username\|CredentialsInvalid' /config/plugins/configurations/Jellyfin.Plugin.OpenSubtitles.xml` — expect `Caveman5` and `false` |
| TV library has `SaveSubtitlesWithMedia=true`, `SubtitleDownloadLanguages=["eng"]`, `RequirePerfectSubtitleMatch=false` | `curl -s -H "X-Emby-Token: $TOK" http://localhost:8096/Library/VirtualFolders` |
| Free-tier quota remaining today (≥ episode count, else plan multi-day) | `docker logs --tail 200 jellyfin 2>&1 \| grep "Remaining downloads" \| tail -1` (free = 20/day, resets 00:00 UTC) |
| Source files have audio language tag | `ffprobe` sample episode |
If any prereq fails, stop. Fix it before running the recipe.
---
## Step 1 — Probe the source
Pick one episode of the target show. Run `ffprobe` on it:
```bash
ssh user@192.168.0.100 'docker exec jellyfin /usr/lib/jellyfin-ffmpeg/ffprobe -hide_banner "<path-to-mkv>" 2>&1 | grep -E "Stream|Duration"'
```
Record in the run log:
- video codec + resolution + frame rate
- audio language tag(s)
- whether any subtitle streams are embedded
- container
Decide based on probe:
| Probe result | Branch |
|---|---|
| English audio, no embedded subs | "simple" path (this recipe) |
| Foreign-dub audio, no embedded subs | "foreign-dub" path (deferred to v?) |
| Embedded English subs already present | skip — Jellyfin will use them |
| Embedded PGS/VobSub bitmap subs | "OCR" path (deferred to v?) |
---
## Step 2 — Resolve series + episode IDs
```bash
TOK=<jellyfin-admin-token>
SERIES_NAME='American Dad'
ssh user@192.168.0.100 "docker exec jellyfin curl -s -H 'X-Emby-Token: $TOK' \
'http://localhost:8096/Items?searchTerm=${SERIES_NAME// /+}&IncludeItemTypes=Series&Recursive=true&Limit=3'" \
| python3 -c "import json,sys; [print(x['Id'],x['Name']) for x in json.load(sys.stdin).get('Items',[])]"
```
Record series Id. Then list episodes:
```bash
SERIES=<series-id>
ssh user@192.168.0.100 "docker exec jellyfin curl -s -H 'X-Emby-Token: $TOK' \
'http://localhost:8096/Items?ParentId=$SERIES&IncludeItemTypes=Episode&Recursive=true&Fields=Path,ParentIndexNumber,IndexNumber'" \
| python3 -c "import json,sys; [print(e['Id'],'S%02dE%02d'%(e['ParentIndexNumber'],e['IndexNumber']),e['Name']) for e in json.load(sys.stdin)['Items']]"
```
---
## Step 3 — Pick fetch path
Four paths, ordered cheapest-quota-cost-first:
| Path | Cost / day cap | Coverage | Tool |
|---|---|---|---|
| **v3 Addic7ed** | free, no daily cap (anon) | English-only; near-complete on broadcast US shows; spotty on animated specials / niche titles | `lib/sub-a7d-fetch.py` |
| **v2 OS REST** | 20 / day on free OS account | best overall coverage; survives any S/E numbering quirk via per-ep `imdb_id` | `lib/sub-rest-fetch.py` |
| **v1 plugin** | counts against same OS 20/day | only works when library numbering matches OS catalogue (e.g. fails on American Dad past S01E07) | `lib/sub-fetch.sh` |
| **v3.5 YouTube auto-CC** | free, ratelimited only | for shows distributed YouTube-first (no community subs anywhere); produces lowercase, no-punctuation, name-mangled subs — **stop-gap, violates STYLE.md** | `lib/sub-yt-fetch.sh` + `lib/yt-clean.py` |
| **v4 WhisperX (planned)** | local CPU/GPU time | full-quality auto-transcription, restores STYLE.md bar for niche shows | TBD `lib/sub-whisperx-fetch.py` (ROADMAP H5) |
Default: try **v3** first to spare quota; fall back to **v2** for episodes
v3 misses or for non-English needs. **v1** stays for shows where simple
plugin auto-fetch is enough. **v3.5** is the stop-gap when nothing exists
on community providers; **v4** replaces v3.5 once the GPU node is set up.
Quick check whether v1 plugin will suffice (skip the rest if yes):
1. Pick the first episode of season 2 in the library.
2. Run `curl -s -H 'X-Emby-Token: $TOK' 'http://localhost:8096/Items/$EP/RemoteSearch/Subtitles/eng'` (read-only).
3. If results > 0 — v1 works.
4. If results == 0 but the show exists on opensubtitles.com — numbering mismatch (e.g. American Dad: library uses Hulu S1=7 eps; OS uses different). Use **v3** then **v2** for misses.
---
## Step 4 — Fetch subs per episode
### v3 — Addic7ed (default, free)
```bash
JELLYFIN_TOKEN=<admin-token> \
OPENSUBTITLES_API_KEY=$HOME/.config/arrflix-opensubtitles-api.txt \
playbooks/subtitles/lib/sub-a7d-fetch.py <series-id> --season N [--start E] [--end E]
```
Pre-flight with `DRY_RUN=1`. The OS REST key is used only for search
(quota-free) to translate library S/E to the show's catalogue numbering.
### v2 — OpenSubtitles REST (fallback for v3 misses)
```bash
JELLYFIN_TOKEN=<admin-token> \
OPENSUBTITLES_API_KEY=$HOME/.config/arrflix-opensubtitles-api.txt \
OPENSUBTITLES_USER=Caveman5 \
OPENSUBTITLES_PASS=<password> \
playbooks/subtitles/lib/sub-rest-fetch.py <series-id> --season N [--start E] [--end E]
```
20 / day cap, resets at 00:00 UTC.
### v1 — Jellyfin plugin (when library numbering matches OS)
`lib/sub-fetch.sh` — see header for env. Counts against the same 20/day cap.
### Verify after each batch
```bash
ssh user@192.168.0.100 'ls "<media-dir>/" | grep -c eng.srt'
```
---
## Step 5 — Library scan + de-dup (v1 only)
If you used the v1 plugin path, the metadata-cache copy and the media-folder
sidecar both register as subtitle streams in Jellyfin (counted twice).
Delete the cache copies:
```bash
ssh user@192.168.0.100 'docker exec jellyfin bash -c "find /config/metadata/library -path \"*<show-name>*S0[1-9]E*.eng.srt\" -delete -print"'
```
v2 writes directly to the media folder so there is no cache copy to clean.
Trigger a validation-only refresh so Jellyfin sees the new sidecars:
```bash
ssh user@192.168.0.100 "docker exec jellyfin curl -s -X POST -H 'X-Emby-Token: $TOK' \
'http://localhost:8096/Items/$SERIES/Refresh?MetadataRefreshMode=ValidationOnly&Recursive=true'"
```
Confirm one episode has exactly 1 external eng sub stream:
```bash
ssh user@192.168.0.100 "docker exec jellyfin curl -s -H 'X-Emby-Token: $TOK' \
'http://localhost:8096/Items/<sample-ep-id>?Fields=MediaStreams'" \
| python3 -c "import json,sys; subs=[s for s in json.load(sys.stdin).get('MediaStreams',[]) if s['Type']=='Subtitle']; print(len(subs),'sub streams')"
```
---
## Step 6 — Quality gate
For the run to pass:
- [ ] **Coverage**: every episode has a matching `<base>.eng.srt` sidecar
- [ ] **Sync sample**: at least one episode of each season is opened in
Jellyfin web and subs visually align with audio (±1 s) on a known dialogue
line
- [ ] **Flag check**: no `.sdh.srt`, `.forced.srt`, or `.hi.srt` files
(machine pick should have filtered)
- [ ] **Stream count**: Jellyfin shows exactly 1 external eng sub per episode
If any check fails, log it in `runs/<show>.md` under "breakage" and propose
the recipe amendment in `CHANGELOG.md`.
---
## Quota hygiene
Free OpenSubtitles.com account = 20 downloads / day, resets 00:00 UTC.
Plan large series across multiple days, or switch to VIP (~$3/mo, unlimited).
Quota check:
```bash
ssh user@192.168.0.100 'docker logs --tail 200 jellyfin 2>&1 | grep "Remaining downloads" | tail -1'
```
When quota hits 0 the API returns 0 results, indistinguishable from a real
miss. Always check quota before declaring a "no subs" failure.
Per-show fetch logs stay here under [`runs/`](runs/) — they're history,
not procedure. [`CHANGELOG.md`](CHANGELOG.md) preserves the ARRFLIX recipe
evolution (v1 → v3.5) for context. The local [`lib/`](lib/) copies remain
as the ARRFLIX-host-specific reference (hardcoded Jellyfin container
name, internal URL, and SSH target).