# 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 ~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/` |
