legacy-arrflix/playbooks/import-media
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
..
runs playbooks/import-media: log Archer S02 (2009) run 2026-05-10 05:06:05 +01:00
CHANGELOG.md playbooks/import-media: v1.1 — fix two Jellyfin endpoint bugs + nullstone alias 2026-05-10 06:49:17 +01:00
README.md playbooks/import-media: v1.1 — fix two Jellyfin endpoint bugs + nullstone alias 2026-05-10 06:49:17 +01:00

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. Permissionschown user:user, files 644, dirs 755
  4. Verify scanLibraryMonitor 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

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)

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

rsync -a --info=progress2 --no-owner --no-group \
  "/home/admin/staging-jelly/<Title> (<Year>)" \
  user@nullstone:/home/user/media/movies/

TV (one season)

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'):

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)

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)

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

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

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)

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:

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):

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:

# 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
Existing run examples runs/