| .. | ||
| runs | ||
| CHANGELOG.md | ||
| README.md | ||
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
- Stage on onyx — rename to canonical form, cleanup junk
- rsync to nullstone
/home/user/media/<library>/<Title> (<Year>)/ - Permissions —
chown user:user, files644, dirs755 - Verify scan —
LibraryMonitorauto-refreshes ~1–3 s after copy;Items/Countsshould bump - 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 insideSeason <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
LibraryMonitorauto-fires within 1–3 s andPOST /Library/Refreshis the fallback. Both proved unreliable across consecutive runs (lilo-stitch-2002,archer-s02-2009). Always trigger theScan Media Libraryscheduled 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/Countsfor verification. It is scope-cached/user-default-view-cached and stays stale even when the scan completed and items were added (observed inarcher-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.srtnext 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.srtor stripped) Scan Media Librarytask triggered via/ScheduledTasks/Running/<id>(NOT/Library/Refresh) andLastExecutionResult.EndTimeUtcadvanced- Per-series query (
/Shows/<id>/Episodes?Season=N) shows expected count (do NOT trust/Items/Counts) ProviderIdspopulated 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: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 |
| Existing run examples | runs/ |