legacy-arrflix/playbooks/import-media/README.md
s8n 5b80cfd095
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
playbooks/import-media: v1.1 — fix two Jellyfin endpoint bugs + nullstone alias
Step 4 rewritten: /Library/Refresh is a silent no-op on this build,
must POST to /ScheduledTasks/Running/<scan-task-id> directly. Old
endpoint moved to known-broken table.

Step 5 rewritten: /Items/Counts is scope-cached and stays stale
even after items are indexed. Use /Shows/<id>/Episodes?Season=<NN>
as authoritative verify with provider + image-tag checks.

Both bugs surfaced in archer-s02-2009 run. LibraryMonitor inotify
auto-fire also confirmed broken (failed on lilo-stitch-2002 and
archer-s02-2009 runs).

Replaced user@192.168.0.100 (LAN IP, RFC1918 — flagged by
gitleaks lan-ip-rfc1918) with user@nullstone throughout. SSH config
already aliases nullstone -> 192.168.0.100. Aligns with CLAUDE.md
two-file doc rule: IPs belong in SYSTEM.md, not operational docs.
2026-05-10 06:49:17 +01:00

279 lines
11 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.

# Import Media Playbook
> 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).
Version: **v1.1** (2026-05-10)
---
## 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/` |