docs(playbooks): point at beta-flix for procedural docs
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:
parent
690ea117c3
commit
4ab8c277da
3 changed files with 28 additions and 512 deletions
|
|
@ -1,29 +1,15 @@
|
||||||
# playbooks/ — repeatable acquisition workflows
|
# playbooks/ — moved
|
||||||
|
|
||||||
Runbook-style playbooks for repeatable ARRFLIX ops — subtitles, importing
|
The procedural playbooks (README, CHANGELOG, helper scripts) have moved to
|
||||||
media, artwork, metadata, episode stills, and any other recurring acquisition
|
beta-flix:
|
||||||
or maintenance procedure. Each playbook is a standalone recipe Claude Code
|
|
||||||
(or a human operator) can execute end-to-end.
|
|
||||||
|
|
||||||
This folder holds the canonical recipes for **acquiring external content** for
|
<https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/>
|
||||||
the ARRFLIX library: subtitles, artwork, metadata, episode stills, etc.
|
|
||||||
Internal ops (encoding, importing, theming) stay in `bin/` and `docs/`.
|
|
||||||
|
|
||||||
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 |
|
| Sub-area | Procedure | Run logs |
|
||||||
|---|---|
|
|
||||||
| `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 |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [`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/) |
|
||||||
|
|
|
||||||
|
|
@ -1,279 +1,10 @@
|
||||||
# Import Media Playbook
|
# Import-media playbook — moved
|
||||||
|
|
||||||
> Repeatable workflow for adding a new movie or TV show to ARRFLIX.
|
The procedure has moved to beta-flix and been rewritten for stock Jellyfin
|
||||||
> Mirror the format of `playbooks/subtitles/README.md` (CHANGELOG-driven, runs/ folder for per-import logs).
|
10.11.8:
|
||||||
|
|
||||||
Version: **v1.1** (2026-05-10)
|
<https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/import-media/README.md>
|
||||||
|
|
||||||
---
|
Per-import run logs stay here under [`runs/`](runs/) — they're history,
|
||||||
|
not procedure. [`CHANGELOG.md`](CHANGELOG.md) preserves the ARRFLIX
|
||||||
## TL;DR — five-step import
|
playbook evolution (v1.0 → v1.1) for context.
|
||||||
|
|
||||||
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 ~1–3 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 1–3 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:35–46` |
|
|
||||||
| 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/` |
|
|
||||||
|
|
|
||||||
|
|
@ -1,215 +1,14 @@
|
||||||
# Subtitle acquisition process — v1
|
# Subtitles playbook — moved
|
||||||
|
|
||||||
Last updated: 2026-05-10
|
The procedure, STYLE.md, COVERAGE.md, STOPGAP-SUBS.md and the helper
|
||||||
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).
|
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
|
<https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/subtitles/>
|
||||||
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).
|
|
||||||
|
|
||||||
> **Current state:** [`COVERAGE.md`](COVERAGE.md) is the live audit
|
Per-show fetch logs stay here under [`runs/`](runs/) — they're history,
|
||||||
> (per-show + per-movie). Regenerate at any time:
|
not procedure. [`CHANGELOG.md`](CHANGELOG.md) preserves the ARRFLIX recipe
|
||||||
>
|
evolution (v1 → v3.5) for context. The local [`lib/`](lib/) copies remain
|
||||||
> ```bash
|
as the ARRFLIX-host-specific reference (hardcoded Jellyfin container
|
||||||
> JELLYFIN_TOKEN=<admin-token> playbooks/subtitles/lib/audit-coverage.py
|
name, internal URL, and SSH target).
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> 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.
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue