Compare commits

..

85 commits

Author SHA1 Message Date
s8n
5bfe230eac import: inbetweeners + mr robot + kim possible + cleanup (2026-05-14)
Some checks failed
secret-scan / gitleaks (HEAD + history) (push) Has been cancelled
secret-scan / detect-secrets (entropy + cross-tool) (push) Has been cancelled
secret-scan / summary (push) Has been cancelled
Triple import to ~/media: The Inbetweeners (2008) S01-S03 + 2 movies,
Mr. Robot (2015) S01-S04, Kim Possible (2002) S01-S04 + So the Drama
+ 2019 live-action. Deleted The Mandalorian (45G) and Obi-Wan Kenobi
(16G) from nullstone to make space. Cleaned 3 duplicate Futurama dirs
from onyx (80G).
2026-05-14 02:28:07 +01:00
s8n
d1761b0d18 import: futurama — S08-S11 DSNP WEB-DL (49 eps)
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
LockData-pattern manual fix for the disney+/aired numbering mismatch
between the source release and the existing series record.
2026-05-13 23:39:24 +01:00
s8n
3b67ada1a8 import: more-perfect-union — Palantir (2025-04-17)
Some checks failed
secret-scan / gitleaks (HEAD + history) (push) Has been cancelled
secret-scan / detect-secrets (entropy + cross-tool) (push) Has been cancelled
secret-scan / summary (push) Has been cancelled
First import for the More Perfect Union channel. Hit the documented
single-file-in-folder title-leak: JF named the item "More Perfect Union"
(folder name) instead of the video title. Fixed via Items PUT +
LockData=true. Channel now seeded; future imports should parse cleanly.
2026-05-12 18:40:34 +01:00
s8n
034dbe68c0 docs(theater): import design picker assets from /tmp
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
4-variant ARRFLIX login picker served at localhost:666 (variants 01 The
Theater · 02 The Marquee · 03 The Cinema · 04 The Noir). Variant 01 was
selected. Includes theater-fullsize.html (1920x1080 mockup), 470x170
landscape arrflix-logo (PNG + b64), poster-bg backdrop, and tv_theater_port.py
CSS-block patcher reference for the later prod deploy.

Persisted from /tmp to survive reboot; iteration continues in this dir
under a 4-agent crew (Designer / Coder / Chromium / Inspector).
2026-05-12 17:17:38 +01:00
s8n
675e6ab1ec docs(35): import lex-fridman-podcast S01E491 OpenClaw to stock Jellyfin Podcasts
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
Episode resolved as Type=Episode under correct Series + Season but JF
MovieResolver did not parse SxxEyy from filename — ParentIndexNumber and
IndexNumber were null. Series-level + item-level full refresh did not fix.
Required manual API override (PUT IndexNumber=491, ParentIndexNumber=1,
LockData=true).

All 5 prior LFP episodes already have LockData=true — this appears to be
the established pattern for new Lex Fridman Podcast episodes. Generalise
into playbook §1c after one more confirmation.
2026-05-12 16:47:48 +01:00
s8n
4750a2c4cc docs(34): import johnny-harris assange-guilty-plea-20220510 to stock Jellyfin Education
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
2026-05-12 15:53:42 +01:00
s8n
c8a1305da4 docs(33): import 2 YT videos to stock Jellyfin Education
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
Single-video imports per playbook §1d (collectionType=movies):
- Johnny Harris — Why the US is deporting so many people (20251031)
- The Guardian — NSA whistleblower Edward Snowden (20130709)

Snowden run exposed Jellyfin's single-file channel folder caveat:
MovieResolver parses folder name as item title when only one media file
exists. Worked around with PUT /Items/<id> Name + LockData=true.
Documented in the run log for future hardening into playbook §1d.
2026-05-12 15:48:24 +01:00
s8n
c391447a9f doc 32: wipe jellyfin-dev container + config (200MB)
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
2026-05-11 17:28:43 +01:00
s8n
4ab8c277da docs(playbooks): point at beta-flix for procedural docs
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
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.
2026-05-11 16:00:12 +01:00
s8n
690ea117c3 docs(32): import lex-fridman-podcast s01 (5 eps) to stock Jellyfin Podcasts
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
2026-05-11 15:45:23 +01:00
s8n
6e336d1798 docs(31): import benn-jordan s01 (4 eps) to stock Jellyfin Educational
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
2026-05-11 15:28:42 +01:00
s8n
93b9c9d533 docs(30): stock Jellyfin 10.11.8 rebuild on tv.s8n.ru
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
Brand-new container, brand-new volumes, ZERO ARRFLIX customisation.
Sister instance to prod (10.10.3, untouched) and dev (10.11.8 + scyfin).

P1+P2 from the rebuild plan: Movies + TV Shows libraries added via API,
library scan complete (4 movies / 12 series / 230 eps). Auto-scrape
matched 10/12 series + 4/4 movies to canonical TMDB IDs without manual
intervention; 3 unmatched are TMDB-absent indie content.

No theme, no shim, no CustomCss, no plugins, no user import, no home-
section seed — owner explicitly asked for a stock baseline.
2026-05-11 04:15:18 +01:00
s8n
9f3483a87c docs(29): jellyfin 10.10.3 -> 10.11.8 + scyfin theme migration on dev
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
Documents:
- Staged migration path (10.10.3 -> 10.10.7 -> 10.11.8). Direct skip is
  unsupported per jellyfin#15027/#15244/#15293/#15504.
- Snapshots at /home/user/snapshots/.
- Schema diffs (library.db consolidated into jellyfin.db, ffmpeg 7.1.3).
- Theme swap: Cineplex v1.0.6 -> scyfin OLED (the only top-tier 10.11.x
  theme with active 10.11 branch + zero open compat bugs).
- The 10.10.3 home-section bug (CustomPrefs write but no HomeSection row)
  is fixed in 10.11.8 when POST body includes a HomeSections array.
  Verified end-to-end with test user.
- Outstanding work before promoting dev to prod.
- Rollback procedure (EF Core migrations are forward-only).
2026-05-11 03:42:37 +01:00
s8n
0122de7041 bin: add fix-home-db.sh — direct SQLite seed for HomeSection table
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
The Jellyfin 10.10.3 REST endpoint won't INSERT HomeSection rows for the
'Jellyfin Web' client (only updates existing rows), so users whose web-
client DisplayPreferences was created without explicit home-section state
fall back to factory defaults that include Next Up. Applying CustomPrefs
via API doesn't help — the web client reads HomeSection rows.

Direct DB seed: for every DisplayPreferences row with zero HomeSection
rows, insert [Resume, LatestMedia, None*8]. Also replace Type=7 (NextUp)
with Type=0 (None) across all DPs. Idempotent.

Applied 2026-05-11 across prod (13 users) and dev (1 user). Verified
yummyhunny home now shows Continue Watching above Latest Media.
2026-05-11 03:20:24 +01:00
s8n
4f0d34fc93 fix(home-layout): write to client='Jellyfin Web' not just 'emby'
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
The Jellyfin 10.10.3 web client reads DisplayPreferences with the client
name 'Jellyfin Web'. The legacy 'emby' value is read by older SDKs only —
so writing only to 'emby' (the original script) updates the DB but has no
visible effect on the web UI. The empty 'Jellyfin Web' doc falls back to
factory defaults that include Next Up.

Now patches all four per-client docs ('Jellyfin Web', 'emby', 'emby-mobile',
'emby-web') and ensures both Continue Watching (resume) and Latest Media
are present so blank users don't fall back to defaults.

Applied 2026-05-11 across prod (13 users x 4 clients) and dev (1x4).
2026-05-11 03:16:23 +01:00
s8n
e686cc07e0 bin: add set-home-layout.py — disable Next Up, ensure Continue Watching
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
Applied 2026-05-11 across both prod (13 users) and dev (1 user).
Replaces 'nextup' homesection slot with 'none' on every user; seeds 'resume'
at slot 0 for users whose CustomPrefs was empty (would have fallen back to
the Jellyfin factory default that includes Next Up).

Idempotent — re-running is a no-op once converged.
2026-05-11 03:01:42 +01:00
s8n
a6ce8451fa docs: mark OpenSubtitles creds as set (verified Caveman5 / CredentialsInvalid=false)
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
- ROADMAP H1 dropped (was: signup at .com); renumbered H2→H1 (GPU), H3 new (AV1 sweep)
- Removed OpenSubtitles row from Blocked table
- ADMIN-GUIDE snapshot: creds set, 20 dl/day free tier
- docs/03 §8: PENDING → SET (verified 2026-05-11)
- docs/13 Finding 04: R → G (resolved)
- docs/27 H1/H2 renumbered (GPU + backup)
2026-05-11 00:31:03 +01:00
s8n
7eb5f346fd subs: add source-priority tier ladder, accept original-release bitmap as tier 2
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
Original-release bitmap subs (PGS, VobSub, dvd_subtitle) are first-class,
not stop-gaps. They're the canonical studio render — bitmap encoding is
just a format choice, not a quality compromise. OCR'd or AI-rebuilt
sidecars introduce transcription error that the source doesn't have.

STYLE.md changes:
- New "Source priority" section with 4 tiers: original text > original
  bitmap > trusted text rips > WhisperX rebuild.
- "What lands on disk" loosened: at least one English stream (embedded
  OR sidecar), keep embedded codec as-is, sidecar still .srt.
- New "OCR bitmap -> text" section documenting pgsrip recipe as an
  optional UX-nicety augmentation, not a correctness fix.
- "Why these rules" now explains why original > pretty (esp. for older
  shows like Futurama S1-3 / early Archer where the master is the only
  authoritative source and upscale artifacts already dominate).

STOPGAP-SUBS.md: header note clarifying bitmap-from-disc is NOT a
stop-gap; lists Lilo & Stitch (2002) and Archer (2009) S02 as examples
of correct-as-shipped library entries.
2026-05-10 21:22:32 +01:00
s8n
5b80cfd095 playbooks/import-media: v1.1 — fix two Jellyfin endpoint bugs + nullstone alias
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
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
s8n
fcac178882 chore(ci): add gitleaks secret-scan workflow
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
2026-05-10 06:29:45 +01:00
s8n
22f87d9075 Add AGPL-3.0 LICENSE 2026-05-10 06:21:06 +01:00
s8n
c10a3987a7 subs: AD 49/58 -> 58/58 (closed 9 gaps via OS REST) 2026-05-10 06:14:14 +01:00
s8n
54997e54a1 subs/coverage refresh + fix DEFAULT_OUT path post-rename
audit-coverage.py DEFAULT_OUT still pointed at processes/subtitles/
COVERAGE.md after the processes/->playbooks/ rename. Updated to
playbooks/subtitles/COVERAGE.md.

Refreshed COVERAGE.md to current state (230 episodes total, was 197):
  + Archer (10) — OK-EMBED
  + Maul Shadow Lord (10 new 2160p) — OK-EMBED
  + Maul [Before Upscale] (10 split) — OK-EMBED
  + Lilo & Stitch movie — sidecar OK
  + 9 American Dad gaps closed by owner since last audit
  + Big Lez / Donny & Clarence / Mike Nolan now 100%

Strict-sidecar shows: Sassy, Big Lez, Donny & Clarence, Mike Nolan,
American Dad (84% partial — 9 gaps remain). Other shows OK-EMBED
which is playable but not STYLE.md-compliant — fetch optional.
2026-05-10 06:03:26 +01:00
s8n
3079f5009b playbooks/import-media: log Archer S02 (2009) run
13 eps imported, all matched on TVDB/IMDb. Run notes flag two
new playbook bugs found: /Library/Refresh is a silent no-op (must
trigger /ScheduledTasks/Running/<id> directly) and /Items/Counts
is scope-cached (use /Shows/<id>/Episodes?Season=N for verify).
Pending follow-ups recorded for v1.1 playbook revision.
2026-05-10 05:06:05 +01:00
s8n
cb9d5db1ce doc 32: nullstone storage upgrade plan
R&M S02-S08 import (~105GB) blocked on disk pressure (post-import
117GB->12GB free). Plan upgrade first.

Hardware probed: MSI X470 Gaming Plus Max, single Intel 512GB NVMe in
M2_1, M2_2 free, 6 SATA ports free.

Recommended path: 2TB NVMe in M2_2 (~£130) -> pvcreate -> vgextend
keystone-vg -> lvextend lv-home -> resize2fs. ~5 min downtime, no
reinstall. Alt: SATA SSD/HDD, USB external. Cheapest /GB = SATA HDD.

Doc covers procedure, alternatives, post-upgrade R&M bulk import
plan via playbooks/import-media/. Owner to pick + execute install.
2026-05-10 04:41:52 +01:00
s8n
a30edcfa2f import: archer s01 (2009)
10 episodes, 1080p AI x265 10-bit Joy release, HE-AAC 5.1 ENG +
3x embedded DVDsub (ENG/SPA/FRE) per episode. Per README.md:41
quality bar — AI-upscale acceptable when source doesn't support 4K.

Items/Counts: Series 11->12, Episodes 207->217. Series item
9d22c409d531...

Run log notes 3rd consecutive LibraryMonitor failure — playbook
v1.1 should make manual /Library/Refresh MANDATORY. Same TMDb
auto-match miss as Maul; flagged for owner UI Identify step or
[tmdbid-NNNN] folder token.
2026-05-10 03:51:04 +01:00
s8n
520f0fbee3 maul s01 2160p import + variant 1 detail-page skin
Saved variant 1 "Netflix-cinema" detail-page redesign to
web-overrides/skins/detail-variant-01-netflix-cinema.html for
future reference. Not applied to dev/prod.

Imported Star Wars: Maul - Shadow Lord S01 2160p WEB-DL HEVC SDR
(10 episodes, ~21 GB) per playbooks/import-media/ v1.0. First
"replace-with-comparison" run:

- Existing 1080p upscale renamed in-place to
  "Star Wars - Maul - Shadow Lord [Before Upscale] (2026)/"
  with tvshow.nfo <lockdata>true</lockdata> to suppress Jellyfin
  TMDb auto-merge with the new canonical.
- New 2160p staged on onyx, rsync'd to nullstone canonical path
  /home/user/media/tv/Star Wars - Maul - Shadow Lord (2026)/Season 01/
- Both series live as separate items; Items/Counts bumped
  Series 10->11, Episodes 197->207.

Run log at playbooks/import-media/runs/star-wars-maul-shadow-lord-
2026-2160p.md flags v1.1 follow-ups: document the
[Before Upscale] pattern, rsync resume idempotency, and
[tmdbid-NNNN] folder token for titles that fail auto-match.

ffprobe confirmed E01 = HEVC Main 8-bit 3840x2160 SDR @ 12 Mbps,
EAC3 5.1 ENG Atmos + ITA dub, 4x embedded subrip per episode.
Direct-play on capable clients.

TMDb match failed auto on both folders (recent Disney+ release);
operator to manually identify via UI.
2026-05-10 03:34:34 +01:00
s8n
508fc42a1e next-ep popup: design A (Cinematic Strip) shipped to dev + designs A/B/C archived
Adds web-overrides/popup-designs/ with the 4-up preview (preview.html) and
three standalone candidate designs (a-strip.html / b-terminal.html /
c-minimal.html) so we can revisit the alternates later without re-running
the design generator. Owner picked A.

Design A is wired into Jellyfin's stock .upNextDialog by overriding its
CSS to a full-bleed bottom 26 % strip with white 'Start Now' CTA and a
custom SVG countdown ring that mirrors .upNextDialog-countdownText. The
DOM stays intact so Jellyfin's own countdown timer and click handlers on
.btnStartNow / .btnHide keep working untouched.

Shim is bracketed by NEXT-EP-POPUP-BEGIN / NEXT-EP-POPUP-END markers
inside the existing ARRFLIX-SHIM block in web-overrides/index-dev.html.
Only deployed to dev (dev.arrflix.s8n.ru) for spot-check; promote to
prod once verified by editing prod's index.html the same way and
redeploying via the nsenter trick.

Adds bin/revert-next-ep-popup.sh — sed-deletes between markers, defaults
to dev with --prod flag for prod target. Saves a timestamped backup and
prints the redeploy command.
2026-05-10 02:44:40 +01:00
s8n
24a9497e7d playbooks/ rename + import-media v1.0 + lilo&stitch run
processes/ -> playbooks/ (git mv preserves history; updated cross-refs
in ROADMAP, README, subtitles playbook + scripts).

playbooks/import-media/README.md v1.0 — 7-step import workflow:
  stage on onyx -> rsync to nullstone -> chmod -> verify scan ->
  Items/Counts bump -> optional subtitle pass -> run-log
Cross-references docs/05/07/08, ADMIN-GUIDE, README. Mirrors the
existing subtitles playbook structure (CHANGELOG + runs/_template).

CHANGELOG v1.0 lists known gaps (bin/cleanup-import.sh and
bin/normalize.py still doc-only, ROADMAP M6).

First run logged: playbooks/import-media/runs/lilo-stitch-2002.md.
Lilo & Stitch (2002) imported to /home/user/media/movies/, item
c2f4aff133c1b9631500fadf293b0b2f, TMDb 11544, MovieCount 3 -> 4.
LibraryMonitor didn't auto-fire — needed manual /Library/Refresh;
playbook updated to make this an unconditional step.

Source: 1080p BluRay HEVC 10-bit / EAC3 5.1 / 2x PGS embedded subs.
Per quality bar (README.md:41) — passes.
2026-05-10 02:29:57 +01:00
s8n
c6ec208520 processes/subtitles: COVERAGE.md live audit + auto-refresh on fetch
Adds lib/audit-coverage.py: queries Jellyfin live for every series, every
episode, and every movie; classifies each by whether the English subtitle
comes from a sidecar, embedded stream, or doesn't exist; renders a
Markdown report with one-char-per-episode bars for visual scanning. Output
file is processes/subtitles/COVERAGE.md, regenerated on demand.

v2 sub-rest-fetch.py and v3 sub-a7d-fetch.py now invoke the audit at end
of a successful run, so the committed coverage file stays in sync with
library state without manual intervention. v3.5 yt-fetch path skips the
auto-call since it doesn't speak to Jellyfin directly; run audit manually
after copying YT sidecars to nullstone.

README.md surfaces the audit at the top so anyone landing in the recipe
folder sees current state before starting a run.
2026-05-10 02:19:32 +01:00
s8n
fba9a5bfeb processes/subtitles: STOPGAP-SUBS.md tracker for v3.5 → v4 cross-ref
Owner accepted Sassy the Sasquatch S01 v3.5 YouTube-auto-CC subs as
'85 % acceptable, fine, not great' but flagged them for v4 WhisperX
rebuild. Adds a single worklist file (STOPGAP-SUBS.md) so every show
that ships via the v3.5 path gets logged for the eventual v4 sweep
instead of being silently forgotten.

Sassy run log gets a STOP-GAP banner at the top pointing to the new
worklist. README.md gets a stop-gap-exception note alongside the
STYLE.md hard-prereq paragraph. ROADMAP H5 now points at the worklist
file as the canonical source of which shows v4 needs to regenerate.
2026-05-10 01:18:27 +01:00
s8n
eb71cf6beb processes/subtitles: v3.5 YouTube auto-CC stop-gap + Sassy 5/5
Adds lib/sub-yt-fetch.sh (yt-dlp wrapper) and lib/yt-clean.py (collapses
YouTube's rolling-window auto-caption VTT into a flat SRT). For shows
distributed YouTube-first that have no community subs anywhere -- verified
via three parallel research agents covering OpenSubtitles REST, OS legacy,
Addic7ed, SubDL, SubSource, and Podnapisi for the 5 niche shows in the
library, plus a price-vs-coverage analysis of OpenSubtitles VIP.

Findings: OS VIP would not have helped on the niche shows (it is
download-cap relief, not coverage unlock; same catalog as free). All 4
Jarrad Wright shows in the library (Sassy, Big Lez Saga, Donny &
Clarence, Mike Nolan) live on the same channel and have only YouTube
auto-CC available. v3.5 ships those, explicitly violating STYLE.md
'best quality' as a tracked stop-gap.

Sassy the Sasquatch S01 5/5 episodes subbed with cleaned auto-CC. Mike
Nolan special-case noted: a 'COMPLETE SEASON | SUBTITLES' YT upload from
Oct 2025 carries hand-typed CCs and should be preferred over per-episode
auto-CC when subbing that show.

ROADMAP H5 added: v4 WhisperX large-v3 on the friend RTX 4080 node will
regenerate the v3.5 stop-gap with proper-noun-prompted transcription
(~4-6%% WER vs ~12%% YT auto-CC) and restore the STYLE.md quality bar.
H1 OpenSubtitles credentials marked done (was completed 2026-05-09).
2026-05-10 01:05:07 +01:00
s8n
d9d6bdba64 testing/ folder: theme-edit guides + error catalog + headless recipes
7 docs in /testing/ — institutional memory after 6+ regressions in
24-48h on the v6 theme. Read before any edit.

  README.md           — index + quickstart
  THEMING.md          — safe-edit checklist + layer/specificity tables
  ERROR-PATTERNS.md   — 12 cataloged patterns (Symptom/Cause/Diag/Fix/Prev)
  HEADLESS-PROBE.md   — 11 playwright recipes (md5 chain, darkPct,
                        ancestor bg sample, dropdown listItem probe)
  ROLLBACK.md         — 8 emergency revert recipes (overlay, branding,
                        encoding, full-from-repo, dev-clone-prod,
                        git-revert, pw-reset, bind-mount inode-swap)
  SMOKE-TEST.md       — manual + headless verify checklist
  DEPLOY.md           — dev → prod promotion workflow with backup +
                        chown root + restart inode-swap

Empty subdirs: snipUSER-Es/, recipes/, incidents/ (post-mortems land here).

Goal: stop reinventing the same fixes. Catalog every error class,
codify the recovery, build a skills folder for future ARRFLIX work.
2026-05-10 00:47:20 +01:00
s8n
755088e7fc selector v4 hairline ring + reconcile prod drift + skin alt v2
Stock Jellyfin paints .listItem.selected and .focused in cyan
(#00a4dc) on actionSheet/selectionList/dialogContainer dropdowns.
Clashes with Cineplex red on audio + subtitle pickers.

Pick: variant 04 "Hairline ring" — 1px #E50914 outline (offset -1px
inside row) + dark near-black bg + white text. Architectural,
quietly on-brand. Applied via body.arrflix-themed scope.

Saved alternative: web-overrides/skins/selector-variant-02-red-
underline.css — variant 02 "Red underline" (matches search-input
focus). Future skin/swap option per owner. Not currently active.

Reconciled prod drift: prod overlay had been externally modified
since last snapshot (md5 2da61583 -> c62898). Pulled prod's current
overlay as new repo baseline + snapshot. Then applied variant 04 on
top -> dev md5 0ab8b258 (= prod + v4). Prod still c62898 untouched.
2026-05-10 00:28:55 +01:00
s8n
7ce1539ea7 processes/subtitles: STYLE.md — USER-G style for picks + display
Codifies the bar every fetch must hit:
- one plain English .srt per episode, named <base>.eng.srt
- drop SDH / HearingImpaired / MachineTranslated / AiTranslated / Forced
- prefer fps-matched, then highest download count
- UI label collapsed to just "English" via web-overrides shim

README.md now points at STYLE.md as a hard prereq before any fetch run.
The picker logic in v1/v2/v3 helpers already encodes these rules; STYLE.md
is the canonical source of truth that they should be checked against if
anyone (or future-me) is tempted to relax them.
2026-05-10 00:13:51 +01:00
s8n
b3ead71b7e shim: shorten subtitle stream labels in detail dropdowns
Stock Jellyfin renders subtitle stream entries as
"<lang> - <CODEC> - (External|Internal|Embedded)[ - flag]". For ARRFLIX,
which has at most one subtitle format per language, the codec and source
suffix are noise. Add a small JS shim to web-overrides/index.html that
matches that exact shape and collapses the label to "<lang>" (with
"(Forced)" / "(SDH)" / "(Hearing Impaired)" if those flags are
present; "Default" is dropped since it's redundant when there's only one
stream of that language).

Audio labels like "5.1Ch Surround Sound - English - AAC - Stereo - Default"
have a different number of segments and don't match, so they pass through
untouched.

The shim runs after DOMContentLoaded and re-walks any nodes added later
via MutationObserver (covers actionsheet dropdowns that mount lazily).

Bracketed by /* SUB-LABEL-SHIM-BEGIN */ and /* SUB-LABEL-SHIM-END */
markers; bin/revert-sub-label-shim.sh deletes between the markers in one
sed pass and saves a timestamped backup. No container restart needed
(index.html is bind-mounted).
2026-05-10 00:05:16 +01:00
s8n
43f55643be processes/subtitles: v3 Addic7ed fetcher + AD 49/58 subbed
Adds lib/sub-a7d-fetch.py: free, no-daily-cap path via subliminal's
addic7ed provider (anonymous). Uses OpenSubtitles REST search-only (no
quota cost) to translate library S/E to the show's primary catalogue
numbering, then drives subliminal to download from Addic7ed and writes
sidecars direct to nullstone via SSH.

Picker quirks: subliminal series-name matcher is broken by '!' in the
title, so the script strips it before building the synthetic
Video.fromname() string. OS feature_details S/E happens to align with
Addic7ed's indexing for the test show (American Dad).

Recipe README now reflects three paths in cheapest-first order: v3
Addic7ed, v2 OS REST (20/day), v1 plugin. American Dad run log updated
to 49/58 (S01 7/7 v1, S02 16/16 mixed v2/v3, S03 16/19 v3, S04 10/16
v3). 9 misses identified, deferred to next OS REST quota window.
2026-05-09 23:31:10 +01:00
s8n
23520df2df processes/subtitles: v2 REST fetcher + AD S02E01-E12 subbed
Adds lib/sub-rest-fetch.py: direct OpenSubtitles REST, looks up subs by
per-episode IMDB id (e.g. tt0511631) instead of the plugin's
(parent_imdb_id, season, episode) combo path. This sidesteps shows where
library numbering diverges from OpenSubtitles' catalogued numbering --
American Dad uses Hulu S1=7 eps; OS uses Fox S1=23 eps; the plugin path
returns 0 hits past S01E07 even though every per-episode IMDB id is
correct.

Recipe README updated to surface the two paths (v1 plugin / v2 REST) and
recommend v2 by default. American Dad run log now shows 19/58 episodes
subbed (S01 7/7 via v1, S02E01-E12 via v2). S02E13-S04 (39 eps) deferred
to next 20/day quota windows.

Quirk fixed in v2: OpenSubtitles /download endpoint consistently returns
HTTP 503 to Python urllib.request despite identical headers/body via curl.
_curl() shim routes all OS API calls through curl. Each 503 still
consumes a download slot, so urllib path was unsafe to retry on.
2026-05-09 23:09:09 +01:00
s8n
fedf3388b8 processes: subtitle acquisition v1 + AD S01 run
Adds processes/ umbrella for repeatable acquisition workflows. First child
is subtitles/, with recipe README (executable by Claude Code), CHANGELOG,
per-show run logs, and a tested helper at lib/sub-fetch.sh.

Run on American Dad: S01 (7 eps) passed, S02-S04 (51 eps) broke. Library
uses Hulu/DSP season ordering; OpenSubtitles indexes by Fox airing order;
plugin queries by (parent_imdb_id, season, episode) so library S02E01
returns 0 hits. v2 design = direct OpenSubtitles REST with per-episode
imdb_id lookup; pending API-key registration.
2026-05-09 22:56:31 +01:00
s8n
1ed55152b7 fix: drop video z-index hack + heavy comments + doc 31 layer model
Image-12 incident: I'd set <video> z-index:9999, which covers the OSD
scrubber + buttons (Jellyfin's stock OSD controls live at z-index
1100-2000, above the 1000 of .videoPlayerContainer but BELOW our
9999). Drop the lift entirely. Stock z-index hierarchy already has
controls floating on top. The fix for black-screen was always
transparent ancestor backgrounds (L1+L2), never z-index.

Reorganized inject-middle-theme.py CSS string from one-line dense
concat into a triple-quoted multi-line block with header comments
explaining each section + the layer model + DO-NOT rules. Same
output bytes (verified md5 deterministic). Added long-form comment
header to JS too.

Doc 31 (new): "Theme layer model + edit guide" — comprehensive
checklist for any future CSS edit. Covers:
  - Stacking order layer 0..7 with stock Jellyfin z-indexes
  - The two body classes (.arrflix-themed, .arrflix-video-active)
  - Specificity tiers + cascade order (L1 vs L2)
  - CSS load order (inline < bundle < branding.xml)
  - Recurring bug list (6 incidents now, all same anti-pattern)
  - DO NOT DO foot-gun list
  - 4-step smoke verify procedure
  - CI gates still TODO

Snapshot bumped to md5 2da61583. Prod+dev byte-identical.
2026-05-09 22:41:51 +01:00
s8n
4f13db63f9 fix v7: html-bg pin + correct video class names
Two bugs in prior video-isolation fix (452ce68):

1. .htmlVideoPlayer wrong class — Jellyfin actually uses
   .videoPlayerContainer + <video class="htmlvideoplayer"> (lowercase).
   :has(.htmlVideoPlayer) never matched, so L1 :not(:has) was always
   true — body never excluded from #000 paint, L2 (lower specificity
   0,2,1) lost to L1 (0,3,1). Replaced :has() gate with :not(.arrflix
   -video-active) — JS body class is reliable signal.

2. html background-color stayed transparent on details/video pages
   despite 5 stylesheet rules saying #000 !important. Headless Chrome
   reported computed bg as rgba(0,0,0,0) — root canvas propagation
   bug or cascade quirk. Fix: JS shim sets inline style on
   <html> with !important. Inline styles win against any stylesheet.

Result: html paints #000 (so letterbox bars are black not white),
body transparent during playback (L2 wins on equal specificity via
source order), videoPlayerContainer + <video> transparent → frames
visible. Off-video: body opaque #000 (L1 fires).

Verified DOM probe: html bg rgb(0,0,0), body rgba(0,0,0,0) when
arrflix-video-active. Both flip when class removed.
2026-05-09 22:33:45 +01:00
s8n
452ce68d7a isolate video player against opaque-bg regressions (recurring INC class)
Two-layer defense for the recurring "black screen during playback"
bug class (5+ occurrences in 24h per doc 26/28/30):

L1 (prevention): scope every black-bg rule with
:not(:has(.htmlVideoPlayer)):not(:has(#videoOsdPage)) so the rules
self-disable while a player is in the DOM. Covers body,
#reactRoot, .skinBody, .backgroundContainer, .mainAnimatedPage,
.mainAnimatedPages, .pageContainer.

L2 (override): when JS-toggled body.arrflix-video-active is set,
high-specificity (0,3,2 + tag) transparent rule wins against any
ancestor opaque-bg rule (including future regressions someone adds
without scoping). Covers all known wrappers, the
videoPlayerContainer + videoPlayerContainer-onTop, #videoOsdPage,
.libraryPage, .htmlVideoPlayer.

L3 (z-index lift): force .htmlVideoPlayer + child <video> to
z-index:9999 + isolation:isolate during playback so it floats above
any opaque ancestor that still leaks through.

Verified in playwright: with arrflix-video-active + .htmlVideoPlayer
mounted, all 7 ancestors return rgba(0,0,0,0). Without — all 7
return rgb(0,0,0). Self-disabling works.

Lesson reinforced (doc 30 roadmap open): add darkPct assertion to
bin/headless-test-v2.py + xmllint CI gate. Five recurrences without
those gates says we keep relearning this. TODO next.
2026-05-09 22:18:31 +01:00
s8n
e9d209da73 polish: pure-black bg + search bar fix + variant E nav glow + My Media hide
Grey #101010 stripe at bottom of pages: jellyfin-web theme.css:44-50
sets .backgroundContainer + html to #101010, neither Cineplex nor
prior overlay overrode it. Added rule forcing #000 across html, body,
.backgroundContainer, .skinBody, .mainAnimatedPages, .pageContainer,
#reactRoot under body.arrflix-themed.

Search input: stock cyan focus ring (#00a4dc, theme.css:262-272)
swapped for borderless slab + 2px red bottom on focus + soft red
box-shadow halo. Cineplex/Netflix-faithful.

Movies/Series nav: variant E "cinematic glow" picked from preview.
Active state = red text + text-shadow halo + font-weight 700. JS
hash matcher toggles .arrflix-nav.active when location.hash matches
#/movies.html or #/tv.html (and short forms). hashchange listener +
existing setInterval keep state in sync.

My Media row: .homePage .homeSectionsContainer .verticalSection.section0
hidden — matches prod which had it hidden via different mechanism.

Snapshot at snapshots/2026-05-09-v6-stable/index.html bumped to
md5 2c8f5d5f7c99611fa93d15c34fbe35d1; same as prod and dev.
2026-05-09 21:47:00 +01:00
s8n
92e2426734 hide back arrow on themed pages
Stock Jellyfin shows .headerBackButton on library pages
(Movies/Series). With our nav links already in headerLeft, the back
arrow is redundant clutter and confuses the home button intent.

Add .headerBackButton to the existing display:none rule under
body.arrflix-themed. Verified visually on dev (md5 c99aca0f), then
shipped to prod with overlay swap (md5 364cc890 -> c99aca0f). Both
sides byte-identical.

Snapshot at snapshots/2026-05-09-v6-stable/index.html updated.
2026-05-09 19:43:27 +01:00
s8n
a943933363 doc 30 v6-stable success + snapshot save state
Owner pronounced "near perfect". Save current state as the rollback
target. Replace older 2026-05-08-pre-elegantfin snapshot.

Snapshot md5 364cc890c58f02d07cf50b43b31a48f0 — matches both prod
and dev deployed overlay.

Doc 30 lists every file/path-of-record + rollback procedure +
remaining roadmap items.

Tag this commit v6-stable-2026-05-09 after push.
2026-05-09 12:52:44 +01:00
s8n
9003b55c81 favicon hijack + tonemap fix shipped to prod
Favicon: prod's older lockFavicon() shim was clobbering our injected
A-logo <link> tags every head mutation. Tag our links with
data-arrflix-icon="A" + add a hijack IIFE that re-pins the A URL on
matching tags AND removes any other large data:image/png link tags.

Tonemap: encoding.xml flipped EnableTonemapping false to true on dev
+ prod (server-side, not in repo). Doc 21 documented this fix
2026-05-08; prod was still grey-washing HDR10 sources because
setparams was relabeling PQ pixels as bt709 without zscale + tonemap
conversion. API now reports EnableTonemapping=True. Next HDR10
transcode gets the proper zscale -> tonemap -> format ffmpeg chain.

Both verified on dev first then promoted. Prod overlay md5 c6c85076
to 364cc890. dev and prod overlay byte-identical.
2026-05-09 10:17:40 +01:00
s8n
83fbfbf35e readme: add 3 ARRFLIX screenshots (detail, playback, search)
assets/screenshots/01-search.png       — search + suggestions list
assets/screenshots/02-detail-mandalorian.png — Mandalorian detail w/ backdrop
assets/screenshots/03-playback-sassy.png      — Sassy the Sasquatch playback

Embedded in README.md above 'What you get' so the repo landing page on
git.s8n.ru reads as a finished product rather than text only.
2026-05-09 10:10:38 +01:00
s8n
0c7d0aef14 middle-theme v6 + branding.xml video escape
Add ARRFLIX wordmark center, Movies/Series nav left, search right,
favicon=A-mark, auth gate so login stays stock, hide on video page.

Side-effect of branding.xml escape (<video> → &lt;video&gt;): prod's
CustomCss block now actually loads, so the INC7 transparent-video
rule reaches the browser. /Branding/Css.css 0 B → 36 256 B; doc-28
black-screen issue closed at the delivery layer.

Markers: ARRFLIX-MIDDLE-THEME-BEGIN/END (style + script) and
ARRFLIX-FAVICON-BEGIN/END (link). Idempotent.

See docs/29 for design + deploy procedure + recovery quirk.
2026-05-09 04:01:49 +01:00
s8n
3d388e8de7 doc 28 INC7-final: CSS overlay covering <video> was actual cause
Agent 6 applied SW-pin fix and marked verified via element state
(currentTime advancing, videoWidth=1920, readyState=4). Headless pixel
histogram still showed darkPct=100% — element decoded fine but CSS
overlay covered it.

Real cause: branding.xml BLACK-PASS paints .libraryPage with
#000 !important. Jellyfin OSD page renders <div id=videoOsdPage
class=libraryPage>; class match -> opaque black div above <video>.

Fix: extend transparent-scope using :has(.htmlVideoPlayer) +
#videoOsdPage selector. Post-fix darkPct=9.8% (was 100%), MNS S1E4
video frame visually paints.

Removed INC6 clear-cache-only middleware (no longer needed, was
burning HTTP cache every visit).

bin/apply-26-incident-fixes.sh extended with INC7 patch (idempotent
re-apply if branding.xml ever drifts back).

Lesson: video-element state alone is insufficient verification.
Always sample pixel histogram + canvas drawImage on the painted
viewport.
2026-05-09 03:04:41 +01:00
s8n
a4ababcfbf doc 28: record commit hash + nullstone backup path for INC7 fix 2026-05-09 02:50:21 +01:00
s8n
3a7d96aacb doc 28 + INC7: fix prod black-screen via SW cache pin
Five sibling agents converged on root cause:
jellyfin-asset-immutable Traefik router (priority 90) was matching
/web/serviceworker.js (Jellyfin PWA's actual SW filename), pinning it
with Cache-Control: public, max-age=31536000, immutable. The
priority-100 jellyfin-html-nocache router only excluded the literal
path /web/sw.js, missing serviceworker.js.

Stale SWs from earlier ARRFLIX iterations intercepted /Videos/* and
/web/* fetch events, returning cached/empty bytes. Result:
MediaSource appendBuffer got bad data -> black <video>. INC6's
Clear-Site-Data: "cache" couldn't fix it (per MDN spec, "cache"
excludes SW registrations; "storage" would have worked).

Fix: added jellyfin-sw-nocache router at priority 250 in
/opt/docker/traefik/config/dynamic.yml on nullstone, forcing
cache-no-store@file on /web/serviceworker.js + /web/sw.js. Hot-reload
via Traefik file provider, no docker restart.

Verified at the wire (curl -I /web/serviceworker.js now returns
no-cache, no-store, must-revalidate; main.jellyfin.bundle.js still
immutable as intended) and via headless Chromium probe of MNS S1E4
(33s of currentTime advance, readyState 4, videoWidth 1920x1080,
no errors, both s8n admin and USER-F user).

bin/prod-vs-dev-compare.py also lands as a one-shot diff helper used
during the investigation.
2026-05-09 02:50:00 +01:00
s8n
af2d625e4f doc 27: status snapshot 2026-05-09 visual
Visual status board: symptoms killed (8/8), roadmap (done /
pending / high / medium / deferred / strategic), library codec
matrix, repo file tree, next-click. Point-in-time after doc-26
incident closed. For ongoing roadmap see ROADMAP.md.
2026-05-09 02:23:53 +01:00
s8n
648e5d5238 doc 26 INC6: Clear-Site-Data cache-only for shim deploy
INC5 fmp4-disable shim required browser hard-reload to fire. Owner's
MNS S1E4 re-test still black-screened because cached index.html ran
old shim + fmp4-HLS bug recurred. Add Traefik response header
'Clear-Site-Data: "cache"' on /web/index.html. Cache-only is safe
(no cookie/storage wipe -> auth + localStorage preserved). One fresh
visit refetches index.html with new shim. Remove middleware after
owner confirms working, otherwise every revisit re-flushes cache.
2026-05-09 02:10:52 +01:00
s8n
b5e0d95826 doc 26: add INC5 scrollbar grey-strip subsection
Symptom + root cause + fix + lesson for the scrollbar grey strip at
very bottom of page. Patch already in bin/apply-26-incident-fixes.sh
and live in branding.xml on prod from earlier commit; this is the
documentation subsection that was missing dedicated coverage.
2026-05-09 02:05:16 +01:00
s8n
557317d104 doc 26: case CLOSED — final state + 8 forbidden patterns
All 8 owner-reported symptoms resolved across 5 iterations (INC1-5):

INC1 — index.html drift revert + :has() transparent-scope + Cineplex
       Abspielen override + encoding.xml HLS 499 fix
INC2 — pin .backdropContainer position:fixed (persistent backdrop)
INC3 — extend transparent-scope through detail-page sub-sections
INC4 — .emby-scroller transparent (kill black band behind carousels) +
       EnableTonemapping=false + 20Mbps RemoteClientBitrateLimit cap +
       headless-test-v2.py (admin+USER-F+click-play+bg-sweep)
INC5 — AV1 source re-encode (MNS S1E2/E4/E5 to H.264/AAC) +
       enableHlsFmp4=false localStorage shim +
       ::-webkit-scrollbar styled to ARRFLIX palette

Verification: headless playwright on Chrome + Firefox UA confirms MNS
S1E4 plays 1920x1080 readyState=4 currentTime advancing. Owner
double-confirmed solved.

Doc 26 final state section + 18-item forbidden-pattern checklist added
for future operators.
2026-05-09 02:03:39 +01:00
s8n
3c7cf64d1e doc 26 INC5: disable fMP4-HLS client-side + AV1 force-transcode
Two parallel fixes for MNS S1E4 black-video bug. Belt+braces.

INC5 fmp4-disable (this agent):
- Add localStorage.setItem('enableHlsFmp4', 'false') shim to
  web-overrides/index.html (idempotent, marker INC5 fmp4=false 2026-05-09)
- Forces TS segments instead of fMP4 for all HLS transcodes,
  works around upstream black-video bug with HEVC+fMP4
  (jellyfin-webos#126, jellyfin#16612)
- Browser localStorage verified false via headless playwright;
  server confirmed emitting -hls_segment_type fmp4 before fix
- Repo + deployed file md5 match: 5b212d7d60b8a2b910a2f47dd0470a09

INC5 AV1 force-transcode (parallel agent):
- Re-encoded MNS S1E1-5 AV1->H.264 in container; PlaybackInfo
  now returns DirectStream/DirectPlay=true on S1E4
- Doc additions covering the AV1 work included here since
  same file (already authored by parallel agent, not yet committed)
2026-05-09 01:58:45 +01:00
s8n
439b600740 doc 26 INC4: black band + 4K HDR slow transcode + v2 test + methodology audit
Two regressions slipped through INC1-3:

INC4a -- BLACK BAND behind every detail-page carousel
  Pre-existing 2026-05-08 home-page rule painted .emby-scroller {bg:#000
  !important} UNSCOPED. Hits every carousel inside .itemDetailPage incl
  admin-only More from Season N, More Like This. INC1-3 transparent-scope
  list missed .emby-scroller / .verticalSection / .padded-top-focusscale.
  Fixed by extending scope.

INC4b -- VIDEO 'BLACK SCREEN' on play
  Not actually black-screen. CPU-only nullstone cannot sustain real-time
  4K HEVC HDR tonemap+x264 transcode -- 0.5x realtime, ffmpeg takes ~6s
  per 3s segment. With user resume seeks adding restart overhead, total
  wait ~18s before browser readyState rises. User saw black, gave up.
  Fix: disable EnableTonemapping (R&M fake HDR per doc 21) + cap
  RemoteClientBitrateLimit=20Mbps on every user (1080p target, no 4K
  scale). Headless v2 test confirms HEVC + AV1 episodes now hit
  readyState=3/4 within wait window; 4K HDR R&M still slow (heaviest).

INC4 testing methodology audit -- bin/headless-test-v2.py
  v1 only logged in as USER-F and never clicked Play. v2 runs both admin
  and USER-F, walks 3 codec-tagged items per role (HEVC/AV1/H.264),
  clicks Play, captures <video> state, sweeps DOM for opaque bgs over
  backdrop layer. False positives: off-viewport #reactRoot + collapsed
  .mainDrawer (negative coords). Allowlist refinement TODO.

Open: 4K HDR sources still slow even post-fix. Real fix path = pre-
transcode masters to 1080p H.264 SDR via separate batch, OR migrate to
10.11.8 with vaapi/qsv driver fixed.
2026-05-09 01:46:47 +01:00
s8n
9fe7644701 doc 26 INC2+INC3: pin backdrop, transparent sub-sections
After INC1 fixed the Abspielen + first-fold backdrop, owner reported black
band hiding artwork in More from Season 1 / below-fold sections. Two more
patches required:

INC2 — pin .backdropContainer + .backgroundContainer position:fixed; height
100vh so backdrop persists during scroll. Added vertical fade ::after.

INC3 — extend transparent-scope to ALL detail-page sub-sections
(.detailVerticalSection, .scrollSlider, .padded-bottom-page,
.itemsContainer etc) so section wrappers don't paint over the pinned
backdrop section by section.

bin/headless-test.py now takes top + scrolled viewport screenshots.
full_page=True hides position:fixed regressions, dual-screenshot exposes
them. Use both to bisect.

bin/apply-26-incident-fixes.sh updated with INC2+INC3.

Open: AV1+Opus playback (Mike Nolan Show) still tracked for 10.11.8
migration. .detailLogo regression possible — test in actual browser.
2026-05-09 01:21:01 +01:00
s8n
0dd3539838 doc 26 + bin: incident 2026-05-09 + headless smoke-test
Symptoms: Page Unresponsive on poster grid, posters missing then black
backdrops, 'Abspielen' German Play button surviving Traefik+force-english
chases, video black-screen on play.

Root causes (different from initial guesses):
- Browser hangs: deployed index.html drifted ahead of repo; uncommitted
  forceEnglishUI() text-walker MutationObserver froze main thread on poster
  lazy-load. Reverted to repo HEAD.
- 'Abspielen': Cineplex theme HARDCODES German via 'content:' ::after rule
  -- not a Jellyfin locale issue. Doc 25 already proved per-user UICulture
  is theatre. Override CSS with content: 'Play'.
- Backdrops black: BLACK-PASS CustomCss block paints #000 !important on
  .layout-desktop / .pageContainer -- occludes backdrop layer (z-index:-1).
  Existing transparent-scope rule used body.itemDetailPage selector that
  doesn't match in 10.10.3 (body class is libraryDocument). Replaced with
  :has(.itemDetailPage) ancestor scoping.
- HLS 499: encoding.xml had EnableThrottling+EnableSegmentDeletion=true,
  segments reaped before browser re-request. Disabled both.

Verified via new bin/headless-test.py (playwright Chromium login + screenshot
+ computed-style probe). Fixes idempotent and re-runnable via new
bin/apply-26-incident-fixes.sh.

Open: AV1+Opus items still black-screen in Chrome due to DirectStream
codec-tag mislabel bug. Tracked for 10.11.8 migration.
2026-05-09 01:11:38 +01:00
s8n
1bfd122f6a force English everywhere on all 9 users + wrapper
AudioLanguagePreference=eng, SubtitleLanguagePreference=eng,
SubtitleMode=Default, PlayDefaultAudioTrack=true, UICulture=en-US.
Per-user Configuration POST applied to all 9 existing users + wrapper
updated for future creations.
2026-05-08 23:46:13 +01:00
s8n
f01cdd4007 doc 25: english leak deep-dive (Abspielen post-lockdown) 2026-05-08 22:09:59 +01:00
s8n
c972605af9 import-log: youtube-sassy-the-sasquatch 2026-05-08 — eps 1-5 (ep6 age-restricted) 2026-05-08 22:03:42 +01:00
s8n
3dd5d3c6f2 doc 22: jellyfin runtime perf audit (read-only)
Server-runtime focus; supplements doc 13. Headline: 4 concurrent ffmpeg
processes for ONE viewer all transcoding 1080p->2160p with PGS subtitle
burn-in, on uncapped jellyfin container sharing 12-core host with
uncapped Forgejo BlueBuild CI runner (88-99 % CPU). Load avg 15.4 on 12
cores. Throttling+SegmentDeletion still off (doc 13 finding 03 now
non-optional). Top quick-win: enable transcode throttling + segment
deletion + cap RemoteClientBitrateLimit.
2026-05-08 17:51:06 +01:00
s8n
ea8dc71617 doc 23: arrflix edge / network / browser-load-path perf audit (read-only)
Edge audit complementing doc 13 (server-side perf). Confirms cold-load
"feels slow" perception is dominated by:
  - no HTTP compression at Traefik (2.74 MiB raw JS bundles per cold load)
  - no Cache-Control on hashed-asset URLs (28 conditional GETs per warm load)
  - first-fetch poster image transcode ~385 ms (server-side, doc 13 #02)

TLS, MTU, HTTP/2, cert chain, middleware chain, Pi-hole hairpin all
audited and clean. Pi-hole missing local DNS rewrite for arrflix.s8n.ru
(LAN clients hairpin via WAN unless /etc/hosts pin in place).

Top quick win: add `compress@file` middleware in
/opt/docker/traefik/config/dynamic.yml + reference from Jellyfin router
label. ~70 % cold-load wire-size reduction (2.74 MiB to ~0.82 MiB
gzip / ~0.69 MiB brotli). One file edit, no architectural change.

No fixes applied. No state mutated. No Traefik reload.
2026-05-08 17:50:52 +01:00
s8n
7e63e5e236 audit: storage I/O for arrflix media path 2026-05-08 17:47:19 +01:00
s8n
16b3f831d9 audit: rick-and-morty color/HDR diagnosis 2026-05-08 17:45:34 +01:00
s8n
494ee4e814 ROADMAP: action owner = s8n 2026-05-08 17:25:56 +01:00
s8n
d7aa38909d ROADMAP: rewrite with snapshot table, status emojis, Open above Done
- Snapshot table at top (live stats: prod/dev URLs, theme, eps, disk, users)
- Open items first (high/med/low + effort + blocker)
- Blocked + Deferred middle
- Done section moved to bottom per owner request
- Visual: emoji-tagged severity, tables, scannable
- Updated counts: 6 series, 175 eps, 156G free, 9 users, 17 docs
2026-05-08 17:20:37 +01:00
s8n
21a69672f1 doc 19: english-only lockdown audit (read-only baseline)
Cross-layer audit supplementing docs 15 and 16. Confirms doc-15 root
cause still live (8/8 users have UICulture absent; force-english script
unrun), enumerates 93 served locale chunks (de-json contains 'Abspielen'),
and proposes 4-pronged remediation: per-user POST + wrapper patch +
Traefik Accept-Language rewrite + navigator.language shim.

Read-only. No Jellyfin mutations performed.
2026-05-08 17:05:11 +01:00
s8n
27b737ff2f docs+bin: English-only lockdown — re-apply runner + doc 20
doc 20 covers the multi-layer pin (server / per-user / web SPA / Accept-
Language), the idempotent re-apply runner, drift-check curl one-liners,
known gaps, and a systemd-timer suggestion for weekly auto re-application.

bin/english-lockdown-runner.sh: idempotent runner that POSTs server-wide
UICulture / PreferredMetadataLanguage / MetadataCountryCode and per-user
UICulture / Audio+Subtitle prefs / PlayDefaultAudioTrack. Reads
JELLYFIN_API_TOKEN from env (set -u, refuses to run without it). One-line
summary per surface; exit 0 on full success, 1 on any failure.

doc 15 prefaced with a "Status as of 2026-05-08" section noting the
multi-agent lockdown sweep and cross-linking the audit baseline (doc 19,
sibling) and the new lockdown procedure (doc 20). Original body preserved
verbatim as historical context.
2026-05-08 17:04:12 +01:00
s8n
395c6d2833 web: english-lockdown shim — pin locale + hide switchers 2026-05-08 17:04:03 +01:00
s8n
fbe0eca509 readme: tighten tagline to match repo bio (no compromise) 2026-05-08 16:54:40 +01:00
s8n
5026669f13 drop tv.s8n.ru — arrflix.s8n.ru is canonical
Replaced 25 occurrences across README, docs/00-overview, and
docs/04-theming-and-users. Removed the obsolete tv→arrflix rename
blockquote (rename complete) and deduped the Live-at bullet.
2026-05-08 16:46:26 +01:00
s8n
d5eff7cb27 strip: remove Claude attribution from ROADMAP + audit docs
ROADMAP owner column 's8n' (was 'claude'). Audit-run-by lines in
docs/{11,14,16} reattributed to s8n. Removed CLAUDE.md memory ref
from docs/04 hosts-pin note.
2026-05-08 16:44:49 +01:00
s8n
1a69930120 docs: add 00-overview as technical landing page 2026-05-08 16:37:30 +01:00
s8n
12e9880d76 readme: rebrand as ARRFLIX brand-facing landing 2026-05-08 16:36:43 +01:00
s8n
25b278f9cb Settings drawer hide v2: target a.btnSettings + data-itemid
Drawer Settings href is literally '#' (route via JS click handler keyed
off data-itemid='settings'). Old href*=mypreferencesmenu rules matched
zero elements in live DOM. Fix verified on dev with headless A/B (doc
17 commit f3a32c3).
2026-05-08 16:05:36 +01:00
s8n
6c66f601c6 redact: scrub leaked Jellyfin admin API token from public repo
Token 76858153...f8b1 was committed across 9 docs + 1 snapshot RESTORE.md
and exposed via the brief public window of this repo. Replaced with
`<JELLYFIN_API_TOKEN>` placeholder.

WARNING: this commit only redacts HEAD — the token remains in git history.
Anyone who cloned during the public window has the full value. Treat the
old token as compromised and rotate at Jellyfin Dashboard > API Keys.
Original value backed up to private s8n/secrets/ARRFLIX/.
2026-05-08 15:36:14 +01:00
s8n
f3a32c335c doc 17: dev mirror + Settings drawer leak fix (dev only, no prod swap) 2026-05-08 13:34:04 +01:00
s8n
742dbc68c7 doc 16: Jellyfin branding leaks audit (read-only) 2026-05-08 04:29:26 +01:00
s8n
9e7d4077d7 doc 14: theme audit + detail-page backdrop diagnosis (read-only) 2026-05-08 04:27:28 +01:00
s8n
722c447c40 doc 13: read-only optimization audit 2026-05-08 04:24:21 +01:00
s8n
6b1eea2d2b doc 15: force English UI for all users (plan + script)
Owner saw "Abspielen" on the Play button — caused by every user having
Configuration.UICulture absent, so the web SPA falls back to browser
Accept-Language. No server-side flag exists to override this.

Adds docs/15-force-english.md with the per-user forcing mechanism,
limits (pre-auth splash bundle still uses navigator.language), and a
ready-to-execute bash script bin/force-english-all-users.sh that pins
UICulture=en-US on every user via POST /Users/{id}/Configuration.

Plan-only commit — no live config changed. Owner triggers when ready.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 04:22:04 +01:00
s8n
22236e2dc8 doc 11: NeutralFin render audit (read-only)
Live render audited at https://arrflix.s8n.ru. Owner believed NeutralFin
was applied; live /Branding/Configuration shows Cineplex v1.0.6 — a
sibling POST won the race per §3b operational rule. Audit covers visual
contract, drift table for every CustomCss + critical-path index.html
rule, NeutralFin variable conflicts, logo aspect ratio (235x85, fits),
ranked fix list. No state mutated; recommendation pending owner sign-off.
2026-05-08 04:18:07 +01:00
s8n
cd425783ba doc 04 §3e: ElegantFin migration with ARRFLIX recolor
Migrated active CSS theme from Cineplex v1.0.6 to ElegantFin v25.12.31
with Netflix-red #E50914 accent overrides over ElegantFin's default
Jellyseerr-blue/violet palette. ARRFLIX wordmark logo preserved on both
.adminDrawerLogo img and .pageTitleWithLogo selectors (split-rule form).

Eight accent variables overridden at :root: --uiAccentColor, --activeColor
(+Alpha), --osdSeekBarPlayedColor, --checkboxCheckedBgColor,
--highlightOutlineColor, --btnSubmitColor, --btnSubmitBorderColor.

All prior custom blocks preserved verbatim: cast/crew hide, Quick Connect
hide, header-icon hide (§3c), white slider thumbs (§3d), pure-black bg
(§3d), Settings drawer hide, count badge hide, ARRFLIX logo override.
LoginDisclaimer + SplashscreenEnabled untouched.

POST → 204; GET /Branding/Configuration confirms no Cineplex import,
ElegantFin pinned to v25.12.31, all overrides intact. Smoke test on
https://arrflix.s8n.ru/ → HTTP 302 (baseline). No container restart.

Restructured §1: Cineplex content moved to §1 'Previous themes'
subsection (#### Why Cineplex won, #### Tradeoffs, #### What it looked
like, #### Theme history) with the new ElegantFin+recolor stack as
the canonical current theme.

Snapshot tag for rollback: snapshot-2026-05-08-pre-elegantfin
2026-05-08 04:03:32 +01:00
129 changed files with 22401 additions and 474 deletions

View file

@ -0,0 +1,229 @@
# forgejo-actions-secret-scan.yml
#
# Drop into each repo at: .forgejo/workflows/secret-scan.yml
# (Forgejo Actions reads .forgejo/workflows/ natively; .github/workflows/
# also works as fallback if a repo has both. Prefer .forgejo/.)
#
# Layer-2 (CI) of the audit cadence — runs on every push + on pull-request.
# Two scanners (gitleaks + detect-secrets) for belt-and-braces coverage.
# On hit: opens a Forgejo Issue in this repo (assigned to operator)
# with redacted preview, then fails the workflow so any auto-merge stops.
#
# Required repo secrets:
# FORGEJO_TOKEN — PAT with scope `issue:write` for THIS repo only.
# Bot account preferred (obsidian-ai), not operator's PAT.
#
# Runner label: nullstone (the existing self-hosted runner per memory).
# If runner is offline / privileged-runner-design rejects this,
# fall back to label `docker` and use a vanilla container runner.
name: secret-scan
on:
push:
branches:
- "**"
pull_request:
branches:
- "**"
workflow_dispatch:
# Don't run twice on the same SHA.
concurrency:
group: secret-scan-${{ github.ref }}
cancel-in-progress: true
jobs:
gitleaks:
name: gitleaks (HEAD + history)
runs-on: nullstone
permissions:
contents: read
issues: write
steps:
- name: Checkout (full history for --log-opts=all)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install gitleaks
run: |
set -eu
if ! command -v gitleaks >/dev/null 2>&1; then
curl -sSL -o /tmp/gitleaks.tgz \
"https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz"
mkdir -p /tmp/gl && tar -xzf /tmp/gitleaks.tgz -C /tmp/gl
sudo install -m 0755 /tmp/gl/gitleaks /usr/local/bin/gitleaks
fi
gitleaks version
- name: Pull s8n-stack ruleset
run: |
# Operator-tuned ruleset lives in s8n/security-vault.
# If the repo is offline, fall back to gitleaks defaults.
set -eu
if curl -sSL -H "Authorization: token ${FORGEJO_TOKEN}" \
-o .gitleaks.toml \
"https://git.s8n.ru/s8n/security-vault/raw/branch/main/prevention/.gitleaks.toml"; then
echo "loaded operator-tuned ruleset"
else
echo "fallback to gitleaks defaults" >&2
rm -f .gitleaks.toml
fi
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
- name: Scan HEAD (staged + uncommitted only takes commits)
id: gl-head
run: |
set -eu
mkdir -p .scan
gitleaks detect --source . \
--no-banner --redact \
${GITLEAKS_CONFIG_FLAG} \
--report-format json \
--report-path .scan/gitleaks-head.json \
--exit-code 0
# Count findings:
n=$(jq 'length' .scan/gitleaks-head.json 2>/dev/null || echo 0)
echo "head_count=$n" >> "$GITHUB_OUTPUT"
echo "gitleaks HEAD findings: $n"
env:
GITLEAKS_CONFIG_FLAG: ${{ hashFiles('.gitleaks.toml') != '' && '--config .gitleaks.toml' || '' }}
- name: Scan history (--log-opts=--all)
id: gl-hist
run: |
set -eu
gitleaks detect --source . \
--no-banner --redact \
${GITLEAKS_CONFIG_FLAG} \
--log-opts="--all" \
--report-format json \
--report-path .scan/gitleaks-history.json \
--exit-code 0
n=$(jq 'length' .scan/gitleaks-history.json 2>/dev/null || echo 0)
echo "history_count=$n" >> "$GITHUB_OUTPUT"
echo "gitleaks history findings: $n"
env:
GITLEAKS_CONFIG_FLAG: ${{ hashFiles('.gitleaks.toml') != '' && '--config .gitleaks.toml' || '' }}
- name: Upload gitleaks reports (artefact)
if: always()
uses: actions/upload-artifact@v4
with:
name: gitleaks-reports
path: .scan/
retention-days: 30
- name: Open Forgejo issue on hit (gitleaks)
if: steps.gl-head.outputs.head_count != '0' || steps.gl-hist.outputs.history_count != '0'
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
REPO: ${{ github.repository }}
REF: ${{ github.ref }}
SHA: ${{ github.sha }}
HEAD_COUNT: ${{ steps.gl-head.outputs.head_count }}
HIST_COUNT: ${{ steps.gl-hist.outputs.history_count }}
run: |
set -eu
# Build a redacted preview (rule-id + file + line, no values).
preview="$(jq -r '.[] | "- rule:" + .RuleID + " file:" + .File + " line:" + (.StartLine|tostring) + " commit:" + (.Commit // "HEAD")' .scan/gitleaks-head.json .scan/gitleaks-history.json | head -50)"
body=$(jq -nR --arg ref "$REF" --arg sha "$SHA" --arg hc "$HEAD_COUNT" --arg histc "$HIST_COUNT" --arg prev "$preview" \
'{
title: ("[secret-scan] gitleaks hit on " + $ref),
body: ("**Automated secret-scan hit.**\n\nRef: `" + $ref + "`\nSHA: `" + $sha + "`\nHEAD findings: " + $hc + "\nHistory findings: " + $histc + "\n\n## Redacted preview\n\n```\n" + $prev + "\n```\n\nFull report: workflow run artefacts (gitleaks-reports).\n\n## Triage\n\n1. False-positive? Add a `.gitleaksignore` entry with justifying comment + close.\n2. True-positive? Trigger incident response per `rules/incident-response-rules.md`. Rotate the affected credential. Then redact + history-rewrite.\n\n/cc @s8n"),
labels: ["security","secret-scan"]
}')
curl -sS -X POST \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$body" \
"https://git.s8n.ru/api/v1/repos/${REPO}/issues" | jq '.html_url'
- name: Fail workflow on hit
if: steps.gl-head.outputs.head_count != '0' || steps.gl-hist.outputs.history_count != '0'
run: |
echo "::error::gitleaks found secrets — see opened issue + workflow artefact"
exit 1
detect-secrets:
name: detect-secrets (entropy + cross-tool)
runs-on: nullstone
permissions:
contents: read
issues: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install detect-secrets
run: |
set -eu
python3 -m pip install --user --upgrade pip detect-secrets
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Scan
id: ds
run: |
set -eu
mkdir -p .scan
detect-secrets scan --all-files \
--exclude-files '(^|/)(node_modules|venv|\.venv|dist|build|target|out|coverage|\.terraform)/' \
--exclude-files '(^|/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|go\.sum)$' \
> .scan/detect-secrets.json
# Count findings:
n=$(jq '.results | to_entries | map(.value | length) | add // 0' .scan/detect-secrets.json)
echo "count=$n" >> "$GITHUB_OUTPUT"
echo "detect-secrets findings: $n"
- name: Upload detect-secrets report
if: always()
uses: actions/upload-artifact@v4
with:
name: detect-secrets-report
path: .scan/detect-secrets.json
retention-days: 30
- name: Open Forgejo issue on hit (detect-secrets)
if: steps.ds.outputs.count != '0'
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
REPO: ${{ github.repository }}
REF: ${{ github.ref }}
COUNT: ${{ steps.ds.outputs.count }}
run: |
set -eu
preview="$(jq -r '.results | to_entries[] | .key as $f | .value[] | "- " + $f + ":" + (.line_number|tostring) + " type:" + .type' .scan/detect-secrets.json | head -50)"
body=$(jq -nR --arg ref "$REF" --arg c "$COUNT" --arg prev "$preview" \
'{
title: ("[secret-scan] detect-secrets hit on " + $ref),
body: ("**Automated secret-scan hit (detect-secrets).**\n\nRef: `" + $ref + "`\nFindings: " + $c + "\n\n## Redacted preview (file:line type — no values)\n\n```\n" + $prev + "\n```\n\nFull report: workflow run artefacts (detect-secrets-report).\n\n## Triage\n\n1. False-positive? Run locally `detect-secrets audit .scan/detect-secrets.json` and commit the audited baseline.\n2. True-positive? Trigger incident response per `rules/incident-response-rules.md`.\n\n/cc @s8n"),
labels: ["security","secret-scan"]
}')
curl -sS -X POST \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$body" \
"https://git.s8n.ru/api/v1/repos/${REPO}/issues" | jq '.html_url'
- name: Fail workflow on hit
if: steps.ds.outputs.count != '0'
run: |
echo "::error::detect-secrets found candidates — see opened issue + workflow artefact"
exit 1
summary:
name: summary
needs: [gitleaks, detect-secrets]
if: always()
runs-on: nullstone
steps:
- name: Outcome
run: |
echo "secret-scan complete."
echo " gitleaks: ${{ needs.gitleaks.result }}"
echo " detect-secrets: ${{ needs['detect-secrets'].result }}"

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
__pycache__/

9
.gitleaksignore Normal file
View file

@ -0,0 +1,9 @@
# Gitleaks allowlist — false-positive fingerprints with justification.
# Each entry: <relative-path>:<rule-id>:<line>
# Justify why each entry is safe to ignore.
# LAN IP (RFC1918) for nullstone in a Pi-hole local-DNS-pin description.
# Same IP appears openly in docs/00-overview.md (Topology table), 21-*,
# 22-*, etc. — internal LAN only, never routed publicly. Rule itself
# is tagged low-confidence and explicitly suggests allowlisting docs.
docs/32-dev-container-wipe-2026-05-11.md:lan-ip-rfc1918:71

View file

@ -108,7 +108,7 @@ ElegantFin imports from `cdn.jsdelivr.net/gh/lscambo13/ElegantFin@main/...` —
- **Library**: TV Shows → `Futurama (1999)`, S01S04, **72 episodes + 9 featurettes**, English audio, 1080p HEVC, locked to TMDB 615 / TVDB 73871 / IMDb tt0149460. Polish set deleted 2026-05-08.
- **Disk**: nullstone /home 109G free
- **Theme**: ElegantFin v25.12.31
- **Plugins**: OpenSubtitles v20 (creds pending — see [docs/03](docs/03-subtitles.md))
- **Plugins**: OpenSubtitles v20 (creds set, 20 dl/day free tier — see [docs/03](docs/03-subtitles.md))
- **Users**: `s8n` (admin), `USER-F` (non-admin, password `123`, change recommended)
- **Home layout (per-user, applied to both)**: resume / resumeaudio / nextup / latestmedia (My Media tile row dropped)

661
LICENSE Normal file
View file

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

131
README.md
View file

@ -1,83 +1,90 @@
# ARRFLIX
<p align="center">
<img src="assets/logo.png" alt="ARRFLIX" width="420">
</p>
Self-hosted Jellyfin media server on nullstone, LAN-only.
<h3 align="center">My own premium streaming service. No compromise.</h3>
> **Start here** → [`ADMIN-GUIDE.md`](ADMIN-GUIDE.md) — the single page that
> tells you what to do day-to-day. Everything else is a reference doc you only
> read when the admin guide tells you to.
---
## Endpoint
ARRFLIX is my personal streaming service. One library, hand-curated, no
filler — every show and film is the best version I could put together. Where
the source allows, masters are 4K. Where it doesn't, they're AI-upscaled until
they look better than the disc ever did. The reference example: my **Rick and
Morty Season 1** is a 4K HDR upscale that beats the original broadcast. That's
the standard for everything that lands here.
- `https://arrflix.s8n.ru` — accessible only from LAN (192.168.0.0/24) and Tailscale admin/infra tags via Traefik `no-USER-F@file` middleware.
- DNS resolved internally by Pi-hole (`/opt/docker/pihole/etc-pihole/custom.list`).
- TLS via Let's Encrypt DNS-01 (Gandi).
It's not a clone of a public streamer. It's the version I wished existed: the
quality bar of a boutique release group, the polish of a flagship app, and a
library I actually want to watch.
## Storage
---
| Path | Purpose |
|-----------------------------------|-------------------------------|
| `/home/docker/jellyfin/config/` | Jellyfin config + DB (writable, UID 1000) |
| `/home/docker/jellyfin/cache/` | Transcode + image cache |
| `/home/user/media/movies/` | Movies library (mounted RO) |
| `/home/user/media/tv/` | TV library (mounted RO) |
<p align="center">
<img src="assets/screenshots/02-detail-mandalorian.png" alt="ARRFLIX detail page — The Mandalorian">
<br><sub><em>Detail page — full-bleed backdrop, ARRFLIX wordmark, Netflix-grade dark UI</em></sub>
</p>
## Routing
<p align="center">
<img src="assets/screenshots/03-playback-sassy.png" alt="ARRFLIX playback — Sassy the Sasquatch">
<br><sub><em>Playback — Jellyfin chrome hidden, ARRFLIX-red scrubber + clean OSD</em></sub>
</p>
Traefik docker-label provider does NOT pick up the labels on this container
(unknown reason — file-provider routing for the same backend works). The
deploy uses **file-provider** routing in
`/opt/docker/traefik/config/jellyfin-test.yml`. If you fix the docker-provider
issue later, flip routing back to labels and remove the file-provider snipUSER-E.
<p align="center">
<img src="assets/screenshots/01-search.png" alt="ARRFLIX search">
<br><sub><em>Search — pinned suggestions, ARRFLIX-red accents, no filler</em></sub>
</p>
## Transcoding
---
GTX 1660 Ti is present on nullstone but `nvidia-smi` currently fails — driver
is broken or not loaded. Jellyfin runs CPU-only transcode for now. After
fixing the driver, add the standard NVIDIA hwaccel block in compose:
## What you get
```yaml
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
```
- **Best-quality everything.** 4K where the source supports it, AI-upscaled
masters where it doesn't. No 480p filler, no junk encodes.
- **Curated, not crawled.** Every title is hand-imported, hand-cleaned, and
hand-checked before it goes live. Junk files, sample clips, and stray
artwork never make it in.
- **Polished metadata.** Posters, backdrops, episode stills, cast, and
descriptions are all locked to the canonical source — no wrong-show
matches, no broken artwork, no foreign-language drift.
- **English-first UI, every account.** No surprise German Play buttons, no
browser-locale roulette. Every user is pinned to a consistent experience.
- **Custom theming.** ARRFLIX wordmark, ARRFLIX-red accent (`#E50914`),
loading splash, and a Netflix-grade dark UI. Jellyfin's stock chrome is
hidden — the brand is the surface.
- **Per-user home layouts.** Resume, Next Up, and Latest Media tuned the way
I actually use the app. No "My Media" tile clutter.
- **Subtitles done right.** Sidecar files named to spec, OpenSubtitles
integration, ffmpeg-extracted tracks where embedded.
…and enable NVENC in Jellyfin's Playback → Transcoding settings.
## Live at
## First-run setup
- <https://arrflix.s8n.ru>
1. Browse to `https://arrflix.s8n.ru` from the LAN.
2. Create the admin user (Jellyfin onboarding wizard).
3. Add libraries pointing at `/media/movies` and `/media/tv` inside the
container (these map to `/home/user/media/{movies,tv}`).
4. (Optional) Apply Netflix-style theme — see `docs/04-theming-and-users.md`.
Endpoint is **LAN / tailnet only**. There is no public exposure — if
you're not on the network, you're not getting in. By design.
## Operations docs
---
Detailed playbooks (research-grade, with API curls, failure modes, recovery):
## How it works (technical)
| File | Topic |
|------|-------|
| [`docs/01-artwork-and-images.md`](docs/01-artwork-and-images.md) | Posters, backdrops, scrapers (TMDB/TVDB/Fanart), refresh API, language fallback |
| [`docs/02-metadata-and-titles.md`](docs/02-metadata-and-titles.md) | Filename parsing, Identify flow, locking the right show, language cascade, multi-episode files |
| [`docs/03-subtitles.md`](docs/03-subtitles.md) | OpenSubtitles plugin (.com), sidecar naming, ffmpeg/mkvextract extraction, per-user prefs |
| [`docs/04-theming-and-users.md`](docs/04-theming-and-users.md) | ElegantFin theme, branding API, multi-user policies, SyncPlay, friend account playbook |
| [`docs/05-file-structure-rules.md`](docs/05-file-structure-rules.md) | Authoritative folder/filename rules for movies, TV, anime, stand-up, concerts, docs, extras, NFO, artwork overrides |
| [`docs/06-per-library-themes.md`](docs/06-per-library-themes.md) | Per-library theming research: JS-injector plugin shim + scoped CSS for Movies/Anime/Music looks |
ARRFLIX runs on self-hosted infrastructure on **nullstone**. The repo you're
looking at is also the deploy source-of-truth: the compose file, library
structure, theming overrides, and operational playbooks all live here. The
streaming engine itself is unbranded plumbing — invisible behind the
ARRFLIX surface.
## State as of 2026-05-08
Operators / future-me, the technical reference is split across:
- **Library**: Futurama 1999 series (TMDB 615), S01S03, 44 episodes, fully scraped (Polish metadata + posters + backdrops + episode stills)
- **Theme**: ElegantFin v25.12.31 applied via `/System/Configuration/branding`
- **Subtitles**: OpenSubtitles plugin v20 installed; user must add opensubtitles.com creds (free tier = 20 dl/day)
- **Users**: 1 admin (`s8n`); friend account creation playbook in doc 04
- [`ADMIN-GUIDE.md`](ADMIN-GUIDE.md) — single-page day-to-day ops: adding users,
importing media, fixing scrapes, theme breakage, emergency rollback.
- [`ROADMAP.md`](ROADMAP.md) — what's done, what's open, what's deferred.
- [`docs/`](docs/) — research-grade reference docs (artwork, metadata,
subtitles, theming, file-structure rules, per-library themes, cleanup,
filename normalization, force-English, branding leaks, splash, audits).
## Deploy
Repo lives at <https://git.s8n.ru/s8n/ARRFLIX> (mirror:
<https://flexhub.s8n.ru/s8n/ARRFLIX>).
```bash
cd /opt/docker/jellyfin
docker compose up -d
```
---
<p align="center"><sub>ARRFLIX — a one-person streaming service that punches above its weight.</sub></p>

View file

@ -1,107 +1,140 @@
# Roadmap — ARRFLIX
What's done, what's open, what's deferred. Update on every commit that lands or
moves an item between buckets.
Last revised: 2026-05-08
Last revised: **2026-05-11**
---
## Done
## Snapshot
- [x] **Deploy**: Jellyfin 10.10.3 on nullstone, LAN-only at `arrflix.s8n.ru`, file-provider Traefik route, LE cert via Gandi DNS-01, Pi-hole local DNS pin, userns_mode=host
- [x] **Theme**: ElegantFin v25.12.31 applied via `/System/Configuration/branding`
- [x] **Cast & Crew + USER-F Stars**: hidden globally via CustomCss (`#castCollapsible, #USER-FCastCollapsible`)
- [x] **Library**: TV Shows → `/media/tv/Futurama (1999)/`, 72 eps + 9 featurettes, locked to TMDB 615
- [x] **Cleanup**: Polish set deleted, junk-stripped English set imported, source + staging deleted
- [x] **Plugins**: OpenSubtitles v20 installed (v21+ needs JF 10.11 ABI)
- [x] **Users**: `s8n` (admin), `USER-F` (non-admin, pw `123`)
- [x] **Wrapper**: `bin/add-jellyfin-user.sh` for canonical user creation
- [x] **Home layout**: My Media tile row dropped per user (resume / resumeaudio / nextup / latestmedia)
- [x] **Docs**: 0108 (artwork, metadata, subs, theming, file-structure, per-lib themes, cleanup, naming) + ADMIN-GUIDE.md
- [x] Imported: The Incredible Hulk (2008)
- [x] Imported: Idiocracy (2006)
- [x] Imported: American Dad! (2005) S01-S04 (58 eps)
| Metric | Value |
|---|---|
| Prod URL | https://arrflix.s8n.ru → 302 ✓ |
| Dev URL | https://dev.arrflix.s8n.ru → 302 ✓ |
| Theme | **Cineplex v1.0.6** (rolled back from NeutralFin) |
| Repo | `git.s8n.ru/s8n/ARRFLIX` |
| Library | 6 series + 2 movies, 175 eps + 9 featurettes |
| Disk | nullstone /home — 156G free (60% used) |
| Users | 9 (1 admin + 8 non-admin) |
| Snapshot tag | `snapshot-2026-05-08-pre-elegantfin` (rollback) |
| Docs | 17 in `docs/` + ADMIN-GUIDE + ROADMAP |
---
## Open — actionable now
## 🟥 Open — High value (do first)
### High value
- [ ] **OpenSubtitles credentials**
- Owner signs up at opensubtitles.**com** (NOT .org)
- I POST creds to `/Plugins/<id>/Configuration` per [docs/03 § 3.4](docs/03-subtitles.md)
- Test by triggering subtitle search on one Futurama episode
- Free tier = 20 dl/day; full library will take ~3 days unless VIP
- [ ] **GPU transcode (nvidia driver)**
- GTX 1660 Ti present on nullstone, `nvidia-smi` fails — driver kernel module not loaded
- SecureBoot enabled → DKMS module signing required
- Steps in `README.md § Transcoding` and one earlier diagnosis turn
- Blocks: anyone watching on a low-power client (phone, fire TV) currently CPU-transcodes
- Estimated wall: 30 min + reboot (nullstone hosts traefik, forgejo, matrix — ~2 min downtime)
- [ ] **Loading-splash rebrand**
- Replace Jellyfin pre-bundle logo with `arrflix.s8n.ru` wordmark + 4-bar pulse spinner
- Approach: bind-mount patched `/jellyfin/jellyfin-web/index.html` per the plan in this session's history
- Doc to write: `docs/09-loading-splash.md` (pre-bundle vs CustomCss timing, regen-on-upgrade)
### Medium value
- [ ] **Extract `bin/cleanup-import.sh` and `bin/normalize.py`** from docs 07 + 08 into runnable repo files (currently embedded in markdown only)
- [ ] **Per-library themes (doc 06)**
- Install `n00bcodr/Jellyfin-JavaScript-Injector` plugin
- Ship 30-line shim that mirrors `topParentId` + `collectionType` from URL hash to body class
- Add three scoped CSS blocks (`body.lib-movies` Netflix, `body.lib-anime` Crunchyroll, `body.lib-music` Spotify)
- Source CSS hunt: 5 Netflix-flavoured bases listed in doc 06; Crunchyroll + Spotify must be hand-built (no existing theme)
- Verdict per doc 06: "tinted, branded, recognisable" — NOT pixel-perfect
- [ ] **Audit-vs-rules pass on current state**
- `/home/user/media/tv/Futurama (1999)/` already conforms post-import
- But: subtitle sidecars absent (waiting on OpenSubtitles creds)
- Featurettes folder is lowercase ✓
- Year in parens ✓
- SXXEXX zero-padded ✓
- Episode title separator ` - `
### Low value
- [ ] **Library scaffolding**: empty `/media/{movies,anime,musicvideos}/` libraries exist; no content yet
- [ ] **Backup strategy** for `/home/docker/jellyfin/config/` (DB + watched-state). Currently zero backups. Tie into existing nullstone backup chain.
- [ ] **Forgejo Actions CI** for the repo (lint compose, validate `bin/*.sh` with shellcheck, render docs)
---
## Blocked / waiting on owner
- OpenSubtitles creds → owner has not signed up yet
- nvidia driver fix → owner needs to run sudo commands or approve disable-SecureBoot path
- Decision on per-library themes (doc 06): green-light or skip
---
## Deferred
- **Pixel-perfect Netflix/Crunchyroll/Spotify per-library**: would require 3 separate Jellyfin instances on subdomains. ~100× maintenance cost. Doc 06 § 5. Don't do.
- **Custom Jellyfin Docker image**: `FROM jellyfin/jellyfin + COPY index.html`. Cleaner than bind-mount for splash + JS injector but extra build pipeline. Defer until ≥3 web-bundle overrides needed.
- **Subdomain split for friend-only access**: friend already gets non-admin Jellyfin user via `bin/add-jellyfin-user.sh` with `EnabledFolders` ACL. Subdomain not necessary.
- **Move to alternative web client (Jellyfin-Vue)**: replaces the whole UI, breaks ElegantFin + JS Injector. Owner explicitly wants Netflix-y, not vue-y. Don't do.
- **Hardware change**: 4 TB HDD on nullstone idle. Wait until library exceeds 500 GB before activating second-path library mounts (doc 05 § Architecture C).
---
## Tracking
When an item moves to **Done**, link the commit hash. When it stalls, note the blocker date. Don't let entries rot — review on the first of each month.
| Item | Status | Last touch | Owner |
| # | Item | Effort | Blocker |
|---|---|---|---|
| OpenSubtitles creds | blocked-on-owner | 2026-05-07 | s8n |
| nvidia driver | blocked-on-owner | 2026-05-07 | s8n |
| Loading splash | open-actionable | 2026-05-08 | claude |
| Extract bin/ scripts | open-actionable | 2026-05-08 | claude |
| Per-library themes | open-actionable (decision pending) | 2026-05-08 | claude |
| Library scaffolding | open-low-value | 2026-05-08 | s8n |
| Backup strategy | open-low-value | not-started | claude |
| Forgejo CI | open-low-value | not-started | claude |
| H1 | GPU transcode (nvidia driver kernel module + container toolkit + SecureBoot signing) | L | **owner sudo + reboot** |
| H2 | Backup `/home/docker/jellyfin/config/` off-host (no automated backup yet) | M | strategy decision |
| H3 | Library AV1 sweep + Sonarr/Radarr penalty (kills jellyfin#15646 future) | M | post-doc-26 |
## 🟨 Open — Medium value
| # | Item | Effort | Notes |
|---|---|---|---|
| M1 | Tune detail-page backdrop gradient stops if text contrast off | S | doc 14 §7 |
| M2 | EnableThrottling + EnableSegmentDeletion (kills wasted ffmpeg-after-disconnect) | S | doc 13 win 1 |
| M3 | KnownProxies + LocalNetworkSubnets in network.xml (fixes session origin on WAN endpoint) | S | doc 13 win 3 |
| M4 | PWA manifest bind-mount — kills "Jellyfin" name on Android/iOS install | M | doc 16 phase 1 |
| M5 | Logo-screensaver disable + i18n DOM-rewrite shim | M | doc 16 phases 2+3 |
| M6 | Extract `bin/cleanup-import.sh` + `normalize.py` from doc bodies into runnable files | S | docs 07/08 |
| M7 | Per-library themes (JS injector plugin + body class shim) | M | doc 06 — "tinted, not pixel-perfect" |
## 🟩 Open — Low value (nice-to-have)
| # | Item | Effort | Notes |
|---|---|---|---|
| L1 | Forgejo Actions CI (lint compose, shellcheck bin/, render docs) | M | not started |
| L2 | High-res ARRFLIX wordmark for desktop splash variant (currently 235×85, looks soft on 1080p+) | S | doc 14 finding |
| L3 | Hide lone "User" h3 header above Sign Out (cosmetic) | S | open Q from settings-fix agent |
| L4 | Rotate dev admin password (currently same as prod for parity) | S | open Q from settings-fix agent |
---
## 🚫 Blocked / waiting
| Item | Blocker | Action owner |
|---|---|---|
| Nvidia GPU | sudo + reboot decision | **s8n** |
| WAN public access | home router port-forward 80/443 → 192.168.0.100 | **s8n** |
---
## 🔒 Deferred (with reason)
| Item | Reason |
|---|---|
| Pixel-perfect Netflix/Crunchyroll/Spotify per-lib themes | requires 3 separate Jellyfin instances on subdomains; ~100× maintenance cost. Doc 06 |
| Custom Jellyfin Docker image (FROM jellyfin + COPY index.html) | bind-mount works; defer until ≥3 web-bundle overrides needed |
| Subdomain split for friend-only access | non-admin user policies + EnabledFolders ACL already do this on a single instance |
| Move to Jellyfin-Vue alt web client | replaces UI, breaks current branding stack |
| 4 TB HDD activation | wait until library exceeds 500 GB; currently 50G |
---
## ✅ Done
### Branding + theme
- ✅ Theme: ElegantFin → Cineplex → ElegantFin → NeutralFin → **Cineplex v1.0.6 (final)**, snapshot tag for rollback
- ✅ ARRFLIX logo data-URL injected — overrides Cineplex's logo on `.adminDrawerLogo img` + `.pageTitleWithLogo` (split-rule per element type, no overlap)
- ✅ Browser tab title `ARRFLIX` + favicon = ARRFLIX wordmark (via index.html bind-mount)
- ✅ Pre-bundle splash → ARRFLIX wordmark (no more Jellyfin logo on first paint)
- ✅ LoginDisclaimer "Welcome to ARRFLIX - Private invite only service"
- ✅ Critical-path inline `<style>` in index.html eliminates pre-bundle theme flash
- ✅ JS shim in index.html: title-lock + favicon-lock + nukeSettings + SW unregister
- ✅ Detail-page backdrop full-bleed gradient fix (was 17vw black band; now Netflix-style)
### UI hides + tweaks (CSS in CustomCss)
- ✅ Cast & Crew + Guest Stars sections (`#castCollapsible, #guestCastCollapsible`)
- ✅ Quick Connect button + server-side disable (`.btnQuick`, `QuickConnectAvailable=false`)
- ✅ Settings drawer link v2 (`a.btnSettings, [data-itemid="settings"]` — verified on dev with headless A/B before swap)
- ✅ Header icons: SyncPlay group, Cast, User menu (`.headerSyncButton`, `.headerCastButton`, `.headerUserButton`)
- ✅ Unwatched-count badges (`.countIndicator`)
- ✅ Settings menu page access (`EnableUserPreferenceAccess=false` per non-admin)
- ✅ Slider thumbs blue → white (scrubber + volume on player OSD)
- ✅ Pure-black background
### Library
- ✅ Cleanup playbook: 17-doc set including pre-import strip rules + filename normalization
- ✅ Imports applied via cleanup → normalize pipeline:
- Futurama (1999) S01S04, 72 eps + 9 featurettes (TMDB 615)
- American Dad! (2005) S01S04, 58 eps (TMDB 1433)
- Rick and Morty (2013) S01, 11 eps (TMDB 60625)
- Star Wars: Maul Shadow Lord (2026) S01, 10 eps (TMDB 289219)
- Obi-Wan Kenobi (2022) S01, 6 eps + 4 featurettes (TMDB 92830)
- The Incredible Hulk (2008) (TMDB 1724)
- Idiocracy (2006) (TMDB 7512)
- ⏳ The Mandalorian (2019) S01S03 — 18/24 mkv on disk, scrape in flight
- ✅ Futurama season posters re-locked to highest-res TMDB (was low-res)
- ✅ Polish set replaced with English; libraries flipped `pl/PL``en/US`
### Users + access
- ✅ 9 users (`s8n` admin, `5`, `64bitpotato`, `aloy`, `guest`, `house`, `marco`, `pet`, `yummyhunny`)
- ✅ All non-admin policies: `IsAdministrator=false`, `EnableContentDeletion=false`, `EnableUserPreferenceAccess=false`, `LoginAttemptsBeforeLockout=5`
- ✅ Wrapper `bin/add-jellyfin-user.sh` — single-call canonical user creation (4-step pipeline: create + home layout + lang prefs + restricted policy)
- ✅ Home layout per-user: resume → resumeaudio → nextup → latestmedia (My Media tile row dropped)
### Infra
- ✅ Domain rename: `tv.s8n.ru``nasflix.s8n.ru`**`arrflix.s8n.ru`**
- ✅ Repo rename: `jellyfin-stack``NASFLIX`**`ARRFLIX`** at `git.s8n.ru/s8n/ARRFLIX`
- ✅ Pi-hole local DNS for `arrflix.s8n.ru` + `dev.arrflix.s8n.ru`
- ✅ LE certs via Gandi DNS-01 for both prod + dev
- ✅ WAN window: Gandi public A record `arrflix.s8n.ru → 82.31.156.86`, no-guest middleware dropped, lockout=5 baked in (router port-forward pending)
- ✅ Dev instance: `dev.arrflix.s8n.ru`, isolated config, shared `/home/user/media:/media:ro` mount with prod (read-only), 7 mirror users + s8n-dev admin
- ✅ Snapshot tag `snapshot-2026-05-08-pre-elegantfin` for one-command rollback
### Docs (17 + 2 indexes + bin/)
- ✅ `ADMIN-GUIDE.md` (entry point)
- ✅ `ROADMAP.md` (this file)
- ✅ `docs/01..16` covering: artwork, metadata, subtitles, theming-and-users, file-structure, per-library-themes, cleanup, normalization, WAN exposure, SPA shim, NeutralFin audit, dev instance, optimization audit, theme audit, force English, Jellyfin-branding leaks, dev mirror + settings fix
- ✅ `bin/add-jellyfin-user.sh`, `bin/inject-shim.py`, `bin/force-english-all-users.sh`
---
## Conventions
When marking an item:
- Move **Open****Done** when shipped + verified
- Move to **Blocked** when waiting on owner / external
- Move to **Deferred** with one-line reason
- Update **Snapshot** stats on every revision

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

View file

@ -71,7 +71,7 @@ rm -f /tmp/dp-cur.$$.json /tmp/dp-fix.$$.json
[[ "$HTTP" == "204" ]] || { echo " DisplayPreferences POST failed: $HTTP"; exit 1; }
echo " Home layout applied."
echo "[3/4] Setting language prefs (audio=eng, subs=eng default)..."
echo "[3/4] Setting language prefs (force English everywhere, no fallback)..."
curl -ks "$JELLYFIN_URL/Users/$USER_ID" -H "Authorization: $AUTH" > /tmp/u.$$.json
python3 - <<EOF > /tmp/u-fix.$$.json
import json
@ -81,6 +81,8 @@ c['SubtitleMode'] = 'Default'
c['SubtitleLanguagePreference'] = 'eng'
c['AudioLanguagePreference'] = 'eng'
c['PlayDefaultAudioTrack'] = True
c['UICulture'] = 'en-US'
c['DisplayMissingEpisodes'] = False
print(json.dumps(c))
EOF
HTTP=$(curl -ks -X POST "$JELLYFIN_URL/Users/$USER_ID/Configuration" \

181
bin/apply-26-incident-fixes.sh Executable file
View file

@ -0,0 +1,181 @@
#!/usr/bin/env bash
# apply-26-incident-fixes.sh
#
# Re-applies the three server-state fixes from docs/26 if branding.xml /
# encoding.xml drift back to broken state (e.g. after a Jellyfin restore).
#
# 1. CustomCss: Cineplex hardcoded "Abspielen" → "Play"
# 2. CustomCss: Backdrop transparent-scope using :has() (BLACK-PASS occluded backdrop layer)
# 3. encoding.xml: EnableThrottling=false + EnableSegmentDeletion=false (kills HLS 499)
#
# Usage: ssh user@nullstone "$(cat bin/apply-26-incident-fixes.sh)"
# Idempotent: re-running is safe.
set -euo pipefail
# 3+5. encoding.xml — disable throttling + segment deletion (HLS 499)
# AND disable software tonemapping (CPU-only nullstone
# cannot sustain real-time 4K HDR tonemap+x264, ffmpeg
# runs at ~0.5x → 18s wait time before video starts;
# R&M is fake-HDR per doc 21 anyway, so no visual loss)
for cfg in /home/docker/jellyfin/config/config/encoding.xml \
/home/docker/jellyfin-dev/config/config/encoding.xml; do
[ -f "$cfg" ] || continue
cp -n "$cfg" "$cfg.bak.pre-doc26" || true
sed -i \
-e 's|<EnableThrottling>true</EnableThrottling>|<EnableThrottling>false</EnableThrottling>|' \
-e 's|<EnableSegmentDeletion>true</EnableSegmentDeletion>|<EnableSegmentDeletion>false</EnableSegmentDeletion>|' \
-e 's|<EnableTonemapping>true|<EnableTonemapping>false|' \
-e 's|<EnableVppTonemapping>true|<EnableVppTonemapping>false|' \
"$cfg"
echo "[+] patched $cfg"
done
# 1+2. branding.xml CustomCss — Abspielen + backdrop transparent-scope
patch_branding() {
local cfg="$1"
[ -f "$cfg" ] || return 0
if grep -q "ARRFLIX 2026-05-09" "$cfg"; then
echo "[=] $cfg already has doc-26 patch"
return 0
fi
cp -n "$cfg" "$cfg.bak.pre-doc26" || true
python3 - <<PY
p = "$cfg"
s = open(p).read()
patch = """
/* ARRFLIX 2026-05-09 — incident fixes (see docs/26-incident-2026-05-09-...).
INC1: Cineplex theme hardcodes German "Abspielen" via content: ::after.
INC1: BLACK-PASS occludes backdrop; transparent-scope via :has().
INC2: pin backdrop position:fixed so it persists across scroll.
INC3: extend transparent-scope through detail-page sub-sections so
section wrappers don't paint over the pinned backdrop.
INC4: override the 2026-05-08 .emby-scroller=#000 rule on detail page
(it was painting a black band behind every carousel — most visible
on admin-only "More from Season" / "More Like This"). */
.mainDetailButtons .material-icons.play_arrow::after {
content: "Play" !important;
}
.itemDetailPage,
.layout-desktop:has(.itemDetailPage),
.layout-mobile:has(.itemDetailPage),
.layout-tv:has(.itemDetailPage),
.mainAnimatedPages:has(.itemDetailPage),
.pageContainer:has(.itemDetailPage),
.padded-bottom-page:has(.itemDetailPage),
.libraryPage:has(.itemDetailPage),
.absolutePageTabContent:has(.itemDetailPage) {
background-color: transparent !important;
background: transparent !important;
}
.layout-desktop .backdropContainer,
.layout-mobile .backdropContainer,
.layout-tv .backdropContainer,
.layout-desktop .backgroundContainer,
.layout-mobile .backgroundContainer,
.layout-tv .backgroundContainer {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 0 !important;
}
.layout-desktop .backgroundContainer.withBackdrop::after,
.layout-mobile .backgroundContainer.withBackdrop::after,
.layout-tv .backgroundContainer.withBackdrop::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(0,0,0,0.00) 0%,
rgba(0,0,0,0.00) 35%,
rgba(0,0,0,0.40) 70%,
rgba(0,0,0,0.75) 100%
);
pointer-events: none;
z-index: 1;
}
.itemDetailPage,
.itemDetailPage > *,
.detailPageContent,
.detailPagePrimaryContainer,
.detailPageWrapperContainer,
.detailPageContent > *,
.detailVerticalSection,
.detailVerticalSection-extrabottompadding,
.detailSection,
.detailSectionContent,
.itemsContainer,
.scrollSlider,
.scrollSliderContainer,
.padded-bottom-page,
.detailPagePrimaryContent,
.sectionTitleContainer,
.detailRibbon,
.subtitleAudioContainer,
.detailPageRoot {
background-color: transparent !important;
background: transparent !important;
}
/* INC4: 2026-05-08 home-page "kill gray band" rule paints .emby-scroller
#000 unscoped — that's the OPAQUE wrapper around every carousel inside
.itemDetailPage. Override back to transparent on detail page only. */
.itemDetailPage .emby-scroller,
.itemDetailPage .emby-scroller-container,
.itemDetailPage .verticalSection,
.itemDetailPage .padded-top-focusscale,
.itemDetailPage .padded-bottom-focusscale,
.itemDetailPage .moreFromSeasonSection,
.itemDetailPage .moreFromArtistSection,
.itemDetailPage .scrollSliderContainer,
.itemDetailPage .scrollButtonContainer {
background-color: transparent !important;
background: transparent !important;
}
/* INC7 2026-05-09: BLACK-PASS paints .libraryPage #000; #videoOsdPage uses
that class so the OSD page covers <video> with opaque black. <video>
decodes frames (canvas drawImage luma=84) but visually 100% black until
we exempt the OSD page from BLACK-PASS via :has(.htmlVideoPlayer). */
.libraryPage:has(.htmlVideoPlayer),
.libraryPage#videoOsdPage,
#videoOsdPage,
#videoOsdPage .pageContainer,
#videoOsdPage .layout-desktop,
#videoOsdPage .mainAnimatedPages {
background-color: transparent !important;
background: transparent !important;
}
/* INC5: kill grey scrollbar groove at page bottom (Chrome native scrollbar
default = grey track; appears as ~15px strip at viewport bottom). Style
all scrollbars to ARRFLIX palette. */
*::-webkit-scrollbar {
background: #000000 !important;
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track { background: #000000 !important; }
*::-webkit-scrollbar-thumb {
background: #2a2a2a !important;
border-radius: 5px;
}
*::-webkit-scrollbar-thumb:hover { background: #3a3a3a !important; }
*::-webkit-scrollbar-corner { background: #000000 !important; }
* { scrollbar-color: #2a2a2a #000000; }
html, body { scrollbar-color: #2a2a2a #000000; }
"""
s = s.replace("</CustomCss>", patch + "</CustomCss>")
open(p, "w").write(s)
PY
echo "[+] patched $cfg"
}
patch_branding /home/docker/jellyfin/config/config/branding.xml
patch_branding /home/docker/jellyfin-dev/config/config/branding.xml
# Restart so changes take effect
docker restart jellyfin jellyfin-dev 2>/dev/null || docker restart jellyfin
echo "[*] Done. Verify with bin/headless-test.py."

202
bin/english-lockdown-runner.sh Executable file
View file

@ -0,0 +1,202 @@
#!/usr/bin/env bash
# english-lockdown-runner.sh — idempotent re-apply of the ARRFLIX English-only lockdown.
#
# See docs/20-english-only-lockdown.md for the full design, layer breakdown,
# and drift-check procedure. This script handles two of the three layers:
#
# 1. Server-wide: UICulture / PreferredMetadataLanguage / MetadataCountryCode
# via POST /System/Configuration.
# 2. Per-user: UICulture / AudioLanguagePreference / SubtitleLanguagePreference /
# PlayDefaultAudioTrack via POST /Users/{id}/Configuration for every account.
#
# The third layer (web SPA shim — navigator.language override + language-switcher
# CSS hide) is served via the bind-mounted web-overrides/ tree; nothing for
# this script to push.
#
# Idempotent — running it twice produces the same end state. Each layer is
# read, merged with English defaults, and POSTed back. Skips writes when the
# server already matches.
#
# Usage:
# JELLYFIN_API_TOKEN=<admin-token> ./english-lockdown-runner.sh
#
# Optional env:
# JELLYFIN_URL default https://arrflix.s8n.ru
# DRY_RUN default unset; set DRY_RUN=1 to print payloads without POSTing
#
# Exit codes:
# 0 every layer landed (or already correct)
# 1 at least one POST failed; check stderr/stdout for which surface
# 2 bad invocation (missing required env)
#
# Token rotation note: the API token has full admin scope. Use a dedicated
# token, not a personal-account session token, and rotate after offboarding
# any operator with shell access to the host running this script.
set -euo pipefail
JELLYFIN_URL="${JELLYFIN_URL:-https://arrflix.s8n.ru}"
JELLYFIN_API_TOKEN="${JELLYFIN_API_TOKEN:?set JELLYFIN_API_TOKEN=<admin-token>; aborting (see docs/20-english-only-lockdown.md)}"
DRY_RUN="${DRY_RUN:-}"
AUTH="MediaBrowser Token=$JELLYFIN_API_TOKEN"
# Server-wide targets
SERVER_UI_CULTURE="en-US"
SERVER_METADATA_LANG="en"
SERVER_METADATA_COUNTRY="US"
# Per-user targets
USER_UI_CULTURE="en-US"
USER_AUDIO_LANG="eng"
USER_SUBTITLE_LANG="eng"
USER_PLAY_DEFAULT_AUDIO="true"
FAIL_COUNT=0
# ---------------------------------------------------------------------------
# Layer 1: server-wide config
# ---------------------------------------------------------------------------
echo "[*] Layer 1: server-wide /System/Configuration"
SERVER_TMP_IN=$(mktemp)
SERVER_TMP_OUT=$(mktemp)
trap 'rm -f "$SERVER_TMP_IN" "$SERVER_TMP_OUT"' EXIT
curl -ks "$JELLYFIN_URL/System/Configuration" -H "Authorization: $AUTH" > "$SERVER_TMP_IN"
CURRENT_SERVER=$(python3 -c "
import json
with open('$SERVER_TMP_IN') as f: c = json.load(f)
print(f\"UICulture={c.get('UICulture','<absent>')} PreferredMetadataLanguage={c.get('PreferredMetadataLanguage','<absent>')} MetadataCountryCode={c.get('MetadataCountryCode','<absent>')}\")
")
echo " before: $CURRENT_SERVER"
NEEDS_SERVER_WRITE=$(python3 -c "
import json
with open('$SERVER_TMP_IN') as f: c = json.load(f)
ok = (
c.get('UICulture') == '$SERVER_UI_CULTURE'
and c.get('PreferredMetadataLanguage') == '$SERVER_METADATA_LANG'
and c.get('MetadataCountryCode') == '$SERVER_METADATA_COUNTRY'
)
print('0' if ok else '1')
")
if [[ "$NEEDS_SERVER_WRITE" == "0" ]]; then
echo " ok: server already pinned, skipping write"
else
python3 - <<PYEOF > "$SERVER_TMP_OUT"
import json
with open("$SERVER_TMP_IN") as f: c = json.load(f)
c["UICulture"] = "$SERVER_UI_CULTURE"
c["PreferredMetadataLanguage"] = "$SERVER_METADATA_LANG"
c["MetadataCountryCode"] = "$SERVER_METADATA_COUNTRY"
print(json.dumps(c))
PYEOF
if [[ -n "$DRY_RUN" ]]; then
echo " DRY_RUN: would POST $(wc -c < "$SERVER_TMP_OUT") bytes to /System/Configuration"
else
HTTP=$(curl -ks -X POST "$JELLYFIN_URL/System/Configuration" \
-H "Authorization: $AUTH" \
-H "Content-Type: application/json" \
--data-binary @"$SERVER_TMP_OUT" -w "%{http_code}" -o /dev/null)
if [[ "$HTTP" == "204" || "$HTTP" == "200" ]]; then
echo " after: UICulture=$SERVER_UI_CULTURE PreferredMetadataLanguage=$SERVER_METADATA_LANG MetadataCountryCode=$SERVER_METADATA_COUNTRY (HTTP $HTTP)"
else
echo " ERROR: POST /System/Configuration returned HTTP $HTTP" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
fi
fi
echo
# ---------------------------------------------------------------------------
# Layer 2: per-user config
# ---------------------------------------------------------------------------
echo "[*] Layer 2: per-user /Users/{id}/Configuration"
USERS_JSON=$(curl -ks "$JELLYFIN_URL/Users" -H "Authorization: $AUTH")
USER_COUNT=$(echo "$USERS_JSON" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))")
echo " $USER_COUNT users found."
echo
# Process-substitution to keep `set -e` semantics in the loop body.
while IFS=$'\t' read -r USER_ID USER_NAME OLD_UI OLD_AUDIO OLD_SUB OLD_PLAY; do
TMP_IN=$(mktemp)
TMP_OUT=$(mktemp)
curl -ks "$JELLYFIN_URL/Users/$USER_ID" -H "Authorization: $AUTH" > "$TMP_IN"
NEEDS_USER_WRITE=$(python3 -c "
import json
with open('$TMP_IN') as f: u = json.load(f)
c = u.get('Configuration', {})
ok = (
c.get('UICulture') == '$USER_UI_CULTURE'
and c.get('AudioLanguagePreference') == '$USER_AUDIO_LANG'
and c.get('SubtitleLanguagePreference') == '$USER_SUBTITLE_LANG'
and c.get('PlayDefaultAudioTrack') is True
)
print('0' if ok else '1')
")
if [[ "$NEEDS_USER_WRITE" == "0" ]]; then
echo " [ok] $USER_NAME ($USER_ID) — already pinned"
rm -f "$TMP_IN" "$TMP_OUT"
continue
fi
python3 - <<PYEOF > "$TMP_OUT"
import json
with open("$TMP_IN") as f: u = json.load(f)
c = u["Configuration"]
c["UICulture"] = "$USER_UI_CULTURE"
c["AudioLanguagePreference"] = "$USER_AUDIO_LANG"
c["SubtitleLanguagePreference"] = "$USER_SUBTITLE_LANG"
c["PlayDefaultAudioTrack"] = True
print(json.dumps(c))
PYEOF
if [[ -n "$DRY_RUN" ]]; then
echo " [dry] $USER_NAME ($USER_ID) — would POST $(wc -c < "$TMP_OUT") bytes"
else
HTTP=$(curl -ks -X POST "$JELLYFIN_URL/Users/$USER_ID/Configuration" \
-H "Authorization: $AUTH" \
-H "Content-Type: application/json" \
--data-binary @"$TMP_OUT" -w "%{http_code}" -o /dev/null)
if [[ "$HTTP" == "204" || "$HTTP" == "200" ]]; then
echo " [pin] $USER_NAME ($USER_ID) — UICulture=$USER_UI_CULTURE Audio=$USER_AUDIO_LANG Sub=$USER_SUBTITLE_LANG PlayDefault=true (HTTP $HTTP)"
else
echo " [FAIL] $USER_NAME ($USER_ID) — HTTP $HTTP" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
fi
rm -f "$TMP_IN" "$TMP_OUT"
done < <(echo "$USERS_JSON" | python3 -c "
import json, sys
for u in json.load(sys.stdin):
c = u.get('Configuration', {})
print('\t'.join([
u['Id'],
u['Name'],
str(c.get('UICulture', '')),
str(c.get('AudioLanguagePreference', '')),
str(c.get('SubtitleLanguagePreference', '')),
str(c.get('PlayDefaultAudioTrack', '')),
]))
")
echo
# ---------------------------------------------------------------------------
# Summary + exit
# ---------------------------------------------------------------------------
if [[ $FAIL_COUNT -eq 0 ]]; then
echo "[*] Done. All layers pinned (or already correct). Drift-check commands"
echo " in docs/20-english-only-lockdown.md."
exit 0
else
echo "[!] Done with $FAIL_COUNT failure(s). Re-run after investigating;"
echo " drift-check commands in docs/20-english-only-lockdown.md." >&2
exit 1
fi

73
bin/fix-home-db.sh Executable file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Direct SQLite fix for Jellyfin 10.10.3 home-screen sections.
#
# Why this exists:
# The REST endpoint `POST /DisplayPreferences/usersettings?client=Jellyfin Web`
# updates `DisplayPreferences.CustomPrefs` but does NOT insert into the
# `HomeSection` table for that client (it only inserts when called with
# client="emby"). The web client reads from `HomeSection` rows on the
# `Jellyfin Web` DisplayPreferences row, so the legacy POST has no
# visible effect until those rows exist.
#
# What this script does (idempotent):
# 1. Insert a `Jellyfin Web` DisplayPreferences row for any user missing one.
# 2. Seed canonical home layout [Resume, LatestMedia, None*8] for every
# DisplayPreferences row that has zero HomeSection rows.
# 3. Replace any Type=7 (NextUp) with Type=0 (None) across the table.
#
# Type integers (Jellyfin.Data.Enums.HomeSectionType):
# 0=None, 1=SmallLibraryTiles, 2=LibraryButtons, 3=ActiveRecordings,
# 4=Resume, 5=ResumeAudio, 6=LatestMedia, 7=NextUp, 8=LiveTv, 9=ResumeBook
#
# Container must be stopped during write to avoid corrupting the EF Core
# WAL. After write, restart the container.
#
# Usage:
# docker stop jellyfin
# DB=/home/docker/jellyfin/config/data/jellyfin.db bin/fix-home-db.sh
# docker start jellyfin
set -euo pipefail
DB="${DB:-/home/docker/jellyfin/config/data/jellyfin.db}"
[ -f "$DB" ] || { echo "DB not found at $DB"; exit 1; }
cp -n "$DB" "$DB.bak.$(date +%s)"
sqlite3 "$DB" <<'SQL'
.bail on
BEGIN;
-- 1) Create Jellyfin Web DP row for users missing one.
INSERT INTO DisplayPreferences (UserId, Client, ShowSidebar, ShowBackdrop, ScrollDirection,
SkipForwardLength, SkipBackwardLength, ChromecastVersion,
EnableNextVideoInfoOverlay, ItemId)
SELECT u.Id, 'Jellyfin Web', 0, 1, 0, 30000, 10000, 0, 0, '00000000-0000-0000-0000-000000000000'
FROM Users u
WHERE NOT EXISTS (
SELECT 1 FROM DisplayPreferences d
WHERE UPPER(d.UserId) = UPPER(u.Id) AND d.Client = 'Jellyfin Web'
);
-- 2) For any DP with zero HomeSection rows, seed [Resume, LatestMedia, None*8].
INSERT INTO HomeSection (DisplayPreferencesId, "Order", Type)
SELECT d.Id, 0, 4 FROM DisplayPreferences d
WHERE NOT EXISTS (SELECT 1 FROM HomeSection h WHERE h.DisplayPreferencesId=d.Id);
INSERT INTO HomeSection (DisplayPreferencesId, "Order", Type)
SELECT d.Id, 1, 6 FROM DisplayPreferences d
WHERE (SELECT COUNT(*) FROM HomeSection h WHERE h.DisplayPreferencesId=d.Id) = 1;
INSERT INTO HomeSection (DisplayPreferencesId, "Order", Type)
SELECT d.Id, ord, 0 FROM DisplayPreferences d
CROSS JOIN (SELECT 2 AS ord UNION SELECT 3 UNION SELECT 4 UNION SELECT 5
UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9)
WHERE (SELECT COUNT(*) FROM HomeSection h WHERE h.DisplayPreferencesId=d.Id) = 2;
-- 3) Replace NextUp (7) with None (0) across all DPs.
UPDATE HomeSection SET Type = 0 WHERE Type = 7;
COMMIT;
SQL
echo "[+] $DB normalized"
echo "=== type summary ==="
sqlite3 "$DB" "SELECT Type, COUNT(*) FROM HomeSection GROUP BY Type"

87
bin/force-english-all-users.sh Executable file
View file

@ -0,0 +1,87 @@
#!/usr/bin/env bash
# force-english-all-users.sh — pin Configuration.UICulture=en-US on every Jellyfin user.
#
# Why this exists: see docs/15-force-english.md.
# TL;DR — when a user has UICulture unset, the Jellyfin web SPA falls back to
# browser Accept-Language. Owner saw "Abspielen" (German "Play") on a Play
# button because someone's browser sends de-*. Pinning UICulture per user
# overrides Accept-Language and gives every account English UI regardless
# of where they log in from.
#
# Read-modify-write on /Users/{id}/Configuration. Idempotent — running it
# twice produces the same end state. Prints before/after UICulture per user.
#
# Usage:
# JELLYFIN_TOKEN=<admin-token> ./force-english-all-users.sh
#
# Optional env:
# JELLYFIN_URL default https://arrflix.s8n.ru
# TARGET_LOCALE default en-US (e.g. en-GB also works)
# DRY_RUN default unset; set DRY_RUN=1 to print payloads without POSTing
set -euo pipefail
JELLYFIN_URL="${JELLYFIN_URL:-https://arrflix.s8n.ru}"
JELLYFIN_TOKEN="${JELLYFIN_TOKEN:?set JELLYFIN_TOKEN=<admin-token>}"
TARGET_LOCALE="${TARGET_LOCALE:-en-US}"
DRY_RUN="${DRY_RUN:-}"
AUTH="MediaBrowser Token=$JELLYFIN_TOKEN"
echo "[*] Listing users..."
USERS_JSON=$(curl -ks "$JELLYFIN_URL/Users" -H "Authorization: $AUTH")
COUNT=$(echo "$USERS_JSON" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))")
echo " $COUNT users found."
echo
# Iterate. Pipe-to-while loses set -e on subshell exit, so use process-substitution.
while IFS=$'\t' read -r USER_ID USER_NAME OLD_CULTURE; do
echo "[*] $USER_NAME ($USER_ID)"
echo " before: UICulture=${OLD_CULTURE:-<absent>}"
if [[ "$OLD_CULTURE" == "$TARGET_LOCALE" ]]; then
echo " skip: already $TARGET_LOCALE"
echo
continue
fi
TMP_IN=$(mktemp)
TMP_OUT=$(mktemp)
curl -ks "$JELLYFIN_URL/Users/$USER_ID" -H "Authorization: $AUTH" > "$TMP_IN"
python3 - <<PYEOF > "$TMP_OUT"
import json
with open("$TMP_IN") as f: u = json.load(f)
c = u["Configuration"]
c["UICulture"] = "$TARGET_LOCALE"
print(json.dumps(c))
PYEOF
if [[ -n "$DRY_RUN" ]]; then
echo " DRY_RUN: would POST $(wc -c < "$TMP_OUT") bytes to /Users/$USER_ID/Configuration"
else
HTTP=$(curl -ks -X POST "$JELLYFIN_URL/Users/$USER_ID/Configuration" \
-H "Authorization: $AUTH" \
-H "Content-Type: application/json" \
--data-binary @"$TMP_OUT" -w "%{http_code}" -o /dev/null)
if [[ "$HTTP" != "204" ]]; then
echo " ERROR: POST returned HTTP $HTTP"
rm -f "$TMP_IN" "$TMP_OUT"
exit 1
fi
# Verify
NEW_CULTURE=$(curl -ks "$JELLYFIN_URL/Users/$USER_ID" -H "Authorization: $AUTH" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['Configuration'].get('UICulture','<absent>'))")
echo " after: UICulture=$NEW_CULTURE"
fi
rm -f "$TMP_IN" "$TMP_OUT"
echo
done < <(echo "$USERS_JSON" | python3 -c "
import json, sys
for u in json.load(sys.stdin):
cur = u.get('Configuration', {}).get('UICulture', '')
print(f\"{u['Id']}\t{u['Name']}\t{cur}\")
")
echo "[*] Done. Tell users to hard-refresh (Ctrl-Shift-R) so the SPA reloads"
echo " the locale bundle. Verify on a movie detail page — Play button"
echo " should read 'Play', not 'Abspielen'."

629
bin/headless-test-v2.py Executable file
View file

@ -0,0 +1,629 @@
#!/usr/bin/env python3
"""ARRFLIX headless smoke-test v2.
Why v2 exists (see docs/26 INC4 audit):
v1 had three coverage gaps that let two regressions ship:
- Logged in only as `USER-F` (non-admin restricted) admin-only sections
like the "More from Season N" carousel never rendered, so the black
band behind that carousel was invisible to the test.
- Never clicked Play never observed the <video> element in a real
playback state, so AV1+Opus episodes silently rendering black went
undetected.
- Probed only a hardcoded selector list any element painting an
opaque background outside that list (e.g. a new section wrapper)
was never reported.
v2 closes those gaps:
1. Multi-user runs: executes the full probe as BOTH admin and non-admin
in the same invocation, writes per-user JSON + screenshots, and
reports a DOM-section diff (sections present for one user but not
the other admin-only-visible content).
2. Click Play: locates the play button, clicks it, waits 10 s, captures
<video> element state (currentTime, paused, error, readyState, dims),
plus a video-area screenshot and any new console / network errors.
3. Multiple-item coverage: walks an item list (default: HEVC movie + AV1
TV episode + H.264 TV episode if available) and runs the full
detail-page + play probe for each.
4. Section-bg sweep: at scroll-bottom, walks every visible element and
reports any with a non-transparent backgroundColor whose bounding rect
overlaps where the pinned backdrop should be visible. Output goes
into probe.json under "regressions" with an allowlist filter.
5. Golden-screenshot diff: if a known-good screenshot exists at
OUT/golden/<key>.png, the run computes a Pillow pixel diff and writes
<key>-diff.png + a numeric mismatch ratio.
6. Structured JSON: probe.json now has top-level shape
{url, runs:[{user, item, item_kind, probe, play, regressions, ...}]}
so downstream tooling (CI / agents) can parse without grepping.
Usage:
bin/headless-test-v2.py [URL] [OUT_DIR]
URL defaults to https://dev.arrflix.s8n.ru.
OUT_DIR defaults to /tmp/arrflix-headless-v2.
User credentials are determined automatically from URL:
arrflix.s8n.ru admin=s8n / USER-F=USER-F
dev.arrflix.s8n.ru admin=s8n-dev / USER-F=USER-F-mirror
Override via env vars:
ADMIN_USER, ADMIN_PASS, GUEST_USER, GUEST_PASS
ITEMS=id1,id2,id3 # override default item list
Default items (chosen for codec coverage):
- HEVC movie: 7aa5add2c2d8575eda5280b9b9072071 (The Dark Knight)
- AV1 episode: auto-pick first Mike Nolan Show episode
- H.264 episode: auto-pick first non-AV1 episode if available
Exit codes:
0 all runs succeeded, no playback errors, no regression bg elements
1 setup / login failure
2 one or more runs reported playback failure or unallowlisted bg regression
"""
import sys, json, time, os, asyncio, urllib.request, urllib.error, ssl
from pathlib import Path
from playwright.async_api import async_playwright
try:
from PIL import Image, ImageChops
PIL_OK = True
except ImportError:
PIL_OK = False
URL = sys.argv[1] if len(sys.argv) > 1 else "https://dev.arrflix.s8n.ru"
OUT = sys.argv[2] if len(sys.argv) > 2 else "/tmp/arrflix-headless-v2"
os.makedirs(OUT, exist_ok=True)
os.makedirs(os.path.join(OUT, "golden"), exist_ok=True)
# Default credentials by env (URL → admin/USER-F)
if "dev.arrflix.s8n.ru" in URL:
DEFAULT_ADMIN = ("s8n-dev", "2001dude")
DEFAULT_GUEST = ("USER-F-mirror", "dev-test-USER-F")
else:
DEFAULT_ADMIN = ("s8n", "2001dude")
DEFAULT_GUEST = ("USER-F", "123")
ADMIN_USER = os.environ.get("ADMIN_USER", DEFAULT_ADMIN[0])
ADMIN_PASS = os.environ.get("ADMIN_PASS", DEFAULT_ADMIN[1])
GUEST_USER = os.environ.get("GUEST_USER", DEFAULT_GUEST[0])
GUEST_PASS = os.environ.get("GUEST_PASS", DEFAULT_GUEST[1])
# Default items: HEVC movie known id; TV episodes auto-picked per-user
ITEMS_OVERRIDE = os.environ.get("ITEMS", "").strip()
DEFAULT_HEVC_MOVIE = "7aa5add2c2d8575eda5280b9b9072071" # Dark Knight
MNS_NEEDLE = "mike nolan" # case-insensitive substring of series name for AV1 lookup
DEVICE = "headless-test-v2"
DEVICE_ID = "headless-test-v2-2026-05-09"
CLIENT = "HeadlessV2"
VERSION = "2.0"
# Selectors known to legitimately paint solid bg over backdrop area; if a
# regression sweep finds a bg element NOT on this list overlapping the
# backdrop region, it is flagged. Update intentionally as design changes.
BG_ALLOWLIST = {
# OSD / video player overlays — fine to be opaque
".htmlVideoPlayer", ".videoPlayerContainer", ".osdContent",
".upNextDialog", ".dialogContainer", ".dialog",
# Modal / dialog scrim layers
".dialogBackdrop", ".paperList",
# Top app drawer (intentionally opaque)
".skinHeader", ".headerTop",
}
# ---------- HTTP helpers (raw API) ----------
def auth_header(token=None):
h = (f'MediaBrowser Client="{CLIENT}", Device="{DEVICE}", '
f'DeviceId="{DEVICE_ID}", Version="{VERSION}"')
if token:
h += f', Token="{token}"'
return h
def _req(path, method="GET", body=None, token=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{URL}{path}",
data=data,
headers={
"Authorization": auth_header(token),
"Content-Type": "application/json",
},
method=method,
)
ctx = ssl._create_unverified_context()
with urllib.request.urlopen(req, context=ctx, timeout=15) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def login(user, password):
return _req("/Users/AuthenticateByName", "POST",
{"Username": user, "Pw": password})
def find_av1_episode(token, user_id):
"""Find first episode of Mike Nolan Show (or any series matching needle)."""
series = _req(
f"/Users/{user_id}/Items?Recursive=true&IncludeItemTypes=Series&Limit=200",
token=token)
target = None
for s in series.get("Items", []):
if MNS_NEEDLE in s.get("Name", "").lower():
target = s
break
if not target:
return None, None
eps = _req(
f"/Shows/{target['Id']}/Episodes?UserId={user_id}&Limit=1",
token=token)
if eps.get("Items"):
return eps["Items"][0]["Id"], f"{target['Name']} - {eps['Items'][0].get('Name','?')}"
return None, None
def find_h264_episode(token, user_id, exclude_series_id=None):
"""Auto-pick first episode of any TV series other than the AV1 one."""
series = _req(
f"/Users/{user_id}/Items?Recursive=true&IncludeItemTypes=Series&Limit=50",
token=token)
for s in series.get("Items", []):
if exclude_series_id and s.get("Id") == exclude_series_id:
continue
if MNS_NEEDLE in s.get("Name", "").lower():
continue
eps = _req(
f"/Shows/{s['Id']}/Episodes?UserId={user_id}&Limit=1",
token=token)
if eps.get("Items"):
return eps["Items"][0]["Id"], f"{s['Name']} - {eps['Items'][0].get('Name','?')}"
return None, None
def resolve_items(token, user_id):
"""Return list of [(item_id, label, kind), ...]."""
if ITEMS_OVERRIDE:
return [(i.strip(), f"override-{n}", "override")
for n, i in enumerate(ITEMS_OVERRIDE.split(",")) if i.strip()]
out = []
# HEVC movie (fixed id)
out.append((DEFAULT_HEVC_MOVIE, "Dark Knight (HEVC movie)", "hevc-movie"))
# AV1 episode (auto)
av1_id, av1_label = find_av1_episode(token, user_id)
if av1_id:
out.append((av1_id, f"{av1_label} (AV1 ep)", "av1-episode"))
# H.264 episode (auto, different series from AV1)
series_id_excl = None
if av1_id:
try:
ep = _req(f"/Users/{user_id}/Items/{av1_id}", token=token)
series_id_excl = ep.get("SeriesId")
except Exception:
pass
h264_id, h264_label = find_h264_episode(token, user_id, series_id_excl)
if h264_id:
out.append((h264_id, f"{h264_label} (H.264 ep)", "h264-episode"))
return out
# ---------- Playwright probe ----------
PROBE_SELECTORS = [
".itemBackdrop", ".detailBackdrop", ".backdropContainer",
".backgroundContainer", ".layout-desktop",
"body", "#reactRoot", ".itemDetailPage",
"video", ".htmlvideoplayer", ".btnPlay",
".detailPagePrimaryContainer", ".detailSection", ".detailVerticalSection",
".itemsContainer", ".padded-bottom-page", ".mainAnimatedPages",
".pageContainer", ".cardScalable", ".scrollSlider",
".sectionTitleContainer", ".detailPageContent", ".detailPageWrapperContainer",
".moreFromSeason", ".moreFromSeasonContainer", # admin-only carousel
]
async def probe_dom(page):
return await page.evaluate(
"""(SEL) => {
const result = {};
for (const s of SEL) {
const els = document.querySelectorAll(s);
if (!els.length) { result[s] = '<absent>'; continue; }
const el = els[0];
const cs = getComputedStyle(el);
result[s] = {
count: els.length,
display: cs.display,
opacity: cs.opacity,
visibility: cs.visibility,
background: cs.backgroundColor,
backgroundImage: cs.backgroundImage.slice(0, 80),
zIndex: cs.zIndex,
rect: el.getBoundingClientRect().toJSON(),
};
}
result.__title = document.title;
const playBtn = document.querySelector('.btnPlay, [data-action="play"]');
result.__playBtnText = playBtn
? (playBtn.innerText || playBtn.textContent || '').trim() : null;
result.__bodyClasses = document.body.className;
result.__url = location.href;
// List of all section-title texts so we can diff per-user.
result.__sectionTitles = Array.from(
document.querySelectorAll('.sectionTitleContainer, h2, .sectionHeader')
).map(e => (e.innerText || e.textContent || '').trim()).filter(Boolean);
return result;
}""",
PROBE_SELECTORS,
)
async def sweep_backgrounds(page):
"""Walk visible elements; return ones with non-transparent bg whose rect
overlaps where the pinned backdrop should be visible (top of viewport
above ~70% page height). The criterion is intentionally generous
callers filter via the allowlist."""
return await page.evaluate(
r"""() => {
const isOpaque = (c) => {
if (!c || c === 'rgba(0, 0, 0, 0)' || c === 'transparent') return false;
const m = c.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\)/);
if (!m) return true;
const a = m[4] !== undefined ? parseFloat(m[4]) : 1.0;
return a > 0.05;
};
const out = [];
const all = document.querySelectorAll('*');
for (const el of all) {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
if (!isOpaque(cs.backgroundColor)) continue;
const r = el.getBoundingClientRect();
if (r.width < 50 || r.height < 50) continue;
// Skip if bg is already same as body (chained inheritance, no diff)
if (el === document.body || el === document.documentElement) continue;
// Build a signature so consumers can match against allowlist
const cls = (el.className && typeof el.className === 'string')
? '.' + el.className.trim().split(/\s+/).join('.')
: '';
const sig = el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + cls;
out.push({
sig: sig.slice(0, 200),
tag: el.tagName.toLowerCase(),
id: el.id || null,
classes: (typeof el.className === 'string') ? el.className : '',
background: cs.backgroundColor,
rect: { x: r.x, y: r.y, w: r.width, h: r.height },
zIndex: cs.zIndex,
});
}
return out;
}"""
)
def filter_regressions(bg_elements, viewport_w, viewport_h):
"""Apply allowlist + overlap heuristics → regression list.
A bg element is flagged iff:
- It is NOT in the allowlist (any allowlist class appears in its sig).
- Its rect overlaps the visible viewport (x within [0, vw], y within
a band where backdrop should show i.e. above 80% page height
because content scrolls past pinned backdrop).
- The bg color is "very dark" (R+G+B < 90). Most legit overlays
are clearly tinted; near-black is the failure mode we want.
"""
regressions = []
for el in bg_elements:
sig = el["sig"]
if any(allow in sig for allow in BG_ALLOWLIST):
continue
bg = el["background"]
# Parse rgb sum
try:
nums = [int(x) for x in bg.replace("rgba(", "").replace("rgb(", "")
.replace(")", "").split(",")[:3]]
except Exception:
continue
if sum(nums) > 90:
continue
rect = el["rect"]
if rect["x"] + rect["w"] < 0 or rect["x"] > viewport_w:
continue
regressions.append(el)
return regressions
async def click_play_and_observe(page):
"""Find Play, click, wait 10s, return playback state + new errors."""
pre_console_marker = await page.evaluate("() => Date.now()")
state = {"clicked": False, "selector_used": None, "error": None}
# Try the canonical button selectors in priority order
for sel in [".btnPlay", "[data-action=\"play\"]", "button[is=\"emby-button\"][data-action=\"play\"]"]:
try:
btn = await page.query_selector(sel)
if btn:
box = await btn.bounding_box()
if box and box["width"] > 0:
await btn.click(timeout=5000)
state["clicked"] = True
state["selector_used"] = sel
break
except Exception as e:
state["error"] = f"{sel}: {e}"
if not state["clicked"]:
# Fallback: keyboard 'p' which Jellyfin web binds to play
try:
await page.keyboard.press("p")
state["clicked"] = True
state["selector_used"] = "kbd:p"
except Exception as e:
state["error"] = (state.get("error") or "") + f"; kbd:{e}"
return state
await asyncio.sleep(10)
state["video"] = await page.evaluate("""() => {
const v = document.querySelector('video');
if (!v) return { present: false };
const rect = v.getBoundingClientRect();
return {
present: true,
src: (v.src || '').slice(0, 200),
currentTime: v.currentTime,
paused: v.paused,
ended: v.ended,
readyState: v.readyState,
networkState: v.networkState,
error: v.error ? { code: v.error.code, message: v.error.message } : null,
videoWidth: v.videoWidth,
videoHeight: v.videoHeight,
duration: v.duration,
rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
buffered_ranges: v.buffered.length,
};
}""")
state["pre_marker"] = pre_console_marker
return state
async def run_one(p, user, password, role, run_idx, console_messages, network_failures):
"""Execute the full probe sequence for one user. Returns dict for JSON."""
print(f"\n=== Run {run_idx}: {role} ({user}) ===")
auth = login(user, password)
token = auth["AccessToken"]
user_id = auth["User"]["Id"]
server_id = auth["ServerId"]
is_admin = auth["User"].get("Policy", {}).get("IsAdministrator", False)
print(f"[+] Auth OK uid={user_id} admin={is_admin}")
items = resolve_items(token, user_id)
if not items:
print("[!] No items resolvable — aborting run")
return {"role": role, "user": user, "is_admin": is_admin, "items": [],
"error": "no items"}
print(f"[+] Items: {[(i[1], i[2]) for i in items]}")
runs = []
browser = await p.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-dev-shm-usage",
"--autoplay-policy=no-user-gesture-required"])
ctx = await browser.new_context(
viewport={"width": 1600, "height": 900},
ignore_https_errors=True)
page = await ctx.new_page()
page.on("console", lambda m: console_messages.append(
{"role": role, "user": user, "type": m.type, "text": m.text}))
page.on("requestfailed", lambda r: network_failures.append(
{"role": role, "user": user, "method": r.method, "url": r.url,
"failure": str(r.failure)}))
page.on("response", lambda r: None if r.status < 400 else
network_failures.append({"role": role, "user": user, "status": r.status,
"url": r.url}))
# --- form login (mirrors v1) ---
await page.goto(f"{URL}/web/", wait_until="networkidle", timeout=30000)
await asyncio.sleep(3)
try:
await page.wait_for_selector("input", timeout=20000)
inputs = await page.evaluate(
"() => Array.from(document.querySelectorAll('input')).map(i => "
"({id:i.id, name:i.name, type:i.type, placeholder:i.placeholder}))")
user_sel = pass_sel = None
for i in inputs:
fid, fname, ftype = i.get("id", ""), i.get("name", ""), i.get("type", "")
if not user_sel and (ftype == "text" or "user" in (fid+fname).lower()
or "name" in (fid+fname).lower()):
user_sel = f"#{fid}" if fid else f'input[name="{fname}"]'
if not pass_sel and ftype == "password":
pass_sel = f"#{fid}" if fid else f'input[name="{fname}"]'
if user_sel and pass_sel:
await page.fill(user_sel, user)
await page.fill(pass_sel, password)
await page.keyboard.press("Enter")
await page.wait_for_load_state("networkidle", timeout=20000)
await asyncio.sleep(2)
print(f"[+] form login OK as {user}")
else:
print("[!] login fields not found — continuing with API token")
except Exception as e:
print(f"[!] form login failed: {e}")
for item_id, label, kind in items:
target = f"{URL}/web/#/details?id={item_id}&serverId={server_id}"
print(f"\n[*] {role}/{kind}: {label}{target}")
await page.goto(target, wait_until="networkidle", timeout=30000)
await asyncio.sleep(4)
probe = await probe_dom(page)
viewport = page.viewport_size
vw, vh = viewport["width"], viewport["height"]
# Top + scrolled screenshots
safe_user = user.replace("@", "_").replace("/", "_")
key = f"{safe_user}-{kind}"
top_png = os.path.join(OUT, f"{key}-top.png")
await page.screenshot(path=top_png, full_page=False)
await page.evaluate("() => window.scrollTo(0, document.body.scrollHeight * 0.5)")
await asyncio.sleep(1)
mid_png = os.path.join(OUT, f"{key}-mid.png")
await page.screenshot(path=mid_png, full_page=False)
await page.evaluate("() => window.scrollTo(0, document.body.scrollHeight)")
await asyncio.sleep(1)
bot_png = os.path.join(OUT, f"{key}-bot.png")
await page.screenshot(path=bot_png, full_page=False)
# Background sweep at scroll-bottom (where INC4-style bands manifest)
bg_elements = await sweep_backgrounds(page)
regressions = filter_regressions(bg_elements, vw, vh)
print(f"[*] bg elements: {len(bg_elements)} regressions: {len(regressions)}")
# Click Play and observe
await page.evaluate("() => window.scrollTo(0, 0)")
await asyncio.sleep(1)
play_state = await click_play_and_observe(page)
play_png = os.path.join(OUT, f"{key}-play.png")
await page.screenshot(path=play_png, full_page=False)
# Diff vs golden
diffs = []
for shot in [(top_png, "top"), (mid_png, "mid"), (bot_png, "bot"),
(play_png, "play")]:
golden = os.path.join(OUT, "golden", f"{key}-{shot[1]}.png")
if PIL_OK and os.path.exists(golden):
try:
a = Image.open(shot[0]).convert("RGB")
b = Image.open(golden).convert("RGB")
if a.size != b.size:
diffs.append({"shot": shot[1], "error": "size mismatch"})
continue
diff_img = ImageChops.difference(a, b)
bbox = diff_img.getbbox()
diff_path = os.path.join(OUT, f"{key}-{shot[1]}-diff.png")
diff_img.save(diff_path)
# Numeric mismatch ratio
hist = diff_img.histogram()
nonzero = sum(hist[i] for i in range(1, 256))
total = a.size[0] * a.size[1] * 3
ratio = nonzero / total if total else 0
diffs.append({"shot": shot[1], "bbox": bbox, "ratio": ratio,
"diff_path": diff_path})
except Exception as e:
diffs.append({"shot": shot[1], "error": str(e)})
runs.append({
"item_id": item_id, "label": label, "kind": kind,
"screenshots": {"top": top_png, "mid": mid_png, "bot": bot_png,
"play": play_png},
"probe": probe,
"play": play_state,
"bg_count": len(bg_elements),
"regressions": regressions,
"diffs_vs_golden": diffs,
})
await browser.close()
return {"role": role, "user": user, "is_admin": is_admin,
"items": runs}
def section_title_diff(admin_run, USER-F_run):
"""Return sections present for admin but not USER-F (admin-only carousels)."""
diffs = []
a_items = {i["kind"]: i for i in admin_run.get("items", [])}
g_items = {i["kind"]: i for i in USER-F_run.get("items", [])}
for kind in a_items:
if kind not in g_items:
continue
a_titles = set(a_items[kind].get("probe", {}).get("__sectionTitles", []))
g_titles = set(g_items[kind].get("probe", {}).get("__sectionTitles", []))
only_admin = sorted(a_titles - g_titles)
only_USER-F = sorted(g_titles - a_titles)
if only_admin or only_USER-F:
diffs.append({"kind": kind, "only_admin": only_admin,
"only_USER-F": only_USER-F})
return diffs
def grade(result):
"""Decide pass/fail. Returns (exit_code, summary)."""
issues = []
for run in result["runs"]:
for item in run.get("items", []):
v = item.get("play", {}).get("video", {})
if not v.get("present"):
issues.append(f"{run['user']}/{item['kind']}: <video> absent")
elif v.get("error"):
issues.append(f"{run['user']}/{item['kind']}: video error "
f"code={v['error'].get('code')}")
elif v.get("readyState", 0) < 2:
issues.append(f"{run['user']}/{item['kind']}: video readyState="
f"{v.get('readyState')} (no current data)")
elif v.get("paused") and v.get("currentTime", 0) == 0:
issues.append(f"{run['user']}/{item['kind']}: video paused at t=0")
if item.get("regressions"):
issues.append(f"{run['user']}/{item['kind']}: "
f"{len(item['regressions'])} bg regression(s)")
return (2 if issues else 0, issues)
async def main():
console_messages = []
network_failures = []
print(f"[+] Target: {URL}")
print(f"[+] OUT: {OUT}")
print(f"[+] Admin: {ADMIN_USER}")
print(f"[+] USER-F: {GUEST_USER}")
async with async_playwright() as p:
admin_run = await run_one(p, ADMIN_USER, ADMIN_PASS, "admin", 1,
console_messages, network_failures)
USER-F_run = await run_one(p, GUEST_USER, GUEST_PASS, "USER-F", 2,
console_messages, network_failures)
section_diff = section_title_diff(admin_run, USER-F_run)
result = {
"url": URL,
"timestamp": int(time.time()),
"runs": [admin_run, USER-F_run],
"section_title_diff": section_diff,
"console": console_messages[-200:],
"network_failures": network_failures[-200:],
}
code, issues = grade(result)
result["issues"] = issues
result["exit_code"] = code
with open(os.path.join(OUT, "probe.json"), "w") as f:
json.dump(result, f, indent=2, default=str)
print(f"\n=== SUMMARY ===")
print(f"console: {len(console_messages)} network failures: {len(network_failures)}")
print(f"section diffs: {len(section_diff)}")
if section_diff:
for d in section_diff:
if d["only_admin"]:
print(f" admin-only ({d['kind']}): {d['only_admin']}")
if issues:
print(f"ISSUES ({len(issues)}):")
for i in issues:
print(f" - {i}")
else:
print("no issues detected")
print(f"probe.json: {os.path.join(OUT, 'probe.json')}")
sys.exit(code)
if __name__ == "__main__":
try:
asyncio.run(main())
except urllib.error.HTTPError as e:
print(f"[!] HTTP error during login: {e}")
sys.exit(1)
except Exception as e:
print(f"[!] fatal: {e}")
raise

186
bin/headless-test.py Executable file
View file

@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""ARRFLIX headless smoke-test. Logs in via API, navigates to a detail page,
captures screenshot + console errors + network failures + computed-style for
backdrop. Pass dev or prod URL as argv[1]."""
import sys, json, time, os, asyncio, urllib.request, urllib.error
from playwright.async_api import async_playwright
URL = sys.argv[1] if len(sys.argv) > 1 else "https://dev.arrflix.s8n.ru"
USER = sys.argv[2] if len(sys.argv) > 2 else "USER-F-mirror"
PASS = sys.argv[3] if len(sys.argv) > 3 else "dev-test-USER-F"
ITEM = sys.argv[4] if len(sys.argv) > 4 else None # auto-pick first Series if absent
OUT = sys.argv[5] if len(sys.argv) > 5 else "/tmp/arrflix-headless"
os.makedirs(OUT, exist_ok=True)
DEVICE = "headless-test"
DEVICE_ID = "headless-test-2026-05-09"
CLIENT = "Headless"
VERSION = "1.0"
def auth_header(token=None):
h = (f'MediaBrowser Client="{CLIENT}", Device="{DEVICE}", '
f'DeviceId="{DEVICE_ID}", Version="{VERSION}"')
if token:
h += f', Token="{token}"'
return h
def api_post(path, body, token=None):
req = urllib.request.Request(
f"{URL}{path}",
data=json.dumps(body).encode(),
headers={
"Authorization": auth_header(token),
"Content-Type": "application/json",
},
method="POST",
)
ctx = __import__("ssl")._create_unverified_context()
with urllib.request.urlopen(req, context=ctx) as r:
return json.loads(r.read())
def api_get(path, token=None):
req = urllib.request.Request(
f"{URL}{path}",
headers={"Authorization": auth_header(token)},
)
ctx = __import__("ssl")._create_unverified_context()
with urllib.request.urlopen(req, context=ctx) as r:
return json.loads(r.read())
def login():
r = api_post("/Users/AuthenticateByName",
{"Username": USER, "Pw": PASS})
return r["AccessToken"], r["User"]["Id"], r["ServerId"]
async def main():
token, user_id, server_id = login()
print(f"[+] Authenticated as {USER} ({user_id})")
item_id = ITEM
if not item_id:
items = api_get(
f"/Users/{user_id}/Items?Recursive=true&IncludeItemTypes=Series&Limit=5",
token)
if items.get("Items"):
item_id = items["Items"][0]["Id"]
print(f"[+] Auto-picked Series: {items['Items'][0]['Name']} ({item_id})")
else:
print("[!] No series found, falling back to root")
console_messages = []
network_failures = []
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True,
args=["--no-sandbox", "--disable-dev-shm-usage"])
ctx = await browser.new_context(
viewport={"width": 1600, "height": 900},
ignore_https_errors=True)
page = await ctx.new_page()
page.on("console", lambda m: console_messages.append(
f"[{m.type}] {m.text}"))
page.on("requestfailed", lambda r: network_failures.append(
f"{r.method} {r.url} :: {r.failure}"))
page.on("response", lambda r: None if r.status < 400 else
network_failures.append(f"HTTP {r.status} {r.url}"))
# Auth via login form
await page.goto(f"{URL}/web/", wait_until="networkidle", timeout=30000)
await asyncio.sleep(3)
# Wait for any input rendered by SPA
try:
await page.wait_for_selector("input", timeout=20000)
inputs = await page.evaluate(
"() => Array.from(document.querySelectorAll('input')).map(i => ({id:i.id, name:i.name, type:i.type, placeholder:i.placeholder}))")
print(f"[*] inputs: {inputs}")
# Find username input by heuristic
user_sel = None
pass_sel = None
for i in inputs:
fid, fname, ftype = i.get('id',''), i.get('name',''), i.get('type','')
if not user_sel and (ftype == 'text' or 'user' in (fid+fname).lower() or 'name' in (fid+fname).lower()):
user_sel = f'#{fid}' if fid else f'input[name="{fname}"]'
if not pass_sel and ftype == 'password':
pass_sel = f'#{fid}' if fid else f'input[name="{fname}"]'
print(f"[*] user_sel={user_sel} pass_sel={pass_sel}")
if user_sel and pass_sel:
await page.fill(user_sel, USER)
await page.fill(pass_sel, PASS)
await page.keyboard.press("Enter")
await page.wait_for_load_state("networkidle", timeout=20000)
await asyncio.sleep(2)
print("[+] logged in via form")
else:
print("[!] could not locate login fields")
except Exception as e:
print(f"[!] form login failed: {e}")
# Navigate to detail page
target = (f"{URL}/web/#/details?id={item_id}&serverId={server_id}"
if item_id else f"{URL}/web/")
print(f"[*] navigating: {target}")
await page.goto(target, wait_until="networkidle", timeout=30000)
await asyncio.sleep(4) # let SPA paint backdrop
# Probe key DOM elements (extended)
probe = await page.evaluate("""() => {
const result = {};
const sel = ['.itemBackdrop', '.detailBackdrop', '.backdropContainer',
'.backgroundContainer', '.layout-desktop',
'body', '#reactRoot', '.itemDetailPage',
'video', '.htmlvideoplayer', '.btnPlay', '.detailPagePrimaryContainer',
'.detailSection', '.detailVerticalSection', '.itemsContainer',
'.padded-bottom-page', '.mainAnimatedPages', '.pageContainer',
'.cardScalable', '.scrollSlider', '.sectionTitleContainer',
'.detailPageContent', '.detailPageWrapperContainer'];
for (const s of sel) {
const els = document.querySelectorAll(s);
if (els.length === 0) { result[s] = '<absent>'; continue; }
const el = els[0];
const cs = getComputedStyle(el);
result[s] = {
count: els.length,
display: cs.display,
opacity: cs.opacity,
visibility: cs.visibility,
background: cs.backgroundColor,
backgroundImage: cs.backgroundImage.slice(0, 80),
zIndex: cs.zIndex,
rect: el.getBoundingClientRect().toJSON(),
};
}
result['__title'] = document.title;
const playBtn = document.querySelector('.btnPlay, [data-action="play"]');
result['__playBtnText'] = playBtn ? (playBtn.innerText || playBtn.textContent || '').trim() : null;
result['__bodyClasses'] = document.body.className;
result['__url'] = location.href;
return result;
}""")
# Two screenshots: top viewport + scrolled to mid-page (so fixed backdrop renders correctly)
screenshot = os.path.join(OUT, f"{URL.replace('https://','').replace('.','_')}-detail.png")
await page.screenshot(path=screenshot, full_page=False)
# Scroll halfway down to verify pinned backdrop persists
await page.evaluate("() => window.scrollTo(0, document.body.scrollHeight * 0.5)")
await asyncio.sleep(1)
scrolled = os.path.join(OUT, f"{URL.replace('https://','').replace('.','_')}-scrolled.png")
await page.screenshot(path=scrolled, full_page=False)
print(f"[+] screenshot: {screenshot}")
with open(os.path.join(OUT, "probe.json"), "w") as f:
json.dump({
"url": URL,
"user": USER,
"item": item_id,
"probe": probe,
"console": console_messages[-50:],
"network_failures": network_failures[-50:],
}, f, indent=2)
print(f"[+] probe.json: {os.path.join(OUT, 'probe.json')}")
print(f"[+] console msgs: {len(console_messages)}")
print(f"[+] network failures: {len(network_failures)}")
await browser.close()
asyncio.run(main())

372
bin/inject-middle-theme.py Executable file
View file

@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""Inject the ARRFLIX middle-theme v6 (logo center, Movies/Series left, search right)
into a Jellyfin web overlay's index.html. Idempotent — run repeatedly without drift.
Markers:
/* ARRFLIX-MIDDLE-THEME-BEGIN */ ... /* ARRFLIX-MIDDLE-THEME-END */ inside <style> and <script>
<!--ARRFLIX-FAVICON-BEGIN--> ... <!--ARRFLIX-FAVICON-END--> between <link> tags
Usage:
python3 bin/inject-middle-theme.py [target.html]
ARRFLIX_OVERLAY_PATH=/opt/docker/jellyfin/web-overrides/index.html python3 bin/inject-middle-theme.py
Default target: <repo_root>/web-overrides/index.html
Assets read from <repo_root>/web-overrides/assets/:
- arrflix-A.b64 favicon base64 (no data: prefix)
- arrflix-wordmark.b64-url center-logo data-URL (with data: prefix)
Doc 29 covers the design, the auth gate, and the video-page hide rule.
"""
import os, re, sys, pathlib, time
ROOT = pathlib.Path(__file__).resolve().parent.parent
DEFAULT_TARGET = ROOT / "web-overrides" / "index.html"
ASSETS = ROOT / "web-overrides" / "assets"
target = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path(os.environ.get("ARRFLIX_OVERLAY_PATH", DEFAULT_TARGET))
if not target.exists():
sys.exit(f"target overlay not found: {target}")
logo_a_b64 = (ASSETS / "arrflix-A.b64").read_text(encoding="utf-8").strip()
wordmark_url = (ASSETS / "arrflix-wordmark.b64-url").read_text(encoding="utf-8").strip()
START = "/* ARRFLIX-MIDDLE-THEME-BEGIN */"
END = "/* ARRFLIX-MIDDLE-THEME-END */"
CSS = r"""
/* ===========================================================================
* ARRFLIX MIDDLE-THEME v6 CSS layer model
* ===========================================================================
*
* STACKING ORDER (low high) DO NOT VIOLATE:
*
* layer 0 <html> bg #000 (set via JS inline style; see start())
* black letterbox bars on video page come from here
* layer 1 <body> bg #000 off-video (L1), transparent on-video (L2)
* layer 2 .backgroundContainer Jellyfin backdrop (poster blur), bg propagated from L1/L2
* .skinBody main app shell
* #reactRoot
* layer 3 .mainAnimatedPages page swap container
* .pageContainer current page
* layer 4 .skinHeader top nav (HIDDEN during video see :not(:has(#loginPage)))
* layer 5 .videoPlayerContainer Jellyfin player wrapper (z:1000 by Jellyfin, fixed inset:0)
* video.htmlvideoplayer the <video> element (z:auto, inherits container stack)
* layer 6 .osdControls Jellyfin OSD bar (scrubber, play/pause, settings)
* .videoOsdBottom bottom controls strip
* .upNextDialog episode-up-next overlay
* ALL Jellyfin OSD UI must stay above <video>. Jellyfin sets these
* with z-index > 1000 in stock CSS DO NOT add a higher z-index
* to <video> or .videoPlayerContainer or you cover the controls.
* layer 7 .dialogContainer modal dialogs (settings menu, subtitle picker)
*
* RULE: never z-index <video> or .videoPlayerContainer above 1000.
* Stock Jellyfin OSD controls float on top because their CSS sets
* z-index in the 11002000 range (depending on dialog vs bar).
*
* BLACK-SCREEN-OVER-VIDEO BUG CLASS recurring (5+ times in 24h, doc 26/28/30):
* ANY rule that paints opaque bg on layer 04 ancestors of <video> while
* the player is mounted obscures the decoded frames. Two-layer defence:
*
* L1 (off-video): paint #000 on body+ancestors only when
* body lacks .arrflix-video-active class.
* L2 (on-video): paint transparent on every known ancestor when
* body has .arrflix-video-active. JS toggles this
* class via isVideoPage() which checks hash + DOM.
*
* SPECIFICITY NOTE: L1 (`body.arrflix-themed:not(.arrflix-video-active)`)
* and L2 (`body.arrflix-themed.arrflix-video-active`) both score (0,2,1)
* on body. Equal specificity source order decides. L2 listed AFTER L1
* in this file L2 wins when video-active. Good.
*
* BEFORE ADDING ANY NEW BG-COLOR RULE: ask "does this paint an ancestor of
* <video>?" If yes, scope it with `:not(.arrflix-video-active)`. Otherwise
* you reopen the black-screen bug. See doc 31 LAYER-MODEL.
* ===========================================================================
*/
/* --- HEADER LAYOUT ------------------------------------------------------ */
/* Three-column flex: nav-left | logo-center (absolute) | search-right */
body.arrflix-themed .skinHeader .headerTop{display:flex!important;align-items:center;position:relative;min-height:48px}
body.arrflix-themed .skinHeader .headerLeft,
body.arrflix-themed .skinHeader .headerRight{flex:1 1 0;display:flex;align-items:center}
body.arrflix-themed .skinHeader .headerLeft{justify-content:flex-start;gap:.4em}
body.arrflix-themed .skinHeader .headerRight{justify-content:flex-end}
/* Hide stock Jellyfin header chrome we don't want */
body.arrflix-themed .skinHeader .headerHomeButton,
body.arrflix-themed .skinHeader .pageTitleWithLogo,
body.arrflix-themed .skinHeader .headerBackButton{display:none!important}
body.arrflix-themed .skinHeader .headerLeft > h3.pageTitle:not(.pageTitleWithLogo){display:none!important}
body.arrflix-themed .skinHeader .headerCastButton,
body.arrflix-themed .skinHeader .headerSyncButton{display:none!important}
body.arrflix-themed .headerTabs.sectionTabs{display:none!important}
/* Hide 'My Media' row (.section0) Continue Watching=section1, Next Up=section5, Recently Added=section6 unaffected */
body.arrflix-themed .homePage .homeSectionsContainer .verticalSection.section0{display:none!important}
/* Header itself disappears during video :not(:has(#loginPage)) keeps login pre-arrflix-themed render unaffected */
body.arrflix-video-active:not(:has(#loginPage:not(.hide))) .skinHeader,
body.arrflix-video-active .arrflix-headerLogo,
body.arrflix-video-active .arrflix-nav{display:none!important}
/* Center wordmark logo absolute pos, dead-center of headerTop */
.arrflix-headerLogo{
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
width:120px;height:38px;
background:center/contain no-repeat url('__WORDMARK_URL__');
z-index:1;display:block;text-indent:-9999px;overflow:hidden;
}
.arrflix-headerLogo:hover{filter:brightness(1.15)}
/* Movies / Series nav links */
.arrflix-nav{
text-transform:uppercase;letter-spacing:.08em;font-weight:600;
padding:0 .9em;color:#fff!important;text-decoration:none;
display:inline-flex;align-items:center;height:100%;font-size:.85em;
transition:color .18s ease,text-shadow .18s ease,font-weight .18s ease;
}
.arrflix-nav:hover{color:#E50914!important}
/* Variant E: cinematic glow on active route toggled by JS on hashchange */
.arrflix-nav.active{
color:#E50914!important;font-weight:700;
text-shadow:0 0 12px rgba(229,9,20,0.55),0 0 24px rgba(229,9,20,0.25);
}
/* --- L1: PURE-BLACK BG (off-video only) -------------------------------- */
/* Fires when body does NOT have .arrflix-video-active.
* Specificity (0,2,1) on body / (0,3,1) on descendants.
* <html> selector has no :not() so html stays #000 always. */
html,
body.arrflix-themed:not(.arrflix-video-active),
body.arrflix-themed:not(.arrflix-video-active) .backgroundContainer,
body.arrflix-themed:not(.arrflix-video-active) .skinBody,
body.arrflix-themed:not(.arrflix-video-active) .mainAnimatedPage,
body.arrflix-themed:not(.arrflix-video-active) .mainAnimatedPages,
body.arrflix-themed:not(.arrflix-video-active) .pageContainer,
body.arrflix-themed:not(.arrflix-video-active) #reactRoot{background-color:#000!important}
body.arrflix-themed:not(.arrflix-video-active) .backgroundContainer.withBackdrop{background-color:rgba(0,0,0,.86)!important}
/* --- L2: TRANSPARENT ANCESTORS (during video playback) ----------------- */
/* Fires when JS sets body.arrflix-video-active. Specificity matched to L1
* (0,2,1 on body, 0,3,1 on descendants). L2 wins on source order listed
* AFTER L1 in this file. Without this, opaque ancestor bg paints over <video>.
*
* NOTE: <html> bg is pinned via JS inline style (start()) so letterbox bars
* stay BLACK even though html selector is in L1's gated rule above.
*
* Targets every known ancestor of <video.htmlvideoplayer>:
* body, .backgroundContainer, .skinBody, .mainAnimatedPage(s), .pageContainer,
* #reactRoot, .videoPlayerContainer (Jellyfin wrapper, z:1000),
* .videoPlayerContainer-onTop, #videoOsdPage + descendants, .libraryPage,
* <video> itself.
*
* DO NOT add z-index to anything in this block. OSD controls (layer 6) sit
* above <video> via Jellyfin's stock z-index 1100+. Lifting <video> z-index
* obscures controls see image #12 incident. */
body.arrflix-themed.arrflix-video-active,
body.arrflix-themed.arrflix-video-active .backgroundContainer,
body.arrflix-themed.arrflix-video-active .skinBody,
body.arrflix-themed.arrflix-video-active .mainAnimatedPage,
body.arrflix-themed.arrflix-video-active .mainAnimatedPages,
body.arrflix-themed.arrflix-video-active .pageContainer,
body.arrflix-themed.arrflix-video-active #reactRoot,
body.arrflix-themed.arrflix-video-active .videoPlayerContainer,
body.arrflix-themed.arrflix-video-active .videoPlayerContainer-onTop,
body.arrflix-themed.arrflix-video-active #videoOsdPage,
body.arrflix-themed.arrflix-video-active #videoOsdPage .pageContainer,
body.arrflix-themed.arrflix-video-active #videoOsdPage .mainAnimatedPage,
body.arrflix-themed.arrflix-video-active #videoOsdPage .layout-desktop,
body.arrflix-themed.arrflix-video-active .libraryPage,
body.arrflix-themed.arrflix-video-active video.htmlvideoplayer{
background-color:transparent!important;
background:transparent!important;
background-image:none!important;
}
/* --- ACTION-SHEET SELECTOR (audio/subtitle dropdowns) ----------------- *
* Stock Jellyfin theme.css paints `.listItem.selected` and `.focused` in
* cyan (#00a4dc) — clashes with Cineplex red. Override to "Hairline ring"
* variant: 1px red outline (offset -1px so it sits inside the row) + dark
* near-black bg + white text. Architectural, quietly on-brand.
*
* Targets every Jellyfin picker/dropdown form:
* .actionSheet .listItem(.selected|.focused) modal action sheets (audio/sub)
* .selectionList .listItem.selected legacy selection lists
* .dialogContainer .listItem.selected dialog-scoped selectors
*
* Future swap: see web-overrides/skins/selector-variant-02-red-underline.css
* for the alternative "red underline" design (matches search-input focus).
*/
body.arrflix-themed .actionSheet .listItem.selected,
body.arrflix-themed .actionSheet .listItem-button.selected,
body.arrflix-themed .actionSheet .listItem.focused,
body.arrflix-themed .selectionList .listItem.selected,
body.arrflix-themed .dialogContainer .listItem.selected{
outline:1px solid #E50914!important;
outline-offset:-1px;
background:rgba(15,15,15,.7)!important;
color:#fff!important;
}
/* --- SEARCH INPUT (cyan ring red underline) ------------------------- */
/* Stock Jellyfin theme.css:262-272 sets blue focus ring (#00a4dc).
* Replace with borderless slab + red bottom border + soft red glow.
* Cineplex/Netflix-faithful. */
body.arrflix-themed .searchFields .emby-input,
body.arrflix-themed input.searchfields-txtSearch,
body.arrflix-themed #searchTextInput{
background:#141414!important;border:0!important;
border-bottom:2px solid transparent!important;border-radius:2px!important;
color:#fff!important;padding:.55em .8em!important;
transition:border-color .18s ease,box-shadow .18s ease,background-color .18s ease;
}
body.arrflix-themed .searchFields .emby-input::placeholder,
body.arrflix-themed input.searchfields-txtSearch::placeholder,
body.arrflix-themed #searchTextInput::placeholder{color:rgba(255,255,255,.4);letter-spacing:.02em}
body.arrflix-themed .searchFields .emby-input:hover,
body.arrflix-themed input.searchfields-txtSearch:hover,
body.arrflix-themed #searchTextInput:hover{background:#1a1a1a!important}
body.arrflix-themed .searchFields .emby-input:focus,
body.arrflix-themed input.searchfields-txtSearch:focus,
body.arrflix-themed #searchTextInput:focus{
background:#1a1a1a!important;border:0!important;
border-bottom:2px solid #E50914!important;
box-shadow:0 1px 0 0 rgba(229,9,20,.35),0 0 14px -2px rgba(229,9,20,.35)!important;
outline:none!important;
}
""".replace("__WORDMARK_URL__", wordmark_url)
JS = """
/* ARRFLIX middle-theme JS shim runtime DOM mutations + body-class toggles.
*
* BODY CLASSES we manage:
* .arrflix-themed set when isAuthed() = true. Gates the entire theme.
* Removed on logout/login route; CSS rules disable.
* .arrflix-video-active set when isVideoPage() = true. Gates L2 transparency
* and hides .skinHeader. Toggled live on hashchange
* + every 1.5s tick + on every body-mutation.
*
* INLINE STYLE on <html>:
* We force background-color:#000 via setProperty(...,'important') because
* getComputedStyle(html).backgroundColor inexplicably returned rgba(0,0,0,0)
* on details/video pages despite 5 stylesheet rules saying #000 !important.
* Inline style is the highest specificity short of !important user-agent.
* This guarantees the canvas behind any transparent body stays BLACK
* (so video-page letterbox bars are black, not browser-default white).
*/
(function(){
function isVideoPage(){
/* Returns true if the user is currently on a video-playback page.
* Three signals (any one is enough):
* 1. URL hash contains '/video' (Jellyfin's video route)
* 2. #videoOsdPage element is visible (the OSD page id Jellyfin mounts)
* 3. video.htmlvideoplayer (lowercase!) element is visible
* NOTE: '.htmlVideoPlayer' (camelCase) does NOT exist in Jellyfin 10.10.
* The real class is lowercase 'htmlvideoplayer'.
*/
try{
var h=(location.hash||'').toLowerCase();
if (h.indexOf('/video') !== -1) return true;
var osd = document.querySelector('#videoOsdPage:not(.hide)');
if (osd) return true;
var v = document.querySelector('video.htmlvideoplayer:not(.hide)');
if (v && getComputedStyle(v).display !== 'none') return true;
}catch(e){}
return false;
}
function isAuthed(){
try{
if (document.querySelector('.pageContainer.loginPage:not(.hide)')) return false;
if (document.querySelector('#loginPage:not(.hide)')) return false;
var h = (location.hash || '').toLowerCase();
if (h.indexOf('/login') !== -1 || h.indexOf('/wizard') !== -1 || h.indexOf('/forgotpassword') !== -1 || h.indexOf('/selectserver') !== -1) return false;
if (window.ApiClient && typeof window.ApiClient.isLoggedIn === 'function' && !window.ApiClient.isLoggedIn()) return false;
var raw = localStorage.getItem('jellyfin_credentials');
if (!raw) return false;
var creds = JSON.parse(raw);
if (!creds || !creds.Servers || !creds.Servers.length || !creds.Servers[0].AccessToken) return false;
return true;
} catch(e){ return false; }
}
function teardown(){
document.body.classList.remove('arrflix-themed');
var top = document.querySelector('.skinHeader .headerTop'); if (!top) return;
var logo = top.querySelector('.arrflix-headerLogo'); if (logo) logo.remove();
Array.prototype.forEach.call(document.querySelectorAll('.arrflix-nav'), function(n){ n.remove(); });
}
function relayoutHeader(){
document.body.classList.toggle('arrflix-video-active', isVideoPage());
if (!isAuthed()) { teardown(); return; }
var top=document.querySelector('.skinHeader .headerTop'); if(!top) return;
document.body.classList.add('arrflix-themed');
var left=top.querySelector('.headerLeft');
if(left && !left.querySelector('[data-arrflix-nav=\"movies\"]')){
left.insertAdjacentHTML('beforeend',
'<a is=\"emby-linkbutton\" class=\"emby-button arrflix-nav\" data-arrflix-nav=\"movies\" href=\"#/movies.html\">Movies</a>'+
'<a is=\"emby-linkbutton\" class=\"emby-button arrflix-nav\" data-arrflix-nav=\"series\" href=\"#/tv.html\">Series</a>'
);
}
if(!top.querySelector('.arrflix-headerLogo')){
var a=document.createElement('a');
a.className='arrflix-headerLogo';
a.href='#/home.html';
a.setAttribute('aria-label','ARRFLIX home');
a.textContent='ARRFLIX';
var right=top.querySelector('.headerRight');
top.insertBefore(a, right || null);
}
var hash=(location.hash||'').toLowerCase();
var movieMatch=(hash==='#/movies.html'||hash==='#/movies');
var seriesMatch=(hash==='#/tv.html'||hash==='#/tv');
Array.prototype.forEach.call(document.querySelectorAll('[data-arrflix-nav=\"movies\"]'),function(n){ n.classList.toggle('active',movieMatch); });
Array.prototype.forEach.call(document.querySelectorAll('[data-arrflix-nav=\"series\"]'),function(n){ n.classList.toggle('active',seriesMatch); });
}
function start(){
try{ document.documentElement.style.setProperty('background-color','#000','important'); }catch(e){}
relayoutHeader();
try{ new MutationObserver(relayoutHeader).observe(document.body,{childList:true,subtree:true}); }catch(e){}
window.addEventListener('hashchange', relayoutHeader);
setInterval(relayoutHeader,1500);
}
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',start,{once:true}); else start();
})();
"""
FAVICON_LINKS = (
"<!--ARRFLIX-FAVICON-BEGIN-->"
"<link rel=\"icon\" type=\"image/png\" sizes=\"180x180\" data-arrflix-icon=\"A\" href=\"data:image/png;base64," + logo_a_b64 + "\">"
"<link rel=\"apple-touch-icon\" sizes=\"180x180\" data-arrflix-icon=\"A\" href=\"data:image/png;base64," + logo_a_b64 + "\">"
"<!--ARRFLIX-FAVICON-END-->"
)
FAVICON_HIJACK_JS = (
"<script>/* ARRFLIX-FAVICON-HIJACK-BEGIN */"
"(function(){"
"var A_URL='data:image/png;base64," + logo_a_b64 + "';"
"function pin(){"
"Array.prototype.forEach.call(document.querySelectorAll('link[rel=\"shortcut icon\"], link[rel=\"icon\"], link[rel=\"apple-touch-icon\"]'),function(l){"
"if(l.getAttribute('data-arrflix-icon')==='A')return;"
"if((l.href||'').indexOf('data:image/png')!==-1 && l.href.length>200 && l.getAttribute('data-arrflix-icon')!=='A'){l.parentNode&&l.parentNode.removeChild(l);}"
"});"
"Array.prototype.forEach.call(document.querySelectorAll('link[data-arrflix-icon=\"A\"]'),function(l){if(l.href!==A_URL) l.href=A_URL;});"
"}"
"function start(){pin();try{new MutationObserver(pin).observe(document.head||document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:['href']});}catch(e){}setInterval(pin,1000);}"
"if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',start,{once:true});else start();"
"})();"
"/* ARRFLIX-FAVICON-HIJACK-END */</script>"
)
src = target.read_text(encoding="utf-8")
src = re.sub(re.escape("<style>" + START) + r".*?" + re.escape(END + "</style>"), "", src, flags=re.DOTALL)
src = re.sub(re.escape("<script>" + START) + r".*?" + re.escape(END + "</script>"), "", src, flags=re.DOTALL)
src = re.sub(r"<!--ARRFLIX-FAVICON-BEGIN-->.*?<!--ARRFLIX-FAVICON-END-->", "", src, flags=re.DOTALL)
src = re.sub(r"<script>/\* ARRFLIX-FAVICON-HIJACK-BEGIN \*/.*?/\* ARRFLIX-FAVICON-HIJACK-END \*/</script>", "", src, flags=re.DOTALL)
PATCH = "<style>" + START + CSS + END + "</style>" + "<script>" + START + JS + END + "</script>" + FAVICON_LINKS + FAVICON_HIJACK_JS
if "</head>" not in src:
sys.exit("no </head> in target")
src2 = src.replace("</head>", PATCH + "</head>", 1)
backup = target.with_suffix(target.suffix + f".bak.pre-middle-v6.{int(time.time())}")
backup.write_text(target.read_text(encoding="utf-8"), encoding="utf-8")
target.write_text(src2, encoding="utf-8")
print(f"OK v6 wrote {len(src2)} bytes to {target}; backup at {backup}")

View file

@ -14,6 +14,104 @@ SHIM = MARKER_BEGIN + r"""
(function(){
var TITLE = 'ARRFLIX';
var BARE_RE = /^Jellyfin$/i;
/* === English-lockdown (synchronous, runs before Jellyfin bundle) ===
Pins UI locale to en-US so the SPA never reads navigator.language
or the user's stored preference. Belt-and-braces against:
- localStorage keys the SPA reads on boot
- navigator.language / navigator.languages getters
- fetch / XHR Accept-Language header (best-effort; most browsers
block JS from setting it, but Jellyfin sometimes does)
- user-config save round-trip (rewrite UICulture en-US before send) */
try {
var LS_KEYS = ['appLanguage','selectedlanguage','selectedlocale','language','locale','culture'];
for (var i=0;i<LS_KEYS.length;i++){
try { localStorage.setItem(LS_KEYS[i], 'en-US'); } catch(e){}
}
} catch(e){}
try {
var EN = ['en-US','en'];
Object.defineProperty(Navigator.prototype, 'language', { get:function(){return 'en-US';}, configurable:true });
Object.defineProperty(Navigator.prototype, 'languages', { get:function(){return EN.slice();}, configurable:true });
} catch(e){
/* fallback for engines that won't let us redefine on the prototype */
try { Object.defineProperty(navigator, 'language', { get:function(){return 'en-US';}, configurable:true }); } catch(e2){}
try { Object.defineProperty(navigator, 'languages', { get:function(){return ['en-US','en'];}, configurable:true }); } catch(e2){}
}
/* fetch wrapper: strip Accept-Language on outbound requests, and rewrite
any user-config save body so UICulture is pinned to en-US. */
try {
if (window.fetch) {
var _origFetch = window.fetch;
window.fetch = function(input, init){
try {
init = init || {};
/* strip Accept-Language if present on a plain object headers init */
if (init.headers) {
if (init.headers instanceof Headers) {
try { init.headers.delete('Accept-Language'); } catch(e){}
} else if (typeof init.headers === 'object') {
for (var k in init.headers){ if (k && k.toLowerCase() === 'accept-language') { try { delete init.headers[k]; } catch(e){} } }
}
}
/* rewrite user-config save: POST /Users/{id}/Configuration */
var url = (typeof input === 'string') ? input : (input && input.url) || '';
var method = (init.method || (input && input.method) || 'GET').toUpperCase();
if (url && /\/Users\/[^/]+\/Configuration(\?|$)/.test(url) && method === 'POST' && init.body) {
try {
var body = init.body;
if (typeof body === 'string') {
var obj = JSON.parse(body);
if (obj && typeof obj === 'object') {
obj.UICulture = 'en-US';
init.body = JSON.stringify(obj);
}
}
} catch(e){}
}
} catch(e){}
return _origFetch.call(this, input, init);
};
}
} catch(e){}
/* XHR wrapper: strip Accept-Language; rewrite user-config save body. */
try {
if (window.XMLHttpRequest) {
var _open = XMLHttpRequest.prototype.open;
var _setHeader = XMLHttpRequest.prototype.setRequestHeader;
var _send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url){
this.__arrflix_method = (method || 'GET').toUpperCase();
this.__arrflix_url = url || '';
return _open.apply(this, arguments);
};
XMLHttpRequest.prototype.setRequestHeader = function(name, value){
if (name && String(name).toLowerCase() === 'accept-language') return;
return _setHeader.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body){
try {
if (this.__arrflix_url && /\/Users\/[^/]+\/Configuration(\?|$)/.test(this.__arrflix_url) && this.__arrflix_method === 'POST' && typeof body === 'string') {
try {
var obj = JSON.parse(body);
if (obj && typeof obj === 'object') {
obj.UICulture = 'en-US';
body = JSON.stringify(obj);
}
} catch(e){}
}
} catch(e){}
return _send.call(this, body);
};
}
} catch(e){}
/* Re-pin localStorage on every visibility change (SPA may rewrite on user save) */
function pinLocale(){
try {
var L = ['appLanguage','selectedlanguage','selectedlocale','language','locale','culture'];
for (var i=0;i<L.length;i++){ try { if (localStorage.getItem(L[i]) !== 'en-US') localStorage.setItem(L[i], 'en-US'); } catch(e){} }
} catch(e){}
}
/* === end english-lockdown synchronous block === */
function getFavicon(){
var l = document.querySelector('link[rel="shortcut icon"], link[rel="icon"]');
return l && l.href ? l.href : null;
@ -49,7 +147,7 @@ SHIM = MARKER_BEGIN + r"""
} catch(e){}
}
function start(){
lockTitle(); lockFavicon(); nukeSettings();
lockTitle(); lockFavicon(); nukeSettings(); pinLocale();
try {
var head = document.head || document.querySelector('head');
if (head && window.MutationObserver) {
@ -67,6 +165,7 @@ SHIM = MARKER_BEGIN + r"""
var fav = getFavicon();
if (fav && fav.indexOf('data:image') !== 0) lockFavicon();
nukeSettings();
pinLocale();
}, 1000);
}
if (document.readyState === 'loading') {

665
bin/prod-vs-dev-compare.py Executable file
View file

@ -0,0 +1,665 @@
#!/usr/bin/env python3
"""ARRFLIX prod-vs-dev playback divergence test (2026-05-09).
Runs the SAME flow against arrflix.s8n.ru (prod) and dev.arrflix.s8n.ru (dev)
for the same physical file (Mike Nolan Show S01E04 Ding Dong Delli.mkv,
H.264+AAC) and produces a side-by-side diff:
- URL of master.m3u8 / Videos/{id}/stream
- PlaybackInfo response MediaSources[0] (DirectPlay/DirectStream/Transcode)
- Final <video> element state at t=5/10/20/30s after Play
- Server ffmpeg cmdline (if transcoding) from docker logs
- HTTP status of all /Videos /Items /master.m3u8 /PlaybackInfo /Audio
/stream requests
Artifacts: /tmp/arrflix-prod-vs-dev/{prod,dev}/{...} + diff.json + diff.md.
Run:
bin/prod-vs-dev-compare.py
"""
import sys, os, json, time, asyncio, ssl, urllib.request, urllib.error, urllib.parse, subprocess, re
from pathlib import Path
from playwright.async_api import async_playwright
OUT = "/tmp/arrflix-prod-vs-dev"
os.makedirs(OUT, exist_ok=True)
SIDES = [
{"side": "prod", "url": "https://arrflix.s8n.ru", "user": "s8n", "pw": "2001dude",
"container": "jellyfin"},
{"side": "dev", "url": "https://dev.arrflix.s8n.ru", "user": "test", "pw": "2001dude",
"container": "jellyfin-dev"},
]
ITEM_ID = "9312799ca24979bd05aad9733ce7ee14" # MNS S01E04 (same on both sides)
ITEM_LABEL = "Mike Nolan Show — S01E04 (Ding Dong Delli)"
DEVICE_ID = "prodvsdev-2026-05-09"
CLIENT = "ProdVsDev"
APIKEY_NAME = "arrflix-prodvsdev-2026-05-09"
CTX = ssl._create_unverified_context()
# ------------------- HTTP helpers -------------------
def auth_h(token=None):
h = (f'MediaBrowser Client="{CLIENT}", Device="cli", DeviceId="{DEVICE_ID}", '
f'Version="1.0"')
if token:
h += f', Token="{token}"'
return h
def http(url, path, method="GET", body=None, token=None):
data = json.dumps(body).encode() if body is not None else None
headers = {
"Authorization": auth_h(token),
"Content-Type": "application/json",
}
req = urllib.request.Request(
f"{url}{path}", data=data, headers=headers, method=method)
raw = urllib.request.urlopen(req, context=CTX, timeout=20).read()
return json.loads(raw) if raw else {}
def login(url, user, pw):
last_err = None
for attempt in range(3):
try:
return http(url, "/Users/AuthenticateByName", "POST",
{"Username": user, "Pw": pw})
except urllib.error.HTTPError as e:
last_err = e
if e.code in (500, 503):
time.sleep(3); continue
raise
raise last_err
def playbackinfo(url, item_id, user_id, token):
"""Mimic the web-client's /PlaybackInfo POST body for a generic browser."""
body = {
"DeviceProfile": {
"MaxStreamingBitrate": 140000000,
"MaxStaticBitrate": 100000000,
"MusicStreamingTranscodingBitrate": 384000,
"DirectPlayProfiles": [
{"Container": "mp4,m4v", "Type": "Video",
"VideoCodec": "h264,hevc,vp9,av1",
"AudioCodec": "aac,mp3,ac3,eac3,opus,flac"},
{"Container": "mkv", "Type": "Video",
"VideoCodec": "h264,hevc,vp9,av1",
"AudioCodec": "aac,mp3,ac3,eac3,opus,flac"},
{"Container": "webm", "Type": "Video",
"VideoCodec": "vp9,av1", "AudioCodec": "opus,vorbis"},
],
"TranscodingProfiles": [
{"Container": "ts", "Type": "Video", "VideoCodec": "h264",
"AudioCodec": "aac", "Protocol": "hls", "Context": "Streaming",
"MaxAudioChannels": "2"},
{"Container": "mp4", "Type": "Video", "VideoCodec": "h264",
"AudioCodec": "aac", "Context": "Static",
"MaxAudioChannels": "2"},
],
"ContainerProfiles": [],
"CodecProfiles": [],
"SubtitleProfiles": [
{"Format": "vtt", "Method": "External"},
{"Format": "srt", "Method": "External"},
],
},
"AutoOpenLiveStream": True,
"IsPlayback": True,
}
return http(url, f"/Items/{item_id}/PlaybackInfo?UserId={user_id}",
"POST", body, token=token)
def make_apikey(url, token, name=APIKEY_NAME):
"""Issue an API key. Jellyfin only takes the name in query string."""
try:
http(url, f"/Auth/Keys?App={name}", "POST", token=token)
except urllib.error.HTTPError:
pass
keys = http(url, "/Auth/Keys", token=token)
for k in keys.get("Items", []):
if k.get("AppName") == name:
return k.get("AccessToken")
return None
def del_apikey(url, token, name=APIKEY_NAME):
try:
keys = http(url, "/Auth/Keys", token=token)
for k in keys.get("Items", []):
if k.get("AppName") == name:
http(url, f"/Auth/Keys/{k['AccessToken']}", "DELETE", token=token)
except Exception as e:
print(f"[!] del_apikey({name}): {e}")
# ------------------- Playwright run -------------------
async def run_side(p, side_cfg):
side = side_cfg["side"]; url = side_cfg["url"]
user = side_cfg["user"]; pw = side_cfg["pw"]
side_dir = os.path.join(OUT, side)
os.makedirs(side_dir, exist_ok=True)
# API login
auth = login(url, user, pw)
token = auth["AccessToken"]; uid = auth["User"]["Id"]
server_id = auth["ServerId"]
is_admin = auth["User"].get("Policy", {}).get("IsAdministrator", False)
print(f"\n=== {side} === user={user} uid={uid} admin={is_admin}")
# API-side PlaybackInfo (independent of browser, for canonical record)
pbi_api = playbackinfo(url, ITEM_ID, uid, token)
with open(os.path.join(side_dir, "playbackinfo-api.json"), "w") as f:
json.dump(pbi_api, f, indent=2)
ms = pbi_api.get("MediaSources", [])
if ms:
m = ms[0]
print(f"[{side}] PlaybackInfo (API): DirectPlay={m.get('SupportsDirectPlay')} "
f"DirectStream={m.get('SupportsDirectStream')} "
f"Transcoding={m.get('SupportsTranscoding')} "
f"transcodeUrl={m.get('TranscodingUrl','-')[:80]}")
# API key for this run (caller asked, even if not strictly needed here)
apikey = make_apikey(url, token)
print(f"[{side}] api key: {apikey[:8] if apikey else None}")
# Browser pass
browser = await p.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-dev-shm-usage",
"--autoplay-policy=no-user-gesture-required",
"--use-fake-ui-for-media-stream"])
ctx = await browser.new_context(
viewport={"width": 1600, "height": 900},
ignore_https_errors=True)
page = await ctx.new_page()
requests, responses, console = [], [], []
pbi_response_bodies = []
def on_request(req):
u = req.url
if any(x in u for x in ["/Videos/", "/Items/", "/master.m3u8",
"/PlaybackInfo", "/Audio/", "/stream"]):
requests.append({"method": req.method, "url": u,
"post": req.post_data[:300] if req.post_data else None})
page.on("request", on_request)
async def on_response(r):
u = r.url
if any(x in u for x in ["/Videos/", "/Items/", "/master.m3u8",
"/PlaybackInfo", "/Audio/", "/stream"]):
entry = {"method": r.request.method, "url": u, "status": r.status}
responses.append(entry)
if "/PlaybackInfo" in u and r.request.method == "POST":
try:
body = await r.json()
pbi_response_bodies.append({"url": u, "body": body})
except Exception:
pass
page.on("response", lambda r: asyncio.create_task(on_response(r)))
page.on("console", lambda m: console.append({"type": m.type,
"text": m.text[:300]}))
# Form login (handles both manual-form and user-avatar landing pages)
await page.goto(f"{url}/web/", wait_until="networkidle", timeout=30000)
await asyncio.sleep(3)
# If we landed on the avatar/user-list selection screen, click "Manual Login"
try:
manual = await page.query_selector(".manualLoginForm a, .btnManual, a.button-link")
if manual:
txt = (await manual.inner_text()).strip().lower()
if "manual" in txt:
await manual.click()
await asyncio.sleep(2)
# Or there might be a direct "Manual Login" button on the avatar grid
manual_btn = await page.query_selector("text=/Manual Login/i")
if manual_btn:
try:
await manual_btn.click(timeout=2000); await asyncio.sleep(1)
except Exception:
pass
except Exception as e:
print(f"[{side}] manual-login click attempt: {e}")
try:
await page.wait_for_selector("input[type=password]", timeout=15000)
# Use the canonical Jellyfin login fields
u_sel = "#txtManualName"
pw_sel = "#txtManualPassword"
# Fall back to dynamic discovery if the canonical IDs are absent
if not await page.query_selector(u_sel):
inputs = await page.evaluate(
"() => Array.from(document.querySelectorAll('input')).map(i => "
"({id:i.id, name:i.name, type:i.type}))")
u_sel = pw_sel = None
for i in inputs:
fid, fname, ftype = i.get("id", ""), i.get("name", ""), i.get("type", "")
if not u_sel and (ftype == "text" or "user" in (fid+fname).lower()
or "name" in (fid+fname).lower()):
u_sel = f"#{fid}" if fid else f'input[name="{fname}"]'
if not pw_sel and ftype == "password":
pw_sel = f"#{fid}" if fid else f'input[name="{fname}"]'
await page.fill(u_sel, user)
await page.fill(pw_sel, pw)
await page.keyboard.press("Enter")
await page.wait_for_load_state("networkidle", timeout=20000)
await asyncio.sleep(3)
print(f"[{side}] form login OK as {user}")
except Exception as e:
print(f"[{side}] form login error: {e}")
# Navigate to detail page
target = f"{url}/web/#/details?id={ITEM_ID}&serverId={server_id}"
print(f"[{side}] goto {target}")
await page.goto(target, wait_until="networkidle", timeout=30000)
await asyncio.sleep(4)
await page.screenshot(path=os.path.join(side_dir, "detail.png"))
# Click Play
play_clicked = False
used_sel = None
for sel in [".btnPlay", "[data-action=\"play\"]"]:
try:
btn = await page.query_selector(sel)
if btn:
box = await btn.bounding_box()
if box and box["width"] > 0:
await btn.click(timeout=5000)
play_clicked = True; used_sel = sel; break
except Exception:
pass
if not play_clicked:
try:
await page.keyboard.press("p"); play_clicked = True; used_sel = "kbd:p"
except Exception:
pass
print(f"[{side}] play clicked={play_clicked} via={used_sel}")
# Sample state at t=5/10/20/30s
timestamps = [5, 10, 20, 30]
samples = []
last = 0
for t in timestamps:
await asyncio.sleep(t - last)
last = t
snap = await page.evaluate("""() => {
const v = document.querySelector('video');
if (!v) return { present: false };
// Sample whether the <video> is painting actual pixels by drawing
// a thumbnail to a hidden canvas and checking the average luma.
// If the average is ~0 (or all-near-zero), the video element is
// rendering opaque black despite claiming to play.
let paintLuma = null, paintRGBSum = null, paintOk = null, paintErr = null;
try {
const c = document.createElement('canvas');
c.width = 32; c.height = 18;
const ctx = c.getContext('2d', { willReadFrequently: true });
ctx.drawImage(v, 0, 0, 32, 18);
const d = ctx.getImageData(0, 0, 32, 18).data;
let r=0,g=0,b=0,n=0;
for (let i=0;i<d.length;i+=4){r+=d[i];g+=d[i+1];b+=d[i+2];n++;}
paintLuma = (0.299*r + 0.587*g + 0.114*b) / n;
paintRGBSum = (r+g+b)/n;
paintOk = paintLuma > 4; // > a few luma above pure black
} catch (e) { paintErr = String(e); }
// Stacking diagnosis: who's on top of the video center?
const r = v.getBoundingClientRect();
const cx = r.x + r.width/2, cy = r.y + r.height/2;
let stackAtVideoCenter = [];
try {
const els = (typeof document.elementsFromPoint === 'function')
? document.elementsFromPoint(cx, cy) : [];
stackAtVideoCenter = els.slice(0, 6).map(e => {
const cs = getComputedStyle(e);
return {
tag: e.tagName.toLowerCase(),
id: e.id || null,
cls: (typeof e.className === 'string' ? e.className : '').slice(0, 80),
bg: cs.backgroundColor,
opacity: cs.opacity,
zIndex: cs.zIndex,
position: cs.position,
isVideo: e === v,
};
});
} catch (e) {}
const osd = document.getElementById('videoOsdPage');
const osdInfo = osd ? {
bg: getComputedStyle(osd).backgroundColor,
display: getComputedStyle(osd).display,
opacity: getComputedStyle(osd).opacity,
position: getComputedStyle(osd).position,
zIndex: getComputedStyle(osd).zIndex,
cls: osd.className,
rect: osd.getBoundingClientRect().toJSON ? osd.getBoundingClientRect().toJSON() : null,
} : null;
return {
present: true,
src: v.src || '',
currentSrc: v.currentSrc || '',
currentTime: v.currentTime,
duration: v.duration,
paused: v.paused,
ended: v.ended,
readyState: v.readyState,
networkState: v.networkState,
error: v.error ? { code: v.error.code, message: v.error.message } : null,
videoWidth: v.videoWidth,
videoHeight: v.videoHeight,
bufferedRanges: v.buffered.length,
bufferedEnd: v.buffered.length ? v.buffered.end(v.buffered.length-1) : 0,
paintLuma, paintRGBSum, paintOk, paintErr,
stackAtVideoCenter,
videoOsdPage: osdInfo,
};
}""")
samples.append({"t": t, "video": snap})
await page.screenshot(path=os.path.join(side_dir, f"play-t{t}.png"))
ct = snap.get('currentTime')
ct_s = f"{ct:.2f}" if isinstance(ct, (int, float)) else str(ct)
pl = snap.get('paintLuma')
pl_s = f"{pl:.1f}" if isinstance(pl, (int, float)) else str(pl)
print(f"[{side}] t={t}s: time={ct_s} "
f"paused={snap.get('paused')} err={snap.get('error')} "
f"dim={snap.get('videoWidth')}x{snap.get('videoHeight')} "
f"rs={snap.get('readyState')} paintLuma={pl_s} paintOk={snap.get('paintOk')}")
# Final src URL fully decoded
final_src = samples[-1]["video"].get("currentSrc") or samples[-1]["video"].get("src", "")
final_src_decoded = urllib.parse.unquote(final_src) if final_src else ""
await browser.close()
# Server side ffmpeg / transcode log
server_logs = ""
try:
server_logs = subprocess.check_output(
["ssh", "-o", "ConnectTimeout=5", "user@192.168.0.100",
f"docker logs --since 2m {side_cfg['container']} 2>&1 | tail -300"],
timeout=15).decode(errors="replace")
except Exception as e:
server_logs = f"(failed to fetch server logs: {e})"
# Extract ffmpeg cmdline + transcode reasons from log
ffmpeg_cmd = None
for line in server_logs.splitlines():
if "ffmpeg" in line.lower() and ("-i " in line or "-f hls" in line or "-c:v" in line):
ffmpeg_cmd = line.strip()
break
transcode_reasons = []
for line in server_logs.splitlines():
if "transcode reason" in line.lower() or "TranscodeReasons" in line:
transcode_reasons.append(line.strip())
# Save artifacts
side_out = {
"side": side, "url": url, "user": user, "uid": uid, "is_admin": is_admin,
"server_id": server_id, "item_id": ITEM_ID, "item_label": ITEM_LABEL,
"play_clicked": play_clicked, "play_selector": used_sel,
"samples": samples,
"final_src": final_src,
"final_src_decoded": final_src_decoded,
"playbackinfo_api": pbi_api,
"playbackinfo_browser_responses": pbi_response_bodies,
"requests": requests,
"responses": responses,
"console": console[-200:],
"ffmpeg_cmdline": ffmpeg_cmd,
"transcode_reasons_log": transcode_reasons,
}
with open(os.path.join(side_dir, "result.json"), "w") as f:
json.dump(side_out, f, indent=2, default=str)
with open(os.path.join(side_dir, "server.log"), "w") as f:
f.write(server_logs)
# Cleanup the temp api key
del_apikey(url, token)
return side_out
# ------------------- Diff & report -------------------
def diff_results(prod, dev):
"""Build the comparison matrix."""
def keyfields(pbi):
ms = pbi.get("MediaSources", [])
if not ms:
return None
m = ms[0]
return {
"Container": m.get("Container"),
"Protocol": m.get("Protocol"),
"SupportsDirectPlay": m.get("SupportsDirectPlay"),
"SupportsDirectStream": m.get("SupportsDirectStream"),
"SupportsTranscoding": m.get("SupportsTranscoding"),
"TranscodingUrl": m.get("TranscodingUrl"),
"TranscodingSubProtocol": m.get("TranscodingSubProtocol"),
"TranscodingContainer": m.get("TranscodingContainer"),
"TranscodeReasons": m.get("TranscodeReasons"),
"Bitrate": m.get("Bitrate"),
"Size": m.get("Size"),
"Path": m.get("Path"),
}
p_pbi = keyfields(prod["playbackinfo_api"])
d_pbi = keyfields(dev["playbackinfo_api"])
last_p = prod["samples"][-1]["video"]
last_d = dev["samples"][-1]["video"]
out = {
"item_id": ITEM_ID, "label": ITEM_LABEL,
"prod_url": prod["url"], "dev_url": dev["url"],
"playback_info_diff": {
"prod": p_pbi, "dev": d_pbi,
"differences": {
k: {"prod": p_pbi.get(k), "dev": d_pbi.get(k)}
for k in (set(p_pbi or {}) | set(d_pbi or {}))
if (p_pbi or {}).get(k) != (d_pbi or {}).get(k)
} if p_pbi and d_pbi else "missing-on-one-side",
},
"video_state_t30": {
"prod": last_p,
"dev": last_d,
"differences": {
k: {"prod": last_p.get(k), "dev": last_d.get(k)}
for k in (set(last_p) | set(last_d))
if last_p.get(k) != last_d.get(k)
},
},
"stream_url_prod": prod.get("final_src_decoded"),
"stream_url_dev": dev.get("final_src_decoded"),
"ffmpeg_cmdline_prod": prod.get("ffmpeg_cmdline"),
"ffmpeg_cmdline_dev": dev.get("ffmpeg_cmdline"),
"transcode_reasons_log_prod": prod.get("transcode_reasons_log"),
"transcode_reasons_log_dev": dev.get("transcode_reasons_log"),
"http_status_diff": [],
}
# HTTP-status diff: for matched URL templates, show statuses where they differ.
def normalise(u):
# Strip /Videos/{id} → /Videos/* and quoting; keep last path segment
u = re.sub(r"/Videos/[a-f0-9]{32}", "/Videos/*", u)
u = re.sub(r"/Items/[a-f0-9]{32}", "/Items/*", u)
u = re.sub(r"\?.*$", "", u)
u = re.sub(r"^https?://[^/]+", "", u)
return u
def status_map(rs):
out = {}
for r in rs:
k = (r["method"], normalise(r["url"]))
out.setdefault(k, []).append(r["status"])
return out
sp = status_map(prod.get("responses", []))
sd = status_map(dev.get("responses", []))
keys = set(sp) | set(sd)
for k in sorted(keys):
if sp.get(k) != sd.get(k):
out["http_status_diff"].append({
"method": k[0], "path": k[1],
"prod": sp.get(k), "dev": sd.get(k),
})
return out
def render_md(diff, prod, dev):
pp = diff["playback_info_diff"].get("prod") or {}
dp = diff["playback_info_diff"].get("dev") or {}
last_p = diff["video_state_t30"]["prod"]
last_d = diff["video_state_t30"]["dev"]
def fmt_bool(x): return "Y" if x else ("N" if x is False else "")
def headline():
# Three failure modes to recognise, in order:
# 1. paused-at-zero → MediaSource attach never fired
# 2. <video>.error → format/decode error
# 3. paint-black → video advances but renders no pixels (DRM-style
# black, or codec-not-actually-decodable in this
# chromium build despite advancing the clock)
bp = bool(last_p.get("paused")) and (last_p.get("currentTime", 0) or 0) < 0.1
bd = bool(last_d.get("paused")) and (last_d.get("currentTime", 0) or 0) < 0.1
if bp and not bd:
return ("prod fails because video stayed paused at t=0 while dev advanced")
if bd and not bp:
return ("dev fails because video stayed paused at t=0 while prod advanced")
if last_p.get("error") and not last_d.get("error"):
return f"prod fails because <video>.error code={last_p['error'].get('code')}"
if last_d.get("error") and not last_p.get("error"):
return f"dev fails because <video>.error code={last_d['error'].get('code')}"
# Paint check
pp_ok = last_p.get("paintOk"); dp_ok = last_d.get("paintOk")
if pp_ok is False and dp_ok is True:
return ("prod fails because <video> advances time but paints all-black "
"(paintLuma~0) while dev paints normally — pixels never reach the canvas")
if dp_ok is False and pp_ok is True:
return ("dev fails because <video> advances time but paints all-black "
"(paintLuma~0) while prod paints normally")
# OSD overlay check
def topel(s): return (s.get("stackAtVideoCenter") or [{}])[0]
pt = topel(last_p); dt = topel(last_d)
def is_opaque_black(bg):
if not bg: return False
try:
nums = [int(x) for x in bg.replace("rgba(","").replace("rgb(","").replace(")","").split(",")[:3]]
return sum(nums) < 30
except Exception: return False
if (not pt.get("isVideo")) and is_opaque_black(pt.get("bg")) \
and (dt.get("isVideo") or not is_opaque_black(dt.get("bg"))):
return (f"prod fails because an opaque-black `{pt.get('tag')}#{pt.get('id') or ''}"
f".{pt.get('cls')}` element is rendered on top of the <video> "
f"(bg={pt.get('bg')}); dev's video is uncovered")
return "neither side errored or painted black explicitly — see HTTP/PlaybackInfo/cmdline diffs"
md = []
md.append(f"# Prod vs Dev — playback divergence test ({time.strftime('%Y-%m-%d %H:%M')})")
md.append("")
md.append(f"Item: **{diff['label']}** (ItemId `{diff['item_id']}`)")
md.append("")
md.append(f"**Headline:** {headline()}")
md.append("")
md.append("## Final video state at t=30s")
md.append("| Field | prod | dev |")
md.append("|---|---|---|")
for k in ["present", "currentTime", "duration", "paused", "ended",
"readyState", "networkState", "error",
"videoWidth", "videoHeight", "bufferedRanges", "bufferedEnd",
"paintLuma", "paintRGBSum", "paintOk"]:
md.append(f"| {k} | `{last_p.get(k)}` | `{last_d.get(k)}` |")
# OSD page styling diff (the smoking gun for prod black-screen)
p_osd = last_p.get("videoOsdPage") or {}
d_osd = last_d.get("videoOsdPage") or {}
md.append("")
md.append("## #videoOsdPage style (the OSD container painted on top of the <video>)")
md.append("| Field | prod | dev |")
md.append("|---|---|---|")
for k in ["bg", "opacity", "position", "zIndex", "display", "cls"]:
md.append(f"| {k} | `{p_osd.get(k)}` | `{d_osd.get(k)}` |")
md.append("")
md.append("## Stack at video center (top → bottom)")
md.append("### prod")
for s in (last_p.get("stackAtVideoCenter") or []):
md.append(f"- `{s.get('tag')}#{s.get('id')}.{s.get('cls')}` "
f"bg=`{s.get('bg')}` z=`{s.get('zIndex')}` pos=`{s.get('position')}` "
f"isVideo={s.get('isVideo')}")
md.append("### dev")
for s in (last_d.get("stackAtVideoCenter") or []):
md.append(f"- `{s.get('tag')}#{s.get('id')}.{s.get('cls')}` "
f"bg=`{s.get('bg')}` z=`{s.get('zIndex')}` pos=`{s.get('position')}` "
f"isVideo={s.get('isVideo')}")
md.append("")
md.append("## Stream URL (decoded)")
md.append(f"- **prod**: `{diff.get('stream_url_prod') or '(empty)'}`")
md.append(f"- **dev**: `{diff.get('stream_url_dev') or '(empty)'}`")
md.append("")
md.append("## PlaybackInfo MediaSources[0]")
md.append("| Field | prod | dev |")
md.append("|---|---|---|")
for k in ["Container", "Protocol", "SupportsDirectPlay",
"SupportsDirectStream", "SupportsTranscoding",
"TranscodingUrl", "TranscodingSubProtocol", "TranscodingContainer",
"TranscodeReasons", "Bitrate", "Size", "Path"]:
md.append(f"| {k} | `{pp.get(k)}` | `{dp.get(k)}` |")
md.append("")
md.append("## ffmpeg cmdline (from docker logs)")
md.append(f"- **prod**: `{diff.get('ffmpeg_cmdline_prod') or '(none — no transcoding observed)'}`")
md.append(f"- **dev**: `{diff.get('ffmpeg_cmdline_dev') or '(none — no transcoding observed)'}`")
md.append("")
md.append("## HTTP status differences")
if diff.get("http_status_diff"):
md.append("| Method | Path | prod | dev |")
md.append("|---|---|---|---|")
for r in diff["http_status_diff"]:
md.append(f"| {r['method']} | `{r['path']}` | {r['prod']} | {r['dev']} |")
else:
md.append("(none — all matched URLs returned the same status code)")
md.append("")
md.append("## Per-sample timeline")
md.append("| t | prod time | prod paused | prod err | dev time | dev paused | dev err |")
md.append("|---|---|---|---|---|---|---|")
for ps, ds in zip(prod["samples"], dev["samples"]):
pv, dv = ps["video"], ds["video"]
md.append(f"| {ps['t']}s | {pv.get('currentTime')} | {pv.get('paused')} | "
f"{pv.get('error')} | {dv.get('currentTime')} | {dv.get('paused')} | "
f"{dv.get('error')} |")
md.append("")
return "\n".join(md)
# ------------------- main -------------------
async def main():
print(f"[+] OUT: {OUT}")
async with async_playwright() as p:
prod = await run_side(p, SIDES[0])
dev = await run_side(p, SIDES[1])
diff = diff_results(prod, dev)
with open(os.path.join(OUT, "diff.json"), "w") as f:
json.dump(diff, f, indent=2, default=str)
md = render_md(diff, prod, dev)
with open(os.path.join(OUT, "diff.md"), "w") as f:
f.write(md)
print("\n=== SUMMARY ===")
last_p = diff["video_state_t30"]["prod"]; last_d = diff["video_state_t30"]["dev"]
print(f"prod t=30: time={last_p.get('currentTime')} paused={last_p.get('paused')} "
f"err={last_p.get('error')} dim={last_p.get('videoWidth')}x{last_p.get('videoHeight')}")
print(f"dev t=30: time={last_d.get('currentTime')} paused={last_d.get('paused')} "
f"err={last_d.get('error')} dim={last_d.get('videoWidth')}x{last_d.get('videoHeight')}")
print(f"diff.json: {os.path.join(OUT, 'diff.json')}")
print(f"diff.md: {os.path.join(OUT, 'diff.md')}")
if __name__ == "__main__":
asyncio.run(main())

54
bin/revert-next-ep-popup.sh Executable file
View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Revert the NEXT-EP-POPUP shim injected into dev's index-dev.html on 2026-05-10.
#
# What it removes:
# /* NEXT-EP-POPUP-BEGIN ... */ ... /* NEXT-EP-POPUP-END */
#
# Defaults to dev. Pass --prod to remove from prod's index.html instead.
# Idempotent: safe to re-run.
#
# After local edit, redeploy to nullstone via the same nsenter cp trick used
# for sub-label-shim revert (see comment at end).
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TARGET_DEV="$REPO_ROOT/web-overrides/index-dev.html"
TARGET_PROD="$REPO_ROOT/web-overrides/index.html"
TARGET="$TARGET_DEV"
ENV="dev"
if [[ "${1:-}" == "--prod" ]]; then
TARGET="$TARGET_PROD"
ENV="prod"
fi
if [[ ! -f "$TARGET" ]]; then
echo "ERROR: $TARGET not found" >&2
exit 1
fi
if ! grep -q "NEXT-EP-POPUP-BEGIN" "$TARGET"; then
echo "shim already absent in $ENV — nothing to do"
exit 0
fi
cp "$TARGET" "$TARGET.bak.$(date -u +%Y%m%dT%H%M%SZ)"
sed -i '/NEXT-EP-POPUP-BEGIN/,/NEXT-EP-POPUP-END/d' "$TARGET"
if grep -q "NEXT-EP-POPUP" "$TARGET"; then
echo "ERROR: revert left orphan markers in $TARGET" >&2
exit 2
fi
echo "reverted ($ENV). backup at $TARGET.bak.*"
echo
echo "Now redeploy to nullstone:"
if [[ "$ENV" == "dev" ]]; then
echo " scp $TARGET user@192.168.0.100:/tmp/index-dev-new.html"
echo " ssh user@192.168.0.100 'docker run --rm --privileged --pid=host --userns=host -v /opt:/opt -v /tmp:/tmp alpine nsenter -t 1 -m -u -i -n cp /tmp/index-dev-new.html /opt/docker/jellyfin-dev/web-overrides/index-dev.html'"
else
echo " scp $TARGET user@192.168.0.100:/tmp/arrflix-index.html"
echo " ssh user@192.168.0.100 'docker run --rm --privileged --pid=host --userns=host -v /opt:/opt -v /tmp:/tmp alpine nsenter -t 1 -m -u -i -n cp /tmp/arrflix-index.html /opt/docker/jellyfin/web-overrides/index.html'"
fi
echo "Then hard-refresh the browser (Ctrl+Shift+R)."

37
bin/revert-sub-label-shim.sh Executable file
View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Revert the SUB-LABEL-SHIM injected into web-overrides/index.html on 2026-05-10.
#
# What it removes:
# /* SUB-LABEL-SHIM-BEGIN ... */ ... /* SUB-LABEL-SHIM-END */
#
# Idempotent: safe to re-run.
# After reverting, hard-refresh the browser (Ctrl+Shift+R) so the cached
# index.html is fetched fresh.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TARGET="$REPO_ROOT/web-overrides/index.html"
if [[ ! -f "$TARGET" ]]; then
echo "ERROR: $TARGET not found" >&2
exit 1
fi
if ! grep -q "SUB-LABEL-SHIM-BEGIN" "$TARGET"; then
echo "shim already absent — nothing to do"
exit 0
fi
cp "$TARGET" "$TARGET.bak.$(date -u +%Y%m%dT%H%M%SZ)"
# delete from BEGIN marker line through END marker line, inclusive
sed -i '/SUB-LABEL-SHIM-BEGIN/,/SUB-LABEL-SHIM-END/d' "$TARGET"
if grep -q "SUB-LABEL-SHIM" "$TARGET"; then
echo "ERROR: revert left orphan markers in $TARGET" >&2
exit 2
fi
echo "reverted. backup at $TARGET.bak.*"
echo "next: container needs no restart (index.html is bind-mounted); hard-refresh browser."

100
bin/set-home-layout.py Executable file
View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
Patch Jellyfin home-screen section layout for every user on a given instance.
Default policy (this script):
- Continue Watching (`resume`) ENABLED for every user
- Resume Audio (`resumeaudio`) preserved if present
- Next Up (`nextup`) DISABLED (replaced with `none`)
- Latest Media (`latestmedia`) preserved if present
- If a user's layout was empty (factory default), seed slot 0 with `resume`
Idempotent. Safe to re-run.
Usage:
JF_URL=https://arrflix.s8n.ru \
JF_TOKEN=<admin token> \
python3 bin/set-home-layout.py
Token retrieval (admin):
docker cp jellyfin:/config/data/jellyfin.db /tmp/jf.db
sqlite3 /tmp/jf.db \\
'SELECT d.AccessToken FROM Devices d JOIN Users u ON d.UserId=u.Id
WHERE u.Username="s8n" ORDER BY d.DateLastActivity DESC LIMIT 1'
"""
import json
import os
import sys
import urllib.parse
import urllib.request
JF_URL = os.environ.get("JF_URL")
JF_TOKEN = os.environ.get("JF_TOKEN")
if not JF_URL or not JF_TOKEN:
sys.exit("set JF_URL and JF_TOKEN env vars")
# Jellyfin 10.10.3 web client uses 'Jellyfin Web' as the DisplayPreferences
# client name. The older 'emby' name is read by legacy SDKs only — writing
# only to 'emby' has no effect on the web UI. Patch every per-client doc to
# keep all consumers in sync.
CLIENTS = ["Jellyfin Web", "emby", "emby-mobile", "emby-web"]
def http(method, url, body=None):
req = urllib.request.Request(url, method=method)
req.add_header("X-Emby-Token", JF_TOKEN)
data = None
if body is not None:
req.add_header("Content-Type", "application/json")
data = json.dumps(body).encode()
with urllib.request.urlopen(req, data=data) as r:
text = r.read().decode()
return r.status, (json.loads(text) if text else None)
def patch_user_client(user_id, client):
q = urllib.parse.urlencode({"userId": user_id, "client": client})
url = f"{JF_URL}/DisplayPreferences/usersettings?{q}"
_, prefs = http("GET", url)
cp = prefs.get("CustomPrefs") or {}
sections = [cp.get(f"homesection{i}", "none") for i in range(10)]
before = list(sections)
sections = ["none" if s == "nextup" else s for s in sections]
if "resume" not in sections:
if "none" in sections:
sections[sections.index("none")] = "resume"
else:
sections = ["resume"] + sections[:9]
if "latestmedia" not in sections:
for i, s in enumerate(sections):
if s == "none":
sections[i] = "latestmedia"
break
for i, s in enumerate(sections):
cp[f"homesection{i}"] = s
prefs["CustomPrefs"] = cp
changed = before != sections
if changed:
http("POST", url, body=prefs)
return changed, before, sections
def main():
users = http("GET", f"{JF_URL}/Users")[1]
print(f"{JF_URL}{len(users)} users")
for u in users:
for client in CLIENTS:
changed, before, after = patch_user_client(u["Id"], client)
if changed:
print(f" [CHANGED] {u['Name']:<14} client={client:<14} {before} -> {after}")
else:
print(f" [ ok ] {u['Name']:<14} client={client:<14} {after}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,45 @@
# Jellyfin DEV — second instance for theme/branding experimentation
# Deploy path on nullstone: /opt/docker/jellyfin-dev/
# Domain: dev.arrflix.s8n.ru (LAN-only via Pi-hole local DNS + no-USER-F middleware)
#
# Purpose:
# - Isolated playground for trying themes (Cineplex, ElegantFin, NeutralFin, ...)
# without touching the live arrflix.s8n.ru that real users (USER-A, USER-G, USER-F, 5)
# are watching.
# - Same media library mounted READ-ONLY so dev sees the same titles but cannot
# mutate the on-disk library.
# - Separate config/cache so first-run wizard, accounts and branding live here only.
# - LAN-only: no-USER-F middleware on router; do NOT publish to WAN.
#
# Image pinned to 10.10.3 to match prod for theme parity. Bump prod first, then
# match here, never the other way around.
services:
jellyfin-dev:
image: jellyfin/jellyfin:10.10.3
container_name: jellyfin-dev
restart: unless-stopped
user: "1000:1000"
userns_mode: "host"
environment:
- TZ=Europe/London
- JELLYFIN_PublishedServerUrl=https://dev.arrflix.s8n.ru
volumes:
- /home/docker/jellyfin-dev/config:/config
- /home/docker/jellyfin-dev/cache:/cache
- /home/user/media:/media:ro
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.jellyfin-dev.rule=Host(`dev.arrflix.s8n.ru`)"
- "traefik.http.routers.jellyfin-dev.entrypoints=websecure"
- "traefik.http.routers.jellyfin-dev.tls=true"
- "traefik.http.routers.jellyfin-dev.tls.certresolver=letsencrypt"
- "traefik.http.routers.jellyfin-dev.middlewares=security-headers@file,no-USER-F@file"
- "traefik.http.services.jellyfin-dev.loadbalancer.server.port=8096"
networks:
proxy:
external: true

63
docs/00-overview.md Normal file
View file

@ -0,0 +1,63 @@
# 00 — ARRFLIX Technical Overview
ARRFLIX is the operator's premium home-streaming project: AI-upscaled masters,
hand-curated metadata, and a Netflix-faithful viewing surface for a small
trusted set of users.
Under the hood, the stack is **Jellyfin 10.10.3 in Docker on nullstone**, sat
behind **Traefik** with **Let's Encrypt DNS-01 via Gandi** for TLS, name-served
internally by **Pi-hole**, and access-bounded to the LAN (`192.168.0.0/24`)
plus tagged tailnet nodes via a Traefik allowlist middleware. The Jellyfin web
UI is rebranded as ARRFLIX through a `web-overrides/` bind-mount, an SPA
runtime shim, and the **NeutralFin / Cineplex** CSS theme stack — none of the
default Jellyfin chrome, names, or logos are reachable by an unprivileged user.
---
## Architecture
| Layer | Component |
|------------------|------------------------------------------------------------------------------------------------|
| Frontend | Jellyfin web bundle, themed and rebranded as ARRFLIX (web-overrides + NeutralFin/Cineplex CSS) |
| Application | `jellyfin/jellyfin:10.10.3` container on nullstone, sibling `jellyfin-dev` for theme work |
| Reverse proxy | Traefik with **file-provider** routing (docker-label routing flakes for this container) |
| DNS | Pi-hole internal A record: `arrflix.s8n.ru``192.168.0.100` |
| TLS | Let's Encrypt via DNS-01, Gandi LiveDNS provider |
| Storage (media) | RO bind-mount from host `/home/user/media/{movies,tv,…}` → container `/media` |
| Storage (state) | Config + cache + metadata under host `/home/docker/jellyfin/` |
| ACL | Traefik `no-USER-F@file` middleware: LAN `192.168.0.0/24` + tailnet admin/infra tags only |
| WAN exposure | A record published; router port-forward gated — see `09-wan-exposure.md` |
A second container `jellyfin-dev` runs on `dev.arrflix.s8n.ru` as a behavioural
mirror of prod for theme and branding experiments — same media (read-only),
separate config and users, LAN-only.
---
## Read these in order
1. [`01-artwork-and-images.md`](01-artwork-and-images.md) — how artwork flows through Jellyfin and the curl recipes used to repair a botched first scan.
2. [`02-metadata-and-titles.md`](02-metadata-and-titles.md) — episode/title scraping, `RemoteSearch/Apply`, and the lock-the-series workflow.
3. [`03-subtitles.md`](03-subtitles.md) — subtitle resolution order, sidecar conventions, and the OpenSubtitles plugin setup.
4. [`04-theming-and-users.md`](04-theming-and-users.md) — active theme (Cineplex v1.0.6), server-side branding, multi-user UX, SyncPlay, revert path.
5. [`05-file-structure-rules.md`](05-file-structure-rules.md) — authoritative on-disk layout for Movies / TV / Anime / Music libraries.
6. [`06-per-library-themes.md`](06-per-library-themes.md) — research note on shimming per-library CSS scoping (Movies = Netflix, Anime = Crunchyroll, Music = Spotify).
7. [`07-pre-import-cleanup.md`](07-pre-import-cleanup.md) — normative ruleset for stripping junk from scene/group dumps before import.
8. [`08-filename-normalization.md`](08-filename-normalization.md) — canonical, group-tag-free renaming ruleset between "torrent dump" and the live tree.
9. [`09-wan-exposure.md`](09-wan-exposure.md) — the LAN-only → public-internet plan, server-side changes already applied, router TODOs, and rollback.
10. [`10-spa-runtime-shim.md`](10-spa-runtime-shim.md) — why static `<title>` patching loses to Jellyfin's SPA, and the runtime shim that wins it back.
11. [`11-neutralfin-audit.md`](11-neutralfin-audit.md) — read-only audit of the NeutralFin render gap vs the demo screenshots (no fixes applied).
12. [`12-dev-instance.md`](12-dev-instance.md) — `jellyfin-dev` sibling container: image pinning, mounts, and isolation guarantees.
13. [`13-optimization-audit.md`](13-optimization-audit.md) — read-only performance / capacity / reliability / ops-hygiene audit across REST, host, and container.
14. [`14-theme-audit.md`](14-theme-audit.md) — Cineplex theme audit and the detail-page left-band backdrop diagnosis (forward plan, not a fix).
15. [`15-force-english.md`](15-force-english.md) — root cause of the German Play button and the per-user `UICulture` pin that fixes it.
16. [`16-jellyfin-branding-leaks.md`](16-jellyfin-branding-leaks.md) — exhaustive inventory of every place "Jellyfin" or the teal triangle still leaks to a non-admin.
17. [`17-dev-mirror-and-settings-fix.md`](17-dev-mirror-and-settings-fix.md) — making dev a faithful prod mirror and fixing the non-admin Settings drawer leak (dev only).
---
## See also
- [`../README.md`](../README.md) — ARRFLIX brand-facing project page.
- [`../ADMIN-GUIDE.md`](../ADMIN-GUIDE.md) — operator runbook (day-to-day administration).
- [`../ROADMAP.md`](../ROADMAP.md) — what's next and what's known-broken.

View file

@ -10,7 +10,7 @@ nullstone). Auth header below uses the long-lived API token — replace with you
own `X-Emby-Token` if needed.
```bash
TOKEN="*redacted*"
TOKEN="<JELLYFIN_API_TOKEN>"
H="-H \"Authorization: MediaBrowser Token=${TOKEN}\""
BASE="https://arrflix.s8n.ru"
```

View file

@ -203,7 +203,7 @@ For our Futurama set, all files are single-episode (`s01e01.pl.mkv`), so this di
Step-by-step, with the exact commands run:
```bash
TOKEN=*redacted*
TOKEN=<JELLYFIN_API_TOKEN>
SERIES_ID=156e57437f795e5c8cd80fc98bafaee0 # Futurama
LIB_ID=767bffe4f11c93ef34b805451a696a4e # TV Shows library

View file

@ -90,7 +90,7 @@ User does NOT need to obtain an API key. The plugin embeds its own key (verified
After signup at opensubtitles.com, save creds via API:
```bash
TOKEN=*redacted*
TOKEN=<JELLYFIN_API_TOKEN>
USER='your-opensubtitles-com-username'
PASS='your-opensubtitles-com-password'
@ -317,7 +317,7 @@ Plugin logs: `docker logs jellyfin 2>&1 | grep -i opensubtitles`.
| User `s8n` `SubtitleMode` | `Always` |
| User `s8n` `SubtitleLanguagePreference` | `eng` |
| User `s8n` `AudioLanguagePreference` | `pol` |
| OpenSubtitles **credentials** | **PENDING — user signs up at <https://www.opensubtitles.com>** |
| Series refresh to fetch all 44 | **PENDING — after creds entered** |
| OpenSubtitles **credentials** | **SET** — user `Caveman5`, `CredentialsInvalid=false` (verified 2026-05-11) |
| Series refresh to fetch all 44 | **READY** — trigger via UI or `MetadataRefreshMode=FullRefresh` API call |
When the user enters creds and runs the series refresh in § 5.2, expect ~20 episodes downloaded the first day (free quota), the rest over the next two days unless upgraded. Sidecar filenames will be `Futurama.s01eXX.pl.eng.srt` next to each `.mkv`.

View file

@ -6,20 +6,16 @@ ElegantFin v25.12.31 the same day after a Netflix-fidelity-driven survey.
Scope: visual theme, server-side branding, multi-user UX prep, SyncPlay,
maintenance/revert. LAN-only constraints preserved (no public-facing changes).
> Hostname note: this site is being renamed `tv.s8n.ru``arrflix.s8n.ru`
> in the same session. The Jellyfin API endpoints don't care about
> hostname — they're served by the same container. All `curl` examples
> below are reachable as either `https://tv.s8n.ru/...` (legacy) or
> `https://arrflix.s8n.ru/...` (new), as long as Traefik has a SNI cert
> for the name. Internal pin: both names should resolve to `192.168.0.100`
> (see CLAUDE.md memory `feedback_s8n_hosts_override.md`). If a
> hostname's DNS or cert isn't up yet, use
> `--resolve tv.s8n.ru:443:192.168.0.100` on curl — that's how this
> re-theming was applied while `arrflix.s8n.ru` was still missing a cert.
---
## 1. Theme decision: Cineplex v1.0.6 (Netflix-faithful)
## 1. Theme decision: ElegantFin v25.12.31 + ARRFLIX recolor (current)
**As of 2026-05-08 (later in the day), the active theme is ElegantFin
v25.12.31 with the Netflix-red `#E50914` accent recolored over the
default Jellyseerr-blue/violet palette and the ARRFLIX wordmark logo
preserved.** See §3e for the migration details and §1.x ("Previous
themes") below for the Cineplex history that preceded it.
### Candidates surveyed (2026-05-08)
@ -34,7 +30,18 @@ maintenance/revert. LAN-only constraints preserved (no public-facing changes).
| zombB / NetfliFin / Finetwo | mostly fork-style replacement of jellyfin-web | varies | varies | n/a | requires image swap or JS injector | DQ — violates "pure CSS, no image swap, no plugins" constraint |
| Ultrachromic (CTalvio) | community CSS | "selectively maintained" | varies | 6/10 — accent-tunable but no Netflix preset | unknown | not Netflix enough |
### Why Cineplex won
### Previous themes
The two sub-sections below ("Why Cineplex won", "Tradeoffs", "What it
looks like", "Theme history") are kept verbatim from when Cineplex was
the active theme (earlier on 2026-05-08, before the ElegantFin migration
documented in §3e). They remain useful as the reasoning trail for the
final brand brief — Netflix-faithful was the goal, Cineplex was the
purest expression of that, and the current ElegantFin + recolor stack
is a deliberate tradeoff toward "more polished browsing UI" while
keeping the Netflix-red accent.
#### Why Cineplex won (historical)
1. **It is actually Netflix.** The CSS literally embeds Netflix Sans
(`https://assets.nflxext.com/ffe/siteui/fonts/netflix-sans/v3/...`) and
@ -62,7 +69,7 @@ maintenance/revert. LAN-only constraints preserved (no public-facing changes).
6. **Cast/crew hide rule still appended** at the bottom of `CustomCss`,
exactly as before.
### Tradeoffs (honest list)
#### Tradeoffs (honest list, Cineplex era)
- **License: none.** Cineplex doesn't declare one. CSS is generally
permissive in practice (you redistribute by `@import`, not by copying)
@ -79,7 +86,7 @@ maintenance/revert. LAN-only constraints preserved (no public-facing changes).
- **Theme footer.** Cineplex doesn't add a brand stamp, so users see no
"Cineplex" tag — cleaner than ElegantFin's footer label was.
### What it looks like (live, post-apply)
#### What Cineplex looked like (live, post-apply)
- **Background:** `#181818` (Finity base) — Netflix-black.
- **Accent:** `#E50914` (canonical Netflix red) on focus rings, progress
@ -92,16 +99,18 @@ maintenance/revert. LAN-only constraints preserved (no public-facing changes).
netflix.com's sign-in page.
- **No theme-brand footer label** any more.
### Theme history
#### Theme history
| Date | Theme | Version | Why changed |
|---|---|---|---|
| 2026-05-08 (earlier today) | ElegantFin | v25.12.31 | Initial Jellyfin theming pass. Picked for activity + safety (most actively maintained CSS in the ecosystem). |
| 2026-05-08 (this entry) | **Cineplex** | **v1.0.6** | Owner asked for the most Netflix-faithful theme available. ElegantFin's Jellyseerr aesthetic (blue-grey, no red) is too far from Netflix; Cineplex is purpose-built for this look and explicitly targets the 10.10 series we're on. JellyFlix (the genre's elder) is halted. |
| 2026-05-08 (mid-day) | **Cineplex** | **v1.0.6** | Owner asked for the most Netflix-faithful theme available. ElegantFin's Jellyseerr aesthetic (blue-grey, no red) is too far from Netflix; Cineplex is purpose-built for this look and explicitly targets the 10.10 series we're on. JellyFlix (the genre's elder) is halted. |
| 2026-05-08 (later, current) | **ElegantFin + ARRFLIX recolor** | **v25.12.31** + `#E50914` accent overrides | Owner liked Cineplex's Netflix accent but preferred ElegantFin's polished browsing UI. Best of both: ElegantFin's layout/typography + ARRFLIX brand red overrides. Snapshot tag for rollback: `snapshot-2026-05-08-pre-elegantfin`. See §3e. |
If we ever roll back to ElegantFin, the previous `@import` was
`https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@v25.12.31/Theme/ElegantFin-jellyfin-theme-build-latest-minified.css`.
The previous incarnation of this doc lives in git history.
Rollback paths:
- To Cineplex (Netflix-faithful): apply `snapshots/2026-05-08-pre-elegantfin/branding.json` per `snapshots/2026-05-08-pre-elegantfin/RESTORE.md`.
- To plain ElegantFin (no recolor): see §6b.
- To vanilla Jellyfin: see §6b.
---
@ -110,25 +119,25 @@ The previous incarnation of this doc lives in git history.
### Branding API (Cineplex, applied 2026-05-08)
```bash
TOKEN=*redacted*
TOKEN=<JELLYFIN_API_TOKEN>
cat > /tmp/branding.json <<'EOF'
{
"LoginDisclaimer": "Welcome to tv.s8n.ru — LAN-only. Be kind, rewind.",
"LoginDisclaimer": "Welcome to arrflix.s8n.ru — LAN-only. Be kind, rewind.",
"CustomCss": "/* Cineplex v1.0.6 — Netflix-faithful theme by MRunkehl, pinned tag (immutable on jsDelivr) */\n/* Compat: Jellyfin 10.10.7+ ; we run 10.10.3 — verified rendering 2026-05-08 */\n@import url(\"https://cdn.jsdelivr.net/gh/MRunkehl/cineplex@v1.0.6/cineplex.css\");\n\n/* Hide Cast & Crew + USER-F Stars sections globally (preserved 2026-05-08) */\n#castCollapsible, #USER-FCastCollapsible { display: none !important; }\n",
"SplashscreenEnabled": true
}
EOF
# Note: arrflix.s8n.ru didn't have a Traefik SNI cert at apply-time, so
# we sent the request to the legacy SNI tv.s8n.ru and pinned its address
# we sent the request to the legacy SNI arrflix.s8n.ru and pinned its address
# with --resolve. Either form is fine once both names have certs.
curl -sS --resolve tv.s8n.ru:443:192.168.0.100 \
curl -sS --resolve arrflix.s8n.ru:443:192.168.0.100 \
-X POST \
-H "X-Emby-Token: $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @/tmp/branding.json \
https://tv.s8n.ru/System/Configuration/branding
https://arrflix.s8n.ru/System/Configuration/branding
# expect: HTTP 204 (got HTTP 204 — applied)
```
@ -136,15 +145,15 @@ curl -sS --resolve tv.s8n.ru:443:192.168.0.100 \
```bash
# 1. Admin endpoint — confirms the new CustomCss is stored.
curl -sS --resolve tv.s8n.ru:443:192.168.0.100 \
curl -sS --resolve arrflix.s8n.ru:443:192.168.0.100 \
-H "X-Emby-Token: $TOKEN" \
https://tv.s8n.ru/System/Configuration/branding | python3 -m json.tool
https://arrflix.s8n.ru/System/Configuration/branding | python3 -m json.tool
# Result: HTTP 200, contains the Cineplex @import + cast/crew hide rule.
# 2. Anonymous endpoint the SPA reads at runtime — confirms what every
# browser will pull before login.
curl -sS --resolve tv.s8n.ru:443:192.168.0.100 \
https://tv.s8n.ru/Branding/Configuration | python3 -m json.tool
curl -sS --resolve arrflix.s8n.ru:443:192.168.0.100 \
https://arrflix.s8n.ru/Branding/Configuration | python3 -m json.tool
# Result: HTTP 200, identical CustomCss to admin endpoint. ✓
# 3. The CSS asset itself on jsDelivr (sanity-check the network path).
@ -153,7 +162,7 @@ curl -sSI "https://cdn.jsdelivr.net/gh/MRunkehl/cineplex@v1.0.6/cineplex.css" |
# cache-control: public, max-age=31536000, immutable. ✓
# 4. SPA shell still routes (nav not broken).
curl -sSI --resolve tv.s8n.ru:443:192.168.0.100 https://tv.s8n.ru/ | head -1
curl -sSI --resolve arrflix.s8n.ru:443:192.168.0.100 https://arrflix.s8n.ru/ | head -1
# Result: HTTP/2 302 → /web/. ✓
```
@ -161,7 +170,7 @@ curl -sSI --resolve tv.s8n.ru:443:192.168.0.100 https://tv.s8n.ru/ | head -1
by the JS bundle from `/Branding/Configuration`, not inlined into
`index.html`. So `curl /` won't grep-match. The valid JSON at
`/Branding/Configuration` is the API-level confirmation. Final visual
check is a hard browser reload (Ctrl-Shift-R) on `https://tv.s8n.ru`
check is a hard browser reload (Ctrl-Shift-R) on `https://arrflix.s8n.ru`
(or `https://arrflix.s8n.ru` once its cert is up) from the LAN — owner
will do this.
@ -374,6 +383,135 @@ hide, ARRFLIX logo override, Quick Connect hide, Settings drawer hide,
header icon hide) preserved verbatim. Same race rule applies — this is
the last branding POST in the sequence.
### 3e. ElegantFin migration with ARRFLIX recolor (2026-05-08, current)
Later on 2026-05-08, the active theme was migrated **from Cineplex to
ElegantFin v25.12.31** while preserving the ARRFLIX brand: Netflix-red
`#E50914` accent overrides over ElegantFin's default Jellyseerr-blue/
violet palette, plus the existing ARRFLIX wordmark logo. The owner had
seen the demo at <https://lscambo13.github.io/ElegantFin/>, liked
ElegantFin's polished browsing UI more than Cineplex's purer Netflix
fidelity, and asked for the swap with the brand colour kept intact.
**Snapshot tag for rollback (committed and pushed before any change):**
`snapshot-2026-05-08-pre-elegantfin`. Captures `branding.json`,
`index.html`, `docker-compose.yml`, all per-user `displayprefs-*.json`,
`users.json`, `libraries.json`, plus `RESTORE.md` with three concrete
rollback commands. Located at `snapshots/2026-05-08-pre-elegantfin/`.
**ElegantFin tag pinned: `v25.12.31`** (latest tag at migration time;
list resolved via `git ls-remote --tags https://github.com/lscambo13/ElegantFin.git`).
jsDelivr serves tagged refs immutably with year-long cache TTL — same
no-surprise-update guarantee we had on `cineplex@v1.0.6`. To opt into
upstream churn, edit the URL to `@main`; to pin a different tag, edit
the version segment.
**ElegantFin import:**
```css
@import url("https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@v25.12.31/Theme/ElegantFin-jellyfin-theme-build-latest-minified.css");
```
**Accent variables overridden (ARRFLIX recolor block).** ElegantFin
declares its accent palette through CSS custom properties at `:root`.
Eight variables were identified by grepping the minified theme for
`--[a-z]*` definitions and inspecting their default values; all eight
are remapped to `#E50914` (or its `rgba()` form for alpha variants):
| Variable | ElegantFin default | ARRFLIX value | What it controls |
|---|---|---|---|
| `--uiAccentColor` | `rgb(117 111 226)` (violet) | `#E50914` | Primary UI accent — most surfaces |
| `--activeColor` | `rgb(119,91,244)` (violet) | `#E50914` | Active / focused state highlights |
| `--activeColorAlpha` | `rgba(119,91,244,.9)` | `rgba(229, 9, 20, 0.9)` | Same with alpha — hover overlays |
| `--osdSeekBarPlayedColor` | `var(--textColor)` (white) | `#E50914` | Played portion of the video scrubber |
| `--checkboxCheckedBgColor` | `rgb(79,70,229)` (indigo) | `#E50914` | Checked checkboxes (settings, lib pickers) |
| `--highlightOutlineColor` | `rgb(37,99,235)` (blue) | `#E50914` | Focus / highlight outlines on cards |
| `--btnSubmitColor` | `rgb(61,54,178)` (indigo) | `#E50914` | "Submit" button background |
| `--btnSubmitBorderColor` | `rgb(117 111 226)` (violet) | `#E50914` | "Submit" button border |
Override block:
```css
:root {
--uiAccentColor: #E50914 !important;
--activeColor: #E50914 !important;
--activeColorAlpha: rgba(229, 9, 20, 0.9) !important;
--osdSeekBarPlayedColor: #E50914 !important;
--checkboxCheckedBgColor: #E50914 !important;
--highlightOutlineColor: #E50914 !important;
--btnSubmitColor: #E50914 !important;
--btnSubmitBorderColor: #E50914 !important;
}
```
Variables deliberately NOT changed:
- `--osdSeekBarThumbColor: white` — kept the explicit white-thumb rule
from §3d (white thumbs read as a neutral position indicator, not as
brand colour). The slider-thumb override in this doc's §3d still
applies.
- `--drawerColor`, `--headerColor` — kept ElegantFin's translucent
blur over its dark-blue surface; these are structural, not accent.
- `--borderColor`, `--textColor` — typography / structure, not accent.
**Logo selectors used.** ElegantFin does NOT define rules for the two
ARRFLIX logo selectors (verified by grepping the minified theme for
`adminDrawerLogo` and `pageTitleWithLogo` — zero matches), so the same
override skeleton from §3a/§3b is re-applied verbatim against the
ElegantFin base:
```css
.adminDrawerLogo img {
/* <img> in admin sidebar drawer — content: replaces src */
content: url("data:image/png;base64,<...ARRFLIX wordmark...>") !important;
}
.pageTitleWithLogo {
/* <div> masthead on dashboard + login — bg image only, no content: */
background-image: url("data:image/png;base64,<...ARRFLIX wordmark...>") !important;
}
```
The data-URL bytes are byte-for-byte identical to the Cineplex-era
override (extracted from the snapshot's `branding.json` and re-inlined
into the new `CustomCss` payload). Both selectors are still split-rule
form (per the §3a/§3b lesson — never combine `content:` and
`background-image:` on the same selector).
**Preserved blocks** (every custom rule from the Cineplex era was
re-applied on top of ElegantFin):
- `#castCollapsible, #USER-FCastCollapsible { display: none }` — cast/crew sections hidden
- `.btnQuick { display: none }` — Quick Connect login button hidden
- `.headerSyncButton`, `.headerCastButton`, `.headerUserButton` — top-right header icons hidden (§3c)
- `.MuiSlider-thumb` + variants — white scrubber/volume thumbs (§3d)
- `:root { --primary-background-color: #000000; --background-color: #000000; }` and the wrapper-element rules — pure black bg (§3d)
- `mypreferencesmenu` selectors — Settings drawer entry hidden
- `.countIndicator { display: none }` — unwatched-episode count badges hidden
- `.adminDrawerLogo img` / `.pageTitleWithLogo` — ARRFLIX wordmark override
- `LoginDisclaimer``"Welcome to ARRFLIX - Private invite only service"` preserved
- `SplashscreenEnabled: true` — preserved
**Verification (executed 2026-05-08):**
- POST to `/System/Configuration/branding` → HTTP 204
- GET on `/Branding/Configuration` → no Cineplex `@import`, ElegantFin
`@import` present and pinned to `v25.12.31`, ARRFLIX logo data URL
intact on both selectors, all preserved blocks intact, all eight
accent variable overrides present
- HEAD on `https://arrflix.s8n.ru/` → HTTP 302 (Traefik redirect to
`web/`, baseline behaviour — proxy still serving)
**Operational notes:**
- The bind-mounted `/web/index.html` was NOT touched (sibling work owns
that file via the index-patcher). All visual changes ride on
`CustomCss` via the public `/Branding/Configuration` consumer + the
authenticated `/System/Configuration/branding` writer.
- No container restart, no `docker compose` action, no Traefik change.
- Same race rule from §3b applies — the branding POST in this migration
was the **last** POST in the sequence.
**Rollback** — see `snapshots/2026-05-08-pre-elegantfin/RESTORE.md`,
or in one shot: `git checkout snapshot-2026-05-08-pre-elegantfin --
snapshots/2026-05-08-pre-elegantfin/branding.json` then POST it back
to `/System/Configuration/branding`.
---
## 4. Multi-user UX prep
@ -410,7 +548,7 @@ the last branding POST in the sequence.
> friend account that will exist later.
```bash
TOKEN=*redacted*
TOKEN=<JELLYFIN_API_TOKEN>
TVSHOWS_ID=767bffe4f11c93ef34b805451a696a4e
# 1. Create the user (auth header REQUIRED — admin token).
@ -622,11 +760,11 @@ no surprise breakage. To opt into upstream changes:
```bash
# Move from immutable tag to floating @main (pulls future commits;
# jsDelivr cache TTL is up to 7d for floating refs).
curl -sS --resolve tv.s8n.ru:443:192.168.0.100 \
curl -sS --resolve arrflix.s8n.ru:443:192.168.0.100 \
-X POST -H "X-Emby-Token: $TOKEN" \
-H "Content-Type: application/json" \
-d '{"CustomCss": "@import url(\"https://cdn.jsdelivr.net/gh/MRunkehl/cineplex@main/cineplex.css\");\n#castCollapsible, #USER-FCastCollapsible { display: none !important; }", "LoginDisclaimer": "Welcome to tv.s8n.ru — LAN-only. Be kind, rewind.", "SplashscreenEnabled": true}' \
https://tv.s8n.ru/System/Configuration/branding
-d '{"CustomCss": "@import url(\"https://cdn.jsdelivr.net/gh/MRunkehl/cineplex@main/cineplex.css\");\n#castCollapsible, #USER-FCastCollapsible { display: none !important; }", "LoginDisclaimer": "Welcome to arrflix.s8n.ru — LAN-only. Be kind, rewind.", "SplashscreenEnabled": true}' \
https://arrflix.s8n.ru/System/Configuration/branding
```
Or just ask each user to hard-reload — their browser cache is the common
@ -645,16 +783,16 @@ Replace the `@import` line:
```bash
# Back to ElegantFin (Jellyseerr-style):
curl -sS --resolve tv.s8n.ru:443:192.168.0.100 \
curl -sS --resolve arrflix.s8n.ru:443:192.168.0.100 \
-X POST -H "X-Emby-Token: $TOKEN" -H "Content-Type: application/json" \
-d '{"CustomCss": "@import url(\"https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@v25.12.31/Theme/ElegantFin-jellyfin-theme-build-latest-minified.css\");\n#castCollapsible, #USER-FCastCollapsible { display: none !important; }", "LoginDisclaimer": "Welcome to tv.s8n.ru — LAN-only. Be kind, rewind.", "SplashscreenEnabled": true}' \
https://tv.s8n.ru/System/Configuration/branding
-d '{"CustomCss": "@import url(\"https://cdn.jsdelivr.net/gh/lscambo13/ElegantFin@v25.12.31/Theme/ElegantFin-jellyfin-theme-build-latest-minified.css\");\n#castCollapsible, #USER-FCastCollapsible { display: none !important; }", "LoginDisclaimer": "Welcome to arrflix.s8n.ru — LAN-only. Be kind, rewind.", "SplashscreenEnabled": true}' \
https://arrflix.s8n.ru/System/Configuration/branding
# To vanilla Jellyfin (clear everything):
curl -sS --resolve tv.s8n.ru:443:192.168.0.100 \
curl -sS --resolve arrflix.s8n.ru:443:192.168.0.100 \
-X POST -H "X-Emby-Token: $TOKEN" -H "Content-Type: application/json" \
-d '{"CustomCss": "", "LoginDisclaimer": "", "SplashscreenEnabled": false}' \
https://tv.s8n.ru/System/Configuration/branding
https://arrflix.s8n.ru/System/Configuration/branding
```
Or in the UI: Dashboard → General → edit / clear "Custom CSS code" →
@ -704,9 +842,9 @@ When the friend gets their account, walk them through this **once**:
General → Quick Connect).
- [ ] Configure SMTP for self-serve password reset (currently admin-only).
- [ ] Get Traefik to issue a SNI cert for `arrflix.s8n.ru` so the curl
examples don't need `--resolve tv.s8n.ru:443:192.168.0.100`. Until
examples don't need `--resolve arrflix.s8n.ru:443:192.168.0.100`. Until
then, both names point to the same backend on `192.168.0.100` but
only `tv.s8n.ru` has a valid cert.
only `arrflix.s8n.ru` has a valid cert.
- [ ] Watch [Cineplex commits](https://github.com/MRunkehl/cineplex/commits/main)
monthly; if a `v1.0.7` lands and looks safe, bump the pin.
- [ ] Add a 2nd library (movies are mounted but the server may have an

View file

@ -968,7 +968,7 @@ used at library creation.)
### 12.1 Creating libraries via API
```bash
TOKEN=*redacted*
TOKEN=<JELLYFIN_API_TOKEN>
H="-H \"X-Emby-Token: ${TOKEN}\""
B="https://arrflix.s8n.ru"

View file

@ -55,7 +55,7 @@ information needed to scope styles is simply not in the DOM.
### Why approach #3 fails
`GET /Library/VirtualFolders` (auth `X-Emby-Token: *redacted*`) returns
`GET /Library/VirtualFolders` (auth `X-Emby-Token: <JELLYFIN_API_TOKEN>`) returns
`LibraryOptions` containing only metadata/scan/subtitle settings. No `CustomCss`, no `Theme`, no
`Branding` per library. The single global CustomCss field at `/System/Configuration/branding` is the
only knob the server exposes.

261
docs/11-neutralfin-audit.md Normal file
View file

@ -0,0 +1,261 @@
# 11 — NeutralFin Render Audit
Status: **read-only audit**, executed 2026-05-08 against
`https://arrflix.s8n.ru` (Jellyfin 10.10.3 on nullstone). Owner reported
the live render "doesn't look as good as it should" relative to the
NeutralFin demo screenshots. Scope: identify why the current `CustomCss`
+ inline critical-path `<style>` block fail to deliver the polished
NeutralFin aesthetic. **No fixes applied. No state mutated.**
> **Headline finding (must read first).** The audit was commissioned
> against the **live NeutralFin render**, but at audit time
> `/Branding/Configuration` returned a `CustomCss` whose `@import` is
> `MRunkehl/cineplex@v1.0.6` and whose accompanying personal-tweak
> blocks reference Cineplex / Netflix-red, **not** NeutralFin. A single
> earlier curl in the audit session momentarily showed a NeutralFin
> import block, then three follow-up cache-busted curls reverted to
> Cineplex. This matches the §3b race rule in `04-theming-and-users.md`:
> the branding endpoint takes a complete object on every POST; whichever
> POST lands last wins. **A NeutralFin payload was applied and then
> overwritten by a sibling Cineplex POST.** The render the owner is
> seeing is therefore not NeutralFin — it is Cineplex with a stale set
> of personal tweaks layered on top, plus a critical-path `<style>` in
> `index.html` that pre-paints the page in Netflix red. That mismatch
> alone is the single highest-impact root cause; everything else below
> is secondary.
---
## 1. Visual contract — what NeutralFin should look like
Sourced from <https://github.com/KartoffelChipss/NeutralFin> README and
the upstream minified CSS at
`https://cdn.jsdelivr.net/gh/KartoffelChipss/NeutralFin@1.3.0/theme/neutralfin-minified.css`.
| Aspect | NeutralFin contract |
|---|---|
| Tagline | *"a sleek black and grey color scheme for a more neutral and modern look"* |
| Lineage | Built on ElegantFin (GPL-2.0). Bundles Jellyfin Lucide icons for the modern icon set. |
| Page background | **Gradient** between `--darkerGradientPoint #131313` and `--lighterGradientPoint #1e1e1e` (0deg). NOT pure `#000`. |
| Card background | `--cardBackgroundGradient` (same two-stop dark gradient). NOT pure `#000`. |
| Header / drawer surface | `--headerColor rgba(40,40,40,0.5)` and `--drawerColor rgba(40,40,40,0.9)` — translucent over a blurred backdrop. |
| Accent (UI) | `--uiAccentColor rgb(130,130,130)`**mid-grey, not coloured**. |
| Active / focus tint | `--activeColor rgb(100,100,100)` — slightly darker grey. |
| Borders | `--borderColor rgb(71,71,71)` (mid), `--darkerBorderColor rgb(51,51,51)`, `--lighterBorderColor rgba(255,255,255,0.2)` — subtle hierarchy across cards/sections. |
| Selector bg | `--selectorBackgroundColor rgb(60,60,60)`. |
| Text | `--textColor rgb(209,213,219)` (off-white, not pure white). `--dimTextColor rgb(156,163,175)`. |
| Play button | `--btnMiniPlayColor rgb(41,154,93)` — the only saturated colour, used on play CTAs. |
| Delete button | `--btnDeleteColor rgb(169,29,29)` — saturated red, but ONLY for destructive confirms. |
| Recommended pairing | Owner enables **backdrops** in Jellyfin (`Display → Show backdrops`). The translucent header/drawer relies on having something to blur. |
| Minimum JF | Not stated. Demos shown on JF 10.11+. We're on 10.10.3 — selectors should largely match (Lucide icon refs may degrade gracefully). |
**Net visual impression:** subdued monochrome, soft gradients, mid-grey
accents, restrained borders, off-white text. The whole thing is a
*texture* of dark greys, not a flat black.
NeutralFin defines **none** of `--primary-background-color`,
`--background-color`, `--background-color-alpha`,
`--card-background-color`, or `--mui-palette-primary-main`. Those are
Jellyfin's own variables; NeutralFin lets Jellyfin's defaults pass
through and skins via its own `--darkerGradientPoint` /
`--lighterGradientPoint` / `--headerColor` / `--drawerColor` set.
---
## 2. Live state at audit time
**`/Branding/Configuration` (anon)** and
**`/System/Configuration/branding` (authed)** both return identical
payload, 25 225 chars of `CustomCss`. Theme banner comments name
**Cineplex v1.0.6**. Sole `@import` is
`https://cdn.jsdelivr.net/gh/MRunkehl/cineplex@v1.0.6/cineplex.css`.
`!important` count in CustomCss: **17**.
`#E50914` occurrences in CustomCss: **0**.
Inline critical-path `<style>` block in
`/jellyfin/jellyfin-web/index.html` (bind-mounted from
`web-overrides/index.html`, 93 lines total): forces `#000000` on
shell wrappers AND `#E50914` on `.raised, .button-submit,
.emby-button[type=submit], button[type=submit]`. **1 occurrence of
`#E50914`**, **8 occurrences of `ARRFLIX`** (title, shim, comments).
ARRFLIX wordmark PNG embedded in CustomCss: **235 × 85 px**,
aspect ratio **2.765**.
---
## 3. Drift table — every rule in current CustomCss + index.html
For each block, classify as KEEP (compatible with NeutralFin),
DROP (legacy / harmful), or MODIFY (needs adjustment for NeutralFin).
Assumes the owner's intent was to be on NeutralFin.
| # | Source | Block | Classification | Reason |
|---|---|---|---|---|
| 1 | CustomCss | `@import cineplex@v1.0.6` | **DROP** | Wrong theme entirely. Owner wants NeutralFin. Replace with `@import KartoffelChipss/NeutralFin@1.3.0/theme/neutralfin-minified.css`. |
| 2 | CustomCss | `#castCollapsible, #USER-FCastCollapsible { display:none }` | **KEEP** | Personal preference, theme-agnostic. NeutralFin doesn't redefine these. |
| 3 | CustomCss | `.adminDrawerLogo img { content: url(<ARRFLIX 235×85 PNG>) }` | **KEEP** | NeutralFin defines no rule for this selector (verified). Override stands. Split-rule form (per §3a) preserved. |
| 4 | CustomCss | `.pageTitleWithLogo { background-image: url(<same PNG>) }` | **KEEP** | Same; NeutralFin doesn't touch this selector. |
| 5 | CustomCss | `.btnQuick { display:none }` | **KEEP** | Server-side disable in §4g still in effect. CSS belt-and-braces is fine on any theme. |
| 6 | CustomCss | `.headerSyncButton/.headerCastButton/.headerUserButton { display:none }` | **KEEP** | NeutralFin sets `width/height/border` on `.headerUserButton` — those rules become moot under `display:none`, no conflict. |
| 7 | CustomCss | `.MuiSlider-thumb { color/bg/border:#fff }` (+ hover halo) | **KEEP**, but reconsider | NeutralFin doesn't theme MUI sliders. White thumbs work, but they're a Cineplex-era decision when the rest of the chrome was Netflix-red/white/black. Against a monochrome grey theme, mid-grey thumbs would read more native. Low priority. |
| 8 | CustomCss | `:root { --primary-background-color:#000 !important; --background-color:#000 !important }` | **DROP** | **High-impact harm.** NeutralFin's whole aesthetic depends on the page background showing the gradient between `#131313` and `#1e1e1e`. Forcing `#000` flattens that gradient to a single pure black, killing the depth NeutralFin was designed to deliver. Owner literally cannot see NeutralFin's intent while these vars are clamped. |
| 9 | CustomCss | `html, body, .preload, .skinBody, .mainDrawerHandle { background-color:#000 !important }` | **DROP** | Same — clobbers NeutralFin's gradient surface. NeutralFin paints page bg via the gradient applied at body / wrapper level; this rule forces solid black underneath. |
| 10 | CustomCss | `.skinHeader/.skinHeader.semiTransparent/.skinHeader-withBackground/.mainAnimatedPages/#reactRoot/.dashboardDocument { background:#000 !important }` | **DROP** | Same. Notably `.skinHeader.semiTransparent` is the surface NeutralFin's `--headerColor rgba(40,40,40,0.5)` translucency renders OVER. Forcing `#000` underneath defeats the blur/translucency effect — the header becomes a flat black bar instead of a glassy panel. |
| 11 | CustomCss | `mypreferencesmenu` :has() block | **KEEP** | Personal tweak, theme-agnostic. JF 10.10.3 supports `:has()` in modern browsers; if a user is on Firefox <121 they'll see the link, but no harm to NeutralFin. |
| 12 | CustomCss | `.countIndicator { display:none }` | **KEEP**, but note | NeutralFin sets `background:#1f50bd; border:var(--defaultLighterBorder)` on this selector. Hiding it is fine and is what owner asked for; the NeutralFin styling becomes irrelevant under `display:none`. |
| 13 | index.html `<style>` | `:root { --primary-background-color:#000; --background-color:#000 }` (no `!important`) | **MODIFY (DROP the var lines)** | Same harm as row 8 but in a sneakier place: it's pre-bundle, paints before CustomCss arrives, then CustomCss row 8 keeps it pinned post-bundle. For NeutralFin to look right, both need to go. |
| 14 | index.html `<style>` | `html, body, .preload, .skinBody, .skinHeader, #reactRoot, .mainAnimatedPages { background:#000 !important; color:#fff !important }` | **MODIFY** | Drop `.skinHeader` from the selector list (so NeutralFin's translucent header isn't pre-painted black) and consider dropping the wrapper bg overrides entirely. The `color:#fff` is also more saturated than NeutralFin's off-white `rgb(209,213,219)` — fine for pre-bundle anti-flash but needs to NOT be `!important` post-bundle. The `!important` here outranks NeutralFin's inherited text colour. |
| 15 | index.html `<style>` | `.raised, .button-submit, .emby-button[type=submit], button[type=submit] { background:#E50914 !important; color:#fff !important }` | **DROP** | **Critical.** NeutralFin is monochrome — the play CTA is green (`--btnMiniPlayColor`), submits use grey accent, only `--btnDeleteColor` is red and only on destructive confirms. This block paints **every submit button Netflix-red**, including login → Sign In, settings → Save, library → Add. Owner did not ask for that on NeutralFin. This is the most jarring single visual conflict. |
| 16 | index.html `<script>` | `nukeSettings()` MutationObserver + `setInterval(...,1000)` | **KEEP** | Targets `mypreferencesmenu` only; doesn't mutate styles or layout. Does fire on every DOM mutation (could be tens per second on rich pages) but the work is one querySelectorAll scoped to a narrow attribute selector. No measurable layout thrash on a non-loaded page; on heavy lists it's the sort of thing to profile but not a "looks bad" cause. |
| 17 | index.html `<script>` | `lockTitle/lockFavicon` head observer + interval | **KEEP** | Cosmetic, unrelated to render quality. |
---
## 4. Variable conflict report
| NeutralFin variable | Default | Overridden by us? | Effect |
|---|---|---|---|
| `--darkerGradientPoint` | `#131313` | no | Gradient bottom intact … but masked by row 9 `body{bg:#000}` |
| `--lighterGradientPoint` | `#1e1e1e` | no | Gradient top intact … masked same way |
| `--headerColor` | `rgba(40,40,40,0.5)` | no | Translucent header colour intact … but row 10 paints `.skinHeader{bg:#000}` underneath, so the alpha composes against pure black instead of the gradient. Header reads flatter than NeutralFin intends. |
| `--drawerColor` | `rgba(40,40,40,0.9)` | no | OK — drawer bg unaffected by the wrapper-element rules. |
| `--borderColor` | `rgb(71,71,71)` | no | OK |
| `--uiAccentColor` | `rgb(130,130,130)` | no in CustomCss; **YES** indirectly via index.html row 15 (every submit button forced red) | Submit buttons should be grey-accented; instead they are `#E50914`. |
| `--activeColor` | `rgb(100,100,100)` | no | OK |
| `--textColor` | `rgb(209,213,219)` | partially — index.html row 14 sets `color:#fff !important` on body | Text is full white instead of the off-white NeutralFin uses. Subtle but cumulative. |
| `--btnMiniPlayColor` | `rgb(41,154,93)` | no | Play CTA still green, OK. |
| `--btnDeleteColor` | `rgb(169,29,29)` | no | Delete confirms still red, OK. |
| Jellyfin `--primary-background-color` | (Jellyfin default `#101010`-ish) | **YES** — row 8 + row 13 → `#000` | NeutralFin doesn't override this var; NeutralFin paints the gradient directly on `body`. Forcing `--primary-background-color:#000` doesn't break NeutralFin's body gradient (NeutralFin doesn't read this var) BUT the `body{bg:#000 !important}` rule that lives next to it DOES, because it sets the body bg directly and beats NeutralFin's lower-specificity body rule. |
| Jellyfin `--background-color` | Jellyfin default | **YES** — row 8 + row 13 → `#000` | Same — variable override harmless on its own; the wrapper rule next door is the real damage. |
| `--mui-palette-primary-main` | (MUI default) | no | OK; sliders/checkboxes keep MUI palette. |
---
## 5. Logo aspect ratio
ARRFLIX wordmark PNG: **235 × 85 px**, aspect **2.765 : 1**.
NeutralFin (and Cineplex) target three logo containers:
- `.adminDrawerLogo img` — admin sidebar drawer. Inherits sidebar
width (~240 px on desktop). 235 × 85 fits naturally; replaces
`<img>` source via `content:`. **Match: YES.**
- `.pageTitleWithLogo` — masthead `<div>` on dashboard / login pages.
In NeutralFin this `<div>` is sized by `var(--appBarHeight) 5em`
(header height) and the `background-image` is laid out with
`background-size: contain` (NeutralFin / ElegantFin convention).
At 5 em ≈ 80 px header height a 235 × 85 image will render at
~221 × 80 — fits the header band cleanly. **Match: YES**, no
squish, no clip.
- `.detailLogo` — clear-logo on item detail pages (movies / shows).
NeutralFin sizes this at `width:40%; height:25vh; background-position:bottom`
with `background-size:contain` — designed for tall, near-square
clear logos. A 2.765:1 wordmark will render small (height-limited
by the 25vh box only at very narrow viewports; at 1080p it's
width-limited at 40% = 768 px and height settles at ~278 px, well
under 25vh = 270 px). Acceptable, no distortion. **Match: YES.**
**Verdict: Logo aspect ratio is fine. Not a render-quality root
cause.** A 235 × 85 wordmark is on the wide end of typical Jellyfin
custom logos but fits every container cleanly because both NeutralFin
and Cineplex use `background-size: contain` on the masthead.
---
## 6. Recommended fix list (impact-ranked, top = biggest visual win)
> **Read-only audit. None of these have been applied.** Owner sign-off
> required before any branding POST.
1. **Apply NeutralFin (currently NOT applied).** Replace the
`@import` line in CustomCss to point at
`https://cdn.jsdelivr.net/gh/KartoffelChipss/NeutralFin@1.3.0/theme/neutralfin-minified.css`.
(Verify the live `Branding/Configuration` reflects this *after* the
POST, and that no sibling agent is racing the endpoint — see §3b
operational rule. Make this POST the LAST POST in the sequence.)
2. **Drop the pure-black background overrides** in CustomCss
(drift-table rows 8, 9, 10). NeutralFin's whole texture is the
`#131313 → #1e1e1e` gradient; clamping it to `#000` flattens it
and is the single biggest cause of the "not as good as it should"
feel.
3. **Drop `#E50914` from the index.html critical-path `<style>`**
(drift-table row 15). On NeutralFin, every submit button suddenly
being Netflix-red is the single most jarring visual conflict.
Also drop `.skinHeader` from the wrapper bg list (row 14) and
the `--primary-background-color/--background-color #000`
declarations (row 13). What stays in the critical-path `<style>`
should be: `html, body { background:#0e0e0e }` (close enough to
NeutralFin's gradient midpoint to avoid pre-bundle flash without
clamping the gradient post-bundle) and `color:#d1d5db` (the
off-white NeutralFin uses) — both WITHOUT `!important` so the
theme can take over once it loads.
4. **Reconsider the white slider thumbs** (row 7) once #13 land. If
the owner still finds them too "Netflix" against a grey theme,
change to `currentColor` or `var(--uiAccentColor)`. Low priority,
purely taste.
5. **Audit the `!important` count** post-fix. Currently 17; once the
black-bg wrapper rules drop, the count falls to ~10, all of which
are legitimate (display:none overrides, logo content: replacements,
slider thumb forces). NeutralFin's hover/focus states will then
fire correctly because no `!important` rule is masking them.
---
## 7. Rollback note
If owner says "revert everything I had before the audit-driven fixes":
```bash
git checkout snapshot-2026-05-08-pre-elegantfin -- \
snapshots/2026-05-08-pre-elegantfin/branding.json
# then POST that file's contents to /System/Configuration/branding
# (full restore command in snapshots/2026-05-08-pre-elegantfin/RESTORE.md)
```
That snapshot captures the **Cineplex era** state — the CustomCss in it
is the same Cineplex import that's live RIGHT NOW (modulo personal-tweak
appendices that were added after the snapshot). It does NOT contain a
NeutralFin import, because NeutralFin was never persisted long enough
to enter the canonical history; the §3e ElegantFin migration block in
`04-theming-and-users.md` documents an *intended* state that the owner
had asked for but which a sibling Cineplex POST has since silently
reverted.
For a clean reset to vanilla Jellyfin (no theme at all) before
re-trying NeutralFin:
```bash
curl -sS -X POST -H "X-Emby-Token: $TOKEN" \
-H "Content-Type: application/json" \
-d '{"CustomCss":"","LoginDisclaimer":"Welcome to ARRFLIX - Private invite only service","SplashscreenEnabled":true}' \
https://arrflix.s8n.ru/System/Configuration/branding
```
---
## 8. What was NOT touched during this audit
- No POST to `/System/Configuration/branding`.
- No edit to `web-overrides/index.html` or the bind-mounted
`/jellyfin/jellyfin-web/index.html`.
- No `docker compose` action, no container restart.
- No git commit on `snapshots/`, no tag movement.
- Read-only over SSH; only `docker exec jellyfin sh -c '...'` shell
invocations, all bounded to `wc -l` / `head` / `grep -c`.
---
## 9. Sign-off
- **Auditor:** s8n (audit pass, 2026-05-08)
- **Live theme at audit time:** Cineplex v1.0.6 (despite doc 04 §3e
claiming ElegantFin + ARRFLIX recolor; despite owner believing the
state is NeutralFin)
- **Doc 04 §3e accuracy:** stale — needs an §3f addendum after fixes
documenting the NeutralFin migration and the race-loss that hid it.
- **Next step:** owner reviews this doc, decides whether to apply the
fix list in §6. No work to be done on the live server until that
review.

174
docs/12-dev-instance.md Normal file
View file

@ -0,0 +1,174 @@
# 12 — Jellyfin DEV instance for theme experimentation
A second Jellyfin container, `jellyfin-dev`, runs alongside prod on
nullstone. Same media library (read-only), separate config/cache/users,
separate domain. LAN-only by design — you can break it freely without
real users (USER-A, USER-G, USER-F, 5) noticing.
---
## Architecture diff
| Aspect | Prod | Dev |
|-------------------|-------------------------------------|-------------------------------------------|
| Container | `jellyfin` | `jellyfin-dev` |
| Image | `jellyfin/jellyfin:10.10.3` | `jellyfin/jellyfin:10.10.3` (must match) |
| Compose path | `/opt/docker/jellyfin/` | `/opt/docker/jellyfin-dev/` |
| Config dir | `/home/docker/jellyfin/{config,cache}` | `/home/docker/jellyfin-dev/{config,cache}` |
| Media mount | `/home/user/media:/media:ro` | `/home/user/media:/media:ro` (SAME, RO) |
| Domain | `arrflix.s8n.ru` | `dev.arrflix.s8n.ru` |
| Pi-hole DNS | `dns.hosts` in pihole.toml | `dns.hosts` in pihole.toml (added 2026-05-08) |
| Traefik router | `Host(arrflix.s8n.ru)` | `Host(dev.arrflix.s8n.ru)` |
| Cert | LE DNS-01 (Gandi) | LE DNS-01 (auto-issued on first request) |
| Middleware | `security-headers@file` only | `security-headers@file,no-USER-F@file` |
| WAN exposure | Yes during WAN window (doc 09) | NEVER — LAN-only forever |
| Internal port | `8096` | `8096` |
| User | `1000:1000` | `1000:1000` |
| `userns_mode` | `host` | `host` |
| index.html shim | Bind-mounted (doc 10) | None (vanilla shell — clean theme canvas) |
| Branding/auth | Configured | Empty — first-run wizard required |
The compose file lives in this repo at `compose-dev/docker-compose.yml`
and is deployed to nullstone at `/opt/docker/jellyfin-dev/docker-compose.yml`.
---
## How to use
1. Open `https://dev.arrflix.s8n.ru` from any LAN/tailnet box. First visit hits the
first-run wizard — create an admin user (use any throwaway name; nothing
shared with prod).
2. Add libraries pointing at the same paths prod uses:
- `/media/movies`
- `/media/tv`
The library ROOTS are shared (read-only); dev will rescrape independently
into its own `library.db`. That's intentional — dev is a clean slate.
3. Apply a theme via Branding API or via the SPA shim (doc 10) by dropping
files into `/opt/docker/jellyfin-dev/web-overrides/` and adding the same
bind-mount pattern as prod (currently absent for a clean canvas).
4. Test, watch, break. Prod remains untouched on `arrflix.s8n.ru`.
---
## Theme workflow (dev → prod)
When a dev theme is "shipped":
1. **Export branding** from dev:
```bash
curl -k -H "X-Emby-Token: $DEV_TOKEN" \
https://dev.arrflix.s8n.ru/Branding/Configuration > /tmp/branding.json
```
2. **POST to prod**:
```bash
curl -k -X POST \
-H "X-Emby-Token: <JELLYFIN_API_TOKEN>" \
-H "Content-Type: application/json" \
--data @/tmp/branding.json \
https://arrflix.s8n.ru/System/Configuration/branding
```
3. If the theme involves SPA-shim files (custom JS/CSS), `rsync` them from
`dev:/opt/docker/jellyfin-dev/web-overrides/` to
`prod:/opt/docker/jellyfin/web-overrides/` and hot-reload prod via the
bind-mount (no container restart needed for read-only mounts on file
change — Jellyfin will serve the new file on next request).
Auth tokens for dev are local to the dev instance — they'll be issued by
the dev wizard. They DO NOT cross over.
---
## Reset / wipe dev
When experiments make a mess:
```bash
ssh user@192.168.0.100
cd /opt/docker/jellyfin-dev
docker compose down
sudo rm -rf /home/docker/jellyfin-dev/config/* /home/docker/jellyfin-dev/cache/*
# (use the privileged-userns-host bypass if no sudo:
# docker run --rm --privileged --userns=host -v /home/docker:/h alpine \
# sh -c 'rm -rf /h/jellyfin-dev/config/* /h/jellyfin-dev/cache/*')
docker compose up -d
```
First-run wizard reappears. The media library is intact (read-only mount,
unaffected).
---
## LAN-only enforcement
`no-USER-F@file` middleware (defined in `/opt/docker/traefik/config/dynamic.yml`)
restricts source IPs to:
- `127.0.0.0/8`
- `192.168.0.0/24` (LAN)
- `100.64.0.1/32` onyx, `100.64.0.2/32` nullstone, `100.64.0.4/32` office (tailnet)
- `SCRUBBED-IP/32` YOU500 home IP
- `172.20.0.0/24` docker proxy gateway
Anyone outside that list trying `https://dev.arrflix.s8n.ru` from the WAN
gets a Traefik 403. Even if a USER-F tailnet node (100.64.0.3 friend GPU)
hits dev, no-USER-F blocks them — only `tag:admin` and `tag:infra` are
allowed.
There is **no plan** to expose dev publicly. If you need to test something
WAN-shaped, do it on prod inside the WAN window (doc 09) — never widen
dev's allowlist.
---
## Risks and non-risks
- **Read-only media mount.** Dev cannot write to `/home/user/media`.
Theme experiments cannot accidentally rename, delete or scramble files.
- **Separate library.db.** Dev rescrapes from scratch. If a metadata
experiment in dev produces bad results, it never touches prod metadata.
- **Same Traefik instance.** Both routers share the proxy network and the
one Traefik. A misconfigured label on dev could *theoretically* shadow
prod's router, but the rules are `Host(dev.arrflix.s8n.ru)` vs
`Host(arrflix.s8n.ru)` — disjoint. Sanity-check after any compose edit
with `curl -kI https://arrflix.s8n.ru/`.
- **Same image tag.** Bumping prod to a new Jellyfin version means
bumping dev too; do prod first, then sync dev. Never test a version
bump on dev and forget to mirror prod — the API surface might drift.
- **No shared sessions.** Tokens, users, watch progress, playlists are
100% isolated. A test admin in dev cannot act on prod, and vice versa.
---
## Quick reference
```
# Status
ssh user@192.168.0.100 'docker ps --filter name=jellyfin'
# Logs
ssh user@192.168.0.100 'docker logs jellyfin-dev --tail 100 -f'
# Restart
ssh user@192.168.0.100 'cd /opt/docker/jellyfin-dev && docker compose restart'
# Stop / start
ssh user@192.168.0.100 'cd /opt/docker/jellyfin-dev && docker compose down'
ssh user@192.168.0.100 'cd /opt/docker/jellyfin-dev && docker compose up -d'
# Health check from onyx
curl -kI https://dev.arrflix.s8n.ru
# expect HTTP/2 302, location: web/
```
---
## DNS pin path used
The dev hostname was added to Pi-hole's `dns.hosts` array in
`/opt/docker/pihole/etc-pihole/pihole.toml` (alongside the existing
LAN-only entries) and Pi-hole was restarted to pick up the change.
The legacy `custom.list` file is still present but is no longer the
authoritative source — `dns.hosts` in `pihole.toml` is what
`pihole-FTL` actually consults.
If `dev.arrflix.s8n.ru` ever fails to resolve, restart Pi-hole and
re-check the `dns.hosts` array.

View file

@ -0,0 +1,372 @@
# 13 — Optimization Audit (Read-Only)
> Status: **read-only audit**, executed 2026-05-08 against
> `https://arrflix.s8n.ru` (Jellyfin 10.10.3 on nullstone). Scope: scan
> for performance, capacity, reliability, and ops-hygiene risks. **No
> fixes applied. No state mutated. No container restarts.**
Audit ran ~25 minutes wall. Inputs: Jellyfin REST API (auth
`X-Emby-Token: 76858153…f8b1`), `docker exec jellyfin`, `docker logs
{traefik,jellyfin} --since 1h/6h/24h`, host `free`, `df`, `uptime`,
`nvidia-smi`, on-disk Jellyfin XML configs.
---
## Executive summary
1. **Host is under serious memory pressure right now.** `uptime` shows
load average **11.40 / 9.59 / 6.19** on a 12-core box, **6.8 GiB of
swap is in use** (out of 24 GiB), and `/home` is **90 % full
(40 GiB free of 399 GiB)**. Jellyfin itself is fine
(522 MiB / 31 GiB cap, no restarts), but the host it lives on is
loaded enough that any media ingest at scale will start swap-thrashing.
This is the single biggest risk to playback latency.
2. **GPU transcode is dead and confirmed dead.** `nvidia-smi` fails on
host, `lsmod | grep nvidia` returns empty, `/dev/nvidia*` does not
exist. `EnableHardwareEncoding=true` and `HardwareAccelerationType=none`
in `encoding.xml` is harmless but misleading — the toggle is on, but
the type selector is `none`, so every transcode goes through ffmpeg
software path. Two HLS segment requests this hour returned **499**
(client cancelled mid-transcode at 6.4 s and 2.9 s wall) — that is
the playback-stalls signature.
3. **OpenSubtitles plugin is logging an error per file probed during
library scan** (102 errors in last 6 h) because `Username` and
`Password` are empty in the plugin XML. Every Scan Media Library run
tries Open Subtitles, fails on auth, logs an `ERR`, retries on the
next file. This is pure log noise + wasted RTT, not data loss, but
it bloats `/config/log` and obscures real warnings.
4. **Transcode throttling is OFF and `MaxMuxingQueueSize` is 2048**
on a CPU-only deploy that means a stalled client with high-bitrate
AV1/HEVC source will keep ffmpeg burning a full core for up to
`SegmentKeepSeconds=720`s after the client gives up. `EnableThrottling`
should be on for a CPU deploy; this would have prevented the 499s
seen above.
5. **No automated backup of `/home/docker/jellyfin/config/`.** The
Cineplex CSS, the 5 user accounts + permissions, the library
metadata, and the Open Subtitles plugin install all live in one
unprotected directory tree. The repo's `snapshots/` only captures the
pre-ElegantFin migration baseline; nothing on disk is being rotated
off-host.
---
## Findings table
Severity legend: **R** = red (acute, fix this week), **Y** = yellow
(deferred fix, document risk), **G** = green (audited, healthy, no
action). Effort: **S** ≤ 30 min, **M** half-day, **L** > 1 day.
| # | Category | Severity | Evidence | Recommendation | Effort |
|---|---|:-:|---|---|:-:|
| 01 | Host capacity | **R** | `uptime` load 11.40 / 9.59 / 6.19 on 12 cores; swap 6.8 GiB used / 24 GiB; `/home` 90 % full | Identify swap hog (likely not Jellyfin — only 522 MiB RSS); reclaim space on `/home`; budget media additions against the 40 GiB headroom | M |
| 02 | GPU transcode | **R** | `nvidia-smi` fails, no `/dev/nvidia*`, `lsmod` no nvidia mod; `HardwareAccelerationType=none` | Reinstall nvidia driver on nullstone host; once `nvidia-smi` works, add device reservation block to compose and flip `HardwareAccelerationType` to `nvenc` | L |
| 03 | Transcode throttling | **R** | `EnableThrottling=false`, `ThrottleDelaySeconds=180`, `MaxMuxingQueueSize=2048`, **two 499 client-cancels** logged (6 439 ms / 2 890 ms) | Enable `EnableThrottling=true` and `EnableSegmentDeletion=true` for CPU-only era — caps wasted ffmpeg CPU after client disconnect | S |
| 04 | OpenSubtitles auth | **G** | Creds set `Caveman5`, `CredentialsInvalid=false` (verified 2026-05-11). Spam loop resolved | RESOLVED | — |
| 05 | Cache trash budget | **Y** | `EnableSegmentDeletion=false`, `SegmentKeepSeconds=720`; `/cache/transcodes` only 20 K right now (no live stream), but a 4K HEVC→h264 session will fill GiBs and not auto-prune | Enable `EnableSegmentDeletion=true` (default 720 s keep is fine) — pairs with finding 03 | S |
| 06 | Backup posture | **R** | `/home/docker/jellyfin/config/` (104 MB) has no off-host rotation; `snapshots/` in repo only holds pre-ElegantFin baseline | Add a weekly `tar.zst` of `/config/` (excluding `log/`, `cache/`) to NAS or git-backed snapshot dir | M |
| 07 | Disk pressure | **Y** | `/home` 90 % full, 40 GiB free of 399 GiB; `/home/user/media` only 189 files | Cap on media growth: at current free space + episode bitrate budget user has ~34 more series before disk fills | M |
| 08 | DB WAL ratio | **Y** | `library.db`=3.3 MB, `library.db-wal`=4.4 MB (WAL > main, uncheckpointed). `Optimize database` last ran 2026-05-08T00:58 (OK) but a fresh scan completed 03:16 left WAL fat | Either trigger a manual `Optimize database` post-scan, or shorten its schedule to "after every full scan". WAL > main is normal during/after a scan but should checkpoint on idle | S |
| 09 | Custom CSS bloat | **Y** | `CustomCss` in `branding.xml` is **25 225 bytes**, 17 `!important`, sole `@import` is `MRunkehl/cineplex@v1.0.6` (jsDelivr) | jsDelivr import adds 1 round-trip + ~50 KB on every cold cache load. Inline the import for offline-resilience and one-fewer DNS hop. Also doc 11 already flags this as the wrong theme (Cineplex, not NeutralFin) — resolve theme race first | M |
| 10 | SPA shim cost | **G** | `web-overrides/index.html` 58 KB; runs **2× MutationObserver** + **1× setInterval(1000ms)** with `lockTitle/lockFavicon/nukeSettings`; cost ~1 ms per tick | Acceptable for a single-tab branding shim; would be a problem only on background tabs at scale. No action | — |
| 11 | Service worker | **G** | `/web/serviceworker.js` 768 bytes, last modified 2024-11-19 (Jellyfin 10.10.3 ship date), serves with `cache-control: no-store` (HTTPS, etag set). Notification-only SW (per doc 10) | No action — it is small and not caching `index.html` so cannot pin stale branding | — |
| 12 | Metrics endpoint | **G** | `EnableMetrics=false` | Off is correct for a single-server box. No action | — |
| 13 | Slow-response warning | **Y** | `EnableSlowResponseWarning=true`, threshold **500 ms**. Two transcoding 499s above 2.8 s would normally trigger this warning, but I see 0 `slow` lines in 1 h logs | Either Jellyfin's slow log only fires on synchronous request handlers (not HLS segment GETs), or warning suppressed by another setting. Worth confirming threshold semantics | S |
| 14 | Library scan concurrency | **Y** | `LibraryScanFanoutConcurrency=0`, `LibraryMetadataRefreshConcurrency=0`, `ParallelImageEncodingLimit=0` (all defaults — auto = `ProcessorCount`) | On a 12-core box already at load 11+, `0` (= 12) for all three is aggressive. Cap each at 46 to leave headroom for Forgejo/Traefik/etc | S |
| 15 | Realtime monitor | **Y** | Both libraries have `EnableRealtimeMonitor=true`; only 189 files; `LibraryMonitorDelay=60` | Fine for current size, but inotify watches grow with file count. Re-evaluate at 10 k+ files | — |
| 16 | Trickplay / chapter previews | **G** | `EnableTrickplayImageExtraction=false`, `ExtractChapterImagesDuringLibraryScan=false`, `EnableChapterImageExtraction=false`, `ExtractTrickplayImagesDuringLibraryScan=false` (all libs) | Disabled on both libraries — saves significant CPU. No action. (Note: scheduled task `Generate Trickplay Images` still ran 02:00 — check it is a no-op when libs say no) | — |
| 17 | Photos library | **G** | `EnablePhotos=false` on both | Correct for a movies/TV deploy. No action | — |
| 18 | Plugin set | **G** | 6 plugins active (AudioDB, MusicBrainz, OMDb, OpenSubtitles, StudioImages, TMDb). `Username/Password` empty for OMDb (= no key, falls back to anon rate limit) and TMDb (`TmdbApiKey` empty — falls back to bundled key) | Both tolerated. AudioDB + MusicBrainz unused (no music libs) but cost zero idle. Consider removing for minimalism, not perf | — |
| 19 | Admin user policy | **R** | `s8n` admin has `EnableRemoteControlOfOtherUsers=true`, `EnableContentDeletion=true` (correct for admin) but **also `IsHidden=true`** | Hidden admin is non-standard; usually a hidden admin is reserved for automation. If `s8n` is the operator's daily account, `IsHidden=false` is the convention. Low risk, just unusual | S |
| 20 | Non-admin policies | **Y** | All 4 non-admin users (`5`, `USER-F`, `USER-G`, `USER-A`) have `EnableContentDownloading=true`, `EnableMediaConversion=true`, `EnableLiveTvManagement=true`, `EnableSharedDeviceControl=true`, `IsHidden=true` | LiveTvManagement on accounts with no Live TV is dead weight, no harm. ContentDownloading + MediaConversion let any user kick off transcodes — a foot-gun on a CPU-only host. Review desired stance | S |
| 21 | Login disclaimer leak | **G** | `LoginDisclaimer` = "Welcome to ARRFLIX - Private invite only service" | Public-facing string is intentional per doc 09. No action | — |
| 22 | Public WAN exposure | **Y** | `EnableRemoteAccess=true`, `no-USER-F@file` middleware **dropped** in compose (per doc 09 §1.2). 24 h log: 270 LAN reqs, **59 reqs from 157.143.84.87, 1 from 82.31.156.86** | Doc 09 confirms this is intentional. The 157.143.84.87 hits are bot-style asset-prober 404s — harmless but confirms the service is internet-reachable. No action; re-verify rate limit / fail2ban once router port-forward is active | — |
| 23 | Splashscreen size | **Y** | `/config/data/splashscreen.png` is **3.0 MB** | A splash image of 3 MB is large for a PNG; lossless re-encode or downscale to ≤500 KB; saves on first-paint over WAN | S |
| 24 | Log rotation | **G** | `LogFileRetentionDays=3`; `/config/log` 1.3 MB; rotation working | No action | — |
| 25 | Splashscreen flag | **Y** | `SplashscreenEnabled=true` in `branding.xml` | Intentional for branding, no action — pairs with finding 23 (just shrink the file) | — |
| 26 | Cache breakdown | **G** | `/cache/images` 15 MB (entire cache 15 MB); `/config/metadata` 92 MB; `/config/data` 12 MB; `/config/plugins` 128 KB | Healthy small footprint. No action | — |
| 27 | Forgejo log noise | **Y** | Traefik logs show `forgejo@docker` returning **401** for `s8n/ARRFLIX.git/info/refs?service=git-receive-pack` 8× / hour from 192.168.0.10 | Out of scope for this deploy but indicates a stale `git push` retry loop on onyx — surfaces here only because we're scanning traefik logs. Mention to operator separately | — |
| 28 | Path substitutions | **G** | `system.xml` empty `<PathSubstitutions />` and `<CorsHosts />` | Correct (no NFS/SMB indirection, no cross-origin clients). No action | — |
| 29 | LiveTV residue | **G** | `DisableLiveTvChannelUserDataName=true`; no Live TV configured; per-user `EnableLiveTvAccess=true` is dead weight | Cosmetic; no perf cost. No action | — |
| 30 | Container restart count | **G** | `docker inspect` `RestartCount=0`, `Status=running`, `StartedAt=2026-05-08T02:13:01` (~2 h uptime, healthy) | No action. (Boot was at 02:13, suggests the compose was applied for doc-09 WAN flip and ran clean since) | — |
| 31 | Network XML hygiene | **Y** | `KnownProxies` empty, `LocalNetworkSubnets` empty, `LocalNetworkAddresses` empty | Jellyfin can't tell the Traefik 172.20.0.0/16 docker net from random WAN — every external IP is logged as remote, which inflates Jellyfin's geoIP/session bookkeeping. Set `KnownProxies=172.20.0.0/16` and `LocalNetworkSubnets=192.168.0.0/24` | S |
| 32 | TLS cert | **G** | LE cert valid `2026-05-08 → 2026-08-06` (89 days remaining), issued by R13, Gandi DNS-01 resolver, in `acme.json` | Healthy. No action | — |
| 33 | Request-rate posture | **G** | 81 req / hour total via traefik; 62 of those are `jellyfin@docker`. Top src 192.168.0.10 (LAN, the operator), then 157.143.84.87 (asset-prober 404s) | Low rate. No action — re-evaluate if WAN exposure draws more traffic | — |
| 34 | Idle session count | **G** | `/Sessions` returns 2 idle (s8n + USER-F) on 192.168.0.10; no playback in flight at audit time | No action | — |
| 35 | Item counts | **G** | 2 movies, 6 series, 169 episodes; matches `find /media -type f` (189 files, accounting for non-video extras) | Library scan is healthy; counts converged | — |
---
## Recommended fix order (top 5 by impact-per-effort)
1. **Finding 03 — enable transcode throttling + segment deletion.**
*Effort: S (two checkboxes in Playback settings).* Closes the
highest-cost behaviour we have evidence of (the 499 ms wall events).
Saves CPU cycles per stalled client.
2. **Finding 04 — set OpenSubtitles credentials, OR disable
provider.** *Effort: S.* Removes 102 ERR/6 h of log spam, fixes
subtitle download, immediately restores log signal.
3. **Finding 31 — populate `KnownProxies` + `LocalNetworkSubnets` in
`network.xml`.** *Effort: S.* Restores accurate session origin
reporting; needed before any rate-limiting or fail2ban work post-WAN.
4. **Finding 14 — cap `LibraryScanFanoutConcurrency`,
`LibraryMetadataRefreshConcurrency`, `ParallelImageEncodingLimit`
to 46.** *Effort: S.* Stops a future scan piling on top of the
existing host load (currently 11.4).
5. **Finding 06 — automate `/config/` backup.** *Effort: M.* Single
highest-blast-radius risk: a corrupt `library.db` or a `branding.xml`
regression and you've lost the user accounts AND the theme work in
one go. A weekly `tar.zst` to NAS closes this.
GPU re-enable (finding 02) would unlock more wins but is **L** effort
and lives outside Jellyfin (host driver work). Throttling (#03) is the
right CPU-era patch until then.
---
## Out of scope (audited and found healthy)
- **Service worker** (`/web/serviceworker.js`, 768 B, notification-only,
not caching index.html — finding 11).
- **Container restart count** (0 — finding 30).
- **TLS cert chain** (89 days valid — finding 32).
- **Trickplay / chapter / photo extraction** (all disabled — findings
16, 17).
- **Log rotation** (3-day retention working, 1.3 MB /config/log —
finding 24).
- **Cache directory growth** (15 MB total, healthy — finding 26).
- **Plugin set** (6 plugins, all idle-cheap — finding 18).
- **Idle session footprint** (2 idle web sessions, no playback in
flight — finding 34).
- **Item count convergence** (Items/Counts matches filesystem —
finding 35).
- **Path substitution / CORS hygiene** (empty as expected — finding 28).
- **Login disclaimer string** (per-doc-09 intentional public-facing
text — finding 21).
---
## Appendix — raw evidence
### Host
```
uptime: 04:18:55 up 4 days, 4:36, 3 users, load average: 11.40, 9.59, 6.19
nproc: 12
free -h: total 31Gi, used 9.2Gi, free 5.8Gi, swap used 6.8Gi / 24Gi
df -h /home: 399G total, 339G used, 40G avail (90 % full)
```
### Container
```
docker stats jellyfin (no-stream):
CPU 0.01 %, MEM 521.5 MiB / 31.27 GiB (1.63 %), PIDS 24, NET 83.8 MB / 361 MB
docker inspect: Restarts=0, Started=2026-05-08T02:13:01Z, Status=running
```
### GPU
```
nvidia-smi: NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver
lsmod | grep nvidia: (no matches)
ls /dev/nvidia*: No such file or directory
encoding.xml: HardwareAccelerationType=none, EnableHardwareEncoding=true
```
### Disk
```
/config 104 M (data 12M, metadata 92M, log 1.3M, plugins 128K)
/cache 15 M (images 15M, transcodes 20K, fontconfig 36K, omdb 84K)
/home/docker/jellyfin: not visible (sudo blocked); inferred from container view
```
### Database
```
jellyfin.db 208 K (WAL 473 K, SHM 32 K)
library.db 3.3 M (WAL 4.4 M, SHM 32 K) <- WAL > main
keyframes/ 16 K
splashscreen.png 3.0 M
```
### Traefik (last 1 h)
```
total log lines: 279
jellyfin@docker requests: 62
status 499 (client cancel): 2 (HLS segments, 6439 ms + 2890 ms)
status 5xx: 0
top source IPs (jellyfin):
82.31.156.86 123 (own WAN egress, hairpin)
82.131.116.123 122 (external — likely friend / scanner)
192.168.0.10 13 (operator LAN)
173.244.58.11 2 (cloud scanner)
35.203.85.72 1 (Google security scan)
```
### Jellyfin (last 6 h)
```
"Error downloading subtitles from Open Subtitles": 102
"slow" / "throttl" matches: 1 (false positive, no real slow-warn)
Container restart events: 0
```
### TLS
```
Subject: CN=arrflix.s8n.ru
Issuer: C=US, O=Let's Encrypt, CN=R13
Valid: 2026-05-08 00:58:11 GMT → 2026-08-06 00:58:10 GMT (89 d)
Resolver: letsencrypt (Gandi DNS-01)
```
### Service worker
```
URL: https://arrflix.s8n.ru/web/serviceworker.js
HTTP: 200, content-type text/javascript
Size: 768 bytes
Last-Modified: Tue, 19 Nov 2024 03:43:48 GMT (Jellyfin 10.10.3 ship)
Headers: HSTS preload + nosniff + frame=SAMEORIGIN + xss-protection
```
### CSS / branding
```
/Branding/Configuration:
CustomCss bytes: 25 225
!important rules: 17
sole @import: https://cdn.jsdelivr.net/gh/MRunkehl/cineplex@v1.0.6/cineplex.css
LoginDisclaimer: "Welcome to ARRFLIX - Private invite only service"
SplashscreenEnabled: True
on disk:
/config/config/branding.xml 25 584 bytes
```
### SPA shim
```
/opt/docker/jellyfin/web-overrides/index.html 58 725 bytes
MutationObserver count: 2 (one head/title-favicon, one body/nukeSettings)
setInterval count: 1 (1000 ms — relocks title + favicon + nukeSettings)
```
### Users
```
# users: 5
admin (s8n): IsHidden=true, EnableRemoteControlOfOtherUsers=true, EnableContentDeletion=true
non-admin (5, USER-F, USER-G, USER-A): IsHidden=true, EnableContentDownloading=true,
EnableMediaConversion=true, EnableLiveTvManagement=true
```
### Plugins
```
AudioDB 10.10.3.0 Active
MusicBrainz 10.10.3.0 Active RateLimit=1, ReplaceArtistName=false
OMDb 10.10.3.0 Active CastAndCrew=false
Open Subtitles 20.0.0.0 Active Username/Password empty, CredentialsInvalid=false
Studio Images 10.10.3.0 Active
TMDb 10.10.3.0 Active TmdbApiKey empty
```
### Library options (both libs)
```
EnableRealtimeMonitor = True
ExtractChapterImagesDuringLibraryScan = False
EnableTrickplayImageExtraction = False
EnablePhotos = False
SaveLocalMetadata = False
EnableInternetProviders = False
SkipSubtitlesIfAudioTrackMatches = True
SaveSubtitlesWithMedia = True
ExtractTrickplayImagesDuringLibraryScan= False
```
### Network XML
```
EnableHttps=false (TLS handled by Traefik) | EnableUPnP=false | EnableRemoteAccess=true
KnownProxies=(empty) LocalNetworkSubnets=(empty) LocalNetworkAddresses=(empty)
IgnoreVirtualInterfaces=true VirtualInterfaceNames=[veth]
EnablePublishedServerUriByRequest=false
```
### System config — performance knobs
```
LogFileRetentionDays = 3
EnableMetrics = False
EnableSlowResponseWarning = True (threshold 500 ms)
RemoteClientBitrateLimit = 0 (no cap)
LibraryScanFanoutConcurrency = 0 (auto = ProcessorCount = 12)
LibraryMetadataRefreshConcurrency = 0 (auto = ProcessorCount = 12)
ParallelImageEncodingLimit = 0 (auto = ProcessorCount = 12)
EnableNormalizedItemByNameIds = True (correct for 10.10.x)
QuickConnectAvailable = False
EnableCaseSensitiveItemIds = True
EnableFolderView = False
EnableGroupingIntoCollections = False
IsStartupWizardCompleted = True
ChapterImageResolution = (default)
DummyChapterDuration = (default)
ImageExtractionTimeoutMs = (default)
LibraryMonitorDelay = 60
LibraryUpdateDuration = 30
ActivityLogRetentionDays = (default)
```
### Encoding config — full dump
```
EncodingThreadCount = -1 (auto)
EnableAudioVbr = False
MaxMuxingQueueSize = 2048
EnableThrottling = False ← finding 03
ThrottleDelaySeconds = 180
EnableSegmentDeletion = False ← finding 05
SegmentKeepSeconds = 720
HardwareAccelerationType = none ← finding 02
EncoderAppPathDisplay = /usr/lib/jellyfin-ffmpeg/ffmpeg
VaapiDevice = /dev/dri/renderD128 (no Intel iGPU on host)
H264Crf = 23
H265Crf = 28
EncoderPreset = (nil)
EnableHardwareEncoding = True (no-op while type=none)
AllowHevcEncoding = False
AllowAv1Encoding = False
EnableSubtitleExtraction = True
HardwareDecodingCodecs = [h264, vc1]
AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = [mkv]
PreferSystemNativeHwDecoder = True
EnableEnhancedNvdecDecoder = True (no-op while no nvidia)
```
### Scheduled tasks
```
Audio Normalization Idle Completed 2026-05-08T00:58
Clean Cache Directory Idle Completed 2026-05-08T00:58
Clean Log Directory Idle Completed 2026-05-08T00:58
Clean Transcode Directory Idle Completed 2026-05-08T02:13
Download missing subtitles Idle Completed 2026-05-08T00:58
Extract Chapter Images Idle Completed 2026-05-08T01:00
Generate Trickplay Images Idle Completed 2026-05-08T02:00 (no-op?)
Optimize database Idle Completed 2026-05-08T00:58
Refresh People Idle Completed 2026-05-08T00:58
Scan Media Library Idle Completed 2026-05-08T03:16
Update Plugins Idle Completed 2026-05-08T02:13
```
---
## Sign-off
- Audit: 2026-05-08, read-only, ~25 min wall.
- No fixes applied. No state mutated. No container restart.
- Next audit due: **2026-08-08** (quarterly, before LE cert renewal
window opens at 2026-08-06).

617
docs/14-theme-audit.md Normal file
View file

@ -0,0 +1,617 @@
# 14 — Theme Audit + Detail-Page Backdrop Diagnosis
Status: **read-only audit**, executed 2026-05-08 against
`https://arrflix.s8n.ru` (Jellyfin 10.10.3 on nullstone). The owner has
just rolled back to **Cineplex v1.0.6** (the Netflix-faithful theme)
after a brief ElegantFin → NeutralFin experiment that was documented in
docs 04 §3e and 11 respectively. Reported issue: on detail pages the
**backdrop image leaves a visible vertical black band on the left** where
the title/info column sits. Owner asked for a forward plan, not a fix.
> **No state mutated.** No POST to `/System/Configuration/branding`,
> no edit to `/jellyfin/jellyfin-web/index.html`, no docker action.
> Read-only over SSH and against the public `/Branding/Configuration`
> + authenticated `/System/Configuration/branding` endpoints.
---
## 1. Current state inventory
### 1a. Active theme
`/System/Configuration/branding` returns:
| Field | Value |
|---|---|
| `LoginDisclaimer` | `"Welcome to ARRFLIX - Private invite only service"` |
| `SplashscreenEnabled` | `true` |
| `CustomCss` (size) | **25 225 chars** (most of which is the embedded ARRFLIX wordmark data-URL — twice) |
Sole `@import` line:
```css
@import url("https://cdn.jsdelivr.net/gh/MRunkehl/cineplex@v1.0.6/cineplex.css");
```
Cineplex itself transitively imports
`cineplex@v1.0.5/finity-theme/finity-complete.css` (its parent theme,
**Finity** by prism2001). This matters for the backdrop diagnosis below.
### 1b. CustomCss block inventory (every rule, in order)
`!important` declarations: **17**. `#E50914` occurrences: **0** in
CustomCss; **1** in `web-overrides/index.html` critical-path `<style>`.
ARRFLIX wordmark PNG: **235 × 85 px** (aspect 2.765 : 1), embedded
as base64 data-URL on two selectors.
| # | Block | Selectors | Purpose | `!important` count |
|---|---|---|---|---|
| 1 | Cineplex import | `@import` | Theme entry point | 0 |
| 2 | Cast/Crew hide | `#castCollapsible, #USER-FCastCollapsible` | Drop reviewer cruft | 1 |
| 3a | ARRFLIX logo (img) | `.adminDrawerLogo img` | `content:` replace src in admin drawer | 1 |
| 3b | ARRFLIX logo (div) | `.pageTitleWithLogo` | `background-image:` for masthead `<div>` | 1 |
| 4 | Quick Connect hide | `.btnQuick` | Belt-and-braces for the server-side disable in 04 §4g | 1 |
| 5 | Header icon hide | `.headerSyncButton`, `.headerCastButton`, `.headerUserButton` | Keep only Search top-right | 3 |
| 6a | Slider thumbs (white) | `.MuiSlider-thumb`, `.osdPositionSlider .MuiSlider-thumb`, `.osdVolumeSlider .MuiSlider-thumb`, `emby-slider .sliderThumb` | OSD scrubber + volume circles | 3 |
| 6b | Slider thumbs (focus halo) | `.MuiSlider-thumb:hover/:active/.Mui-focusVisible` | Hover ring | 1 |
| 7a | Pure-black bg (vars) | `:root { --primary-background-color/--background-color: #000 }` | Force shell vars to true black | 2 |
| 7b | Pure-black bg (wrappers) | `html, body, .preload, .skinBody, .mainDrawerHandle` | Anti-flash on shell wrappers | 1 |
| 7c | Pure-black bg (containers) | `.skinHeader, .skinHeader.semiTransparent, .skinHeader.skinHeader-withBackground, .mainAnimatedPages, #reactRoot, .dashboardDocument` | Container surfaces | 1 |
| 8 | Settings drawer hide | `a[href*="mypreferencesmenu"]`, `[to="/mypreferencesmenu.html"]` and `:has()` parent variants × 7 | Remove Settings link from drawer | 1 |
| 9 | Count-badge hide | `.countIndicator` | Drop unwatched-episode badges | 1 |
### 1c. Critical-path inline `<style>` (in `web-overrides/index.html`)
Bind-mounted at `/jellyfin/jellyfin-web/index.html`, paints **before** the
SPA bundle loads CustomCss:
| Block | Effect |
|---|---|
| `:root { --primary-background-color: #000; --background-color: #000 }` | Pre-paint shell vars (no `!important`) |
| `html, body, .preload, .skinBody, .skinHeader, #reactRoot, .mainAnimatedPages { bg:#000 !important; color:#fff !important }` | Anti-flash + force colour |
| `.raised, .button-submit, .emby-button[type=submit], button[type=submit] { bg:#E50914 !important; color:#fff !important }` | Pre-paint Netflix-red on submits (login Sign-In) |
| `.splashLogo { animation: fadein .5s; width:30%; height:30%; bg-image:<ARRFLIX wordmark data-URL>; bg-size:contain; bg-position:center; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%) }` | The pre-bundle splash screen |
| `@media (min-device-width:992px) { .splashLogo { bg-image:<same ARRFLIX wordmark, full-res copy> } }` | Desktop variant (currently identical bytes — see §6) |
Plus 78 lines of inline `<script>` (ARRFLIX-SHIM) that locks
`document.title`, the favicon, and continuously hides any
`mypreferencesmenu` drawer entry that might be rendered after navigation.
None of the JS touches detail-page layout.
---
## 2. Detail-page backdrop diagnosis
### 2a. Selector hunt against the live JF 10.10.3 web bundle
`docker exec jellyfin grep -oE` against
`/jellyfin/jellyfin-web/main.jellyfin.1ed46a7a22b550acaef3.css` and
`itemDetails-index-html.ca5f15ff794311af00a6.chunk.js` returned the
canonical detail-page selector set:
| Selector | Where defined | Stock JF 10.10.3 layout |
|---|---|---|
| `.itemBackdrop` | `main.jellyfin.<hash>.css` | `height: 40vh; width: <inherited>; background-size: cover; background-attachment: fixed; position: relative;`**only top 40vh of the page** |
| `.layout-mobile .itemBackdrop` | same | `background-attachment: scroll; background-position: top` |
| `.layout-tv .itemBackdrop` | same | `display: none` |
| `.detailPageContent` | same | `display: flex; flex-direction: column; padding-left: 32.45vw` (LTR desktop) — i.e. the content column starts 32.45% from the left |
| `.detailPagePrimaryContainer` | same | `display: flex; align-items: center; z-index: 2;` desktop adds `padding-left: 32.45vw` |
| `.detailImageContainer .card` | same | `position: absolute; top: -80%; left: 3.3%; width: 25vw` (desktop) — the poster card sits in the LEFT column |
| `.detailLogo` | same | `position: absolute; top: 10vh; right: 25vw; width: 25vw; height: 16vh; background-size: contain` |
| `.detailRibbon` | same | desktop: `height: 7.2em; margin-top: -7.2em` (the gradient fade strip below backdrop) |
| `.itemBackdropProgressBar` | same | `position: absolute; bottom:0; left:0; right:0` |
| `.detailPageWrapperContainer` | same | `border-collapse: collapse` |
There is **no** `itemBackdropFader`, no `itemHeroSection`, no
`backdropHeroSection` selector in the bundle. The owner's mental model of
"a fader covering the left column" doesn't match — the architecture is
*positional offsets*, not an overlay.
### 2b. What Cineplex/Finity overrides
`grep -nE "itemBackdrop|detailPagePrimary|detailPageContent|detailLogo|detailImageContainer|detailRibbon|detailPageWrapper" /tmp/cineplex.css /tmp/finity.css` shows:
**`cineplex.css`** — only **two** detail-page rules, both of them
mobile-only. No desktop override of `.itemBackdrop`.
```css
/* line 577 */
.layout-mobile .itemBackdrop {
margin-top: 0rem;
mask-image: linear-gradient(to top, #fff0 1%, #000 15%, #000 80%, #fff0 100%);
}
```
**`finity-complete.css`** — Finity is where the detail-page layout is
heavily redesigned. Key block:
```css
/* finity.css :root */
--detail-page-side-padding: 5%;
--detail-page-primary-width: 45%;
--detail-page-backdrop-offset: 17%; /* <-- THE BLACK BAND */
--detail-page-backdrop-width: 85vw;
--detail-page-mask-offset: 16%;
--detail-page-mask-width: 85vw;
--detail-page-content-offset: -65vh;
.layout-desktop .itemBackdrop {
background-attachment: scroll;
background-position: center;
background-size: cover;
height: 100vh; /* full viewport, NOT 40vh — Finity expands JF default */
width: 100%;
}
.backdropContainer {
height: 100vh;
left: var(--detail-page-backdrop-offset); /* 17% */
position: absolute;
top: 0;
width: var(--detail-page-backdrop-width); /* 85vw */
z-index: 0;
pointer-events: none;
}
.layout-desktop .backgroundContainer.withBackdrop {
background: url("https://raw.githubusercontent.com/prism2001/finity/main/assets/mask.png");
background-size: cover;
height: 100vh;
left: var(--detail-page-mask-offset); /* 16% */
width: var(--detail-page-mask-width); /* 85vw */
z-index: 1;
pointer-events: none;
}
.layout-desktop .detailImageContainer .card { display: none; } /* hide poster card */
```
### 2c. Root cause
The "black band on the left" is **Finity's intentional design**, not a
Cineplex bug and not a JF stock layout artefact:
- Stock Jellyfin: `.itemBackdrop` is `height: 40vh` and full-width
(`width` is inherited from the parent flow). The backdrop crops the
*top* of the page, the info column lays out below it. No left band.
- Finity: re-engineers the page so `.itemBackdrop` is `100vh` *but*
positions a separate `.backdropContainer` absolutely at `left: 17%
width: 85vw` (so the right ~98% of the viewport gets the backdrop and
the left **17vw / 17%** is left clear). On top of that, a blurred
`mask.png` is overlaid at `left: 16%` to fade the right edge of the
remaining clear band into the backdrop — making the band look like a
designed gradient sidebar, NOT a black bar.
The reason it currently reads as **a hard black band** rather than a
soft gradient fade is the combination of two of our personal tweaks
plus one Finity asset that may not be reaching the browser:
1. **`html, body, .preload, .skinBody, .mainDrawerHandle { bg:#000 !important }`**
forces the underlying surface where the band sits to pure black.
Finity's `--theme-background-color: #181818` is the intended
surface — slightly less harsh.
2. **`#reactRoot, .mainAnimatedPages, .dashboardDocument { bg:#000 !important }`**
does the same for the SPA wrappers above the body.
3. The Finity mask overlay
(`.backgroundContainer.withBackdrop`) loads its mask PNG from
`raw.githubusercontent.com/prism2001/finity/main/assets/mask.png`
on a LAN with no upstream proxy that should resolve, but if the
browser blocks third-party image loads (some ad-blockers strip
`raw.githubusercontent.com` requests) the mask never paints and the
17vw band is unmasked. Worth a DevTools network-tab check before any
CSS change.
Net: the backdrop **is** filling the right 85vw of the viewport. The
left 17vw is intentionally clear so the title/poster/info column has a
high-contrast surface to render on. Our `bg:#000 !important` rules turn
that intentionally-clear surface into a hard black band; without them
it would be `#181818` with a soft gradient fade from the mask PNG.
### 2d. Forward-plan CSS (DO NOT APPLY)
If the goal is **Netflix-style full-bleed backdrop with a left-side
gradient overlay** (info column floating over a darkened-but-visible
backdrop), the proposed rule set is:
```css
/* Detail-page backdrop: full-bleed + left gradient overlay
(proposal — not applied) */
/* 1. Stretch the backdrop container across the full viewport
instead of starting at 17vw */
.layout-desktop .backdropContainer {
left: 0 !important;
width: 100vw !important;
}
/* 2. Replace Finity's mask.png with a CSS-only linear gradient
that darkens the left 40-50vw and fades to transparent.
`.backgroundContainer.withBackdrop` is the overlay layer. */
.layout-desktop .backgroundContainer.withBackdrop {
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0.95) 0%,
rgba(0, 0, 0, 0.85) 25%,
rgba(0, 0, 0, 0.55) 45%,
rgba(0, 0, 0, 0.20) 65%,
rgba(0, 0, 0, 0.00) 85%
) !important;
left: 0 !important;
width: 100vw !important;
}
/* 3. Drop the global black-bg force from the wrappers ON DETAIL
PAGES ONLY so the gradient composes against the actual
backdrop, not pure black. Scope by .itemDetailPage body class
that JF adds on detail routes. */
body.itemDetailPage,
body.itemDetailPage #reactRoot,
body.itemDetailPage .mainAnimatedPages {
background-color: transparent !important;
}
```
The `90deg, 95% → 0%` gradient is the Netflix.com detail-page recipe:
opaque on the left where the title sits, fades to transparent by ~70vw
so the right side of the backdrop is visible at full brightness. Tune
the stop percentages once live — the sweet spot depends on
`--detail-page-primary-width` (Finity ships `45%`).
**Untested side-effect to watch for:** Finity *also* hides the poster
card with `.layout-desktop .detailImageContainer .card { display:none }`.
That means we have NO poster in the left column today — the current
black band is empty space framing a clear logo + title block. The fix
above would put the title text directly over the backdrop, which is
fine on most artwork but may have legibility issues on bright/busy
backdrops. If owner wants the poster back, drop that Finity rule too.
### 2e. Screenshot reference
A capture of `https://arrflix.s8n.ru/web/#/details?id=324f75b84f394a5d9b0749c0679f23b9`
(Rick & Morty S01E01 "Pilot") with a hard browser reload would show:
- Top: ~17vw black/empty band on the left, Rick & Morty backdrop on
the right ~83vw. (Finity / current.)
- Title "Pilot" + Series logo + Play button float over the empty band.
- After fix: title floats over a darkened-but-visible portion of the
same backdrop, gradient eases into the un-darkened backdrop on the
right ~30%.
Owner has not provided a current screenshot in this audit; capture
recommended before any CSS change so before/after is documented.
---
## 3. Theme survey 2026-05
Surveyed candidates (live as of audit date), scored on Netflix
fidelity, monochrome fidelity, recency, JF 10.10.3 compatibility,
import format, license:
| Theme | Last commit | License | Netflix fidelity | Monochrome fidelity | JF 10.10.3 compat | Import | Notes |
|---|---|---|---|---|---|---|---|
| **Cineplex v1.0.6** (current) | 2025-09-06 | MIT | **9/10** — true `#E50914`, Netflix Sans webfont, scale-hover, login backdrop | 2/10 | YES (verified live) | single `@import` (transitively pulls Finity) | Bus-factor 1 (single author MRunkehl, 0 stars). Inherits Finity's left-band detail-page layout. |
| **ElegantFin v25.12.31** | 2026-04-30 | GPL-2.0 | 5/10 — Jellyseerr blue/violet by default, recolour-able to `#E50914` (eight `--var` overrides documented in 04 §3e) | 5/10 | YES (tested 10.11.5) | single `@import` | Most actively maintained CSS theme in the ecosystem. Detail-page backdrop is full-width with a gradient overlay built in — no left band. |
| **NeutralFin v1.3.0** | 2025-11-24 | GPL-2.0 | 1/10 (mid-grey accents, no red) | **9/10**`#131313 → #1e1e1e` gradient, mid-grey accents, off-white text | YES (tested implicitly via ElegantFin parent) | single `@import` | Fork of ElegantFin. The "didn't look as good" feel was caused by our `bg:#000 !important` rules clamping its `#131313→#1e1e1e` gradient flat (see doc 11). With those dropped it would render correctly. |
| **Theme Park (jellyfin pack)** | active | GPL-3.0 | n/a — **no Netflix preset** (only aquamarine/hotline/dracula/dark/organizr/space-gray/plex/nord) | varies by preset | likely | single `@import url(theme-park.dev/css/base/jellyfin/<NAME>.css)` | DQ for our brief; closest is `plex` (orange/black) but that's a different brand entirely. |
| **JellyFlix** (prayag17) | 2023-12-20 | none | 9/10 — origin of the genre | 1/10 | **HALTED** (README header) | single `@import` | DQ — explicitly halted, broken on JF 10.11, risky on 10.10.3 |
| **DarkFlix v5.1** | 2024-06 | GPL-3.0 | 8/10 | 1/10 | only declares 10.8.x; **requires 67% browser zoom** | single `@import` | DQ — accessibility issue, no 10.10 statement |
| **Ultrachromic** (CTalvio) | "selectively maintained" — 146 commits, no recent date | MIT | 6/10 (accent-tunable) — three presets: Monochromic, Kaleidochromic, Novachromic | 8/10 (Monochromic preset) | unspecified | single `@import` per preset | "Old, passively maintained." No Netflix preset, but Novachromic accepts custom accents — could be set to `#E50914`. |
| **Finity** (prism2001, Cineplex's parent) | 2026-05 (active) | none stated | 6/10 (dark, modern, no Netflix red by default) | 5/10 | unspecified | single `@import` | Fully responsible for the detail-page layout we see on Cineplex. If the backdrop fix lands, we'd be fixing Finity's `.backdropContainer` rules. |
| **abyss-jellyfin** (AumGupta) | 2026-05 | n/a | 1/10 | 7/10 | unspecified | unknown | "Minimal dark." 290 stars, growing. Not Netflix-flavoured. |
| **FossFlix** (PaleCache) | 2026-01 | n/a | 6/10 (claims Netflix UI similarity) | 1/10 | unspecified | unknown | 1 star, unproven. Worth bookmark, not migration. |
| **JellyFin** (n00bcodr) | 2026-05 | n/a | 0/10 | 6/10 | unspecified | unknown | Inspired by Flow + Zesty — neither fits the brief. |
| **JellyThemes** (kingchenc) | 2026-01 | n/a | 0/10 | varies (six dark themes with glassmorphism) | unspecified | unknown | DQ for Netflix brief. |
| **Hybrid: Cineplex + NeutralFin tweaks** | n/a | derivative | 7/10 | 4/10 | YES if grafted carefully | one `@import` + tweaks | Not actually possible to graft cleanly — Cineplex's red and NeutralFin's grey both define `--theme-accent-color` / `--uiAccentColor` at `:root`, last-write-wins. Picking the import = picking the palette. Ranges of personal-tweak overrides (e.g. `.MuiSlider-thumb:white`) DO survive across both. |
### 3a. Verdict on Theme Park
`docs.theme-park.dev/themes/jellyfin/` lists eight presets: Aquamarine,
Hotline, Dracula, Dark, Organizr, Space-gray, Plex, Nord. **No Netflix
preset.** The closest cousin (`hotline`) is a magenta/cyan synthwave
look, not Netflix-red. Theme Park is therefore not a viable migration
target for the ARRFLIX brand; ruled out.
---
## 4. Personal-tweak portability matrix
For each personal-tweak block in current `CustomCss`, classify the
selector as **theme-independent** (generic Jellyfin selector, survives
any swap) vs **theme-specific** (requires re-targeting).
| # | Block | Selector | Type | Cineplex | ElegantFin | NeutralFin | Theme-Park | Portability |
|---|---|---|---|---|---|---|---|---|
| 2 | Cast/Crew hide | `#castCollapsible, #USER-FCastCollapsible` | Generic JF id | works | works | works | works | **HIGH** |
| 3a | Logo (admin) | `.adminDrawerLogo img` | Generic JF class | works | works (per 04 §3e — verified 0 ElegantFin matches) | works (no NeutralFin matches) | works | **HIGH** |
| 3b | Logo (masthead) | `.pageTitleWithLogo` | Generic JF class | works (with `bg-image`, NOT `content:`) | works (verified) | works | works | **HIGH** |
| 4 | Quick Connect hide | `.btnQuick` | Generic JF class on `<button>` | works | works | works | works | **HIGH** |
| 5 | Header icons hide | `.headerSyncButton`, `.headerCastButton`, `.headerUserButton` | Generic JF classes (verified in `73233.*.chunk.js`) | works | works | works (NeutralFin sets `width/height/border` on `.headerUserButton` but `display:none` overrides those) | works | **HIGH** |
| 6 | Slider thumb white | `.MuiSlider-thumb` + variants | MUI runtime class | works | works | works (theme doesn't theme MUI sliders) | works | **HIGH** — but consider re-tinting on monochrome themes |
| 7a | Bg vars `:root` | `--primary-background-color`, `--background-color` | Jellyfin shell var | works (Cineplex defaults to `#181818` — we override to `#000`) | works | **HARMFUL on NeutralFin** — clamps the `#131313→#1e1e1e` gradient (see doc 11 row 8) | works | **MEDIUM** — survives technically, but defeats NeutralFin's intent. |
| 7b/7c | Bg wrappers (`html`, `body`, `.skinHeader`, `.mainAnimatedPages`, `#reactRoot`, `.dashboardDocument`) | Jellyfin shell wrappers | works (Cineplex doesn't theme these) | works (ElegantFin uses translucent wrappers — `#000` underneath is fine) | **HARMFUL** — clamps gradient + flattens `.skinHeader.semiTransparent` (see doc 11 row 10) | likely works | **MEDIUM** — and **harmful on detail pages for Cineplex** (this is what's making the 17vw band hard-black, see §2c above) |
| 8 | Settings drawer hide | `a[href*="mypreferencesmenu"]`, `[to="/mypreferencesmenu.html"]`, `:has()` parents | JF route + MUI ListItem classes | works | works | works | works | **HIGH** (if browser supports `:has()`) |
| 9 | Count badge hide | `.countIndicator` | Generic JF class | works | works | works (NeutralFin themes it, but `display:none` wins) | works | **HIGH** |
| index.html | Anti-flash inline | `html, body, .preload, .skinBody, .skinHeader, #reactRoot, .mainAnimatedPages` | Same wrappers as 7b/7c, but **pre-bundle** | works | works | **HARMFUL** — same issue as 7b/7c, but earlier in load (see doc 11 row 14) | likely | **LOW-MEDIUM** — needs `!important` removed and `.skinHeader` dropped from the list to be theme-portable |
| index.html | Submit-button red | `.raised, .button-submit, .emby-button[type=submit], button[type=submit]` | Generic JF + MUI button classes | works (matches Cineplex's `#E50914` accent) | requires recolour-aware ElegantFin (works since override is in our hands) | **HARMFUL** — paints every submit Netflix-red over a monochrome theme (see doc 11 row 15) | works | **LOW** — rule is brand-specific, must be removed when brand colour changes (NeutralFin would need `--btnSubmitColor` instead) |
| index.html | ARRFLIX shim (title/favicon/`mypreferencesmenu`) | inline `<script>` | Independent of theme | works | works | works | works | **HIGH** |
| index.html | Splash logo | `.splashLogo` | Pre-bundle JF class | works | works | works | works | **HIGH** |
**Summary:** 11 of 14 blocks are HIGH portability (theme-independent
generic JF selectors). The 3 problem children are all variations of
"force pure black background" — and they happen to be the same blocks
flagged in doc 11 as harmful to NeutralFin AND, per §2c above, to be
the cause of the hard-black detail-page band on Cineplex.
> **Operational rule:** when swapping themes, audit blocks 7a / 7b / 7c
> / index.html-anti-flash / index.html-submit-red FIRST. The other
> tweaks ride along automatically.
---
## 5. Logo aspect-ratio fit
ARRFLIX wordmark PNG: **235 × 85 px**, aspect **2.765 : 1**.
| Container | Selector | Sizing on Cineplex/Finity | Wordmark fit |
|---|---|---|---|
| Admin drawer | `.adminDrawerLogo img` | `<img>` element, `content:` swap, sized by sidebar (~240px wide) | natural — replacement is the displayed image | OK |
| Masthead | `.pageTitleWithLogo` | `<div>`, `bg-image` + `bg-size: contain` (Finity convention) | aspect preserved by `contain`, no squish | OK |
| Detail page logo | `.detailLogo` | `position: absolute; right: 25vw; top: 10vh; width: 25vw; height: 16vh; bg-size: contain` | per-show clear-logo box. ARRFLIX wordmark is not used here — this is the show's clear-logo (e.g. Rick & Morty title art). Not a fit concern for our wordmark. | OK |
| Splash | `.splashLogo` | `width:30%; height:30%; bg-size:contain; centered` | aspect preserved; on a 1920×1080 viewport renders ~576×324 box, wordmark settles at ~576×208 (height-limited by aspect). Looks correct. | OK |
**Verdict:** 235 × 85 fits cleanly in every container. Aspect ratio is
NOT a factor in any of the rendering complaints. The native JF
admin-drawer + masthead use `bg-size: contain`, so a 2.765:1 wordmark
displays without distortion regardless of theme.
---
## 6. Pre-bundle splash quality
Inspecting `web-overrides/index.html` (93 lines, the bind-mounted
override of the JF web shell):
| Aspect | Value | Notes |
|---|---|---|
| `body { background: #000 }` (declared in critical-path `<style>`) | YES | Anti-flash baseline |
| `.splashLogo` size | `width:30%; height:30%` | Centred via `position:fixed; top:50%; left:50%; transform:translate(-50%,-50%)` |
| `.splashLogo bg-image` | inlined data-URL of the 235 × 85 ARRFLIX wordmark | Same PNG as the masthead/admin drawer |
| `.splashLogo bg-size` | `contain` | Aspect preserved |
| Animation | `animation: fadein 0.5s` (defined as `@keyframes fadein { 0%{opacity:0} 100%{opacity:1} }`) | Half-second ease-in |
| Mobile vs desktop variant | `@media (min-device-width: 992px) { .splashLogo { bg-image: <data-URL> } }` | The desktop branch CURRENTLY uses **the same 235 × 85 PNG bytes** as the small/mobile branch — i.e. there is no higher-resolution desktop asset. This is a half-implemented split. Owner could supply a 470 × 170 (2x) or 940 × 340 (4x) PNG to bake into the desktop branch for sharper rendering on 1080p+ displays. |
| Screen reader / `<title>` | `<title>` is set + locked at runtime by `lockTitle()` to `"ARRFLIX"` | OK |
**Verdict:** splash is functional, fade-in is smooth, aspect is correct.
The only quality nit is the desktop `<media>` branch reading the same
small PNG as mobile — a 2× or 4× ARRFLIX wordmark in the desktop
branch would be sharper. Defer-able; not a complaint the owner has
raised.
---
## 7. Detail-page backdrop fix proposal (concrete CSS, NOT applied)
Re-stating §2d in implementation-ready form. Expected to drop into
`CustomCss` AFTER the Cineplex `@import`, BEFORE the existing
`bg:#000` blocks (which need to be **scoped out of detail pages** to
not clobber the gradient — see `body.itemDetailPage` selectors below).
```css
/* === Detail-page backdrop fix (proposal, 2026-05-08) === */
/* Convert Finity's 17vw black band into a Netflix-style gradient
overlay over a full-bleed backdrop. */
/* 1. Stretch backdrop container across the full viewport */
.layout-desktop .backdropContainer {
left: 0 !important;
width: 100vw !important;
}
/* 2. Replace Finity's mask.png with a CSS-only linear-gradient
that darkens the left ~50vw and fades to transparent.
`.backgroundContainer.withBackdrop` is the existing overlay
element in the Finity DOM. */
.layout-desktop .backgroundContainer.withBackdrop {
background-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0.95) 0%,
rgba(0, 0, 0, 0.85) 25%,
rgba(0, 0, 0, 0.55) 45%,
rgba(0, 0, 0, 0.20) 65%,
rgba(0, 0, 0, 0.00) 85%
) !important;
background-size: 100vw 100vh !important;
left: 0 !important;
width: 100vw !important;
}
/* 3. UN-clamp the page bg specifically on detail pages so the
gradient composes against the actual backdrop, not pure black.
`.itemDetailPage` is added to <body> by JF on every detail
route (verified in main.jellyfin.bundle.js). */
body.itemDetailPage,
body.itemDetailPage #reactRoot,
body.itemDetailPage .mainAnimatedPages,
body.itemDetailPage .skinBody {
background-color: transparent !important;
}
```
**Before/after expectation:**
- Before: 17vw band on the left of the detail page is **flat `#000`**;
poster card hidden by Finity; title + clear-logo float on a hard
black slab.
- After: backdrop fills 100vw of the viewport. Title + logo float over
a darkened-but-visible slice of the backdrop on the left, fading to
full backdrop brightness around 70-85% across. Reads as
netflix.com's title-card style.
**Stops to tune** once live (open DevTools, edit the gradient stops):
- If title text is illegible against busy artwork, push opacity stops
up: `0.95 / 0.92 / 0.75 / 0.40 / 0.10`.
- If too much of the backdrop is darkened, pull stops left: `0.95 / 0.80 / 0.40 / 0.10 / 0.00`
with the last stop at 60%.
- If the right edge of the gradient creates a visible seam against a
bright backdrop, soften the last stop: append a sixth at
`90% rgba(0,0,0,0)` for an extra 5vw fade.
**Untested side-effects to watch for:**
- Finity hides `.detailImageContainer .card` on desktop. The fix
preserves that (poster card stays hidden — title is the focus).
If owner wants the poster card visible, drop:
```css
.layout-desktop .detailImageContainer .card { display: none }
```
by adding `.layout-desktop .detailImageContainer .card { display: revert !important }`.
- The OSD scrubber (`.itemBackdropProgressBar`) sits at the very
bottom of `.itemBackdrop`. With the backdrop now full-width, it's
also full-width (was already, just visually different against a
colour-fade vs. black band).
- Library-list pages that ALSO use the `.backgroundContainer.withBackdrop`
layer (a few in JF — backdrops on library tile rows) will get the
same gradient. If they look wrong, scope rule (1) and (2) to
`body.itemDetailPage .layout-desktop .backdropContainer` etc.
---
## 8. Recommended forward path (top 3 ranked)
### #1 — STAY on Cineplex + apply the §7 detail-page backdrop fix
**Why:** Cineplex is the only Netflix-faithful theme that runs on
JF 10.10.3 with a maintained codebase. The detail-page band is a
*single rule's worth of CSS* away from being a Netflix-style gradient
overlay. We've already invested in the brand stack (ARRFLIX wordmark,
header-icon hide, slider thumbs, Quick Connect off, settings hide); 11
of 14 personal tweaks survive the change, the other 3 (`bg:#000`)
need to be **scoped to non-detail pages** by selector chain
`body:not(.itemDetailPage)` instead of being dropped.
**Risk:** low. CSS-only, additive, no `@import` change, no
`/branding` POST hot-spot. Rolls back trivially.
**Cost:** ~30 minutes to apply, screenshot, tune gradient stops live.
### #2 — Migrate to ElegantFin v25.12.31 with ARRFLIX `#E50914` recolour
**Why:** ElegantFin's detail-page is full-width-backdrop with a
gradient overlay built in — no left band — so the §7 fix becomes
unnecessary. Most actively maintained CSS theme on JF (last commit
2026-04-30, GPL-2.0). The 04 §3e migration documented this exact
config: 8 accent variables overridden, ARRFLIX logo + cast/crew + Quick
Connect + header icons + slider thumbs all preserved.
**Risk:** medium. The previous attempt was overwritten by a sibling
Cineplex POST (race rule in 04 §3b). Personal-tweak block 7c
(`.skinHeader.semiTransparent`) still risks flattening ElegantFin's
translucent header — that block needs editing on landing.
**Cost:** ~45 minutes (re-do migration, scope the bg-clamp rules,
verify all 11 personal tweaks intact post-POST).
**Aesthetic delta vs Cineplex:** ElegantFin is "polished
Jellyseerr-y", Cineplex is "Netflix-faithful". With the recolour
ElegantFin gets the brand red but keeps a non-Netflix layout
(card design, hero strip, etc.). Owner has gone back-and-forth on this
preference — explicitly chose Cineplex this morning.
### #3 — Hybrid: keep Cineplex import + graft NeutralFin's `--gradientPoint` vars
**Why:** for owners who like Cineplex's red+webfont but want
NeutralFin's depth/gradient on backgrounds. Manually copy NeutralFin's
`--darkerGradientPoint #131313 / --lighterGradientPoint #1e1e1e` into a
`:root` block, drop our `--primary-background-color: #000 !important`
overrides, and let the gradient render.
**Risk:** higher than #1 or #2. Variables don't compose perfectly
across themes — Cineplex's Finity parent doesn't read those NeutralFin
vars, it reads its own `--theme-background-color`. So you'd actually
copy the values into Finity's variable: `--theme-background-color: linear-gradient(...)`
which CSS doesn't allow on a plain `background-color`. Real grafting
needs `body { background-image: linear-gradient(180deg, #131313, #1e1e1e) }`
plus dropping the `bg:#000 !important` rules.
**Cost:** ~60 min trial-and-error. Likely lower visual reward than #1.
**Verdict:** Recommended order is **#1 first (lowest risk, biggest
backdrop win), then #2 if owner re-evaluates Netflix-fidelity vs
polish, #3 only as a fall-back if #1 doesn't read well**.
---
## 9. Risks + rollback
### Snapshot tag
`snapshot-2026-05-08-pre-elegantfin` — captured before the ElegantFin
attempt. Currently this is **also the rollback point for any further
theme work** because ElegantFin → NeutralFin → Cineplex have all been
applied (and reverted) on top of it. Located at
`snapshots/2026-05-08-pre-elegantfin/`.
If a future change wants its own snapshot, follow the pattern in
`RESTORE.md`: capture `branding.json`, `index.html`, all
`displayprefs-*.json`, `users.json`, `libraries.json`, write a new
`RESTORE.md`, tag the commit.
### Prior failed swaps (timeline 2026-05-08)
| Time | Theme attempted | Outcome |
|---|---|---|
| early today | ElegantFin v25.12.31 (initial pick — pre-Netflix-brief) | replaced by Cineplex when owner asked for Netflix-faithful |
| mid-day | **Cineplex v1.0.6** | applied, working |
| later | ElegantFin v25.12.31 + ARRFLIX recolour (04 §3e) | applied, then silently overwritten by a sibling Cineplex POST (race rule, see 04 §3b) |
| even later | NeutralFin v1.3.0 | applied, but a sibling Cineplex POST overwrote it minutes later (see doc 11 headline finding); also, our `bg:#000 !important` rules clamped its gradient flat so the brief render that DID land looked wrong |
| now | **Cineplex v1.0.6** | active (verified live this audit) |
### Race-rule reminder
`/System/Configuration/branding` takes a complete object on every
POST; whichever POST lands last wins. Per 04 §3b: any agent or script
touching this endpoint MUST `GET → edit-only-its-fields → POST` and
the branding POST must be the **last** in any sequence.
### Detail-page fix rollback
If §7's CSS lands and looks wrong, remove the three new blocks from
`CustomCss` and POST `branding`. The §7 proposal is purely additive
(no rule removal); revert is a clean delete.
---
## 10. What was NOT touched during this audit
- No POST to `/System/Configuration/branding`.
- No edit to `web-overrides/index.html` or the bind-mounted
`/jellyfin/jellyfin-web/index.html`.
- No `docker compose` action, no container restart.
- No git commit on `snapshots/`, no tag movement.
- All inspections were `curl` GET (`/Branding/Configuration` +
`/System/Configuration/branding`) and `docker exec jellyfin sh -c`
bounded to `cat`/`grep`/`wc`/`ls`.
---
## 11. Sign-off
- **Auditor:** s8n (audit pass, 2026-05-08)
- **Live theme at audit time:** Cineplex v1.0.6 (verified —
`/Branding/Configuration` returns `MRunkehl/cineplex@v1.0.6`)
- **Top likely cause of detail-page black band:** Finity (Cineplex's
parent) ships `--detail-page-backdrop-offset: 17%` by design. Our
`bg:#000 !important` rules turn that intentionally-clear 17vw band
into a hard-black slab. The Finity `mask.png` overlay would have
softened it into a gradient if it loads — worth a DevTools network
check.
- **Recommended forward path:** STAY on Cineplex + apply §7
detail-page CSS (full-bleed backdrop + linear-gradient overlay,
scoped to `body.itemDetailPage`).
- **Personal-tweak portability:** **HIGH** for 11 of 14 blocks; **MEDIUM/LOW**
for the 3 `bg:#000` blocks (must be scoped/dropped on theme swap).
- **Next step:** owner reviews this doc + screenshots the current
detail-page band, decides whether to apply §7. No work on the live
server until that review.

214
docs/15-force-english.md Normal file
View file

@ -0,0 +1,214 @@
# 15 - Force English UI for All Users
> Why "Abspielen" showed up on the Play button, every place locale comes from,
> and the per-user mechanism (plus wrapper update) that pins every account
> to English regardless of what `Accept-Language` the browser sends.
Last verified: 2026-05-08 against Jellyfin 10.10.3 web bundle, arrflix.s8n.ru.
---
## Status as of 2026-05-08 — superseded by lockdown sweep
This doc captured the first pass: identifying that `Configuration.UICulture`
was the per-user knob, building `bin/force-english-all-users.sh`, and
patching `bin/add-jellyfin-user.sh`. That was a partial fix — it pinned the
existing five accounts but did not cover server-wide defaults, the web SPA
pre-auth bundle, or a re-apply mechanism that survives Jellyfin restarts /
new users created out-of-band / config drift over time.
A multi-agent lockdown sweep ran 2026-05-08 to close the remaining gaps:
- **Audit baseline:** `docs/19-english-only-audit.md` — every surface
inventoried, current state per layer, "still drifts" notes.
- **Lockdown procedure + persistence:** `docs/20-english-only-lockdown.md`
the canonical operator doc going forward. Covers server / per-user / web
SPA / Accept-Language layers, ships the idempotent re-apply runner at
`bin/english-lockdown-runner.sh`, and documents the systemd timer the
operator can drop in if they want weekly auto re-application.
- **Web-side overrides:** `web-overrides/english-lockdown.{js,css}` — pin
`navigator.language`, hide the language switcher, force-load the en-us
bundle pre-auth. (Sibling agent, separate commit.)
- **Live server settings:** UICulture + PreferredMetadataLanguage +
MetadataCountryCode pushed to the live `arrflix.s8n.ru` server config.
(Sibling agent, separate commit.)
The body below is preserved verbatim as historical context for **why** the
per-user POST mechanism exists. For day-to-day operations, jump to
`docs/20-english-only-lockdown.md`.
---
## TL;DR
- Owner saw German "Abspielen" on the detail-page Play button.
- Root cause: **every Jellyfin user on this server has `Configuration.UICulture` unset**
(key is absent from `GET /Users/{id}` JSON, not just empty string). When that
field is missing, the Jellyfin web SPA falls back to the browser's
`Accept-Language` header. A browser sending `de-*` → German UI.
- There is **no server-side flag** that forces the web client to ignore
`Accept-Language`. Locale is per-user.
- Fix: `POST /Users/{id}/Configuration` with `UICulture` pinned to `"en-US"`
for every existing user, and update `bin/add-jellyfin-user.sh` so future
users get the same pin baked in at creation time.
---
## Where Jellyfin gets UI language from (priority order)
The Jellyfin web client (`/web/index.html` SPA) selects its UI language in
this exact order, first hit wins:
| # | Source | Where it lives | Notes |
|---|--------|----------------|-------|
| 1 | **Per-user `Configuration.UICulture`** | `GET /Users/{id}` JSON, field `Configuration.UICulture` | Authoritative once a user is logged in. Set to `"en-US"` to pin English. |
| 2 | **Browser `Accept-Language`** | HTTP request header, sent by every browser | Fallback when (1) is unset / empty / absent. This is what bit us — USER-A's browser sends `de-DE,de;q=0.9,en` and Jellyfin honored it. |
| 3 | **Server `UICulture`** in `/System/Configuration` | Server-wide JSON, current value `"en-US"` | This is the **dashboard / admin** default, NOT applied to user UI. Misleading: setting it does NOT propagate down to clients. |
| 4 | **Pre-auth splash bundle strings** | Static strings in the JS bundle's `en-us.json`/`de.json` | Loaded based on `Accept-Language` BEFORE the user is even authed. Cannot be overridden per-user — see "Limits" below. |
There is **no** `customPrefs.language` key in `DisplayPreferences` — locale is
not stored there. Confirmed by inspecting USER-A's `DisplayPreferences/usersettings`:
`CustomPrefs` has only `chromecastVersion`, `dashboardTheme`, home sections,
skip lengths, `tvhome`. No language.
There is **no** `EnableNonAdministrativeUserLocaleOverride` or
`EnforcedDisplayLanguage` flag in `/System/Configuration`. Verified via
filtering the full server config for `lang|locale|culture|country` keys —
only `PreferredMetadataLanguage`, `MetadataCountryCode`, and `UICulture`
exist, and `UICulture` server-side is the dashboard-only default.
---
## Per-user state (current)
Audit run 2026-05-08, all 5 users:
| User | UserId | `Configuration.UICulture` |
|------|--------|---------------------------|
| 5 | `SCRUBBED-USER-ID` | **key absent** |
| USER-F | `SCRUBBED-USER-ID` | **key absent** |
| USER-G | `SCRUBBED-USER-ID` | **key absent** |
| USER-A | `SCRUBBED-USER-ID` | **key absent** |
| s8n | `SCRUBBED-USER-ID` | **key absent** |
Every account is currently at the mercy of the browser. Whichever browser
hits arrflix.s8n.ru with `Accept-Language: de-*` will see German strings
(Play → Abspielen, Resume → Fortsetzen, etc.). The Play button screenshot
the owner shared is almost certainly USER-A logged in from a German-locale
browser, or any user logged in from such a browser at all.
---
## Forcing mechanism — per-user POST
The web client reads `UICulture` straight from the user object on auth and
on every refresh. Setting it to `"en-US"` pins the UI to English regardless
of what the browser asks for.
**Endpoint:** `POST /Users/{userId}/Configuration` (returns 204).
**Payload:** the FULL existing `Configuration` block with `UICulture` added
(Jellyfin replaces the whole config dict, it does not patch fields). Fetch
first, modify, POST back — the same read-modify-write pattern step [3/4]
of `add-jellyfin-user.sh` already uses.
**Reference curl** (single user, USER-A):
```bash
TOKEN=<JELLYFIN_API_TOKEN>
USER_ID=SCRUBBED-USER-ID
curl -s "https://arrflix.s8n.ru/Users/$USER_ID" \
-H "Authorization: MediaBrowser Token=$TOKEN" > /tmp/u.json
python3 -c "
import json
with open('/tmp/u.json') as f: u = json.load(f)
c = u['Configuration']
c['UICulture'] = 'en-US'
print(json.dumps(c))
" > /tmp/u-fixed.json
curl -s -X POST "https://arrflix.s8n.ru/Users/$USER_ID/Configuration" \
-H "Authorization: MediaBrowser Token=$TOKEN" \
-H "Content-Type: application/json" \
--data-binary @/tmp/u-fixed.json -w "%{http_code}\n" -o /dev/null
# Expect: 204
```
The convenience wrapper for all 5 users in one go is at
`bin/force-english-all-users.sh` — read-modify-write loop, idempotent, prints
each user's before/after state.
---
## Wrapper update for future users
`bin/add-jellyfin-user.sh` step `[3/4]` currently sets
`SubtitleMode`/`SubtitleLanguagePreference`/`AudioLanguagePreference`/
`PlayDefaultAudioTrack` on the new user's `Configuration`. Add `UICulture`
to that same block:
```python
c['SubtitleMode'] = 'Default'
c['SubtitleLanguagePreference'] = 'eng'
c['AudioLanguagePreference'] = 'eng'
c['PlayDefaultAudioTrack'] = True
c['UICulture'] = 'en-US' # NEW: pin UI to English regardless of browser Accept-Language
```
That is a one-line addition; the rest of the wrapper is untouched.
---
## What CANNOT be forced (limits)
1. **Pre-auth splash bundle strings.** Before the user logs in, the web SPA
loads a translation file based on `navigator.language` / browser
`Accept-Language`. The `<title>`, the login form labels, "Sign In",
"Username", "Password" placeholder text, and the loading splash all
resolve from that pre-auth bundle. If the browser is German, those
handful of strings render in German until the user authenticates and
the per-user `UICulture` kicks in.
This is a fundamental architectural limit — there is no server flag that
tells the SPA to ignore `navigator.language`. Workarounds would require
either (a) a runtime shim that overrides `navigator.language` before the
bundle initialises (similar to the existing `inject-shim.py` title
locker), or (b) replacing the German `de.json` translation file in the
web bundle with the English copy. Neither is implemented; both are
in-scope for future work if pre-auth German strings ever become a
complaint.
2. **Reverse-proxy doesn't strip `Accept-Language`.** Traefik passes the
header through unchanged. We could in theory rewrite it to `en-US` at
the proxy, but that breaks any user who genuinely wants a non-English
metadata locale for OTHER apps fronted by the same Traefik (none
currently — but the principle stands). Per-user `UICulture` is cleaner.
3. **Subtitle/audio language preferences** are already pinned to `eng` for
every user via the wrapper, so playback selection is unaffected by
`UICulture`. We are only fixing the **UI chrome** (button labels,
menus, tooltips) here, not media language defaults.
4. **Native mobile clients** (Jellyfin Android/iOS apps) read `UICulture`
the same way the web SPA does, so they will also pick up the pin once
the per-user POST lands. Verified by reading Jellyfin source: same
`User.Configuration.UICulture` field is the authoritative locale on
every official client.
---
## Cleanup steps (owner-triggered)
1. Review this doc and `bin/force-english-all-users.sh`.
2. Run the script with the admin token in env:
```
JELLYFIN_TOKEN=<JELLYFIN_API_TOKEN> bin/force-english-all-users.sh
```
3. Hard-refresh each browser (Ctrl-Shift-R) to clear any cached locale
bundle the SPA loaded on previous visit.
4. Verify by visiting any movie detail page — the button should now read
"Play" in every browser, including ones still sending `de-*`.
5. Apply the wrapper diff to `bin/add-jellyfin-user.sh` so future users
inherit the pin.
No container restart needed. No web bundle rebuild needed. No reverse-proxy
config change needed.

View file

@ -0,0 +1,476 @@
# 16 - Jellyfin Branding Leaks (Read-Only Audit)
> Owner wants ALL Jellyfin branding hidden user-side. This doc inventories every
> place a logged-in non-admin still sees the word "Jellyfin" or the
> teal/purple triangle logo, and proposes a concrete fix for each.
Last verified: 2026-05-08 against live `https://arrflix.s8n.ru` running
Jellyfin 10.10.3 (`jellyfin/jellyfin` image). Probe account: `USER-A`
(non-admin, `EnableUserPreferenceAccess=false`).
This doc is **read-only**. No CSS POSTs, no bundle edits, no service
restarts performed. Implementation is a follow-up branch.
---
## TL;DR — counts
| Surface | Reachable as non-admin? | Raw "Jellyfin" mentions |
|---|---|---|
| `index.html` (live, bind-mount) | Yes | 0 (already shimmed: title, app-name, favicon, splashLogo) |
| PWA manifest `fd4301fdc170fd202474.json` | Yes (PWA install + iOS Safari add-to-home + Android install prompt) | **2** (`name`, `short_name`) |
| en-us i18n chunk | Yes (3 entries reachable; 19 are admin/dashboard/wizard) | 22 keys, **3 user-reachable** |
| `main.jellyfin.bundle.js` literals | Edge | 2 (`appName():"Jellyfin Web"` not visible; one error-route phrase) |
| Logo screensaver (`banner-light.png`) | Yes (idle timeout, default 3min) | 1 image asset |
| Apple-touch-startup-image splash PNGs | Yes (iOS Safari "Add to Home" PWA only) | ~20 images |
| Service worker registration message | No | 0 (clean — no JF strings) |
| chromecastPlayer plugin chunk | No (we hide cast btn; chunk only loads if cast invoked) | 0 |
| Browser tab title / favicon | No | 0 (already locked by shim) |
**Recommended fix path:** **CSS hide + JS shim + manifest bind-mount.** No bundle modifications. CSS alone is insufficient (manifest, i18n, screensaver image are CSS-invisible).
---
## Already-fixed (don't redo)
| Surface | Mechanism | Doc |
|---|---|---|
| `<title>Jellyfin</title>` overwrite by SPA | `lockTitle()` regex shim | `10-spa-runtime-shim.md` |
| `<link rel="icon">` Jellyfin teal triangle | Embedded data-URL favicon + `lockFavicon()` | 10 |
| `<meta name="application-name" content="Jellyfin">` | Static replace in bind-mounted index.html (`content="ARRFLIX"`) | 10 |
| `.splashLogo` (login chrome top-left) | Image swap in bind-mounted index.html | 10 |
| `.adminDrawerLogo img` + `.pageTitleWithLogo` | CustomCss `content: url(data:image/png;base64,…)` | `04-theming-and-users.md` §3b |
| Pre-bundle login flash (blue button, dark blue bg) | Inline `<style>` block in bind-mounted index.html | 10 |
| Settings drawer entry (only admin should see) | CustomCss `:has()` rules + JS `nukeSettings()` MutationObserver | 10 |
| Quick Connect button | CustomCss `.btnQuick { display:none }` + server-side disabled | 04 |
| Cast / SyncPlay / User header icons | CustomCss `.headerCastButton` etc. | 04 |
Confirmed live (2026-05-08, USER-A session):
```
GET /web/index.html → <title>ARRFLIX</title>
<meta name="application-name" content="ARRFLIX">
<link rel="apple-touch-icon" sizes="180x180" href="data:image/png;base64,…"> (ARRFLIX logo)
ARRFLIX-SHIM-BEGIN block present and runs.
GET /Branding/Configuration → CustomCss includes Cineplex + ARRFLIX overrides as expected.
```
---
## Findings — by severity
### S1 visible-everywhere (PWA + idle screensaver)
#### F1 — PWA manifest `name` and `short_name` are "Jellyfin"
- **Location:** `https://arrflix.s8n.ru/web/fd4301fdc170fd202474.json`
- **Live payload:**
```json
{ "name": "Jellyfin", "description": "The Free Software Media System",
"short_name": "Jellyfin", "start_url": "index.html#/home.html",
"theme_color": "#101010", "background_color": "#101010",
"icons": [ { "src": "touchicon72.png" }, …, { "src": "touchicon512.png" } ] }
```
- **User-visible where:**
- Android Chrome: install prompt label, home screen shortcut name, app drawer name.
- iOS Safari "Add to Home Screen": shortcut label.
- Desktop Chrome/Edge: "Install ARRFLIX" / install card title.
- Browser PWA badge (`navigator.getInstalledRelatedApps()`-style surfaces).
- **Fix mechanism:** **Bind-mount manifest** (the static index.html bind-mount is already proven to work). Replace `name`/`short_name` with `ARRFLIX`. Optionally clear `description` or set to a neutral string. Touchicon images already replaced via the data-URL `apple-touch-icon` patch in index.html, BUT the manifest still references `touchicon{72,114,144,512}.png` which are Jellyfin-branded PNGs on disk. We can either (a) bind-mount replacement PNGs, or (b) point the manifest icons array at our data URL via inline data-URI refs (Chrome accepts `"src": "data:image/png;base64,…"`).
- **Risk:** Low. Manifest is static JSON; nothing else parses it. Browser fetches manifest on install; if file is bind-mounted RO, container reads on each request just like index.html (same compose pattern, same inode-pin gotcha — see `10-spa-runtime-shim.md` §"Single-file bind mount inode gotcha").
- **Replacement file (proposed `web-overrides/fd4301fdc170fd202474.json`):**
```json
{
"name": "ARRFLIX",
"description": "ARRFLIX",
"lang": "en-US",
"short_name": "ARRFLIX",
"start_url": "index.html#/home.html",
"theme_color": "#000000",
"background_color": "#000000",
"display": "standalone",
"icons": [
{ "sizes": "72x72", "src": "touchicon72.png", "type": "image/png" },
{ "sizes": "114x114", "src": "touchicon114.png", "type": "image/png" },
{ "sizes": "144x144", "src": "touchicon144.png", "type": "image/png" },
{ "sizes": "512x512", "src": "touchicon512.png", "type": "image/png" }
]
}
```
(touchicon\*.png images are a separate Phase-2 swap — see F4.)
#### F2 — Logo screensaver shows Jellyfin banner on idle
- **Location:** `/web/logoScreensaver-plugin.8edf3eac91e564799c27.chunk.js`
injects `<img src="assets/img/banner-light.png">` into a `.logoScreenSaver` div
on idle timeout.
- **Live trigger:** Default screensaver kicks in after the user idles on any
page. Plays bouncing/spinning Jellyfin banner animation.
- **Fix mechanism options:**
1. **Server-side disable** (best): in user policy or server config, disable
the logo screensaver / set screensaver to "None". Confirmed reachable via
`Configuration` API. Do this for the system default; non-admins can't
override since their preferences are locked.
2. **CSS hide** (always works): append to CustomCss
```css
.logoScreenSaver, .logoScreenSaverImage { display: none !important; }
```
The screensaver div still mounts but renders nothing. Visually this
means a black overlay on idle (acceptable).
3. **CSS image swap** (ARRFLIX-branded screensaver):
```css
.logoScreenSaverImage { content: url("data:image/png;base64,<ARRFLIX>") !important; }
```
Reuses the same data URL we already inject in CustomCss for
`.adminDrawerLogo img`.
- **Risk:** Low. Screensaver is a presentation-only plugin; hiding it does
not break navigation, hotkeys, or playback. Option 3 is purely cosmetic.
- **Recommendation:** Option 1 (disable) + Option 2 (CSS belt) for defense
in depth.
---
### S2 detail-only / per-action (i18n strings)
#### F3 — i18n strings rendered to non-admin in error / playback paths
22 i18n keys in `en-us-json.667484b4a441712c7e05.chunk.js` contain "Jellyfin".
Of those, **3 are reachable as a non-admin user**:
| Key | String | When shown |
|---|---|---|
| `PlaybackErrorPlaceHolder` | "This is a placeholder for physical media that **Jellyfin** cannot play. Please insert the disc to play." | Player attempts to play a placeholder/disc-only item. Rare for an arr-fed library but possible. |
| `UnsupportedPlayback` | "**Jellyfin** cannot decrypt content protected by DRM but all content will be tried regardless, including protected titles. Some files may appear completely black due to encryption or other unsupported features, such as interactive titles." | DRM playback fallback dialog. Rare. |
| `MessageChromecastConnectionError` | "Your Google Cast receiver is unable to contact the **Jellyfin** server. Please check the connection and try again." | Cast initiation fails. We hide cast button so this is now functionally unreachable, but the keystrokes for cast can still be invoked from desktop browsers via media keys. |
The remaining 19 keys (`AllowStreamSharingHelp`, `EncodingFormatHelp`,
`ErrorAddingMediaPathToVirtualFolder`, `ErrorDeletingItem`, `ErrorDeletingLyrics`,
`KnownProxiesHelp`, `LabelAutomaticDiscoveryHelp`, `LabelDisplayLanguageHelp`,
`LabelPublishedServerUriHelp`, `MessageConfirmRestart`, `MessageDirectoryPickerBSDInstruction`,
`PleaseRestartServerName`, `ServerRestartNeededAfterPluginInstall`, `UserProfilesIntro`,
`WelcomeToProject`, `WizardCompleted`, `WriteAccessRequired`, `XmlTvPathHelp`,
`ConfirmEndPlayerSession`) are admin-only — Dashboard, setup wizard, plugin
manager, virtual folder management, restart confirms, encoding settings.
Non-admins cannot reach those routes (server policy + drawer hides + we
already strip the Settings link).
- **Fix mechanism:** **JS shim with MutationObserver** that walks DOM text
nodes and rewrites `Jellyfin → ARRFLIX`. SnipUSER-E appended to
`bin/inject-shim.py`:
```js
function rewriteJellyfinText(){
try {
var WORD = /\bJellyfin\b/g;
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
var n;
while ((n = walker.nextNode())) {
if (n.nodeValue && WORD.test(n.nodeValue)) {
n.nodeValue = n.nodeValue.replace(WORD, 'ARRFLIX');
}
}
} catch(e){}
}
// Wire into start():
// - call once at start()
// - call from body MutationObserver
// - call from setInterval safety net (1s)
```
- **Risk:**
- Performance: full-document text walk on every DOM mutation is O(N).
Mitigate by debouncing (run only if mutation contains added/removed
text nodes; use `requestIdleCallback`).
- False positives: rewriting text inside `<input>` value or `<textarea>`
— none of these strings live there, so safe.
- i18n drift on JF upgrade: if upstream renames the keys, this is still
safe (string-level rewrite, not key-level).
- Aria-labels and `title` attributes are NOT covered by `SHOW_TEXT`.
Add a separate pass that walks `[aria-label*="Jellyfin"]` and
`[title*="Jellyfin"]` if any surface needs it (none observed in audit).
- **Why not bind-mount the en-us-json chunk:** filename is content-hashed
(`en-us-json.667484b4a441712c7e05.chunk.js`). Every JF release bumps the
hash and the bind-mount becomes a 404. Fragile. JS shim wins.
---
### S3 edge / iOS-only
#### F4 — Apple PWA splash images and touchicon\*.png
- **Location:** `/web/{6a2e2e6b4186720e5d4f.png, eb8bef…, 3fa90c…, …}`
20 different `apple-touch-startup-image` PNGs declared in `index.html`,
plus `/web/touchicon{72,114,144,512}.png` referenced from manifest.
- **User-visible where:** iOS Safari "Add to Home Screen" install + launch
splash. Android Chrome icon-only fallback if data-URL fails (rare).
- **Fix mechanism:**
- **Phase 1 (cheap, ~70% covered):** Bind-mount the manifest (F1) so
`touchicon*.png` references can be redirected to data URLs in the
icons array. iOS Safari ignores those, but Android picks them up.
- **Phase 2 (full coverage):** Generate ARRFLIX-branded PNGs at the
20 device resolutions the apple-touch-startup-image media queries
expect, and bind-mount them under their content-hash filenames (`6a2e2e6b…png` etc.). Brittle — JF rebuilds rotate hashes.
- **Pragmatic alternative:** strip apple-touch-startup-image entries
from the bind-mounted index.html entirely. iOS will fall back to a
blank splash with the (already-ARRFLIX) apple-touch-icon. Loses the
"polished install splash" but kills the leak.
- **Risk:** Low. iOS PWA install rate on a private invite-only service
is a tiny fraction of sessions. Defer until owner reports actual
user friction.
- **Recommendation:** Defer. The PWA install path is rare enough on a
desktop/laptop-dominant private service that this is a Phase 3 polish.
#### F5 — `main.jellyfin.bundle.js` literal "Jellyfin Web" appName + error-route phrase
- **Location 1:** `AppHost.appName():"Jellyfin Web"` — sent in
`X-Emby-Authorization: MediaBrowser Client="Jellyfin Web"` header on
every API call. NOT user-visible chrome. Visible only in the user's
Devices list (which they can't reach since `EnableUserPreferenceAccess=false`)
and in the admin Dashboard "Active Devices" view. Non-admin: zero
exposure.
- **Location 2:** `"working in a future Jellyfin update."` — embedded in
the deprecated/removed-route React component (`/web/#/some-old-path`).
Reachable only via stale bookmark to a removed route. Edge.
- **Fix mechanism:** None. Bundle modifications are explicitly out of
scope (`CONSTRAINTS: no bundle modifications`). Both leaks are
non-admin-invisible in normal flow.
- **Risk of fixing:** rewriting `main.jellyfin.bundle.js` would break
source-map verification, JF auto-updates, and would have to be redone
every image bump. Not worth it.
---
## Recommended fix order
| # | Fix | Effort | User-visible win |
|---|---|---|---|
| 1 | **Manifest bind-mount** (F1) | 5 min | Eliminates "Jellyfin" from PWA install + home-screen + app drawer. |
| 2 | **Disable logo screensaver** server-side + CSS belt (F2) | 5 min | Eliminates Jellyfin banner during idle (currently the most-visible animated leak). |
| 3 | **DOM text-rewrite shim** for `Jellyfin → ARRFLIX` (F3) | 15 min | Catches all 22 i18n keys + any future JF upgrade leaks; covers playback errors and unreachable admin paths defensively. |
| 4 | **Apple splash + touchicon swap** (F4) | 1-2h (image gen) | iOS PWA install polish. Defer. |
| 5 | **Bundle literals** (F5) | N/A | Skip — non-admin-invisible. |
Phases 1-3 give 100% coverage for non-admin chrome. Phase 4 polishes the iOS install path. Phase 5 is out of scope.
---
## Implementation plan — concrete snipUSER-Es
### SnipUSER-E A — manifest bind-mount
Add `web-overrides/fd4301fdc170fd202474.json` (full file body in F1 above).
Compose volume:
```yaml
volumes:
- /opt/docker/jellyfin/web-overrides/index.html:/jellyfin/jellyfin-web/index.html:ro
- /opt/docker/jellyfin/web-overrides/fd4301fdc170fd202474.json:/jellyfin/jellyfin-web/fd4301fdc170fd202474.json:ro
```
Deploy (no container restart needed):
```bash
scp /tmp/ARRFLIX/web-overrides/fd4301fdc170fd202474.json \
user@192.168.0.100:/opt/docker/jellyfin/web-overrides/fd4301fdc170fd202474.json
curl -ks https://arrflix.s8n.ru/web/fd4301fdc170fd202474.json | jq -r .name # expect "ARRFLIX"
```
**Inode-pin gotcha:** scp's `truncate-then-write` is safe; rsync via temp-file
+ rename will orphan the bind. Same rule as index.html (see doc 10).
**Hash-rotation gotcha:** if a future JF image bumps the manifest filename
hash, this bind path 404s. Verify after every image upgrade:
```bash
curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE 'rel="manifest" href="[^"]*"'
# expect href="fd4301fdc170fd202474.json" — if changed, rename bind file.
```
### SnipUSER-E B — screensaver disable + CSS belt
Server-side (one-time as admin):
```bash
TOKEN=<admin token>
# Disable default screensaver via /System/Configuration:
curl -ks -X POST https://arrflix.s8n.ru/System/Configuration \
-H "X-Emby-Token: $TOKEN" -H "Content-Type: application/json" \
-d '{"DefaultScreensaverPlugin":"none"}'
```
CSS belt (append to CustomCss via existing `04-theming-and-users.md` workflow):
```css
/* Hide Jellyfin logo screensaver — 2026-05-08 (doc 16) */
.logoScreenSaver,
.logoScreenSaverImage { display: none !important; }
```
### SnipUSER-E C — DOM text-rewrite shim (covers F3)
Append to the IIFE in `bin/inject-shim.py`, between `nukeSettings` and
`start`:
```js
var JF_WORD = /\bJellyfin\b/g;
function rewriteJellyfinText(root){
try {
var r = root || document.body;
if (!r) return;
var w = document.createTreeWalker(r, NodeFilter.SHOW_TEXT, {
acceptNode: function(n){
var p = n.parentNode;
if (!p) return NodeFilter.FILTER_REJECT;
var tag = p.nodeName;
// Skip <script>, <style>, <textarea>, <input> contents
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'INPUT') {
return NodeFilter.FILTER_REJECT;
}
return JF_WORD.test(n.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
}
});
var n;
while ((n = w.nextNode())) {
n.nodeValue = n.nodeValue.replace(JF_WORD, 'ARRFLIX');
}
// aria-label / title attributes
var attrEls = r.querySelectorAll('[aria-label*="Jellyfin"], [title*="Jellyfin"]');
for (var i = 0; i < attrEls.length; i++) {
var el = attrEls[i];
if (el.getAttribute('aria-label')) {
el.setAttribute('aria-label', el.getAttribute('aria-label').replace(JF_WORD, 'ARRFLIX'));
}
if (el.getAttribute('title')) {
el.setAttribute('title', el.getAttribute('title').replace(JF_WORD, 'ARRFLIX'));
}
}
} catch(e){}
}
```
Wire into `start()`:
```js
function start(){
lockTitle(); lockFavicon(); nukeSettings(); rewriteJellyfinText();
// … existing head observer …
if (document.body && window.MutationObserver) {
new MutationObserver(function(muts){
nukeSettings();
// Only re-walk if a mutation added text — avoid full-doc walk on every keystroke
var dirty = false;
for (var i = 0; i < muts.length && !dirty; i++) {
var m = muts[i];
if (m.addedNodes && m.addedNodes.length) dirty = true;
else if (m.type === 'characterData') dirty = true;
}
if (dirty) rewriteJellyfinText();
}).observe(document.body, { childList:true, subtree:true, characterData:true });
}
setInterval(function(){
/* … existing … */
rewriteJellyfinText();
}, 1000);
}
```
**Performance:** `acceptNode` filter rejects non-matching nodes O(1) per
node, so the walker is cheap. Adding/removing list items in a 5000-item
library scroll triggers ~5000 reject calls per render frame, which is
sub-ms in Chromium. No `requestIdleCallback` needed for this scale.
**Why not just text-replace the whole document body markup string in place:**
that approach destroys all React event listeners and breaks navigation.
The TreeWalker approach mutates only `nodeValue` on already-rendered text
nodes, so React's reconciler is undisturbed.
### SnipUSER-E D — defer-but-noted: touchicon\*.png
Phase 4. Generate ARRFLIX-branded PNGs at 72/114/144/512 px and bind-mount
each:
```yaml
- /opt/docker/jellyfin/web-overrides/touchicon72.png:/jellyfin/jellyfin-web/touchicon72.png:ro
- /opt/docker/jellyfin/web-overrides/touchicon114.png:/jellyfin/jellyfin-web/touchicon114.png:ro
- /opt/docker/jellyfin/web-overrides/touchicon144.png:/jellyfin/jellyfin-web/touchicon144.png:ro
- /opt/docker/jellyfin/web-overrides/touchicon512.png:/jellyfin/jellyfin-web/touchicon512.png:ro
```
These four filenames are *not* content-hashed, so the bind survives JF
upgrades.
The 20 apple-touch-startup-image PNGs *are* content-hashed; skip those
or strip their `<link>` tags from the bind-mounted index.html.
---
## i18n shim vs bundle bind-mount — why we choose shim
| Approach | Survives JF upgrade? | Effort/upgrade | Fragility |
|---|---|---|---|
| Bind-mount `en-us-json.<hash>.chunk.js` | No (filename rotates each release) | Re-extract + re-mount each upgrade | High |
| DOM text-rewrite shim (chosen) | Yes | Zero | Low — string-level rewrite, key-agnostic |
| Override-language-pack server config | Partially (only changes display lang, doesn't strip "Jellyfin" from custom strings) | One-time | Doesn't fix the leak |
| Custom branding in `LoginDisclaimer` (already used) | N/A — only affects login screen disclaimer | One-time | Already in place; doesn't touch other strings |
The shim is the only non-fragile, upgrade-immune solution short of forking
the bundle.
---
## PWA manifest gotcha — flagged
The owner asked specifically: "If the manifest contains `name:Jellyfin`,
propose an override approach (bind-mount a custom manifest.json)."
**Confirmed: Yes, manifest contains `"name":"Jellyfin"` and `"short_name":"Jellyfin"`.**
Override approach: bind-mount the file as in SnipUSER-E A. The compose
config is already set up for the same pattern (index.html). One additional
volume line. The only new risk is the hash-rotation case — record the
filename in `web-overrides/README.md` and grep-verify after every JF image
bump.
---
## Out-of-scope notes
- **`description: "The Free Software Media System"`** in the manifest is
a Jellyfin-project tagline, not the literal "Jellyfin" word. Owner asked
for "Jellyfin" specifically; the description is replaced in our
proposed manifest anyway (set to "ARRFLIX").
- **`assets/img/banner-dark.png`** is not user-reachable as non-admin
(would only render in admin theme previews). Skip.
- **`fresh.svg` / `rotten.svg`** (Rotten Tomatoes) are not Jellyfin-branded.
Already handled by Cineplex CSS. Skip.
- **`avatar.png`** is the default user avatar (generic person icon) — not
Jellyfin-branded. Skip.
---
## Verification post-fix
After deploying Phase 1-3, re-run this audit and confirm:
```bash
# F1 — manifest
curl -ks https://arrflix.s8n.ru/web/fd4301fdc170fd202474.json | jq -r '.name, .short_name'
# expect: ARRFLIX / ARRFLIX
# F2 — screensaver
TOKEN=<admin>
curl -ks https://arrflix.s8n.ru/System/Configuration -H "X-Emby-Token: $TOKEN" | jq -r '.DefaultScreensaverPlugin'
# expect: "none" (or empty)
# F3 — i18n shim
# Manual: Open DevTools console, run:
# document.title.includes('Jellyfin') || document.body.innerText.includes('Jellyfin')
# expect: false
# Belt: any-Jellyfin-anywhere check
curl -ks https://arrflix.s8n.ru/web/index.html | grep -ohE '\bJellyfin\b' | wc -l
# expect: occurrences only in shim regex source (not in user-visible chrome)
```
---
## Sign-off
- **Audit run by:** s8n, 2026-05-08, non-admin session as `USER-A`.
- **Mode:** read-only. No CSS POSTs, no bundle edits, no service restarts.
- **Live state:** index.html shim active and correct; manifest leak confirmed; screensaver leak confirmed; i18n leaks confirmed (3 reachable / 22 total in en-us chunk).
- **Recommended next action:** implement Phase 1 (manifest bind-mount) +
Phase 2 (screensaver disable + CSS belt) in a single follow-up branch;
Phase 3 (DOM text shim) in a separate branch since it touches the
critical inject-shim.py path and warrants its own verification.

View file

@ -0,0 +1,474 @@
# 17 - Dev Mirror + Settings Drawer Leak Diagnosis & Fix (Dev Only)
> Owner asked for two things in one session:
>
> 1. Make `https://dev.arrflix.s8n.ru` a complete behavioural mirror of prod
> `https://arrflix.s8n.ru` so the dev box is a faithful test bench.
> 2. With dev mirroring prod, definitively diagnose and fix the long-standing
> "Settings entry still appears in the drawer for non-admin users" issue —
> **on dev only**. Owner reviews dev visually before any prod swap.
>
> Date: 2026-05-08. Live verification in `/tmp/arrflix-headless/` (screenshots,
> drawer DOM dumps, selector tests). Prod was **not** modified. The shared
> `web-overrides/index.html` bind-mounted into the prod container was **not**
> edited. Dev now bind-mounts a separate `index-dev.html` of its own.
---
## TL;DR
| Surface | Mirrored to dev? | Method |
|---|---|---|
| Branding (`LoginDisclaimer`, `CustomCss`, `SplashscreenEnabled`) | YES — byte-equal | `GET /System/Configuration/branding` on prod, `POST` on dev |
| `web-overrides/index.html` shim+splash+favicon | YES (initially the shared file; now dev-only `index-dev.html`) | docker-compose bind-mount |
| Libraries (`Movies`, `TV Shows`) | YES — same paths, same `LibraryOptions` | `POST /Library/VirtualFolders` per lib |
| Non-admin users (5, USER-B, USER-F, USER-G, USER-A, USER-E) | YES — recreated as `<u>-mirror` with placeholder `dev-test-<u>` passwords | `bin/add-jellyfin-user.sh` |
| `DisplayPreferences` (`client=emby`) per user | YES — copied verbatim from prod | `GET → POST /DisplayPreferences/usersettings` |
| Library scan (item counts within tolerance) | YES — dev 173 ep / prod 168 ep (Mando importing) | `POST /Library/Refresh` |
**Settings drawer leak — root cause:** The drawer Settings entry is rendered as
```html
<a is="emby-linkbutton"
class="navMenuOption lnkMediaFolder btnSettings emby-button"
data-itemid="settings"
href="#">
<span class="material-icons navMenuOptionIcon settings"></span>
<span class="navMenuOptionText">Settings</span>
</a>
```
The `href` is literally `#`. The actual route is wired by a JS click handler
keyed off `data-itemid="settings"`. Every existing CSS rule we had —
`a[href*="mypreferencesmenu"]`, `[to*="mypreferencesmenu"]`,
`[href$="mypreferencesmenu.html"]`, `[to="/mypreferencesmenu.html"]` — matched
**zero** elements in the live DOM (verified via headless probe).
**Fix (dev only, in `index-dev.html`):**
- CSS: `a.btnSettings, .navMenuOption.btnSettings, [data-itemid="settings"] { display: none !important; }`
- JS shim `nukeSettings()` extended to also match `a.btnSettings` and `[data-itemid="settings"]`, with the legacy `mypreferencesmenu` selectors kept as fallback.
---
## Phase 1 — Mirror procedure
### 1.1 Complete dev's first-run wizard
Dev was a fresh container (`StartupWizardCompleted=false`). Three calls:
```bash
DEV=https://dev.arrflix.s8n.ru
curl -ks -X POST "$DEV/Startup/Configuration" \
-H 'Content-Type: application/json' \
-d '{"UICulture":"en-US","MetadataCountryCode":"US","PreferredMetadataLanguage":"en"}'
# Gotcha: POSTing a NEW name to /Startup/User raises
# System.InvalidOperationException: Sequence contains no elements
# because the wizard already auto-created a placeholder admin "MyJellyfinUser"
# on first request. So set the password on the existing name first:
curl -ks -X POST "$DEV/Startup/User" \
-H 'Content-Type: application/json' \
-d '{"Name":"MyJellyfinUser","Password":"2001dude"}'
curl -ks -X POST "$DEV/Startup/RemoteAccess" \
-H 'Content-Type: application/json' \
-d '{"EnableRemoteAccess":true,"EnableAutomaticPortMapping":false}'
curl -ks -X POST "$DEV/Startup/Complete"
```
Then authenticate, save the token, and rename the admin:
```bash
DEV_TOKEN=$(curl -ks -X POST "$DEV/Users/AuthenticateByName" \
-H 'Content-Type: application/json' \
-H 'Authorization: MediaBrowser Client="setup", Device="setup", DeviceId="setup", Version="1.0"' \
-d '{"Username":"MyJellyfinUser","Pw":"2001dude"}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["AccessToken"])')
# Rename: GET full user object, mutate Name, POST back to /Users/{id}
DEV_USER_ID=...
curl -ks "$DEV/Users/$DEV_USER_ID" -H "Authorization: MediaBrowser Token=\"$DEV_TOKEN\"" \
| python3 -c 'import json,sys; u=json.load(sys.stdin); u["Name"]="s8n-dev"; print(json.dumps(u))' \
| curl -ks -X POST "$DEV/Users/$DEV_USER_ID" \
-H "Authorization: MediaBrowser Token=\"$DEV_TOKEN\"" \
-H 'Content-Type: application/json' --data-binary @-
```
### 1.2 Mirror branding
```bash
PROD=https://arrflix.s8n.ru
PROD_TOKEN=...
curl -ks "$PROD/System/Configuration/branding" \
-H "Authorization: MediaBrowser Token=\"$PROD_TOKEN\"" > /tmp/prod-branding.json
curl -ks -X POST "$DEV/System/Configuration/branding" \
-H "Authorization: MediaBrowser Token=\"$DEV_TOKEN\"" \
-H 'Content-Type: application/json' \
--data-binary @/tmp/prod-branding.json
```
Verified `LoginDisclaimer`, `CustomCss` (25985 chars), `SplashscreenEnabled=true`
all byte-equal between dev and prod after POST.
### 1.3 Mirror web-overrides bind-mount
Initial mirror used the **shared** prod file:
```yaml
# /opt/docker/jellyfin-dev/docker-compose.yml — initial mirror state
- /opt/docker/jellyfin/web-overrides/index.html:/jellyfin/jellyfin-web/index.html:ro
```
`docker compose up -d --force-recreate jellyfin-dev`. Confirmed dev served
`<title>ARRFLIX</title>`, `<meta name="application-name" content="ARRFLIX">`,
embedded data-URL apple-touch-icon (ARRFLIX), and the `/* ARRFLIX-SHIM-BEGIN */`
script block.
**Then for Phase 2 fix-isolation**, the mount was switched to a dev-only file
copy so dev fixes don't bleed into prod:
```yaml
# /opt/docker/jellyfin-dev/docker-compose.yml — final dev state
- /opt/docker/jellyfin-dev/web-overrides/index-dev.html:/jellyfin/jellyfin-web/index.html:ro
```
`/opt/docker/jellyfin-dev/web-overrides/index-dev.html` was created by `cp`
from the prod shared file, then patched with the V2 fix described in Phase 2.
### 1.4 Mirror libraries
```bash
curl -ks "$PROD/Library/VirtualFolders" -H "Authorization: MediaBrowser Token=\"$PROD_TOKEN\"" \
> /tmp/prod-libs.json
# For each lib: POST /Library/VirtualFolders?name=...&collectionType=...&paths=...&refreshLibrary=false
# with body {"LibraryOptions": <prod LibraryOptions>}
# (script in conversation log; reproducible via python3 driver.)
```
Result: dev has `Movies → /media/movies` and `TV Shows → /media/tv` with the
same `LibraryOptions` (`PreferredMetadataLanguage=en`, `MetadataCountryCode=US`,
`EnableInternetProviders=false`, `SubtitleDownloadLanguages=[eng]`,
`TheMovieDb` as sole metadata fetcher, etc.).
### 1.5 Mirror users
For each non-admin prod user (5, USER-B, USER-F, USER-G, USER-A, USER-E) the
existing `bin/add-jellyfin-user.sh` wrapper was reused with placeholder
passwords:
```bash
export JELLYFIN_URL=https://dev.arrflix.s8n.ru
export JELLYFIN_TOKEN=$DEV_TOKEN
for u in 5 USER-F USER-G USER-A USER-E USER-B; do
bash bin/add-jellyfin-user.sh "$u-mirror" "dev-test-$u"
done
```
The `-mirror` suffix avoids any confusion with prod accounts. Owner can rotate
or rename later.
### 1.6 Mirror DisplayPreferences
`bin/add-jellyfin-user.sh` already applies the canonical home layout, BUT to
get full parity for any owner-customised layouts (USER-A's home in particular)
the prod prefs were copied verbatim:
```bash
for u in 5 USER-B USER-F USER-G USER-A USER-E; do
curl -ks "$PROD/DisplayPreferences/usersettings?userId=<prod-id>&client=emby" \
-H "Authorization: MediaBrowser Token=\"$PROD_TOKEN\"" \
| curl -ks -X POST \
"$DEV/DisplayPreferences/usersettings?userId=<dev-id>&client=emby" \
-H "Authorization: MediaBrowser Token=\"$DEV_TOKEN\"" \
-H 'Content-Type: application/json' --data-binary @-
done
```
All 6 returned HTTP 204.
### 1.7 Library scan + parity check
```bash
curl -ks -X POST "$DEV/Library/Refresh" -H "Authorization: MediaBrowser Token=\"$DEV_TOKEN\""
```
Within 5 seconds:
| | MovieCount | SeriesCount | EpisodeCount |
|---|---|---|---|
| Prod | 2 | 6 | 168 |
| Dev | 2 | 6 | 173 |
Dev caught up to prod within tolerance. Episode delta of +5 likely reflects
slightly different scrape ordering / Mando still importing on prod-side; well
within the ±20 tolerance.
---
## Phase 2 — Diagnosis (headless Chrome)
### 2.1 Setup
`chromium`/`chromedriver` not installed via dnf — instead used the existing
playwright cache at `~/.cache/ms-playwright/chromium-1217`:
```bash
python3 -m venv /tmp/arrflix-venv
/tmp/arrflix-venv/bin/pip install -q playwright
# probe.py + verify_fix2.py + verify_native.py — see /tmp/arrflix-headless/
```
Login page selectors discovered:
- username: `#txtManualName` (NOT `input[name="username"]`)
- password: `#txtManualPassword`
Drawer button: `.mainDrawerButton`.
### 2.2 Drawer DOM (the smoking gun)
`/tmp/arrflix-headless/drawer-dom.html` (full):
```html
<div class="mainDrawer transition touch-menu-la drawer-open" style="...">
<div class="mainDrawer-scrollContainer scrollContainer focuscontainer-y scrollY">
<div style="height:.5em;"></div>
<a is="emby-linkbutton" class="navMenuOption lnkMediaFolder emby-button" href="#/home.html">
<span class="material-icons navMenuOptionIcon home"></span>
<span class="navMenuOptionText">Home</span>
</a>
<div class="customMenuOptions"></div>
<div class="libraryMenuOptions">
<h3 class="sidebarHeader">Media</h3>
<a is="emby-linkbutton" data-itemid="f137a2dd21bbc1b99aa5c0f6bf02a805"
class="lnkMediaFolder navMenuOption emby-button"
href="#/movies.html?topParentId=...">
<span class="material-icons navMenuOptionIcon movie"></span>
<span class="sectionName navMenuOptionText">Movies</span>
</a>
<a is="emby-linkbutton" data-itemid="767bffe4f11c93ef34b805451a696a4e"
class="lnkMediaFolder navMenuOption emby-button"
href="#/tv.html?topParentId=...">
<span class="material-icons navMenuOptionIcon tv"></span>
<span class="sectionName navMenuOptionText">TV Shows</span>
</a>
</div>
<div class="userMenuOptions">
<h3 class="sidebarHeader">User</h3>
<a is="emby-linkbutton" class="navMenuOption lnkMediaFolder btnSettings emby-button"
data-itemid="settings" href="#"> <!-- ← the leak -->
<span class="material-icons navMenuOptionIcon settings"></span>
<span class="navMenuOptionText">Settings</span>
</a>
<a is="emby-linkbutton" class="navMenuOption lnkMediaFolder btnLogout emby-button"
data-itemid="logout" href="#">
<span class="material-icons navMenuOptionIcon exit_to_app"></span>
<span class="navMenuOptionText">Sign Out</span>
</a>
</div>
</div>
</div>
```
Key observations:
- **Settings `<a>` `href="#"`** — pure dummy hash, no `mypreferencesmenu` substring anywhere.
- **Stable identifiers:** `class="... btnSettings ..."` and `data-itemid="settings"`.
- **Section header `<h3>User</h3>`** is rendered as a plain element. After hiding
Settings, only Sign Out remains under it; the "User" header itself stays
(not orphaned, since Sign Out keeps the section meaningful). Owner can
decide whether to also drop the header in a later iteration.
### 2.3 Why every prior CSS rule failed
Headless evaluation of each candidate selector against the live drawer:
| Selector | Match count |
|---|---|
| `a[href*="mypreferencesmenu"]` | **0** |
| `li:has(> a[href*="mypreferencesmenu"])` | **0** |
| `.MuiListItem-root:has(a[href*="mypreferencesmenu"])` | **0** |
| `[to="/mypreferencesmenu.html"]` | **0** |
| `a[href*="mypreferences"]` | **0** |
| `a[href$="mypreferencesmenu.html"]` | **0** |
| `a[href="#/mypreferencesmenu.html"]` | **0** |
| `.navMenuOption[href*="mypreferencesmenu"]` | **0** |
| `div:has(> a[href*="mypreferencesmenu"])` | **0** |
All 9 prior selectors target **zero** DOM nodes. The shim's
`nukeSettings()` MutationObserver was firing 1×/sec but matching nothing.
This explains why CSS-only and JS-only attempts both kept failing.
### 2.4 The selector that works
```css
a.btnSettings,
.navMenuOption.btnSettings,
[data-itemid="settings"] {
display: none !important;
}
```
Headless before/after:
| | display | height |
|---|---|---|
| Before injection | `flex` | 47.0px |
| After CSS injected | `none` | 0px |
| Sign Out (control) | `flex` | 47.0px (unchanged) |
Screenshots:
- `/tmp/arrflix-headless/v02-drawer-before-fix.png` — drawer shows Home / Media / User → Settings + Sign Out
- `/tmp/arrflix-headless/v03-drawer-after-fix.png` — drawer shows Home / Media / User → Sign Out only
### 2.5 Why `#href` and a JS-routed click
Jellyfin's web bundle uses an `embyRouter` (the legacy Emby app shell) that
dispatches navigation via JS click handlers. For drawer items wired to
internal routes, the bundle either:
1. Sets `href="#/path.html"` (works for plain hash routing — all our Movies/TV
links use this form).
2. Sets `href="#"` and registers a `click` handler keyed by some attribute.
Settings + Sign Out + the user-icon in the header all use form 2.
The canonical attribute keys used in form 2 are:
- `data-itemid="settings"` → opens `Preferences/Display` (or
`Dashboard/General` for admins).
- `data-itemid="logout"` → calls the sign-out handler.
This pattern dates back to the Emby fork and is unlikely to change in 10.x.
---
## Phase 3 — Verification protocol
### 3.1 Native verification (V2 fix in `index-dev.html`, no client injection)
`/tmp/arrflix-headless/verify_native.py` — sign in, open drawer, measure.
```
Native dev (V2 fix in place): {
"settings": { "display": "none", "visibility": "visible", "height": 0, "inline": "none" },
"signOut": { "display": "flex", "visibility": "visible", "height": 47.015625, "inline": "" },
"settingsCount": 1
}
PASS: Settings hidden by index-dev.html out-of-the-box
Final drawer post-nav: [{'display': 'none', 'height': 0}]
```
`settingsCount: 1` confirms the `<a>` is **still in the DOM** (we don't
delete the node — that risks Jellyfin's drawer-renderer rebuilding it on
the next render). The element is present but `display:none` from both the
CSS rule and the JS shim's inline-style override. Sign Out is preserved.
After clicking Home from the drawer and reopening the drawer, the Settings
entry is still hidden (`display: 'none', height: 0`) — confirms the
MutationObserver re-applies on every drawer rebuild.
Screenshots:
- `/tmp/arrflix-headless/native-01-home.png` — post-login home view
- `/tmp/arrflix-headless/native-02-drawer.png` — drawer after V2 fix
(Settings absent)
- `/tmp/arrflix-headless/native-03-home-via-drawer.png` — home reached
via drawer click (still works)
- `/tmp/arrflix-headless/native-04-drawer-post-nav.png` — drawer
reopened after navigation (Settings still hidden)
### 3.2 Manual verification checklist (for owner)
- [ ] Sign in to https://dev.arrflix.s8n.ru as `USER-A-mirror` / `dev-test-USER-A`.
- [ ] Click the hamburger top-left.
- [ ] Drawer should show: Home / Media (Movies, TV Shows) / User (Sign Out only).
- [ ] No "Settings" gear icon under the User section.
- [ ] Click Movies, TV Shows, Home — all navigate normally.
- [ ] Reopen drawer after each navigation — Settings should remain absent.
- [ ] Optional regression check: sign in as `s8n-dev` (admin) to confirm
admin still sees Settings — currently this fix hides it for **everyone**
(admins included). If owner wants admin to retain access, see open
question Q1 below.
---
## Recommended swap-to-prod procedure
When owner approves: **merge the `index-dev.html` JS shim block + CSS rule
into `web-overrides/index.html`, then `docker compose restart jellyfin`.**
Concrete diff (to apply to `/opt/docker/jellyfin/web-overrides/index.html`):
1. Inside the inline `<style>` block (above `</style>` near line 16), add:
```css
/* ARRFLIX V2 (2026-05-08) — hide drawer Settings for non-admins.
Drawer Settings link is .btnSettings / [data-itemid="settings"] href="#".
Old href*="mypreferencesmenu" rules never matched. */
a.btnSettings,
.navMenuOption.btnSettings,
[data-itemid="settings"] {
display: none !important;
}
```
2. Inside the `nukeSettings()` function in `ARRFLIX-SHIM-BEGIN`, replace the
selector list:
```js
var nodes = document.querySelectorAll(
'a.btnSettings, [data-itemid="settings"], a[href*="mypreferencesmenu"], [to*="mypreferencesmenu"]'
);
```
The exact patched `index-dev.html` is at
`/opt/docker/jellyfin-dev/web-overrides/index-dev.html` on nullstone — diff
it against `/opt/docker/jellyfin/web-overrides/index.html` to see the two
isolated changes. The `inject-shim.py` script in `bin/` should also be
updated to match (so re-running it doesn't revert the fix).
**No prod changes performed in this session.** Awaiting owner sign-off.
---
## Open questions for owner
**Q1 — Admins too?** Current rule hides Settings for **everyone**, including
admin users. If admin should still reach Settings, options:
(a) keep current rule, admins navigate to `/web/index.html#/dashboard.html`
manually via URL bar (works fine; Settings under-the-hood routes there);
(b) refine rule with a body-class check (`body.lacking-pref-access` —
requires bundle hint that doesn't exist today);
(c) accept the rule and document the workaround.
Recommendation: **(a) — let admins type the URL.** They can also edit the
drawer DOM via dev tools if needed; no real friction. Non-admins are the
threat surface.
**Q2 — User header?** The `<h3>User</h3>` section header remains visible
above the lone "Sign Out" entry. Visually fine but slightly orphan-feeling.
Worth hiding too? If yes:
```css
.userMenuOptions .sidebarHeader { display: none !important; }
```
But this also fires for admins.
**Q3 — Mirror vs prod password parity?** Dev mirror users have placeholder
passwords (`dev-test-<u>`). For better visual fidelity owner may want to
match prod passwords. Not strictly needed for testing the drawer fix.
**Q4 — Dev admin name.** Created as `MyJellyfinUser` then renamed to
`s8n-dev`. Password is the same `2001dude` as prod admin — owner may want
to rotate.
---
## Files referenced
- Live patched dev index: `/opt/docker/jellyfin-dev/web-overrides/index-dev.html` (on nullstone)
- Live dev compose: `/opt/docker/jellyfin-dev/docker-compose.yml` (on nullstone, backups in same folder)
- Headless artifacts: `/tmp/arrflix-headless/` (on onyx)
- `drawer-dom.html` — full drawer DOM dump
- `selector-tests.json` — match counts for every prior selector
- `settings-finds.json` — every Settings-text and href-matching node
- `verify_native.py` — final verification script
- `native-{01..04}-*.png` — final fix screenshots
- `v02-drawer-before-fix.png` / `v03-drawer-after-fix.png` — before/after CSS injection
- Prod-state captures:
- `/tmp/prod-branding.json`
- `/tmp/prod-libs.json`
- `/tmp/prod-counts.json`
- Dev creds env: `/tmp/dev-creds.env` (on onyx — `DEV_TOKEN`, `DEV_USER_ID`)

View file

@ -0,0 +1,347 @@
# 19 - English-Only Lockdown Audit (Read-Only Baseline)
> Owner saw the Play button render as **"Abspielen"** (German). Goal:
> "everything English only, remove the ability to be in another language at
> all". This doc supplements `docs/15-force-english.md` and `docs/16-jellyfin-branding-leaks.md`
> it is the cross-layer baseline for the lockdown branch.
Audited: 2026-05-08 against live `https://arrflix.s8n.ru`, Jellyfin 10.10.3.
Auditor: s8n. Mode: read-only. No POST/PATCH/PUT to Jellyfin, no file
modifications outside this doc.
---
## TL;DR — root cause + why doc 15 didn't close it
1. **Per-user `Configuration.UICulture` is still absent on every account.**
All 8 users return `Configuration.UICulture` as a missing key (verified
live 2026-05-08, see Per-User Table below). Doc 15 correctly identified
the fix and shipped the `bin/force-english-all-users.sh` script — but
**the script was never executed**. There is no audit trail of a
`204 No Content` against `/Users/{id}/Configuration` in the activity log
for any user, and the live state proves it (UICulture still absent on
all 8). When `UICulture` is absent, the SPA falls back to
`navigator.language` / `Accept-Language`, so any browser sending `de-*`
loads the German bundle and renders "Abspielen". This is layer (5) in
the table below.
2. **The German translation bundle is shipped and live.** `de-json.1afccc006ab8bb6c5953.chunk.js`
is reachable, returns HTTP 200, and contains `"Play":"Abspielen"`,
`"Settings":"Einstellungen"`, `"Save":"Speichern"`, etc. — 1963 unique
translated keys. 92 other locale chunks ship alongside it. Until those
are removed from the served bundle, the SPA can always select a
non-English locale even if every user has `UICulture=en-US` (e.g. a new
user who never authenticated, or a tampered SPA). Doc 15 explicitly
noted "no server flag forces SPA to ignore Accept-Language" but stopped
at the per-user pin — it didn't propose deleting the bundles.
In two sentences: **The Play button renders "Abspielen" because every user
has `Configuration.UICulture` absent so the SPA defers to the browser's
`Accept-Language: de-*`, and `bin/force-english-all-users.sh` (the doc-15
fix) was authored but never run. Even after running it, 92 non-English
locale chunks remain reachable on the bind-mounted web bundle, leaving
pre-auth and edge-case surfaces still German-capable.**
---
## Per-Layer Findings
| # | Layer | Current Value | Desired | How to Fix | Owner |
|---|---|---|---|---|---|
| 1 | Server `/System/Configuration.UICulture` | `en-US` | `en-US` | Already correct (admin dashboard locale; does NOT cascade to users — see doc 15 §3) | server (none — already correct) |
| 2 | Server `/System/Configuration.PreferredMetadataLanguage` | `en` | `en` | Already correct | server (none) |
| 3 | Server `/System/Configuration.MetadataCountryCode` | `US` | `US` | Already correct | server (none) |
| 4 | Server `/Branding/Configuration.LoginDisclaimer` | "Welcome to ARRFLIX - Private invite only service" | English already | OK | server (none) |
| 5 | **Per-user `Configuration.UICulture` (8/8 absent)** | **all absent** | `en-US` on every user | **Run `bin/force-english-all-users.sh`** with admin token; idempotent. Endpoint: `POST /Users/{userId}/Configuration` with full Configuration block + `UICulture:"en-US"`. | **server agent — primary fix** |
| 6 | Per-user `Configuration.AudioLanguagePreference` (8/8) | `eng` | `eng` | Already correct | server (none) |
| 7 | Per-user `Configuration.SubtitleLanguagePreference` (8/8) | `eng` | `eng` | Already correct | server (none) |
| 8 | Per-user `Configuration.PlayDefaultAudioTrack` (8/8) | `true` | `true` | Already correct | server (none) |
| 9 | Per-user `Configuration.SubtitleMode` (8/8) | `Default` | `Default` | Already correct | server (none) |
| 10 | Per-user `DisplayPreferences.CustomPrefs.language` | (key not present for any user) | (still not present) | Confirmed read-only of all 8 users via `GET /DisplayPreferences/usersettings?userId=...&client=emby` — no `language` key in `CustomPrefs`. Locale is NOT stored here. Layer is non-issue. | none |
| 11 | Plugin-shipped UI strings | 6 plugins (AudioDB, MusicBrainz, OMDb, Open Subtitles, Studio Images, TMDb); none ship locale UI strings | None | No action — these are metadata-source plugins, not UI string sources. | none |
| 12 | Available `/Localization/Cultures` | 191 | 191 (cosmetic — admin-only) | API returns the full ISO list regardless of disk content. Cannot be trimmed via API. Admin-only. Defer. | docs (no action) |
| 13 | Available `/Localization/Options` (display lang) | 71 | 1 (en-US only, ideally) | Same as 12 — API list is hardcoded in Jellyfin. Cannot be trimmed via API. **But the user-facing dropdown that uses this list is on `mypreferencesmenu.html` which is already hidden by the inject-shim.** Non-issue for non-admins; admin keeps full list. | none — already gated by shim |
| 14 | Available `/Localization/Countries` | 139 | 139 | Cosmetic; admin-only. No action. | none |
| 15 | SPA `index.html` HTML response | identical for `Accept-Language: de-DE` and `en-US` | identical | Confirmed: `curl -H 'Accept-Language: de-*'` and `en-US` return byte-identical 59757-byte HTML. **Locale selection happens client-side in JS**, not server-side. So there is no server header rewrite to add. | web (none) |
| 16 | **Web bundle locale chunks `<lang>-json.<hash>.chunk.js`** | **93 locale chunks served (de, fr, es, ru, zh-cn, ja, ko, ...)** including `de-json.1afccc006ab8bb6c5953.chunk.js` containing `"Play":"Abspielen"` | only `en-us-json.<hash>.chunk.js` reachable; all others 404 | **Override 92 non-English chunks** to empty/redirect at the bind-mount layer (see "Files to Delete" §). Compose pattern: bind-mount each as `:/jellyfin/jellyfin-web/<lang>-json.<hash>.chunk.js:ro` from a 1-byte `{}` stub. Drawback: chunk hashes rotate on JF upgrade — record filenames in `web-overrides/README.md` and re-pin after each image bump. **Cleaner alternative:** add a Traefik middleware `regexReplaceHeaders` rule that 404s any `*-json.*.chunk.js` whose lang prefix isn't `en-us`. | **web agent — secondary fix (defense in depth)** |
| 17 | **PWA manifest `lang`** | `"lang": "en-US"` in `fd4301fdc170fd202474.json` | `"lang": "en-US"` (and `name`/`short_name` rebranded — see doc 16 F1) | manifest `lang` is already correct, but `name`/`short_name` are still `Jellyfin`. Folded into doc 16 F1, not duplicated here. | web (doc 16 work) |
| 18 | Pre-auth splash bundle strings | reads `navigator.language` before any user is authed | en-US only | Doc 15 §"What CANNOT be forced" §1 noted this is unfixable without a runtime shim that overrides `navigator.language`. **NEW PROPOSAL:** patch `bin/inject-shim.py` to inject `Object.defineProperty(navigator, 'language', { value: 'en-US' }); Object.defineProperty(navigator, 'languages', { value: ['en-US'] });` BEFORE any other JS executes. The inject-shim runs in `<head>` before bundles load, so this is the right vehicle. | **web agent — closes pre-auth leak** |
| 19 | Reverse-proxy `Accept-Language` | passed through unchanged (Traefik) | rewrite to `en-US` | doc 15 §"What CANNOT be forced" §2 already evaluated and rejected this as too aggressive for the multi-tenant Traefik. **Re-evaluation:** ARRFLIX is the only consumer of arrflix.s8n.ru via this Traefik router; rewriting Accept-Language at the router level is safe and would mean (5) and (16) and (18) are all redundant defense-in-depth. Add a `traefik.http.middlewares.arrflix-lang.headers.customrequestheaders.Accept-Language=en-US,en;q=0.9` middleware. | **web agent — alternative single-layer fix** |
| 20 | New-user creation script `bin/add-jellyfin-user.sh` | does NOT set `UICulture` | sets `UICulture="en-US"` | doc 15 already documented the one-line patch in step `[3/4]`. Apply the diff. | server agent (doc 15 work) |
---
## Per-User Table (live state, 2026-05-08)
| User | UserId | UICulture | Audio Pref | Subtitle Pref | needs-update |
|---|---|---|---|---|---|
| 5 | `SCRUBBED-USER-ID` | **absent** | eng | eng | **Y** |
| USER-D | `SCRUBBED-USER-ID` | **absent** | eng | eng | **Y** |
| USER-B | `SCRUBBED-USER-ID` | **absent** | eng | eng | **Y** |
| USER-F | `SCRUBBED-USER-ID` | **absent** | eng | eng | **Y** |
| USER-G | `SCRUBBED-USER-ID` | **absent** | eng | eng | **Y** |
| USER-A | `SCRUBBED-USER-ID` | **absent** | eng | eng | **Y** |
| USER-E | `SCRUBBED-USER-ID` | **absent** | eng | eng | **Y** |
| s8n (admin) | `SCRUBBED-USER-ID` | **absent** | eng | eng | **Y** |
**Count needing update: 8 of 8 users.** This is the entire active user
roster. Doc 15 (2026-05-08) listed only 5 users (`5`, `USER-F`, `USER-G`,
`USER-A`, `s8n`); the roster has since grown to 8 (added: `USER-D`,
`USER-B`, `USER-E`). All 3 new users were created via `bin/add-jellyfin-user.sh`
**without** the doc-15 wrapper patch (UICulture line not added), so they
also inherit the bug.
---
## Remediation Checklist (concrete endpoints/bodies for sibling agents)
> Do not execute from this audit doc. Sibling agents own implementation.
### Server agent — primary fix (closes layer 5, single biggest impact)
```bash
# All 8 users in one go (idempotent):
JELLYFIN_TOKEN='${JELLYFIN_API_TOKEN}' bin/force-english-all-users.sh
# Spot-verify one user post-fix (expect "en-US"):
curl -ks https://arrflix.s8n.ru/Users/SCRUBBED-USER-ID \
-H "Authorization: MediaBrowser Token=${JELLYFIN_API_TOKEN}" \
| jq -r '.Configuration.UICulture'
```
After this lands, every authenticated session is pinned to en-US
regardless of browser. Pre-auth and chunk-bundle leaks (16, 18) remain.
### Server agent — wrapper patch (closes layer 20, prevents regression)
Apply the doc-15 §"Wrapper update for future users" one-line patch to
`bin/add-jellyfin-user.sh` step `[3/4]`:
```python
c['UICulture'] = 'en-US' # NEW: pin UI to English regardless of browser Accept-Language
```
### Web agent — defense-in-depth chunk lockdown (closes layer 16)
Two paths, pick one:
**Path A — Traefik middleware (preferred, single point of control):**
```yaml
# In docker-compose.yml jellyfin labels:
- "traefik.http.routers.jellyfin.middlewares=arrflix-lang"
- "traefik.http.middlewares.arrflix-lang.headers.customrequestheaders.Accept-Language=en-US,en;q=0.9"
```
Pros: one line, no bind-mounts to maintain, immune to JF upgrades.
Cons: doesn't help with chunk filename leak if the bundle ever fingerprints
on something other than Accept-Language.
**Path B — chunk bind-mount stubs (heavy but airtight):**
For each non-English chunk in `web-overrides/README.md` (record list per
JF image upgrade), bind a 1-byte `{}` stub:
```yaml
- /opt/docker/jellyfin/web-overrides/empty-chunk.js:/jellyfin/jellyfin-web/de-json.1afccc006ab8bb6c5953.chunk.js:ro
- /opt/docker/jellyfin/web-overrides/empty-chunk.js:/jellyfin/jellyfin-web/fr-json.<hash>.chunk.js:ro
... (×91 more)
```
Where `empty-chunk.js` contents:
```js
(self.webpackChunk=self.webpackChunk||[]).push([[XXXXX],{}]);
```
(XXXXX = chunk-id from runtime.bundle.js for that locale; chunk-ids
listed in §"Files to Delete" below.)
Recommended: ship Path A first as the cheap belt; defer Path B to Phase 2
unless the owner specifically wants the chunk files unreachable.
### Web agent — pre-auth splash fix (closes layer 18)
Append to the IIFE in `bin/inject-shim.py`, before the `start()` block:
```js
// Override navigator.language BEFORE webpack bundles read it
try {
Object.defineProperty(navigator, 'language', {
value: 'en-US', configurable: false, writable: false
});
Object.defineProperty(navigator, 'languages', {
value: ['en-US', 'en'], configurable: false, writable: false
});
} catch(e){}
```
Combined with Path A above (Accept-Language rewrite at proxy), pre-auth
splash strings render in English on first paint.
### Docs agent — supersedes notes
After the above lands, update doc 15 with a "Status: applied 2026-05-XX"
header and link forward to this doc. Update doc 16 F1 cross-ref since the
manifest `name`/`short_name` work overlaps with the lockdown branch.
---
## Files to Delete (locale bundles served by web SPA)
> 92 non-English locale chunks served from `/jellyfin/jellyfin-web/`.
> Hashes were captured from the live `runtime.bundle.js` chunk-id-to-hash
> map on 2026-05-08; **these will rotate on every JF image upgrade**
> regenerate this list before each upgrade with:
>
> ```bash
> curl -ks 'https://arrflix.s8n.ru/web/runtime.bundle.js?<query>' | python3 -c "
> import re, sys
> txt = sys.stdin.read()
> hashmap = dict(re.findall(r'(\d+):\"([a-f0-9]{20})\"', txt))
> namemap = dict(re.findall(r'(\d+):\"([a-zA-Z0-9_-]+-json)\"', txt))
> for cid, name in sorted(namemap.items(), key=lambda x: x[1]):
> if not name.startswith('en-us'):
> print(f'{name}.{hashmap[cid]}.chunk.js')
> "
> ```
The single chunk to **keep** is `en-us-json.667484b4a441712c7e05.chunk.js`.
The 92 chunks to **drop** (current hashes — re-extract on upgrade):
```
af-json.c51579ebcde4cc473828.chunk.js
ar-json.1e4d5a6f9a6acf5777ba.chunk.js
as-json.c9ec5dcf74b613f34865.chunk.js
be-by-json.04e26c1f665c26cef640.chunk.js
bg-bg-json.8f63ff103b1093a4367b.chunk.js
bn-json.<hash>.chunk.js
bn_BD-json.<hash>.chunk.js
ca-json.<hash>.chunk.js
ch-json.<hash>.chunk.js
cs-json.<hash>.chunk.js
cy-json.<hash>.chunk.js
da-json.<hash>.chunk.js
de-json.1afccc006ab8bb6c5953.chunk.js ← THE ONE THAT BIT US (contains "Play":"Abspielen")
el-json.<hash>.chunk.js
en-gb-json.<hash>.chunk.js ← keep? en-GB is also English; defer to owner. If owner wants only en-US, drop.
eo-json.<hash>.chunk.js
es-ar-json.<hash>.chunk.js
es-json.<hash>.chunk.js
es-mx-json.<hash>.chunk.js
es_419-json.<hash>.chunk.js
es_DO-json.<hash>.chunk.js
et-json.<hash>.chunk.js
eu-json.<hash>.chunk.js
fa-json.<hash>.chunk.js
fi-json.<hash>.chunk.js
fil-json.<hash>.chunk.js
fo-json.<hash>.chunk.js
fr-ca-json.<hash>.chunk.js
fr-json.<hash>.chunk.js
ga-json.<hash>.chunk.js
gl-json.<hash>.chunk.js
gsw-json.<hash>.chunk.js
gu-json.<hash>.chunk.js
he-json.<hash>.chunk.js
hi-in-json.<hash>.chunk.js
hr-json.<hash>.chunk.js
hu-json.<hash>.chunk.js
hy-json.<hash>.chunk.js
id-json.<hash>.chunk.js
is-is-json.<hash>.chunk.js
it-json.<hash>.chunk.js
ja-json.<hash>.chunk.js
jbo-json.<hash>.chunk.js
ka-json.<hash>.chunk.js
kab-json.<hash>.chunk.js
kk-json.<hash>.chunk.js
kn-json.<hash>.chunk.js
ko-json.<hash>.chunk.js
lt-lt-json.<hash>.chunk.js
lv-json.<hash>.chunk.js
mk-json.<hash>.chunk.js
ml-json.<hash>.chunk.js
mn-mn-json.<hash>.chunk.js
mr-json.<hash>.chunk.js
ms-json.<hash>.chunk.js
nb-json.<hash>.chunk.js
ne-json.<hash>.chunk.js
nl-json.<hash>.chunk.js
nn-json.<hash>.chunk.js
pa-json.<hash>.chunk.js
pl-json.<hash>.chunk.js
pr-json.<hash>.chunk.js
pt-br-json.<hash>.chunk.js
pt-json.<hash>.chunk.js
pt-pt-json.<hash>.chunk.js
ro-json.<hash>.chunk.js
ru-json.<hash>.chunk.js
si-json.<hash>.chunk.js
sk-json.<hash>.chunk.js
sl-si-json.<hash>.chunk.js
so-json.<hash>.chunk.js
sq-json.<hash>.chunk.js
sr-json.<hash>.chunk.js
sv-json.<hash>.chunk.js
sw-json.<hash>.chunk.js
ta-json.<hash>.chunk.js
te-json.<hash>.chunk.js
th-json.<hash>.chunk.js
tr-json.<hash>.chunk.js
uk-json.<hash>.chunk.js
ur_PK-json.<hash>.chunk.js
uz-json.<hash>.chunk.js
vi-json.5ce142c3b4228beafe7a.chunk.js
zh-cn-json.9ef4c0ef42cc04d64912.chunk.js
zh-hk-json.faa0648f6b0f186e6c07.chunk.js
zh-tw-json.d07cd62eb7dd68687b64.chunk.js
zu-json.0c869775f5145121570c.chunk.js
... (full 92-line list saved to web-overrides/README.md when web agent regenerates)
```
The full count is 93 chunk files served at runtime; one (`en-us-json.<hash>`)
is kept, 92 are dropped. Decision required from owner: drop `en-gb-json`
too, or accept en-GB as a tolerable secondary English locale? Doc 15 line 19
mentioned `TARGET_LOCALE=en-GB` is an alternate option, suggesting en-GB is
not categorically rejected. **Default recommendation: drop en-gb too —
"English only, en-US canonical".**
---
## Cross-References
- `docs/15-force-english.md` — original per-user UICulture diagnosis +
`bin/force-english-all-users.sh` (script exists, **not yet run**) +
wrapper patch for `bin/add-jellyfin-user.sh` (**not yet applied**).
This audit confirms doc 15's diagnosis is still accurate and adds the
user-count update (5 → 8).
- `docs/16-jellyfin-branding-leaks.md` — covers the Jellyfin word in PWA
manifest `name`/`short_name` (F1), screensaver banner (F2), i18n keys
containing "Jellyfin" in en-us-json chunk (F3). The PWA manifest `lang`
field is already `en-US` so no action overlap; only the `name`/`short_name`
work overlaps with this doc's branding-vs-locale axis. F3's DOM text
rewrite shim is orthogonal — it strips the *word* Jellyfin from
English strings, while this doc strips *non-English strings entirely*.
- `docs/10-spa-runtime-shim.md` — vehicle for the proposed
`Object.defineProperty(navigator, 'language', …)` snipUSER-E (see Layer 18).
Same `inject-shim.py` already in use; one new `try/catch` block.
- `docs/04-theming-and-users.md` — CustomCss is unrelated to locale; no
overlap, no action.
---
## Sign-off
- **Audit run by:** s8n, 2026-05-08, admin token via `X-Emby-Token` header.
- **Mode:** read-only. Zero POST/PATCH/PUT to Jellyfin. Zero file
modifications outside this `docs/19-english-only-audit.md`.
- **Live state:** all 8 users at UICulture-absent (root cause confirmed);
93 locale bundles served (1 keep / 92 drop); SPA index.html serves
byte-identical regardless of `Accept-Language` (locale is client-side);
doc-15 fix exists but unrun; doc-15 wrapper patch unapplied.
- **Recommended next action:** server agent runs `bin/force-english-all-users.sh`
and applies the wrapper patch (closes 80% of the leak in 30 seconds).
Web agent adds the Traefik `Accept-Language` middleware (Path A) and
the `navigator.language` shim (closes the remaining pre-auth leak).
Defer chunk bind-mounts (Path B) to Phase 2.

View file

@ -0,0 +1,275 @@
# 20 - English-Only Lockdown
> Operator doc for the multi-layer English-only lockdown on arrflix.s8n.ru.
> Goal: everything English only, no opt-out, no drift. Server, per-user,
> and web-SPA layers all pinned; idempotent re-apply runner ships in this
> repo so a Jellyfin restart, container recreate, or new-user-out-of-band
> can never quietly reintroduce another locale.
Date: 2026-05-08
Jellyfin version: 10.10.3 (`jellyfin/jellyfin` image)
Live target: `https://arrflix.s8n.ru`
---
## Goal
**Everything English only, no opt-out, no drift.**
Three things this means in practice:
1. No user — admin or non-admin — can flip the UI to a non-English locale,
either through the settings drawer or by deleting their `UICulture` value
and letting `Accept-Language` win.
2. No new user created (via `bin/add-jellyfin-user.sh`, the web admin panel,
or a future API integration) starts in any state other than `en-US`.
3. No server-side default (UI, metadata language, metadata country) drifts
away from English over time, regardless of Jellyfin upgrades, container
recreates, or admin-panel touches.
The earlier first-pass attempt (`docs/15-force-english.md`,
`bin/force-english-all-users.sh`) only covered point (2) for the five
existing users at the time it ran. Points (1) and (3) and the persistence
mechanism are handled here.
Audit baseline for "what each layer looked like before this lockdown" is in
`docs/19-english-only-audit.md`.
---
## Layers covered
The Jellyfin locale story is layered, and **each layer must be pinned
independently** — fixing one does not protect the others. The lockdown
covers all four:
### 1. Server-wide
Three keys in `/System/Configuration` (the JSON returned by
`GET /System/Configuration`):
| Key | Pinned value | What it controls |
|---|---|---|
| `UICulture` | `en-US` | Dashboard / admin UI default. Does NOT propagate to user UI (that's per-user — see layer 2) but is still pinned for consistency and so admin chrome never drifts. |
| `PreferredMetadataLanguage` | `en` | Default language for metadata fetched from TMDB / TVDB / etc. when a library has no per-library override. |
| `MetadataCountryCode` | `US` | Default country code for region-specific metadata (release dates, ratings boards, etc.). |
The runner POSTs these via `/System/Configuration` (full read-modify-write —
Jellyfin replaces the whole config dict).
### 2. Per-user
Four keys in each user's `Configuration` object (the nested object inside
`GET /Users/{id}` JSON):
| Key | Pinned value | What it controls |
|---|---|---|
| `UICulture` | `en-US` | The actual UI language the web SPA renders for this user. **This is what fixes the "Abspielen" Play-button bug from doc 15.** |
| `AudioLanguagePreference` | `eng` | Default audio track selection for playback. |
| `SubtitleLanguagePreference` | `eng` | Default subtitle language for playback. |
| `PlayDefaultAudioTrack` | `true` | Play the file's default audio track when languages match — keeps playback deterministic. |
The runner iterates `GET /Users` and POSTs the merged config to
`/Users/{id}/Configuration` for every account.
### 3. Web SPA (pre-auth + UI affordance)
Pinning per-user `UICulture` only kicks in **after** authentication. Two
extra surfaces are pre-auth or user-controllable:
- **Pre-auth bundle strings** (login form, splash, "Sign In" button). The
SPA picks the bundle based on `navigator.language` before any
authentication. Without intervention, a `de-*` browser sees German
login chrome.
- **User settings drawer language switcher.** Even with `UICulture` pinned,
a user can technically reopen `MyProfile/Display` and pick another
language — the pin protects the default but not the switcher.
Both are handled by the web overrides shipped in
`web-overrides/english-lockdown.{js,css}` (sibling-agent commit, separate
file from this doc):
- **`english-lockdown.js`** — runs at the top of `index.html` before the
bundle initialises. Overrides `navigator.language`, `navigator.languages`,
and pins `localStorage["language"]` to `"en-us"` so the bundle's pre-auth
locale loader picks English regardless of browser headers.
- **`english-lockdown.css`** — hides the language `<select>` in the user
settings drawer (`MyProfile/Display`) so users cannot switch off English
via the UI.
The shim is bind-mounted into the live container the same way the existing
`web-overrides/index.html` is — see `docs/10-spa-runtime-shim.md` for the
mount mechanism, and `docs/19-english-only-audit.md` for the per-surface
inventory the shim covers.
### 4. DNS / `Accept-Language`
Browsers always negotiate locale via the `Accept-Language` HTTP request
header. We deliberately do NOT strip or rewrite it at Traefik (would break
unrelated backends fronted by the same proxy). Instead the server is now
authoritative because:
- `UICulture` is pinned per-user (layer 2), so Jellyfin ignores the header
for any authenticated request.
- `navigator.language` is overridden in the SPA shim (layer 3), so the
pre-auth bundle loader doesn't honor the header either.
Net effect: `Accept-Language: de-DE,de;q=0.9,en` arriving from a browser
gets parsed by Jellyfin / the SPA, but every layer that would have used it
has been pinned to English first.
---
## Re-apply procedure
The runner is **idempotent** — running it on an already-locked-down server
is a no-op (each layer is set to its target value, the script verifies and
moves on). It exists to:
- Re-apply after a Jellyfin upgrade (some upgrades reset metadata defaults).
- Re-apply after container recreate (`docker compose down && up`).
- Re-apply after a new user is created via the admin panel (which doesn't
go through `bin/add-jellyfin-user.sh` and so misses the wrapper's
English defaults).
- Re-apply on a schedule for paranoia / drift detection.
### One-shot run
```bash
export JELLYFIN_API_TOKEN=<admin-token> # required
export JELLYFIN_URL=https://arrflix.s8n.ru # optional, this is the default
bin/english-lockdown-runner.sh
```
Output is a one-line summary per surface: server config block, then one
line per user. Exit code 0 means every layer landed; exit code 1 means at
least one POST failed (script prints which).
### Optional: weekly via systemd timer
If you want automatic re-application (paranoia / catch admin-panel drift),
drop a user-level systemd timer pair. The repo deliberately does not ship
these unit files — it's an operator decision how often to run, and where
the API token comes from on a given host.
```ini
# ~/.config/systemd/user/jellyfin-english-lockdown.service
[Unit]
Description=Re-apply ARRFLIX English-only lockdown
After=network-online.target
[Service]
Type=oneshot
EnvironmentFile=%h/.config/arrflix/lockdown.env
ExecStart=%h/code/ARRFLIX/bin/english-lockdown-runner.sh
```
```ini
# ~/.config/systemd/user/jellyfin-english-lockdown.timer
[Unit]
Description=Weekly ARRFLIX English-only lockdown re-apply
[Timer]
OnCalendar=weekly
Persistent=true
[Install]
WantedBy=timers.target
```
`~/.config/arrflix/lockdown.env` should contain
`JELLYFIN_API_TOKEN=<token>` (chmod 600). Enable with
`systemctl --user enable --now jellyfin-english-lockdown.timer`.
---
## Drift-check procedure
Quick verification — run any time without touching state:
**Server-wide (UICulture / metadata):**
```bash
curl -ks "$JELLYFIN_URL/System/Configuration" \
-H "Authorization: MediaBrowser Token=$JELLYFIN_API_TOKEN" \
| python3 -c "import json,sys; c=json.load(sys.stdin); print({k:c.get(k) for k in ('UICulture','PreferredMetadataLanguage','MetadataCountryCode')})"
# Expect: {'UICulture': 'en-US', 'PreferredMetadataLanguage': 'en', 'MetadataCountryCode': 'US'}
```
**Per-user (every account):**
```bash
curl -ks "$JELLYFIN_URL/Users" \
-H "Authorization: MediaBrowser Token=$JELLYFIN_API_TOKEN" \
| python3 -c "
import json, sys
for u in json.load(sys.stdin):
c = u.get('Configuration', {})
print(f\"{u['Name']:10s} UI={c.get('UICulture','<absent>')} A={c.get('AudioLanguagePreference','<absent>')} S={c.get('SubtitleLanguagePreference','<absent>')}\")
"
# Expect every line: UI=en-US A=eng S=eng
```
**Web SPA shim (live bind-mount):**
```bash
curl -ks https://arrflix.s8n.ru/web/english-lockdown.js | head -1
# Expect: an actual JS line, not 404
```
If any of those checks comes back wrong, run the runner:
`JELLYFIN_API_TOKEN=<token> bin/english-lockdown-runner.sh`.
---
## Known gaps
These are explicitly **not** covered by the lockdown. They are documented
here so future operators know what's still possible-but-deferred:
1. **Jellyfin web bundle locale files.** The web bundle still ships
`de.json`, `fr.json`, `es.json`, etc. inside the immutable Docker image.
Replacing those bundle files with English copies would harden the
pre-auth layer further (no German strings on disk → no German strings
possible) but is **destructive to upstream upgrades**: every
`jellyfin/jellyfin` image rebuild would have to repeat the bundle swap.
Deferred indefinitely; the `navigator.language` override in
`english-lockdown.js` is sufficient for current threat model.
2. **Native mobile clients (Jellyfin Android / iOS apps).** These read
per-user `UICulture` correctly, so the per-user layer protects them.
They do NOT load the web SPA shim, so the pre-auth layer does not
apply (but pre-auth on mobile is just the login form, served from
client-side localized resources Jellyfin ships in the app — not under
our control).
3. **Library-level `PreferredMetadataLanguage` / `MetadataCountryCode`
overrides.** Each library can override the server defaults. The runner
pins **server** defaults only — library overrides set in the admin
panel are preserved. Worth a periodic audit
(`GET /Library/VirtualFolders`) but not part of this lockdown.
4. **Subtitle / track *display* language vs *preference* language.**
`SubtitleLanguagePreference=eng` selects English subs when present.
It does NOT translate non-English subs to English. Out of scope —
that's a media-pipeline concern, not a UI lockdown concern.
---
## Cross-references
- `docs/15-force-english.md` — historical first pass (UICulture per-user
POST mechanism, "Abspielen" Play-button diagnosis). Read for context on
*why* `Configuration.UICulture` is the authoritative knob.
- `docs/16-jellyfin-branding-leaks.md` — related lockdown sweep
(Jellyfin-name and logo redaction). Same pattern: multi-layer pin +
re-apply runner.
- `docs/19-english-only-audit.md` — pre-lockdown baseline. Per-surface
state before the sweep ran.
- `docs/10-spa-runtime-shim.md` — explains the web-overrides bind-mount
mechanism that delivers `english-lockdown.{js,css}` into the live
container.
- `bin/english-lockdown-runner.sh` — idempotent re-apply runner.
Run it any time the server might have drifted.
- `bin/add-jellyfin-user.sh` — wrapper for new user creation; already
bakes in English defaults per `docs/15`.

View file

@ -0,0 +1,482 @@
# 21 — Rick and Morty Color / HDR Audit (Read-Only)
> Status: **read-only audit**, executed 2026-05-08 against
> `https://arrflix.s8n.ru` (Jellyfin 10.10.3 on nullstone). Scope:
> diagnose why **Rick and Morty looks "kind of gray / washed-out"**
> while other titles render normally. **No fixes applied. No state
> mutated. No transcode triggered.**
>
> Inputs: `ffprobe` via `docker exec jellyfin /usr/lib/jellyfin-ffmpeg/ffprobe`
> against on-disk media; Jellyfin REST `/Items/{id}/PlaybackInfo`,
> `/System/Configuration/encoding`, `/Branding/Configuration` (auth
> `X-Emby-Token: ${JELLYFIN_API_TOKEN}`); contrast probe against
> *The Mandalorian* as a known-good SDR title; review of `CustomCss`
> against the inventory in doc 14 §1b.
---
## 1. Executive summary
**Confirmed root cause:** the Rick and Morty release on disk is an
**HDR10 4K HEVC Main 10 (PQ / BT.2020) "AI Upscale"** of an originally
SDR animated show. Jellyfin classifies it as `VideoRange=HDR`
`VideoRangeType=HDR10` and forces the browser onto the **transcode
path** (`TranscodeReasons=ContainerNotSupported, AudioCodecNotSupported,
SubtitleCodecNotSupported` — every browser session triggers this). The
encoding config has **`EnableTonemapping=false` and
`HardwareAccelerationType=none`**, so ffmpeg software-decodes the
HDR10 source, then h264-encodes **without applying a tonemap**, then
the browser interprets the resulting BT.2020 PQ pixel data as plain
BT.709 SDR. That mis-interpretation is the textbook signature of the
washed-out grey look.
**One-line remediation (lowest blast radius):** in
`/System/Configuration/encoding`, set `EnableTonemapping=true` (the
algorithm `bt2390` is already correctly selected) — this enables CPU
tonemap on the existing software pipeline; CSS, hardware, and source
files do not need to change.
CSS / theme is **ruled out** as a cause — `CustomCss` contains zero
`grayscale(`, zero `saturate(`, zero `hue-rotate(` filters.
---
## 2. ffprobe table — Rick and Morty (Season 01)
All probes via `docker exec jellyfin /usr/lib/jellyfin-ffmpeg/ffprobe -v error -select_streams v:0 …`.
| File | Codec | Profile | Pix fmt | color_space | color_transfer | color_primaries | range | W×H | Bitrate | Size | HDR side-data |
|---|---|---|---|---|---|---|---|---|---|---|---|
| S01E01 — Pilot | hevc | Main 10 | yuv420p10le | bt2020nc | **smpte2084** (PQ) | bt2020 | pc | 3840×2160 | 8.13 Mbit/s | 1.34 GB | **none** (no MasteringDisplay / CLL block) |
| S01E05 — Meeseeks and Destroy | hevc | Main 10 | yuv420p10le | bt2020nc | **smpte2084** | bt2020 | pc | 3840×2160 | 7.97 Mbit/s | 1.26 GB | not present |
| S01E08 — Rixty Minutes | hevc | Main 10 | yuv420p10le | bt2020nc | **smpte2084** | (BT.2020) | (pc) | 3840×2160 | n/a | 1.34 GB | not present |
| S01E11 — Ricksy Business | hevc | Main 10 | yuv420p10le | bt2020nc | **smpte2084** | bt2020 | pc | 3840×2160 | 8.86 Mbit/s | 1.49 GB | not present |
**Reading:**
- `color_transfer=smpte2084` (a.k.a. ST 2084 / PQ) is the **HDR10
transfer function**. All R&M S01 episodes ship with HDR10 tagging.
- `color_primaries=bt2020` + `color_space=bt2020nc` are the BT.2020
wide-gamut primaries (the HDR colour space).
- `pix_fmt=yuv420p10le` = 10-bit-per-component, 4:2:0 chroma sub-
sampling. Required for HDR10 content.
- `color_range=pc` = full-range (01023 for 10-bit) rather than the
TV-range (64940) usually expected. **This is unusual** — most HDR10
Blu-ray / streaming sources are TV-range. PC-range mis-interpreted
as TV-range is itself a contrast/saturation hit, layered on top of
the HDR-as-SDR hit.
- **No HDR side-data** (`MasteringDisplayMetadata`,
`ContentLightLevelMetadata`) is present in any episode — the source
declares HDR10 but ships without the static-metadata blocks that a
proper HDR display or tonemapper would consume. This is a
fingerprint of a **fake HDR10** AI upscale (the file's own embedded
title is `"Rick and Morty - S01E01 - Pilot - 2160p HDR Ai Upscale -Mesc"`).
- 4K x 24 fps x ~8 Mbit/s × 1320 s = file size matches container
declaration, no surprises in muxing.
- The poster art / show landing page itself is rendered by the SPA
from JF's image cache (PNG / JPEG, sRGB) — those are not affected by
HDR. Only the **video element** is washed-out.
### 2a. Comparison vs. The Mandalorian (known-good SDR)
| File | Codec | Profile | Pix fmt | color_space | color_transfer | W×H | Bitrate |
|---|---|---|---|---|---|---|---|
| Mandalorian S01E01 | hevc | Main 10 | yuv420p10le | **bt709** | **bt709** | 1920×804 | 6.69 Mbit/s |
| Mandalorian S02E01 | hevc | Main 10 | yuv420p10le | **bt709** | **bt709** | (1920×…) | n/a |
| Mandalorian S03E01 | hevc | Main 10 | yuv420p10le | **bt709** | **bt709** | 1920×804 | 6.72 Mbit/s |
**Reading:** Mandalorian is **plain SDR BT.709** (the same colour space
the browser's `<video>` defaults to assume). 10-bit pixels here are
fine because the *transfer* is BT.709 SDR, not PQ — the browser /
ffmpeg pipeline sees this and renders it correctly. This is the
control sample that proves the difference is *content-side*, not
config-side.
---
## 3. Jellyfin encoding config — relevant fields
Source: `GET /System/Configuration/encoding`.
| Field | Value | Comment |
|---|---|---|
| `HardwareAccelerationType` | `"none"` | **GPU is dead** (host has no nvidia driver — see doc 13 finding 02). Every transcode is software ffmpeg. |
| `EnableHardwareEncoding` | `true` | No-op while `HardwareAccelerationType=none`. |
| `EnableTonemapping` | **`false`** | **THE BUG.** Software-tonemap is disabled. With HDR source + `=none` HW + tonemap off, the output is HDR pixels with no SDR conversion. |
| `EnableVppTonemapping` | `false` | Intel-VPP path, not relevant for CPU. |
| `EnableVideoToolboxTonemapping` | `false` | macOS path, not relevant. |
| `TonemappingAlgorithm` | `"bt2390"` | **Good choice** when enabled — the BT.2390 EETF is the modern recommendation. (`hable` is the legacy fallback; `mobius` and `reinhard` are alternatives.) |
| `TonemappingMode` | `"auto"` | Fine. |
| `TonemappingRange` | `"auto"` | Fine. |
| `TonemappingDesat` | `0` | Default. |
| `TonemappingPeak` | `100` | Target SDR peak nits — default. |
| `TonemappingParam` | `0` | Algorithm-specific; 0 = default. |
| `EnableDecodingColorDepth10Hevc` | `true` | 10-bit HEVC decode permitted. |
| `H264Crf` | `23` | h264 quality target for transcode output (default for JF). |
| `H265Crf` | `28` | h265 quality target (unused — `AllowHevcEncoding=false`). |
| `AllowHevcEncoding` | `false` | Cannot transcode-out as HEVC (forces h264 output). |
| `AllowAv1Encoding` | `false` | Cannot transcode-out as AV1. |
| `EnableThrottling` | `false` | Per doc 13 finding 03 — separate issue. |
| `EnableSegmentDeletion` | `false` | Per doc 13 finding 05 — separate issue. |
| `MaxMuxingQueueSize` | `2048` | Per doc 13 — separate issue. |
| `EncoderAppPathDisplay` | `/usr/lib/jellyfin-ffmpeg/ffmpeg` | Bundled jellyfin-ffmpeg, not host. |
| `VaapiDevice` | `/dev/dri/renderD128` | Empty on host (no Intel iGPU on AMD Ryzen). |
| `EncodingThreadCount` | `-1` | Auto = all cores. |
**Net:** the *one* knob standing between "washed-out grey" and
"correctly tonemapped SDR" is `EnableTonemapping`. The algorithm is
already set correctly (`bt2390`). Flipping the bool to `true` is a
single POST-able field-edit and applies to every future transcode.
### 3a. Live PlaybackInfo for R&M S01E01 (browser DeviceProfile)
Simulated browser PlaybackInfo (DeviceProfile: Chrome, h264 / aac /
mp3 / ac3 / eac3, hls):
```
SupportsDirectPlay: false
SupportsDirectStream: false
SupportsTranscoding: true
TranscodingSubProtocol: hls
TranscodingUrl:
/videos/<id>/master.m3u8
?VideoCodec=h264
&AudioCodec=aac,mp3,ac3,eac3
&VideoBitrate=139616000
&SegmentContainer=ts
&hevc-level=150
&hevc-videobitdepth=10
&hevc-profile=main10
&TranscodeReasons=
ContainerNotSupported,
AudioCodecNotSupported,
SubtitleCodecNotSupported
```
**Reading:** every browser session for R&M is forced into transcode by
three independent reasons (container `mkv`, audio `truehd` / `ac3`,
subtitle `pgs` / `ass` — confirmed by MediaInfo). It's not just an HDR
issue — the file *cannot* direct-play in any browser, so the transcode
path is mandatory, and inside that path tonemap is currently off.
For comparison, an SDR Mandalorian episode would still hit the
transcode path for the same container/audio reasons, but the
tonemap-off flag wouldn't matter because the source is already BT.709.
---
## 4. Theme / CSS rule-out check
Inspected `/Branding/Configuration → CustomCss` (25 225 chars, full
inventory in doc 14 §1b). Searched the live string for any
filter / saturation / hue-rotate / opacity rule that could desaturate
the video element or its container.
| Filter pattern | Matches in CustomCss | Verdict |
|---|---|---|
| `grayscale(` | **0** | ✓ |
| `saturate(` | **0** | ✓ |
| `hue-rotate(` | **0** | ✓ |
| `sepia(` | **0** | ✓ |
| `brightness(` | **0** | ✓ |
| `contrast(` | **0** | ✓ |
| `invert(` | **0** | ✓ |
| `mix-blend-mode` | **0** | ✓ |
| `filter:` | **0** | ✓ |
| `backdrop-filter:` | **0** | ✓ |
| `opacity:` (on `.itemBackdrop` / `video` / `.osdContainer`) | **0** | ✓ |
Also checked the doc 14 §7 detail-page backdrop rules just landed
(`linear-gradient(90deg, rgba(0,0,0,0.95) 0%, …)`) — that gradient is
applied to `.layout-desktop .backgroundContainer.withBackdrop`, NOT to
the `<video>` element. It tints the *backdrop poster behind the
detail-page header*, not playback. **Not the cause.**
`web-overrides/index.html` (the bind-mounted critical-path style): no
`filter:`, no `mix-blend-mode`, no animation that would alter video.
`ARRFLIX-SHIM` JavaScript only touches `document.title`, favicon, and
`mypreferencesmenu` drawer entries — does not touch playback DOM.
**Theme / CSS rule-out: PASS.** The greyness is in the pixel data
delivered to the browser, not in any post-render CSS effect.
---
## 5. Source-file integrity rule-outs
Already visible in §2, but stated explicitly so each candidate root
cause is closed:
| Hypothesis | Evidence | Verdict |
|---|---|---|
| (a) HDR file + CPU tone-map | All R&M S01 = HDR10 (`smpte2084`/`bt2020`). Encoding config `EnableTonemapping=false`, `HardwareAccelerationType=none`. | **CONFIRMED** root cause. |
| (b) CSS filter on theme | §4 shows zero filter/saturation rules. | RULED OUT. |
| (c) Direct-play tag mismatch | PlaybackInfo §3a shows `SupportsDirectPlay=false` — browser is on transcode path, no chance of DP-tag confusion. | RULED OUT. |
| (d) Source is genuinely SDR but graded flat (wrong tags) | ffprobe reports HDR10 tags consistently across 4 episodes, and Jellyfin agrees (`VideoRangeType=HDR10`). Title-string `"2160p HDR Ai Upscale"` confirms intent. | RULED OUT — the source IS HDR10, just badly so. |
| (e) Container / bit-depth / browser HW-decode bit-crush | Browser never receives the 10-bit HEVC because transcode is mandatory; output is 8-bit h264. So no client-side bit-depth issue is possible. | RULED OUT. |
| (f) Missing Mastering Display / CLL metadata makes tonemap target unknown | True — files have no static HDR metadata. Once tonemap is enabled, ffmpeg will fall back to defaults (peak 1000 nits, etc.) which is fine for cartoon AI-upscale content; better than no tonemap. | NOT a blocker for the fix. |
| (g) `color_range=pc` (full-range) | Full-range PC pixels reinterpreted as TV-range = an additional contrast bump. Tonemap filter handles range conversion. | Subsumed by (a) — same fix. |
---
## 6. Concrete remediation list (ranked: effort vs blast-radius)
### #1 — Enable software tonemap (recommended)
**Action:** flip a single bool in encoding config.
```
PUT /System/Configuration/encoding
EnableTonemapping = true
(TonemappingAlgorithm already = "bt2390" — leave as-is)
(TonemappingPeak already = 100 — leave as-is)
(TonemappingMode already = "auto" — leave as-is)
(TonemappingRange already = "auto" — leave as-is)
(TonemappingDesat already = 0 — leave as-is)
```
(Or via UI: *Dashboard → Playback → Transcoding → "Enable
tone-mapping"*.)
**Effect:** every future HDR-source transcode applies BT.2390 EETF +
gamut conversion (BT.2020 → BT.709) before h264 encoding. Output looks
right in any SDR browser.
**Cost:** zero seek time, no restart needed.
**Blast radius:** **low.** Only HDR sources (currently: Rick and
Morty S01) are affected. SDR sources (Mandalorian etc.) already have
BT.709 tags so the tonemap filter is a no-op for them.
**Caveat:** software tonemap on a 4K HEVC source on the existing
host load (doc 13 finding 01: load 11.4, swap 6.8 GiB) will add
~1.52× extra CPU per stream compared to a tonemap-off transcode.
Pair this with **doc 13 finding 03 (`EnableThrottling=true`)** so a
client-cancelled stream stops burning CPU; otherwise a stalled R&M
playback will eat a core for 12 minutes (`SegmentKeepSeconds=720`).
**Risk of "looks worse than expected":** AI-upscale R&M has no real
HDR — the wide-gamut tonemap will give a result that is more saturated
than the original Adult Swim broadcast (cartoon flat colours pushed
through BT.2020 round-trip), but visibly correct relative to current
washed-out grey. If the operator wants the original cartoon look,
remediation #3 below.
### #2 — Pair tonemap-on with throttling-on (doc 13 finding 03)
**Action:** when applying #1, also set:
```
EnableThrottling = true
EnableSegmentDeletion = true
```
**Effect:** caps wasted ffmpeg CPU after a client disconnects — already
recommended in doc 13 audit, doubly important once we add tonemap
overhead.
**Cost:** zero additional. Same UI page as #1.
### #3 — Replace R&M with a properly-graded SDR release (highest fidelity, highest effort)
**Action:** swap the `Rick.and.Morty.S01...2160p.HDR.Ai.Upscale-Mesc`
files for a native SDR encode (e.g., the original Adult Swim
1080p / WEB-DL releases or the 2160p SDR remasters where they exist).
**Effect:** zero tonemap cost (source is already BT.709), faster
transcodes, files shrink ~3-4× (8 Mbit/s 4K HDR → ~2 Mbit/s 1080p
SDR for a 22-min cartoon is plenty), consistent look with rest of
library.
**Cost:** medium — re-acquisition + re-import + re-scan + 90 GB disk
freed on `/home` which is currently 90% full (doc 13 finding 01).
**Blast radius:** medium. Watched-state and metadata stay (Sonarr
will re-match by `(2013)` + episode index), but each episode item ID
in JF will change → existing playback positions on R&M are lost.
### #4 — Pre-transcode R&M S01 to SDR offline (middle-ground)
**Action:** run `ffmpeg` once (outside Jellyfin) with the same
tonemap pipeline, write SDR-tagged HEVC files alongside, swap them in.
```sh
# Per episode (CPU intensive, ~1 hr per 22-min episode on this host):
ffmpeg -i in.mkv \
-map 0:v:0 -map 0:a -map 0:s? \
-vf "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=bt2390:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p10le" \
-c:v libx265 -preset slow -crf 22 -profile:v main10 \
-c:a copy -c:s copy out.mkv
```
**Effect:** Jellyfin no longer needs to tonemap on every play —
files are SDR-tagged at rest. CPU at playback drops to a normal
HEVC-software-decode-then-h264-software-encode (still no GPU but
no extra tonemap stage).
**Cost:** ~11 hours wall-clock on the existing 12-core box for
S01's 11 episodes (CPU-only HEVC encode); +20 GB during transcode,
files end ~30% smaller than HDR originals.
**Blast radius:** medium-low. Rewrites only R&M — other library
entries untouched. Item IDs change (same as #3).
### #5 — Wait for GPU restoration, then enable VPP / NVENC tonemap
**Action:** once nvidia driver is back on the host (doc 13
finding 02), set:
```
HardwareAccelerationType = nvenc
EnableTonemapping = true
EnableVppTonemapping = true (if Intel — N/A on Ryzen)
HardwareDecodingCodecs = [hevc, h264, vc1] (add hevc)
```
**Effect:** GPU does the HEVC decode + tonemap + h264 encode. No CPU
load, real-time on 4K. This is the long-term right answer.
**Cost:** L (host driver work). Already on doc 13 fix-list.
**Blast radius:** large but already planned. Until GPU is back, do
remediation #1.
### Recommended order
1. **Apply #1 + #2 today** (single Playback-settings page edit). Cost
~30 s of ops time, immediate visual fix on R&M, no media churn.
2. Re-test R&M playback (see §7).
3. If the tonemapped result still feels "wrong" because R&M is a
cartoon and the AI-upscale's HDR is a fiction, go to **#4 or #3**
for the long-term cure.
4. Park #5 behind the GPU restoration backlog.
---
## 7. Test plan to verify after fix
### 7a. Pre-fix baseline (capture now, before flipping the bool)
1. Open `https://arrflix.s8n.ru/web/#/details?id=324f75b84f394a5d9b0749c0679f23b9`
in Chrome/Firefox on onyx.
2. Hit Play. Pause at ~30 s in.
3. Take a screenshot (full-window). File:
`evidence/21-pre-fix-rm-s01e01-30s.png`.
4. Note the visible characteristics: Rick's lab-coat (should be pure
white but currently looks pale-grey), background green of the
garage, skin tones.
### 7b. Apply remediation #1 + #2
UI path: *Dashboard → Playback → Transcoding*:
- Enable "Tone-mapping"
- Enable "Throttle transcodes"
- Enable "Delete transcode segments"
(Or POST `/System/Configuration/encoding` directly with the three
bools flipped.)
No restart needed — Jellyfin re-reads `encoding.xml` per request.
### 7c. Post-fix verification
1. **New playback session** — close and reopen the browser tab so the
SPA requests a fresh `PlaybackInfo` and a fresh `master.m3u8`
(existing in-flight transcode is locked to the pre-fix ffmpeg
command line). Easiest: hard-reload (`Ctrl-Shift-R`) and re-click
Play.
2. Pause at the same ~30 s mark.
3. Screenshot to `evidence/21-post-fix-rm-s01e01-30s.png`.
4. **Side-by-side compare** the two images. Expectations:
- Whites are noticeably whiter (lab coat, ship hull).
- Saturation is higher (garage greens, sky blues, characters).
- Black-level remains similar (or slightly deeper).
- Skin tones look natural rather than greenish-grey.
### 7d. Server-side sanity checks (5 min after first post-fix play)
```sh
# Confirm tonemap is in the actual ffmpeg command line for this stream
ssh user@192.168.0.100 \
"docker exec jellyfin ps -ef | grep ffmpeg | grep -E 'tonemap|zscale' | head"
# Expected: a process line containing `zscale=...:t=linear:...,tonemap=bt2390,...`
# If the line lacks `tonemap`, the encoding.xml change didn't apply or
# JF has a cached transcode session — bounce the container.
# Confirm HDR-aware filter graph fed only by HDR sources (Mandalorian
# should NOT have tonemap in its ffmpeg cmdline)
ssh user@192.168.0.100 \
"docker exec jellyfin tail -200 /config/log/log_*.log | grep -E 'tonemap|smpte2084'"
```
### 7e. Negative test (other libraries unaffected)
Play one episode of:
- Mandalorian (SDR BT.709) — should look identical pre/post.
- Futurama / American Dad / Obi-Wan — same expectation (probe these
if you want to be thorough; they're outside this audit's scope).
If any of these now look *over*-saturated or *under*-saturated post-fix,
the tonemap is leaking onto SDR sources — open a bug, set
`TonemappingMode` from `auto` to a stricter mode.
### 7f. Performance check (CPU is the operative resource)
While the post-fix R&M episode is playing:
```sh
ssh user@192.168.0.100 "uptime && top -bn1 -p \$(pgrep -f 'ffmpeg.*Rick.and.Morty' | head -1) | tail -5"
```
- Expect ffmpeg to consume ~600900 % CPU (69 cores) on this host
for a 4K HEVC→h264 + tonemap pipeline.
- If load average climbs past 16 sustained or swap usage grows past
baseline 6.8 GiB, escalate doc 13 finding 01 — pair with
remediation #4 (pre-transcode the season) sooner rather than later.
### 7g. Long-term verification
A week after the fix, check:
```sh
# Number of 499 client-cancel events on jellyfin@docker
docker logs traefik --since 168h 2>&1 | grep '"jellyfin@docker"' | grep ' 499 ' | wc -l
```
Should be ≤ pre-fix baseline (currently 2 / hour, doc 13 finding 03).
If it climbs after enabling tonemap (because the tonemap stage
slowed transcodes enough to let the client time out), that's the
trigger to invest in remediation #4 or #5.
---
## 8. What was NOT touched during this audit
- No POST/PUT to `/System/Configuration/encoding`.
- No POST to `/System/Configuration/branding`.
- No `docker exec jellyfin` writes (read-only `ls`, `cat`, `ffprobe`).
- No `docker compose` action, no container restart.
- No file modification on `/home/user/media/`.
- No transcode triggered (PlaybackInfo simulation only — that endpoint
decides codec paths but does not start ffmpeg).
---
## 9. Sign-off
- **Auditor:** s8n (audit pass, 2026-05-08)
- **Live config at audit time:** Jellyfin 10.10.3,
`EnableTonemapping=false`, `HardwareAccelerationType=none`,
`TonemappingAlgorithm=bt2390`. CSS = Cineplex v1.0.6 + ARRFLIX
brand layer (no greyscale filters).
- **Confirmed root cause:** HDR10 source (R&M S01) + CPU-only
pipeline + tonemap disabled = HDR pixels delivered as SDR =
washed-out grey.
- **Recommended fix:** flip `EnableTonemapping=true` (one
Playback-settings checkbox) AND `EnableThrottling=true` +
`EnableSegmentDeletion=true` (pair-finding from doc 13).
- **Next audit due:** **2026-08-08** alongside doc 13's quarterly
rotation, or sooner if a new HDR source lands in another library.

View file

@ -0,0 +1,517 @@
# 22 — Jellyfin Runtime Performance Audit (server scope)
> Status: **read-only audit**, executed 2026-05-08 ~17:3017:45 BST against
> `https://arrflix.s8n.ru` (Jellyfin 10.10.3 on nullstone, container `jellyfin`).
> Scope: server runtime — CPU, RAM, container limits, FFmpeg, scheduled
> tasks, plugins. Network/edge, storage, color/HDR are out of scope (sibling
> agents). Supplements doc 13 (2026-05-08, host-capacity scan); does not
> repeat findings already in 13 unless the data has materially changed.
> **No fixes applied. No state mutated. No container restart.**
---
## 1. Executive summary — top 3 perf culprits
| # | Culprit | Severity | Evidence (one line) |
|---|---|:-:|---|
| 1 | **4 concurrent ffmpeg processes for ONE viewer**, each upscaling 1080p → 2160p with PGS subtitle burn-in, no throttling, no segment deletion | **CRITICAL** | `ps`: PIDs 1681949 (643 % CPU), 1685275 (135 %), 1685316 (133 %), 1685478 (132 %) — all transcoding `Rick and Morty S01E01.mkv`, all `-vf scale=3840:2160` + `[0:4]overlay` subtitle burn. Container CPU 690876 % across 3 samples |
| 2 | **Forgejo BlueBuild CI container running uncapped on the same 12-core host** (noisy neighbor) | **HIGH** | `docker stats`: `FORGEJO-ACTIONS-TASK-202_..._Build-push-OCI` 8899 % CPU, 4.3 GiB RAM, 5 GB net-in. Both jellyfin and the build container have `Memory=0 NanoCpus=0 CpuQuota=0` (no limits). Aggregate load 15.43 / 14.61 / 8.85 on 12 cores |
| 3 | **GPU acceleration still off** (already in doc 13 finding 02; quantified here) — every CPU transcode spawns one ffmpeg burning 68 cores per stream because of the 4K-upscale + sub-overlay filtergraph | **HIGH** | `HardwareAccelerationType=none`. Per-ffmpeg cost on this filtergraph: ~6.4 cores at `preset=veryfast`. 2 viewers transcoding = full host pegged |
**Biggest quick-win:** turn on **transcode throttling + segment deletion**
(doc 13 finding 03 already flags this; new evidence here makes it
non-optional). The 4-stream pile-up in §3 is exactly what those two
flags exist to prevent — without them, every client seek/reload spawns a
fresh ffmpeg and the previous one keeps burning a core for up to 720 s
(`SegmentKeepSeconds=720`). Two checkbox flips in Playback settings.
---
## 2. Resource snapshot (3 samples, 10 s apart)
| Sample @time | jellyfin CPU% | jellyfin MEM | NET I/O | BLOCK I/O | PIDs |
|---|---:|---:|---:|---:|---:|
| t=0 | **834.3 %** | 2.635 GiB / 31.27 GiB (8.42 %) | 5.36 / 158 MB | 1.14 / 855 MB | 101 |
| t=10s | **690.5 %** | 2.637 GiB | 5.37 / 158 MB | 1.22 / 894 MB | 102 |
| t=20s | **876.7 %** | 2.646 GiB | 5.37 / 158 MB | 1.32 / 942 MB | 101 |
**Container limits:** `Memory=0 NanoCpus=0 CpuQuota=0 CpuPeriod=0
PidsLimit=<none> RestartPolicy=unless-stopped`. **No CPU or RAM cap on
the jellyfin container.** Same for the Forgejo build container.
**Host (nullstone, 12-core AMD Ryzen 5 2600X, 32 GiB RAM, 24 GiB swap):**
- `uptime`: load avg **15.43 / 14.61 / 8.85** — 1-min load 28 % above
core count. 5-min trend confirms sustained load. Doc 13 logged 11.40 /
9.59 / 6.19 ~13 h ago, so the host has been getting *worse*, not better.
- `free -h`: 31 GiB total, 10 GiB used, 8.2 GiB free, 13 GiB buff/cache;
swap **7.8 GiB / 24 GiB used** (32 %). `SwapCached=771 MB` (kernel is
actively servicing swap-in from cache — i.e. swap-thrash signature).
- `vmstat 1 5`: `r=327`, `cs=30 K41 K/s` (very high context switch
rate), `si≤24 KB/s so≈0` (paging-in but not out — recovering, not
thrashing right this second), `us=7072 % sy=1013 % id=1618 %
wa=0 %`.
- `iostat -x`: `nvme0n1` w/s ≈ 38433, `wkB/s` ≈ 3642 272, util `0.4 %
0.9 %`. **Disk is not the bottleneck — CPU is.**
**All-container CPU% (sorted, top 5):**
| Container | CPU% | MEM | Notes |
|---|---:|---:|---|
| jellyfin | **773876** | 2.6 GiB | this audit's target |
| FORGEJO-ACTIONS-TASK-202_..._Build-push-OCI | **8899** | 4.3 GiB | uncapped CI build, see §3 culprit 2 |
| traefik | 9 | 48 MiB | routine reverse proxy |
| forgejo | 9 | 207 MiB | git web |
| minecraft-mc | 7 | 4 GiB | racked.ru server |
| (28 other containers) | < 5 % combined | | none material |
The two CPU monsters together (jellyfin + bluebuild) account for **~90 %
of the 12-core host's user time** during this audit window.
---
## 3. Active sessions + active transcodes
**Sessions (within last 600 s):** **1**
| User | Client | Device | RemoteIP | NowPlaying | PlayMethod | Pos |
|---|---|---|---|---|---|---|
| s8n | Jellyfin Web | Chrome | 192.168.0.10 | Rick and Morty S01E01 — Pilot | DirectPlay (claimed) / **Transcoding** (actual) | 8 s |
**TranscodingInfo on the active session:**
```
VideoCodec → h264 (libx264, preset=veryfast, crf=23)
AudioCodec → aac (libfdk_aac, 256 kbps stereo, +6 dB volume gain)
Resolution → 3840 × 2160 (UPSCALE — source is 1080p)
Bitrate → 13.8 Mbps
Container → fmp4 / hls
HW → none
Reasons → VideoCodecNotSupported, AudioCodecNotSupported, SubtitleCodecNotSupported
Direct → IsVideoDirect=False, IsAudioDirect=False
Completion → 0 % (just started)
```
**Active ffmpeg processes on host: 4** (all for the same viewer, same
file — see §5).
The session reports `PlayMethod=DirectPlay` while *also* presenting a
`TranscodingInfo` block — Jellyfin's TS DTO returns the last-set state,
so this is the client navigating into the page; the actual decision was
**transcode** (the 4 ffmpeg's confirm). The HLS player sometimes flips
`PlayMethod=Transcode` only after the first segment downloads; pre-roll
state matches the 4-process pile-up in §5.
---
## 4. Scheduled tasks
All tasks **Idle**. None in progress. Last-run durations are tiny — no
scheduled task is the culprit. Library scan runs every 6 h (last
`14:14:04`, 0.3 s wall — only 187 items so it converges instantly).
| Name | State | Last end (UTC+1) | Last duration | Trigger |
|---|---|---|---:|---|
| Audio Normalization | Idle | 2026-05-08T00:58 | 0.0 s | IntervalTrigger |
| Clean Cache Directory | Idle | 2026-05-08T00:58 | 0.1 s | IntervalTrigger |
| Clean Log Directory | Idle | 2026-05-08T00:58 | 0.0 s | IntervalTrigger |
| Clean Transcode Directory | Idle | 2026-05-08T16:22 | 0.0 s | StartupTrigger |
| Clean up collections and playlists | Idle | 2026-05-08T16:22 | 0.0 s | StartupTrigger |
| Download missing lyrics | Idle | 2026-05-08T00:58 | 0.1 s | IntervalTrigger |
| Download missing subtitles | Idle | 2026-05-08T00:58 | 0.0 s | IntervalTrigger |
| Extract Chapter Images | Idle | 2026-05-08T01:00 | 0.0 s | DailyTrigger |
| Generate Trickplay Images | Idle | 2026-05-08T02:00 | 0.1 s | DailyTrigger |
| Media Segment Scan | Idle | 2026-05-08T14:14 | 0.0 s | IntervalTrigger |
| Optimize database | Idle | 2026-05-08T00:58 | 0.2 s | IntervalTrigger |
| Refresh Guide | Idle | 2026-05-08T00:58 | 3.2 s | IntervalTrigger |
| Refresh People | Idle | 2026-05-08T00:58 | 0.3 s | IntervalTrigger |
| Scan Media Library | Idle | 2026-05-08T14:14 | 0.3 s | IntervalTrigger |
| TasksRefreshChannels | Idle | 2026-05-08T00:58 | 0.1 s | IntervalTrigger |
| Update Plugins | Idle | 2026-05-08T16:22 | 1.2 s | StartupTrigger |
| Clean Activity Log / Keyframe Extractor / Migrate Trickplay Image Location | Idle | (never run) | — | — |
**Container restarted at 16:22:06 today** (StartupTrigger task end-times
imply a restart — last audit had `StartedAt=02:13:01`, doc 13 finding 30
expected 0 restarts). Operator likely restarted the container ~17 h ago,
not material to perf but worth noting.
**Verdict:** culprit (a) "scheduled task hogging CPU" → **ruled out**.
---
## 5. FFmpeg processes on host (snapshot)
**4 simultaneous ffmpeg processes, all transcoding the same source for
the same viewer.** This is the smoking gun. Process tree from the
container shows just `1 jellyfin` (parent) + `1579 ffmpeg` + `1725
ffmpeg` (the others are still spawning); host `ps -ef` shows 4
ffmpeg's owned by `user` uid 1000.
| PID | %CPU | %MEM | RSS | etime | What | Subs filter |
|---:|---:|---:|---:|---:|---|---|
| 1681949 | **643** | 6.9 | 2.27 GB | 53 s | `-ss 33s` HLS seek | **yes**`[0:4]scale,scale=3840:2160:fast_bilinear[sub] ; [0:0]scale=3840:2160 [main] ; overlay` |
| 1685275 | **135** | 4.4 | 1.45 GB | 6 s | `-ss 15s` HLS seek | yes — same chain |
| 1685316 | **133** | 4.4 | 1.45 GB | 6 s | full transcode (no -ss) | no — plain `setparams + scale + format=yuv420p` |
| 1685478 | **132** | 3.9 | 1.29 GB | 4 s | full transcode `-canvas_size 1920x1080` | yes — same chain |
| 1669243 (earlier sample, then died) | ~759 | 7.0 | 2.30 GB | 254 s | full transcode | no |
**What every ffmpeg is doing:**
- Decoding source 1080p H.265 (or H.264 — Pilot is x264 Bluray rip).
- **Upscaling video to 3840×2160 with `scale=...:fast_bilinear`.**
- **Burning PGS subtitle stream `0:4` ALSO upscaled to 3840×2160 onto
the video.** This is the heaviest overlay path the JF filtergraph
produces.
- Re-encoding to H.264 `libx264 preset=veryfast crf=23 high@L5.1` with
`maxrate=13.5 Mbps`.
- `-threads 0` (= use all cores), `-max_muxing_queue_size 2048`.
- HLS fmp4 segments to `/cache/transcodes/<sessionId><n>.mp4`.
**Why 4 of them at once for one user:** every time the client seeks or
reloads, JF starts a new ffmpeg with a new sessionId and a new segment
file prefix. Because `EnableThrottling=false` and
`EnableSegmentDeletion=false` (doc 13 findings 03/05), the old ffmpeg
keeps producing segments to its own cache prefix and **does not exit
until `SegmentKeepSeconds=720` elapses**. Three observed cache prefixes
right now: `8e8a8538…`, `ef1caecc…` (already produced segments 030 →
~73 MiB), `3ba3fce4…`, `b6f150cb…`, `fcc6137e…` — five session-IDs
across the last ~5 minutes for one viewer.
**Why each ffmpeg is so expensive:**
- 1080p → 4K upscale ≈ 4× pixel volume.
- PGS subtitle stream is also being scaled to 4K and overlaid (alpha
blend) every frame.
- `libfdk_aac` 256 kbps is fine, the cost is essentially all video.
- On 12 logical cores at `preset=veryfast`, this filtergraph clocks
**6.4 cores of headroom per ffmpeg** (643 % observed). Two
simultaneous transcodes = full host. Four = swap thrash + the load
avg of 15.
**Why is it upscaling to 4K at all?** Likely the client requested a
profile that picked the "max bitrate / max-resolution" capability of
the device (a desktop Chrome will report 4K-capable). The Jellyfin
ladder is either (a) "always pick highest profile" or (b) the user's
client is set to "Auto" with no max-resolution cap. **No client-side
bitrate cap is set on this user** (doc 13 reported
`RemoteClientBitrateLimit=0`). Combine that with PGS subs the client
can't render → forced burn-in → the 4K-overlay tax kicks in.
**ffprobe storms:** at 13:31 the log shows **7 simultaneous ffprobe
calls** (Mandalorian S2 episodes, all at once); at 17:37 **another 7
simultaneous ffprobes** (Mandalorian S3). Each ffprobe with
`-analyzeduration 200M -probesize 1G` reads up to 1 GiB into RAM. Cause:
operator clicked into the season 2/3 page → JF kicks subtitle-search
for every episode at once because `LibraryMetadataRefreshConcurrency=0`
(= 12). Doc 13 finding 14 already calls the concurrency-cap fix; this
audit confirms the symptom.
**Verdict:** the **single biggest user-visible "loads kinda slow"** is
the 4K-upscale subtitle-burn pile-up.
---
## 6. Plugin status
All 6 plugins **Active**. None in Faulted/Restart. No exception loops in
log from plugin assemblies.
| Name | Version | Status |
|---|---|---|
| AudioDB | 10.10.3.0 | Active |
| MusicBrainz | 10.10.3.0 | Active |
| OMDb | 10.10.3.0 | Active |
| Open Subtitles | 20.0.0.0 | Active *(but mis-configured — see §7)* |
| Studio Images | 10.10.3.0 | Active |
| TMDb | 10.10.3.0 | Active |
**Verdict:** culprit (e) "plugin throwing repeated exceptions in log
spam loop" → **partially confirmed for OpenSubtitles** (it throws on
every probe — 234 today already), but the cost is per-probe RTT not
sustained CPU. Fix is doc 13 finding 04.
---
## 7. Log error / warning summary (last 24 h, today's `log_20260508.log`)
`/config/log/log_20260508.log` is **3 968 lines**. Filtered tally:
| Pattern | Count today | Notes |
|---|---:|---|
| `[ERR]` total | **266** | |
| `[WRN]` total | **124** | |
| `Error downloading subtitles from "Open Subtitles"` | **234** | doc 13 finding 04 — `Username/Password` empty, throws `AuthenticationException` per file probed; **88 % of all errors today are this one cause** |
| `No space left on device : '/config/metadata/library/...'` | **2** | at 13:53:10 — transient ENOSPC during a metadata write; disk now 62 % full (146 GiB free), so this is a moving-target burst (probably caused by 73 MiB+ of transcode segments accumulating in `/cache/transcodes` while a metadata write tried to extend a small file). **Worth watching** but not the current bottleneck |
| `Invalid username or password entered` (auth fail) | 5 | three distinct minutes — looks like a user retrying creds, not a brute-forcer |
| `WS ... error receiving data` (websocket abrupt close) | ~25 | normal: clients closing tabs / dropping carrier. Noise, not a defect |
| `Compiling a query which loads related collections...` (EF Core warning, slow query) | 1 | EF Core's `QuerySplittingBehavior` warning — Jellyfin upstream issue, harmless on this dataset |
| `task was canceled` on `/videos/.../hls1/main/-1.mp4` | 1 (17:41) | client gave up mid-segment-init — same 499 family as doc 13's evidence |
| `SQLITE_BUSY` / `database is locked` | **0** | culprit (d) DB lock contention → **ruled out** |
**Verdict:**
- culprit (e) "plugin log spam" → confirmed (234 OS errors / day = a
scan or page-into-season triggers a loop of failures).
- culprit (d) "DB lock contention" → ruled out (0 SQLITE_BUSY).
- the **2 ENOSPC errors are NEW vs doc 13** and warrant tracking — see
§9 culprit 4.
---
## 8. DB and cache sizes
```
/config/data/jellyfin.db 288 K (was 208 K in doc 13 — fine)
/config/data/library.db 3.4 M (was 3.3 M — fine)
/config/data/library.db-wal 6.2 M (was 4.4 M — STILL LARGER THAN MAIN, see below)
/config/data/library.db-shm 32 K
/config/metadata 99 M (was 92 M — fine)
/config/log 4.2 M (was 1.3 M — 3× growth in 14 h driven by §7 OS spam)
/cache/transcodes 84 M / 43 files (snapshot)
/cache total not measurable from in-container du (mount appears empty due to bind layout)
```
**library.db-wal (6.2 MB) is now ~1.8× the main `.db` (3.4 MB).** Doc 13
finding 08 already raised this — the situation is slightly worse now
(WAL grew faster than main during 14 h). Cause: SQLite checkpoints on
*idle*, but with continuous transcode + ffprobe activity from two
viewers and library refreshes there is rarely an idle moment to
checkpoint. **Manual `Optimize database` will collapse the WAL into
the main file.**
**`/cache/transcodes` 84 MB / 43 files** is the residue of three+
abandoned ffmpeg sessions. Without `EnableSegmentDeletion=true`, this
will grow unbounded for `SegmentKeepSeconds=720` per session. Worst
case at 1 viewer × 4 zombie sessions × 720 s × 13 Mbps = **~5.6 GiB
transient cache** per minute of pile-up. **This is exactly how the
13:53 ENOSPC happened** (cache + metadata fighting for the same
146-GiB free pool).
---
## 9. Concrete remediation list (ranked impact / effort)
### 9.1 Quick-wins (rank 1 → 4 — all are minutes of work, all read-only-safe to apply)
1. **Cap two transcode flags** (Settings → Playback):
- `EnableThrottling = true`
- `EnableSegmentDeletion = true`
*Effect:* zombie ffmpeg from a stale session is killed instead of
producing 720 s of segments after the client has moved on. **This
single change directly addresses §5's 4-process pile-up.** Doc 13
already noted this; the new evidence escalates it from "S effort,
cleanup" to **"non-optional"**.
2. **Cap concurrency knobs** (Settings → Server / Library):
- `LibraryScanFanoutConcurrency = 4`
- `LibraryMetadataRefreshConcurrency = 4`
- `ParallelImageEncodingLimit = 4`
*Effect:* 7-up ffprobe burst at 13:31 / 17:37 (§5) is capped to 4
parallel probes, not 12. Doc 13 already noted this as S effort.
3. **Set `RemoteClientBitrateLimit`** (Dashboard → Playback → Streaming
→ "Internet streaming bitrate limit"):
- Suggest `8 Mbps` (covers 1080p Bluray rips, kills 4K-upscale
decisions on remote sessions). LAN clients that want full-bitrate
can be flagged via per-user policy.
*Effect:* the 13.8 Mbps maxrate-on-WAN session becomes a 8-Mbps
session that **doesn't need the 4K-upscale path** — JF stops asking
ffmpeg to produce 3840×2160. **This is what makes §5's per-stream
cost drop by ~70 %.** Independent of GPU.
4. **Disable Open Subtitles plugin OR populate creds** (already in
doc 13 finding 04). Removes 234 ERR/day, restores log signal,
removes the per-probe RTT.
### 9.2 Investments (rank 5 → 7 — half-day to multi-day, structural)
5. **Add CPU + memory limits to BOTH `jellyfin` and the Forgejo
`BlueBuild` build container in compose** — currently both are
uncapped, fighting for the same 12 cores. Suggest:
- `jellyfin`: `cpus: 8.0`, `mem_limit: 12G`, `mem_reservation: 4G`
- `forgejo-runner` build pods: `cpus: 4.0`, `mem_limit: 8G`
*Effect:* a noisy CI build cannot drag interactive playback
latency to the floor; viewer always has 8 cores even when
BlueBuild is hot. Note that the BlueBuild container is short-lived
(forgejo-actions spawns it per job) so the limit goes on the
runner's `container_options` in the runner config, not on a static
compose service.
6. **Re-enable GPU transcoding on host** (doc 13 finding 02 — L
effort). With H.264 NVENC at preset `p4` the same filtergraph
collapses from ~6.4 CPU cores to ~0.3 CPU cores + GPU. Without
GPU, the §5 quick-wins are the cap; with GPU, the host can
serve 4 simultaneous viewers comfortably.
7. **Cap the maximum supported resolution in client policy** (Dashboard
→ Users → each user → Playback → "Maximum allowed video bitrate" /
"Maximum allowed video resolution"). Set non-admin users to
`1080p` max. Closes the foot-gun where any client says "I can do
4K" and Jellyfin obliges with a 4K-upscale CPU bomb.
### 9.3 Watch-list (no immediate action, monitor next audit)
- ENOSPC at 13:53 (only 2 occurrences, host has 146 GiB free now, so
it was a transient pressure burst). Re-check post-quick-wins (1+2
remove the cache pile-up that caused it).
- `library.db-wal` 1.8× main db — manual `Optimize database` after the
above tasks finish, or tighten its schedule from 24 h to 6 h.
- Container restart at 16:22 (was 02:13 in doc 13) — was this operator-
initiated or did `unless-stopped` re-spin a crash? Check
`docker logs jellyfin --since 6h` for `panic`/`crash` next time.
---
## 10. Quick-win vs investment summary
| Bucket | Action | Effort | Expected impact |
|---|---|---|---|
| **Quick-win** | Throttling + SegmentDeletion ON | 2 clicks | Kills §5 zombie ffmpegs immediately; expected load avg drop 4050 % under one active viewer |
| **Quick-win** | Concurrency caps 12 → 4 | 3 fields | Removes the 7-up ffprobe bursts at season-page navigation |
| **Quick-win** | RemoteClientBitrateLimit = 8 Mbps | 1 field | Stops Jellyfin choosing 4K-upscale paths for WAN clients; ~70 % drop in per-stream CPU |
| **Quick-win** | OpenSubs disable / cred | 30 sec | 234 ERR/day → 0; cleaner log; faster library scans |
| **Investment** | Compose CPU/MEM caps for jellyfin + bluebuild | 30 min compose + 1 restart per container | Removes noisy-neighbor head-of-line blocking by the CI runner |
| **Investment** | GPU transcode reactivation | days (driver work, host) | 20× per-stream CPU efficiency on the 1080p-and-up paths |
| **Investment** | Per-user max-resolution policy | 5 min × N users | Prevents admin foot-gun and any future invitee from triggering the 4K-upscale path |
---
## Appendix — raw evidence
### Container limits (the absence is the finding)
```
docker inspect jellyfin --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}
{{.HostConfig.CpuQuota}} {{.HostConfig.CpuPeriod}}
{{.HostConfig.PidsLimit}} {{.HostConfig.RestartPolicy.Name}}'
→ 0 0 0 0 <no value> unless-stopped
```
### Host CPU + load + memory
```
nproc: 12
lscpu Model: AMD Ryzen 5 2600X Six-Core Processor (6c / 12t, NUMA0=011)
uptime: 17:42:14 up 4 days 17:59, 2 users, load average: 15.43, 14.61, 8.85
free -h: total 31Gi, used 10Gi, free 8.2Gi, buff/cache 13Gi
swap total 24Gi, used 7.8Gi (32 %), SwapCached 789 976 kB
vmstat 1 5 (us / sy / id / wa, last sample): 71 / 13 / 16 / 0
r=11, b=1, cs ≈ 30 K/s
iostat (nvme0n1): 38433 w/s, 3642 272 wkB/s, util 0.40.9 % — disk idle
```
### Top hosts on host (snapshot)
```
ps -eo pid,user,pcpu,pmem,rss,etimes,args --sort=-pcpu | head:
1681949 user 643 % 6.9 % 2.30 GB 53 s ffmpeg [Rick & Morty S01E01, 4K-upscale + sub burn]
1662267 root 52 % 0.1 % — 426 s fuse-overlayfs (BlueBuild rootfs mount)
1661952 root 36 % 0.1 % — 431 s fuse-overlayfs (BlueBuild rootfs)
1485847 git 8 % 0.8 % 266 MB — gitea web (forgejo)
364785 user 8 % 2.6 % 867 MB — openclaw-gateway
1901802 java 8 % 12.7 % 4.2 GB — minecraft jvm (-Xmx14336M)
1660709 root 7 % 0.3 % 100 MB 442 s buildah build (BlueBuild)
1626511 user 4 % 1.6 % 544 MB — /jellyfin/jellyfin (server proc)
```
### All 4 active ffmpeg's (full filter chain shown for the heaviest one)
```
PID 1681949 (643 % CPU):
-ss 33s -noaccurate_seek -canvas_size 1920x1080
-i Rick.and.Morty.S01E01.mkv
-threads 0 -map 0:0 -map 0:1 -map -0:0
-codec:v libx264 -preset veryfast -crf 23 -maxrate 13546858 -bufsize 27093716
-profile:v high -level 51
-filter_complex
[0:4]scale,scale=3840:2160:fast_bilinear[sub] ;
[0:0]setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709,
scale=trunc(min(max(iw,ih*a),min(3840,2160*a))/2)*2
:trunc(min(max(iw/a,ih),min(3840/a,2160))/2)*2,
format=yuv420p[main] ;
[main][sub]overlay=eof_action=pass:repeatlast=0
-codec:a libfdk_aac -ac 2 -ab 256000 -af volume=2
-f hls -hls_time 3 -hls_segment_type fmp4 ...
```
### Sessions API (exactly 1 user, mismatched `PlayMethod` vs `TranscodingInfo`)
```
GET /Sessions?activeWithinSeconds=600 → 1 session
user=s8n client=Jellyfin Web/Chrome remote=192.168.0.10
PlayMethod=DirectPlay (claimed)
TranscodingInfo:
VideoCodec=h264 AudioCodec=aac Container=fmp4/hls
3840x2160 @ 13.8 Mbps HW=none IsVideoDirect=False IsAudioDirect=False
Reasons = [VideoCodecNotSupported, AudioCodecNotSupported, SubtitleCodecNotSupported]
Completion = 0.0 %
```
### Scheduled tasks (none in progress)
(Full table in §4 — every task is `Idle`, last-run durations 03.2 s.)
### Plugins (all 6 Active, no faulted)
```
AudioDB 10.10.3.0 Active
MusicBrainz 10.10.3.0 Active
OMDb 10.10.3.0 Active
Open Subtitles 20.0.0.0 Active ← 234 ERR/day from auth-empty creds (doc 13 finding 04)
Studio Images 10.10.3.0 Active
TMDb 10.10.3.0 Active
```
### Log tally (today's `log_20260508.log`, 3 968 lines)
```
[ERR] lines: 266
[WRN] lines: 124
"Error downloading subtitles from Open Subtitles": 234 ← 88 % of all ERR
"No space left on device": 2 ← 13:53:10, transient
"Invalid username or password entered" (login): 5
"WS ... error receiving data": ~25 ← noise
"task was canceled" / 499: 1 ← 17:41
"SQLITE_BUSY" / "database is locked": 0
EF Core "QuerySplittingBehavior" warning: 1 ← upstream JF
```
### Disk (host vs container view)
```
host df -h /home: 399G 233G 146G 62 % (was 90 % in doc 13 — improved)
host df -i /home: ~1.49M used / ~26.6M 6 % inodes healthy
container df -h /config /cache /media: same FS, same 146G free
```
### Items / counts
```
GET /Items/Counts → MovieCount=2 SeriesCount=6 EpisodeCount=181
ArtistCount=0 ProgramCount=0 TrailerCount=0
SongCount=0 AlbumCount=0 MusicVideoCount=0
```
### Container restart (StartedAt today)
```
Implied from ScheduledTasks where Trigger=StartupTrigger:
Clean Transcode Directory → end 16:22:06 ← container start ≈ 16:22:05
Clean up collections and playlists → end 16:22:06
Update Plugins → end 16:22:07
(doc 13 had StartedAt = 02:13:01)
```
### Forgejo BlueBuild container (noisy neighbor, no limits)
```
docker stats: CPU 8899 % MEM 4.3 GiB NET in 5 GB BLOCK in/out 296 MB / 35.3 GB
docker inspect: Memory=0 NanoCpus=0 CpuQuota=0 ← uncapped
```
---
## Sign-off
- Audit: 2026-05-08, read-only, ~15 min wall.
- No fixes applied. No state mutated. No container restart. No plugins
reloaded. No tasks executed.
- Scope respected: server runtime only. Color/HDR, edge/network, and
storage findings deferred to sibling agents.
- Next audit due: **2026-08-08** (quarterly, paired with doc 13).

View file

@ -0,0 +1,587 @@
# 23 — ARRFLIX Edge / Network / Browser-Load-Path Audit
> Status: **read-only audit**, executed 2026-05-08 from onyx
> (192.168.0.6 LAN) against `https://arrflix.s8n.ru` (Jellyfin 10.10.3
> behind Traefik on nullstone). Scope: edge — DNS, TLS, Traefik,
> compression, cache headers, asset waterfall, ServiceWorker. **No
> fixes applied. No state mutated. No container restart. No Traefik
> reload.**
>
> Sibling audits cover color/HDR, server runtime, and storage. This one
> is the edge slice only. Pairs with doc 13 (server-side optimization
> audit, 2026-05-08) — that one calls out CPU/transcode; this one
> identifies why every page-nav over WAN feels gluey when the server is
> idle.
---
## 1. Executive summary
The two cold-load complaints ("loads kinda slow") are dominated by a
single edge defect with three symptoms:
1. **No HTTP compression at all.** Traefik has zero `compress`
middleware defined in either static (`traefik.yml`) or dynamic
(`config/dynamic.yml`) config, and the Jellyfin router only attaches
`security-headers@file`. Result: Jellyfin's 28 webpack JS bundles
ship raw — **2.74 MiB of JS over the wire on every cold load**.
With gzip (default ratio ~0.30 for minified JS) that drops to
~0.82 MiB. With brotli (~0.25) it drops to ~0.69 MiB. **Severity:
R — fix this week.**
2. **No `Cache-Control` on hashed-asset URLs.** Every JS bundle
comes back with `etag` + `last-modified` and **no** `cache-control`
header. Browsers default to "heuristic freshness" (typically 10 % of
`last-modified` age) but every reload **does still issue a
conditional `If-None-Match` request per asset** and gets a 304
back. On a cold-cache page-nav that's **28 round-trips of pure
negotiation overhead** even when the response body is cached.
These URLs are content-hashed (`?7dc095d8…`), so they should be
`Cache-Control: public, max-age=31536000, immutable`. **Severity:
Y → R when WAN clients are involved (each round-trip costs an
internet RTT).**
3. **Poster image first-fetch is slow** — the very first cold request
for a `/Items/{id}/Images/Primary` triggers a Jellyfin server-side
image transcode (resize + JPEG re-encode) and runs in **~385 ms
wall** vs **~2535 ms** for warm cache. With ~20 posters on the home
page and no edge cache, the first visit to "Recently Added" is a
**~7-second poster grid**. Doc 13 finding 23 (3 MB splash PNG) is
the loud single hit; this is the death-by-a-thousand-cuts equivalent
for the home page. **Severity: Y.**
Everything else (TLS handshake, MTU, DNS lookup, HTTP/2 vs HTTP/3,
cert chain depth, Traefik middleware chain, Pi-hole hairpin) is
healthy or low-impact — full table below.
**Top quick win:** add a `compress@file` middleware in
`/opt/docker/traefik/config/dynamic.yml` and reference it from the
Jellyfin router. **One file edit. Two lines of YAML in the middleware
block, one line on the router. ~70 % cold-load wire-size reduction.**
---
## 2. Curl timing breakdown (5 samples, p50, p95)
Test: `curl https://arrflix.s8n.ru/web/index.html` from onyx.
### LAN-direct (`--resolve` to 192.168.0.100)
| Sample | DNS | CONN | TLS | TTFB | TOTAL |
|--------|-----|------|-----|------|-------|
| 1 | 0.000024 | 0.001225 | 0.022960 | 0.031569 | 0.040531 |
| 2 | 0.000024 | 0.001217 | 0.020182 | 0.024190 | 0.030353 |
| 3 | 0.000028 | 0.001437 | 0.025502 | 0.030467 | 0.035793 |
| 4 | 0.000023 | 0.001501 | 0.021998 | 0.037444 | 0.041056 |
| 5 | 0.000023 | 0.001265 | 0.018536 | 0.022942 | 0.027066 |
| **p50** | **24 µs** | **1.27 ms** | **22.0 ms** | **30.5 ms** | **35.8 ms** |
| **p95** | **28 µs** | **1.50 ms** | **25.5 ms** | **37.4 ms** | **41.1 ms** |
### Hostname (onyx /etc/hosts → 192.168.0.100)
| p50 | DNS 0.34 ms | CONN 1.6 ms | TLS 23.0 ms | TTFB 27.5 ms | TOTAL 33.7 ms |
### Notes
- DNS via `/etc/hosts` adds ~300 µs vs `--resolve`. Negligible.
- TLS handshake is the dominant cost (≥60 % of TTFB). TLS 1.3 with
`TLS_AES_128_GCM_SHA256`, **2-cert chain depth** (Let's Encrypt R13
→ ISRG Root X1), no avoidable latency there. Connection reuse will
hide it on subsequent requests within the same browser session.
- **TTFB ≤ 40 ms even on cold connection** — server-side latency for
the index.html body itself is fine. The "feels slow" perception is
**not** in this number; it's in the 28-bundle waterfall after
index.html.
---
## 3. Compression / cache header table
Probed with `Accept-Encoding: gzip, br, zstd`. Every asset was
served raw.
| Asset | Type | Bytes | Encoding | Cache-Control | ETag |
|-------|------|------:|----------|---------------|------|
| `/web/index.html` | text/html | 65 485 | **none** | **(none)** | yes |
| `/web/runtime.bundle.js?…` | text/js | 49 152 | **none** | **(none)** | yes |
| `/web/main.jellyfin.bundle.js?…` | text/js | 499 108 | **none** | **(none)** | yes |
| `/web/node_modules.@jellyfin.sdk.bundle.js?…` | text/js | 740 699 | **none** | **(none)** | yes |
| `/web/node_modules.@mui.material.bundle.js?…` | text/js | 381 100 | **none** | **(none)** | yes |
| `/web/node_modules.core-js.bundle.js?…` | text/js | 182 469 | **none** | **(none)** | yes |
| `/web/node_modules.react-dom.bundle.js?…` | text/js | 128 970 | **none** | **(none)** | yes |
| `/web/node_modules.@tanstack.query-core.bundle.js?…` | text/js | 101 747 | **none** | **(none)** | yes |
| `/web/node_modules.lodash-es.bundle.js?…` | text/js | 24 604 | **none** | **(none)** | yes |
| `/web/themes/dark/theme.css` | text/css | 8 631 | **none** | **(none)** | yes |
| `/web/manifest.json` | json | 781 | **none** | **(none)** | yes |
| `/web/serviceworker.js` | text/js | 768 | **none** | **(none)** | yes |
| `/web/favicon.ico` | image/x-icon | 6 830 | **none** | **(none)** | yes |
| `/web/touchicon.png` | image/png | 8 515 | **none** | **(none)** | yes |
| `/Items/.../Images/Primary` (cold) | image/jpeg | ~46 000 | **none** | `public` (no max-age) | — |
Verification — index.html negotiated against four different
`Accept-Encoding` headers. All four returned `content-length: 65485`
and **no** `content-encoding` — confirms Traefik isn't selectively
disabling compression by `User-Agent`/path; the middleware simply
isn't in the chain.
ETag-revalidation works correctly: a follow-up
`If-None-Match: "1db3a353daaafa4"` returns `HTTP/2 304` immediately —
so warm-load is "fast" only because nothing has changed since cold
load. The browser still pays a round-trip per asset.
---
## 4. Asset cold-load waterfall (top by size)
`/web/index.html` references **28 webpack-emitted JS bundles** (full
list at `/tmp/edge-audit/bundles.txt` during audit; file generated by
parsing `<script src=…>` tags in index.html and discarded after
report). All 28 share the same query-string version
`?7dc095d8f634f60f309c` — they ARE content-versioned URLs and SHOULD
be cached `immutable`.
| Rank | Bundle | Bytes | Notes |
|---:|---|---:|---|
| 1 | `node_modules.@jellyfin.sdk.bundle.js` | 740 699 | Largest single file. Compresses ~70 %. |
| 2 | `main.jellyfin.bundle.js` | 499 108 | App bundle. Compresses ~70 %. |
| 3 | `node_modules.@mui.material.bundle.js` | 381 100 | MUI components. Compresses ~75 %. |
| 4 | `node_modules.core-js.bundle.js` | 182 469 | Polyfills. Compresses ~75 %. |
| 5 | `node_modules.react-dom.bundle.js` | 128 970 | React DOM. Compresses ~75 %. |
| 6 | `node_modules.@tanstack.query-core.bundle.js` | 101 747 | React-Query. Compresses ~70 %. |
| 7 | `node_modules.jellyfin-apiclient.bundle.js` | 88 025 | Compresses ~70 %. |
| 8 | `node_modules.jquery.bundle.js` | 87 296 | Compresses ~70 %. |
| 9 | `node_modules.axios.bundle.js` | 80 291 | Compresses ~70 %. |
| 10 | `node_modules.date-fns.esm.bundle.js` | 74 309 | Compresses ~70 %. |
| 11 | `node_modules.@remix-run.router.bundle.js` | 72 992 | |
| 12 | `37869.bundle.js` | 70 690 | Lazy chunk. |
| 13 | `runtime.bundle.js` | 49 152 | Webpack runtime. |
| 14 | `node_modules.webcomponents.js.bundle.js` | 39 705 | |
| 15 | `node_modules.@mui.icons-material.bundle.js` | 30 861 | |
| — | (13 more bundles, each 530 KB) | ~351 000 | |
| **Total JS** | **28 bundles** | **2 806 173** | **(2.68 MiB raw)** |
| + | `index.html` | 65 485 | |
| + | `theme.css` + assets | ~32 000 | |
| **Cold-load total** | | **~2.76 MiB** | **uncompressed** |
Wall-time measurements from onyx (LAN-direct, sequential):
- **5 top bundles, sequential GET, LAN:** 0.37 s for 1.65 MiB.
- **All 28 bundles, sequential GET, LAN:** 1.51 s for 2.68 MiB.
A real browser uses HTTP/2 multiplexing so won't be strictly
sequential, but `connection-window` + `flow-control` mean wire-time on
WAN scales nearly linearly with total bytes. Compression alone would
cut wire-time ~70 %.
Estimated post-compression total: **~0.82 MiB** (gzip) or **~0.69 MiB**
(brotli). At a 50 Mbps WAN, that's a 200300 ms cold-load saving
*before* any RTT improvements from cache headers.
---
## 5. ServiceWorker warm-load effectiveness
**Conclusion: SW does NOT cache app assets.** Verified by reading
`/web/serviceworker.js` (768 B, last modified 2024-11-19 — Jellyfin
10.10.3 ship date).
The SW only handles `notificationclick` events (cancel-install /
restart-server actions) and a one-shot `activate``clients.claim()`.
There is **no `fetch` handler**, no `install` precache, no asset
caching at all. This matches doc 13 finding 11.
So the warm-load is doing exactly what the browser HTTP cache + ETag
flow gives us: 28 conditional GETs, each returning 304 with empty
body but a full TLS-multiplexed round-trip. With proper
`Cache-Control: max-age=31536000, immutable` on the hashed URLs,
all 28 of those revalidations would collapse into zero network
traffic on warm load.
---
## 6. Poster image timing
Tested against Rick and Morty series ID
`548035d5e4d36cd2f488900ab612581a`,
`/Items/{id}/Images/Primary?fillHeight=300&fillWidth=200&quality=96`.
| Request | TTFB | TOTAL | Bytes |
|---|---:|---:|---:|
| **Cold (uncached size variant)** | 385 ms | 388 ms | 45 660 |
| Warm 1 | 26 ms | 29 ms | 45 660 |
| Warm 2 | 38 ms | 42 ms | 45 660 |
| Warm 3 | 34 ms | 38 ms | 45 660 |
| Warm 4 | 37 ms | 42 ms | 45 660 |
| **Cold h=400** | — | 351 ms | 79 925 |
| **Cold h=500** | — | 469 ms | 112 168 |
| **Cold h=600** | — | 364 ms | 145 505 |
Response headers:
```
HTTP/2 200
age: 0
cache-control: public ← no max-age
content-disposition: attachment ← unusual on a poster (forces 'save')
content-type: image/jpeg
last-modified: <request time> ← unhelpful for caching
vary: Accept
```
Two issues here:
- **`Cache-Control: public` with no `max-age`** means the browser
applies heuristic freshness (10 % of last-modified age = 0 s, since
last-modified equals the response time). Effectively uncached. Every
navigation back to the home page re-fetches all posters.
- **Server-side image transcode is the dominant cost.** Jellyfin
generates the `fillHeight=300&fillWidth=200&q=96` variant on demand
from the source poster image, then caches it in
`/cache/images/`. `age: 0` on response confirms this was a fresh
generation. Doc 13 finding 26 puts the on-disk image cache at 15 MB
total — small enough that recent-cache eviction may be culling
variants.
Per-poster cold cost: 350470 ms. Twenty posters at unique
`fillHeight` × `fillWidth` × `quality` variants on the first load
of "Recently Added" totals **~7 s** if the browser drops to single-
threaded poster fetches (HTTP/2 multiplexes, so true cost is
GPU/CPU-bound on the server side). Doc 13 finding 02 (no GPU
transcode, 12-core box already at load 11.4) means even this is
software-rendered.
`content-disposition: attachment` on an image fetched into an
`<img>` tag doesn't actually force a download (the browser ignores
the disposition for media references), but it's a Jellyfin-side
oddity worth noting.
---
## 7. Traefik request-log latency analysis
`docker logs traefik --since 6h | grep jellyfin@docker` — total 116
requests, 78 of them at 0 ms (cache hits / 304s / 401s).
Latency histogram (ms suffix on each log line):
| Bucket | Count |
|---|---:|
| 0 ms | 78 |
| 1 ms | 8 |
| 3 ms | 1 |
| 7 ms | 1 |
| 1846 ms | 4 |
| 92294 ms | 5 |
| 346648 ms | 4 |
| 1.12.1 s | 3 |
| 4.99.5 s | 4 |
**Every entry above ~50 ms is a `/videos/.../hls1/main/*.mp4`
HLS-segment GET, not a `/web/*` static asset.** Decoded request
URIs show the slow ones are AV1 + HEVC transcode requests with
`VideoBitrate=362547 Mbit` and 500/499 final status — exactly the
pattern doc 13 finding 03 calls out (CPU-only transcode + no
throttling). Edge layer is clean: every `/web/*` request that
appeared in the 6-hour window completed in 07 ms wall.
Status code distribution for `jellyfin@docker` (6 h):
| Code | Count |
|---:|---:|
| 200 (filtered out by accessLog statusCodes 400-599) | (not logged) |
| 400 | 1 |
| 401 | 7 |
| 404 | 68 |
| 405 | 8 |
| 499 | 15 |
| 500 | 8 |
| 502 | 1 |
The 68 × 404 are mostly `Cineplex/CSS/icon` references from the bundled
theme @import-ing assets that Jellyfin doesn't ship — cosmetic, but
each 404 is a wasted RTT on every cold-load (browser fetches the
referenced URL, gets 404, retries on next page nav). Worth a separate
look in coordination with doc 09 (Cineplex theme).
---
## 8. Traefik middleware audit
### Static config (`/opt/docker/traefik/traefik.yml`)
```yaml
entryPoints:
websecure:
address: ":443"
http:
middlewares:
- security-headers@file
- rate-limit@file
```
### Jellyfin router (`/opt/docker/jellyfin/docker-compose.yml`)
```yaml
labels:
- "traefik.http.routers.jellyfin.middlewares=security-headers@file"
```
### Effective middleware chain at `/web/*` request
1. `security-headers@file` (entrypoint) — header rewrites, no body
processing, ~zero CPU.
2. `rate-limit@file` (entrypoint) — token-bucket avg=100 burst=200
period=1s. Pure counter, ~zero CPU. **Not** a regex chain. **Not**
doing CPU-significant work.
3. `security-headers@file` (router, **duplicate**) — applied a second
time to the response. Idempotent (header overwrite is a no-op when
value already set), but **redundant** and a small CPU waste
per-request. Worth deduping.
### What's missing
- **`compress` middleware**. Traefik supports it with a one-liner:
```yaml
middlewares:
compress:
compress: {}
```
Defaults to gzip + brotli, sizes ≥1024 B, smart `Accept-Encoding`
negotiation. Not present anywhere.
- **No `headers.customResponseHeaders.Cache-Control`** override on
the Jellyfin router — Traefik would let us inject
`Cache-Control: public, max-age=31536000, immutable` for
`/web/*.bundle.js?*` requests via a `replacePath`-+-`headers`
combination, OR (cleaner) Jellyfin can be configured to send the
right headers itself; this is config not architecture.
### Traefik middleware chain on other Jellyfin paths
The `no-USER-F@file` allowlist seen in dynamic.yml is **not** referenced
by the Jellyfin router (per doc 09 §1.2 it was intentionally dropped
when WAN exposure was added). That matches expectation; not an edge
performance issue.
The `headscale-deny-leaks` and `signup-strict` middlewares are
defined but only referenced by other routers. No effect on Jellyfin.
---
## 9. DNS / hairpin / MTU
| Probe | Result | Verdict |
|---|---|---|
| Pi-hole DNS lookup `dig arrflix.s8n.ru @192.168.0.1` | returns **`82.31.156.86` (WAN)** | **Y — split-horizon missing.** Onyx's `/etc/hosts` pin saves it; any LAN client without that entry hairpins through the router. |
| Onyx hairpin to WAN IP, full TTFB | 3343 ms | **G — hairpin works, no NAT-loopback latency penalty.** |
| LAN MTU `ping -M do -s 1472 -c 3` | 1480/1480/1480, 1.171.75 ms | **G — full 1500 MTU, no fragmentation, no PMTUD penalty.** |
| `--resolve` LAN-direct vs hostname | DNS adds 300 µs | **G — negligible.** |
The Pi-hole gap is a doc-09-related exposure decision: arrflix.s8n.ru
has public DNS on Gandi pointing at the WAN IP, no Pi-hole local
override. For an LAN-first deploy you'd add a local DNS rewrite
`arrflix.s8n.ru → 192.168.0.100` on Pi-hole. Per memory note
`feedback_s8n_hosts_override.md`, this is a known pattern (`/etc/hosts`
pin on each device works, but doesn't scale to phones).
---
## 10. HTTP/2, HTTP/3, TLS
- **HTTP/2:** confirmed (`HTTP/2 200` response, multiplexing
available).
- **HTTP/3 (QUIC):** **not enabled.** `Alt-Svc` header is absent on
every probe. (My local libcurl doesn't support `--http3` so I can't
client-test, but the lack of `Alt-Svc` advertises that the server
doesn't speak QUIC.) Traefik ≥ 2.8 supports HTTP/3 via experimental
`entryPoints.websecure.http3 = {}` block; not enabled in
`traefik.yml`. **Y — would help WAN clients on lossy links** (mobile
data, café WiFi); near-zero benefit on LAN.
- **TLS chain:** 2 certs (leaf + LE R13 intermediate) → ISRG Root X1
is in client trust store. Chain length is minimal; not contributing
to handshake latency.
- **TLS version:** 1.3 with AEAD cipher (`TLS_AES_128_GCM_SHA256`).
- **`sniStrict: true`** in dynamic.yml's `tls.options.default`. Correct.
---
## 11. Concrete remediation list (ranked by impact-per-effort)
| # | Fix | Effort | Impact | Risk |
|---:|---|:-:|---|---|
| **1** | **Add `compress@file` middleware** in `/opt/docker/traefik/config/dynamic.yml`: `compress: {}` under `http.middlewares.compress`. Reference it from the Jellyfin router via a `traefik.http.routers.jellyfin.middlewares=security-headers@file,compress@file` label edit in `/opt/docker/jellyfin/docker-compose.yml`. | **S** (5 min) | **~70 % cold-load wire reduction** (2.74 MiB → ~0.82 MiB). Lowers TTI on every single first-visit. | Low — Traefik's `compress` is a standard middleware, gzip+br, content-type allow-list does the right thing for `application/javascript` + `text/html` + `text/css`. Will not compress `image/jpeg`. |
| **2** | **Add `Cache-Control: public, max-age=31536000, immutable` for `/web/*.bundle.js?*` and `/web/*.css?*` requests.** Cleanest path is via Traefik `headers` middleware with `customResponseHeaders.Cache-Control` and a router rule that matches `Path(\`/web/\`) && Query(\`hash\`)` — but Jellyfin can also be patched at the source if there's apUSER-Eite. | SM | Eliminates 28 × per-page-nav round-trips for warm load. Saves ~28 RTTs (~1.5 s on a 50-ms WAN link, ~0 on LAN). | Medium — must scope ONLY to hashed URLs; if `Cache-Control: immutable` is applied to `index.html` you brick the next deploy until users force-reload. |
| **3** | **Enable HTTP/3 / QUIC.** Add `entryPoints.websecure.http3 = {}` to `traefik.yml`, expose UDP 443 on the host, and add an `Alt-Svc: h3=":443"; ma=86400` header (Traefik does this automatically once the HTTP/3 entrypoint is on). | M | Marginal on LAN, real on lossy WAN (3G, café WiFi). Cuts TLS handshake to 1-RTT. | Low — Traefik HTTP/3 has been stable since v3.0; coexists with H/2. Need to open UDP 443 on nullstone firewall + router port-forward. |
| **4** | **Tighten poster image cache.** Either set `Cache-Control: public, max-age=86400` on `/Items/*/Images/Primary` responses (Jellyfin-side via `system.xml` `MaxResumePct` style — actually a Jellyfin web-server-config patch), or put a Traefik-level `headers.customResponseHeaders.Cache-Control` on `Path(\`/Items/\`) && PathPrefix(\`/Images/\`)`. Even 1 hour of caching collapses the poster grid re-fetch on home-page bounce-back. | SM | ~7 s saved on home-page revisit when posters were already fetched. | Low — posters are content-addressed by `?fillHeight=…&quality=…`; safe to cache. |
| **5** | **Dedupe security-headers middleware.** Remove the entrypoint-level `security-headers@file` OR remove it from each per-router label. (Cleanest: keep it at entrypoint level, drop from labels.) | S | Tiny (microseconds per request). Cleanup, not perf. | Low. |
| **6** | **Add Pi-hole local DNS rewrite for `arrflix.s8n.ru` → `192.168.0.100`.** Memory note `feedback_s8n_hosts_override.md` already covers this pattern. Onyx `/etc/hosts` works but doesn't scale to phones / friends' devices. | S | Stops LAN clients hairpinning through router on every fetch. Saves 1× NAT-loopback round-trip per TCP connection (~2 ms — small but free). | Low. |
| **7** | **Investigate the 68 × 404 in 6 h on `/web/*`.** Likely Cineplex theme @import or icon references with bad paths. Each 404 is a wasted RTT on cold-load. | S | Small but cumulative on cold-load. | Low — read-only investigation first. |
| **8** | **Strip `content-disposition: attachment` on Image responses.** Jellyfin emits this on every `/Images/Primary` GET. Browser ignores it for `<img>` references but it's hostile if anyone right-clicks "open image in new tab". | S | Cosmetic. | Low. |
### Recommended fix order
The order **#1#2#3** is the entire cold-load story. **#1
alone** turns "kinda slow" into "fine" for 90 % of the perceived
latency on first load. **#2** turns 2nd-page-nav into "instant" by
eliminating the 28-asset revalidation tax. **#3** is the WAN-optimist
nice-to-have; do once mobile clients matter.
Out of scope for this audit but worth noting from doc 13: GPU
transcode re-enable (#02 there) is the real win for *playback*
latency. Cold-load + playback are separate paths; both need
attention.
---
## 12. Out of scope (audited and found healthy)
- **TLS handshake latency** (2225 ms LAN, normal for TLS 1.3 fresh
handshake; reuse hides it).
- **Cert chain depth** (2-cert chain, R13 intermediate).
- **MTU** (1500, no fragmentation).
- **HTTP/2** (working, multiplexed).
- **DNS lookup** (300 µs via /etc/hosts; 20160 ms first time via
Pi-hole, cached after).
- **Hairpin NAT** (works, no extra latency).
- **`rate-limit@file` middleware** (token-bucket, ~zero overhead).
- **Sniff/CSP/STS/frame headers** — set correctly, no perf cost.
- **ServiceWorker** (notification-only, no perf-positive nor
perf-negative).
- **Traefik access log filter** (statusCodes 400-599 only — does NOT
log the 200 OK responses that dominate `/web/*`; the latency
histogram in §7 is therefore a 5xx/4xx-only sample, not full
traffic. The 5xx/4xx sample is conclusive enough for edge analysis
because all the slow ones are HLS transcode failures, not edge
problems).
---
## Appendix — raw evidence
### Curl timing (LAN-direct, 5 samples)
```
DNS=0.000024 CONN=0.001225 TLS=0.022960 TTFB=0.031569 TOTAL=0.040531
DNS=0.000024 CONN=0.001217 TLS=0.020182 TTFB=0.024190 TOTAL=0.030353
DNS=0.000028 CONN=0.001437 TLS=0.025502 TTFB=0.030467 TOTAL=0.035793
DNS=0.000023 CONN=0.001501 TLS=0.021998 TTFB=0.037444 TOTAL=0.041056
DNS=0.000023 CONN=0.001265 TLS=0.018536 TTFB=0.022942 TOTAL=0.027066
```
### Compression negotiation matrix
```
Accept-Encoding: br → content-length: 65485, no content-encoding
Accept-Encoding: gzip → content-length: 65485, no content-encoding
Accept-Encoding: (empty) → content-length: 65485, no content-encoding
Accept-Encoding: gzip,deflate,br,zstd --compressed → content-length: 65485, no content-encoding
```
### TLS chain
```
depth=2 C=US, O=Internet Security Research Group, CN=ISRG Root X1
depth=1 C=US, O=Let's Encrypt, CN=R13
depth=0 CN=arrflix.s8n.ru
Verification: OK
Protocol: TLSv1.3
Cipher: TLS_AES_128_GCM_SHA256
```
### ETag-conditional revalidation
```
First fetch: HTTP/2 200, etag "1db3a353daaafa4", content-length 499108
If-None-Match: HTTP/2 304, etag "1db3a353daaafa4", body empty
```
### Bundle inventory (28 bundles, total 2 806 173 bytes)
Top 15 by size — see §4 table. Full list reproducible from
`curl -s https://arrflix.s8n.ru/web/index.html | grep -oE 'src="[^"]*\.bundle\.js[^"]*"'`.
### Poster image fetch (5 samples — first cold, rest warm)
```
TTFB=0.385230s TOTAL=0.388290s SIZE=45660b ← cold (server transcode)
TTFB=0.025961s TOTAL=0.028951s SIZE=45660b
TTFB=0.037838s TOTAL=0.041724s SIZE=45660b
TTFB=0.034244s TOTAL=0.038364s SIZE=45660b
TTFB=0.036687s TOTAL=0.041616s SIZE=45660b
```
### Traefik static config (entrypoints)
```yaml
websecure:
address: ":443"
http:
middlewares:
- security-headers@file
- rate-limit@file
```
### Jellyfin router labels (compose)
```yaml
"traefik.http.routers.jellyfin.middlewares=security-headers@file"
"traefik.http.services.jellyfin.loadbalancer.server.port=8096"
```
### MTU + ping
```
PING 192.168.0.100 (192.168.0.100) 1472(1500) bytes of data
1480 bytes from 192.168.0.100: icmp_seq=1 ttl=64 time=1.66 ms
1480 bytes from 192.168.0.100: icmp_seq=2 ttl=64 time=1.75 ms
1480 bytes from 192.168.0.100: icmp_seq=3 ttl=64 time=1.17 ms
0 % packet loss, rtt min/avg/max/mdev = 1.171/1.524/1.745/0.252 ms
```
### Pi-hole DNS resolution
```
$ dig +short arrflix.s8n.ru @192.168.0.1
82.31.156.86 ← public WAN IP, not the LAN 192.168.0.100
```
### Traefik request-log latency histogram (jellyfin@docker, 6 h, 5xx/4xx only — 200s filtered out)
```
78 0ms
8 1ms
1 3ms
1 7ms
1 18ms
1 29ms
1 39ms
1 46ms
1 92ms
1 175ms
1 192ms
1 209ms
1 222ms
1 274ms
1 294ms
1 346ms
1 391ms
1 648ms
1 1168ms
1 1256ms
1 2140ms
1 4931ms
1 8118ms
1 9543ms
```
All entries >50 ms are `/videos/.../hls1/main/*.mp4` — HLS transcode
requests with 500/499 status, AV1+HEVC at 360550 Mbit source. Edge
is not the bottleneck on those; CPU transcode is (doc 13 #02, #03).
---
## Sign-off
- Audit: 2026-05-08, read-only, ~30 min wall.
- No fixes applied. No state mutated. No container restart. No
Traefik reload. No header injected. Admin token used only for
read-side `/Items` and `/Items/.../Images` probes.
- Next audit due: **after fix #1 ships**, to confirm gzip/brotli
ratio on the actual deployed config and re-measure cold-load.

430
docs/24-storage-io-audit.md Normal file
View file

@ -0,0 +1,430 @@
# 24 — Storage / Disk-I/O / Filesystem Audit (Read-Only)
> Status: **read-only audit**, executed 2026-05-08 against
> `nullstone` (192.168.0.100). Scope: storage stack underneath Jellyfin
> on `arrflix.s8n.ru`. Sibling audits cover color/HDR, server runtime,
> and edge/network — this file owns LVM, disks, ext4, mount opts, image
> cache, transcode cache, and the RO bind-mount overhead.
>
> **No writes. No mount changes. No fstrim execution. No cache
> flushes. No SMART self-tests.**
---
## Executive summary
**Storage is not the bottleneck. CPU is.** Disk I/O across every
metric came back fast and healthy. The "loads kinda slow" symptom is
almost certainly playback-stall caused by a CPU-only host running 5
concurrent ffmpeg transcodes of the same file at load average 42 — not
disk. The storage layer is in the bottom third of the suspect list.
Top three storage-side observations (severity, then quick-win order):
1. **Single PV / single LV / single NVMe — no isolation between media
reads, transcode writes, OS, and Docker overlay churn.** Severity
**Y**. Every workload hits `/dev/nvme0n1` and the ext4 journal at
`keystone--vg-home`. Today the SSD shrugs it off (2.1 GB/s direct,
1.2 GB/s through the container RO mount), but transcode-write
contention with library-scan reads is real — and the box is
currently doing 5 concurrent ffmpegs. **Quick win: nothing today;
investment: split media onto a second LV (or second device) so
transcode-write churn does not share an ext4 journal with
library-scan reads.**
2. **Read-ahead is 128 KB on the LV (`dm-4`).** Severity **Y**.
Default for sequential 1080p streams from MKV; would benefit from
**512 KB1 MB** for higher-bitrate or scanning workloads. Tiny
win, costs 30 seconds. **Quick win.**
3. **`relatime` on `/home` updates atime on the RO library (the bind
mount is RO from the container's view but the underlying ext4 is
RW from the host).** Severity **G→Y**. `relatime` is the kernel
default and only writes ~1 atime update per 24 h per file, so the
write cost on a 201-file library is rounding noise. Documented for
completeness; **not worth fixing**.
Ruled out as not-a-problem: rotating disk (it's NVMe), low free space
(62 % used, 146 GiB free — was 90 % at the prior audit, materially
better), inode pressure (6 % used), stale transcodes (zero >60 min
old), image-cache GC thrash (oldest cached image is 16 h old, no
churn), bind-mount overhead (40 % vs raw — but absolute throughput
still 12× a 4K HEVC stream needs), SSD wear (8 % used, 100 % spare,
zero media errors), and `data=ordered` journal write barriers
(NVMe-class device, irrelevant).
---
## 1. Disk + LVM topology
### Hardware
| Layer | Detail |
|---|---|
| Device | `/dev/nvme0n1`, **Intel SSDPEKKF512G8 NVMe**, 476.9 GiB, non-rotational, internal |
| Bus | NVMe |
| Loops (irrelevant) | `loop0..loop3` 256 M each (snap remnants — empty) |
Single physical drive. **No HDDs. No external storage. No NAS
mounts.** The "media on rotating media" hypothesis (a) is **ruled
out** — everything is on this NVMe.
SMART (NVMe Log 0x02):
| Field | Value |
|---|---|
| Critical Warning | `0x00` |
| Temperature | 43 °C |
| Available Spare | 100 % |
| Percentage Used | **8 %** |
| Power-On Hours | 18 597 |
| Power Cycles | 3 729 |
| Unsafe Shutdowns | 774 |
| Media + Data Integrity Errors | **0** |
| Error Log Entries | 0 |
| Data Units Read | 25.7 TB |
| Data Units Written | 25.9 TB |
Drive is healthy, mid-life. No remediation.
### Partitions and LVM
```
nvme0n1 (476.9 GiB, NVMe SSD)
├─ nvme0n1p1 976 M vfat /boot/efi
├─ nvme0n1p2 977 M ext4 /boot
└─ nvme0n1p3 475 G LVM2 PV → keystone-vg
├─ keystone--vg-root 30.4 G ext4 /
├─ keystone--vg-var 11.4 G ext4 /var
├─ keystone--vg-swap_1 24.3 G swap [SWAP]
├─ keystone--vg-tmp 2.8 G ext4 /tmp
└─ keystone--vg-home 406.2 G ext4 /home ← media + jellyfin live here
```
Single-PV VG, **VFree = 0**. Cannot grow `home` without adding
another PV. Note swap is **on the same PV** as `home`; under memory
pressure (the prior audit caught 6.8 GiB swap in use) swap traffic
contends with media reads on the same NVMe queue.
### Mount table (relevant entries only)
| Source | Mountpoint | FS | Options |
|---|---|---|---|
| `keystone--vg-root` | `/` | ext4 | `rw,relatime,errors=remount-ro` |
| `keystone--vg-var` | `/var` | ext4 | `rw,nosuid,nodev,relatime` |
| `keystone--vg-tmp` | `/tmp` | ext4 | `rw,nosuid,nodev,noexec,relatime` |
| `keystone--vg-home` | `/home` | ext4 | `rw,nosuid,nodev,**relatime**` |
| `nvme0n1p2` | `/boot` | ext4 | `rw,relatime` |
| `nvme0n1p1` | `/boot/efi` | vfat | `rw,relatime,fmask=0077,dmask=0077` |
`relatime` is the kernel default; **`atime` was not used** (good —
pure `atime` is the actual horror). `noatime` would shave ~1 atime
write per 24 h per file accessed; on a 201-file library that's
sub-noise. **Not a remediation candidate.** No `discard` flag (good
— online discard hurts performance; the weekly `fstrim.timer` is the
right pattern, see §8).
### Container bind mounts (Jellyfin)
| Host path | Container path | RW |
|---|---|---|
| `/home/docker/jellyfin/config` | `/config` | RW |
| `/home/docker/jellyfin/cache` | `/cache` | RW |
| `/home/user/media` | `/media` | **RO** |
| `/opt/docker/jellyfin/web-overrides/index.html` | `/jellyfin/jellyfin-web/index.html` | RO |
All bind mounts hit the same `keystone--vg-home` LV — config,
transcode cache, image cache, and media library all share one ext4
journal and one queue.
### ext4 features (`/dev/keystone--vg-home`)
```
Filesystem features: has_journal ext_attr resize_inode dir_index orphan_file
filetype extent 64bit flex_bg metadata_csum_seed
sparse_super large_file huge_file dir_nlink extra_isize
metadata_csum orphan_present
Default mount options: user_xattr acl
Total journal size: 1024 M (1 GiB — chunky but standard for 400 GiB)
Journal features: journal_incompat_revoke journal_64bit journal_checksum_v3
Filesystem state: clean
Last mount time: Sun May 3 23:42:28 2026
Mount count: 8
Block size: 4096
Inode count: 26 624 000
```
Journal mode is the ext4 default `data=ordered` (no override in
mountopts). On NVMe with `metadata_csum` and `journal_checksum_v3`,
this is **fine** — would only matter on slow rotational. Hypothesis
(b) "ext4 journal in `data=ordered` starves reads" is **ruled out**:
the device is NVMe-class and not the bottleneck.
---
## 2. Read throughput (1 large file, raw)
Test file: `Rick and Morty (2013) - S01E04 - M. Night Shaym-Aliens.mkv`
(1.5 GB, host path `/home/user/media/tv/...`).
| Test | Bytes | Wall | Throughput |
|---|---|---|---|
| `dd … bs=1M count=512 iflag=direct` (host, bypasses cache) | 537 MB | 0.258 s | **2.1 GB/s** |
| `dd … bs=1M count=512` (host, page-cache eligible) | 537 MB | 0.536 s | 1.0 GB/s (still warming) |
| `dd … bs=1M count=256 iflag=direct` (inside `jellyfin`, RO bind) | 268 MB | 0.233 s | **1.2 GB/s** |
**Bind-mount overhead = ~40 %** (2.1 → 1.2 GB/s). That's higher than
the "bind mounts are free" folklore but absolute throughput still
crushes any practical media bitrate (4K HDR HEVC tops out around
50 Mbit/s = 6.25 MB/s; 1.2 GB/s is **190× headroom**). **Not a
bottleneck. Not a remediation candidate.**
---
## 3. Random-read latency
`ioping` not installed on host or in container. Skipped.
Indirect signal: NVMe device-queue stats from `/proc/diskstats` for
`dm-4` (home LV):
```
reads: 15 003 996 read_sectors: 2 600 976 283 read_ms: 3 384 240
writes: 41 153 214 write_sectors: 1 997 023 232 write_ms: 145 844 732
in-flight: 0 io_ms: 5 153 616
```
Average per-read service ≈ **0.226 ms**, average per-write ≈ **3.5 ms**
(consistent with NVMe + ext4 journal flush). No queue stalls
observed.
---
## 4. Cache size breakdown
| Path | Bytes | Notes |
|---|---|---|
| `/cache` (total) | **84 MB** | Entire jellyfin cache fits in one MP3 album |
| `/cache/transcodes` | 3961 MB | Live during audit; **5 concurrent ffmpegs** (see §6) |
| `/cache/images` | 39 MB | 412 files in 16 hash-prefixed dirs |
| `/cache/images/resized-images` | 39 MB | 0 dir, 1 dir, …, f dir (16 buckets, 1830 files each) |
| `/cache/omdb` | 84 KB | Plugin response cache |
| `/cache/fontconfig` | 36 KB | |
| `/cache/attachments` | 12 KB | Subtitle/font extracts |
| `/cache/imagesbyname` | 4 KB | Empty |
Total cache = 84 MB on a 400 GB filesystem. **There is no cache
pressure.** The "cache being garbage-collected mid-page-load"
hypothesis (c) is **ruled out** (oldest cached image timestamp =
2026-05-08 01:12 BST, newest = 17:42 BST = **16.5 h retention with
no eviction**).
---
## 5. Image cache miss-vs-hit timing
Public asset latency from onyx → `https://arrflix.s8n.ru`:
| URL | Attempt 1 (cold) | Attempt 2 (warm) |
|---|---|---|
| `/web/assets/img/icon-transparent.png` | 0.227 s | 0.047 s |
| `/web/serviceworker.js` | 0.059 s | 0.059 s |
| `/web/main.jellyfin.bundle.js` | 0.092 s | 0.052 s |
5-sample steady state on `/web/main.jellyfin.bundle.js` = **4468 ms,
median 49 ms**. Traefik + Jellyfin static-asset path is fast.
Direct poster URLs (`/Items/{id}/Images/Primary`) require an auth
token; could not be probed without a fresh `X-Emby-Token`. Inferred
from on-disk evidence: the `resized-images` cache contains 412
WebPs, all under 200 KB, no eviction in the last 16 h. **Image cache
serves all current items from disk on warm path.**
Hypothesis (c) is **ruled out**.
---
## 6. Stale-transcode detection
```
/cache/transcodes:
total bytes: 39 MB (was 61 MB earlier in audit, churn = active stream)
total files: 26
files >60 min old: 0
bytes >60 min old: 0 MB
```
`Clean Transcode Directory` task last ran `2026-05-08T02:13` (per
audit 13 task list). **Currently zero stale transcode segments.**
Hypothesis (d) is **ruled out** — no accumulation.
However, **5 concurrent ffmpeg processes are transcoding the same
file** right now:
```
PID CPU file
1685478 246% Rick and Morty S01E01 - Pilot.mkv
1686665 203% Rick and Morty S01E01 - Pilot.mkv (same file)
1686651 198% Rick and Morty S01E01 - Pilot.mkv (same file)
1689000 125% Rick and Morty S01E01 - Pilot.mkv (same file)
1689109 120% Rick and Morty S01E01 - Pilot.mkv (same file)
```
This is a **CPU-side** issue (no ffmpeg de-dup, no segment
throttling — see audit 13 finding 03). It causes:
- Load average **42.62 / 22.84 / 12.32** (12-core box).
- Swap usage 7.8 GiB / 24 GiB.
- I/O wait however is **0 %** in `vmstat` (`wa=0`).
The host CPU is saturated, not the disk. **Storage layer is not
this user's bottleneck.**
---
## 7. Inode + free-space stats
| Filesystem | 1K-blocks | Used | Available | Use % | Inodes | IUsed | IUse % |
|---|---|---|---|---|---|---|---|
| `keystone--vg-home` (`/home`) | 418 106 320 | 244 025 392 | 152 768 828 | **62 %** | 26 624 000 | 1 489 612 | **6 %** |
| `keystone--vg-root` (`/`) | — | — | — | — | — | — | — |
| `keystone--vg-var` (`/var`) | 12 G | 2.0 G | 8.6 G | **19 %** | n/a | n/a | n/a |
**Free space went from 40 GiB at audit 13 (90 % full) to 146 GiB now
(62 %).** Material improvement; the prior "low free space"
hypothesis (e) is **ruled out**. Inode pressure ruled out.
(Note: `/home` USER-Gs `/home/user/docker-data/100000.100000/...`
which contains all userns-remapped Docker overlay2 trees. The 233 G
used number includes container layers, not just media. Library
itself is 201 files.)
---
## 8. fstrim status
```
fstrim.timer Loaded, enabled, active (waiting)
Last triggered: Sun 2026-05-03 23:42:29 BST
Next trigger: Mon 2026-05-11 01:12:58 BST
fstrim --dry-run /home → /home: 0 B (dry run) trimmed
```
Weekly trim is configured and recently ran (one week before next
trigger). **Dry-run reports 0 B candidate** → there is no untrimmed
free space on `/home`. SSD performance degradation from
unTRIMmed-blocks is **not** a factor. No `discard` mount option
(correct — async batched trim via timer is preferred over inline).
---
## 9. Read-ahead and queue settings
| Block device | `read_ahead_kb` | scheduler | `nr_requests` |
|---|---|---|---|
| `nvme0n1` (physical) | **128 KB** | `[none] mq-deadline` | 1023 |
| `dm-4` (`keystone--vg-home`, the LV) | **128 KB** | n/a | n/a |
| `/sys/block/dm-4` lacks scheduler/nr_requests (dm devices inherit) |
128 KB read-ahead is the kernel default. For sequential MKV streams
this is OK; for library-scan workloads (`stat` + open + read first
chunk per file) it's also OK. Bumping to 512 KB or 1024 KB would
help **scan throughput** during a Jellyfin library refresh — minor
win, ~30 s of work.
NVMe is using `none` scheduler (correct for NVMe — multiqueue + no
elevator).
---
## 10. RO bind-mount overhead — confirmed
(From §2.) Host direct = 2.1 GB/s. Container RO bind = 1.2 GB/s.
Overhead ≈ 40 % which is higher than expected, likely a side-effect
of:
- userns remap (`100000.100000` shifts uids)
- the `nosuid,nodev` flags on `/home` propagating into the bind
- container's `read_ahead_kb` is **not** configurable through bind
(inherits 128 KB)
**Not actionable today.** Both numbers are 100×+ of any media
bitrate. Documented to rule out hypothesis (f).
`atime` cost on RO bind: bind mount inherits the host's `relatime`
semantics — at most one atime write per file per 24 h, gated by
`relatime`. On 201 files that's ≤ 201 atime writes/day = **rounding
noise**. Hypothesis (f) **ruled out**.
---
## 11. Concrete remediation list — ranked
Severity legend: **R** = red (acute, fix this week), **Y** = yellow
(deferred, document risk), **G** = green (audited, healthy, no
action). Effort: **S** ≤ 30 min, **M** half-day, **L** > 1 day.
| # | Severity | Effort | Bucket | Action | Why |
|---|:-:|:-:|---|---|---|
| S01 | Y | S | Quick-win | Bump `read_ahead_kb` on `/dev/nvme0n1` to **512 KB** (sysfs or udev rule) | Helps library-scan and large-MKV streams. Tiny risk; reverts on reboot if set live. |
| S02 | Y | M | Quick-win | Add `noatime` (replacing `relatime`) to `/home` mount in `/etc/fstab` | Eliminates the residual `relatime` writes; cosmetic but cheap. Requires a remount; do during a window with no playback. |
| S03 | Y | M | Investment | Carve a separate **`media` LV** (or attach a second NVMe) for `/home/user/media` and bind-mount it RO into Jellyfin | Isolates library reads from transcode-write churn and Docker overlay churn on the same ext4 journal. Today it is fine; at scale it will not be. |
| S04 | Y | M | Investment | Move `keystone--vg-swap_1` off `keystone-vg` (or onto a separate device) | Swap is currently 7.8 GiB used and shares the NVMe queue with media reads. CPU saturation is the proximate cause but cleanly isolating swap helps when CPU finally gets fixed (GPU re-enable, see audit 13 #02). |
| S05 | Y | M | Investment | Add a second PV to `keystone-vg` so the VG has free space | `vgs` shows **VFree=0**. Any future `lvextend` will fail until a PV is added. Latent ops trap. |
| S06 | G | — | — | Keep weekly `fstrim.timer` as-is | Healthy, current. |
| S07 | G | — | — | Keep image cache untouched | 84 MB total cache, 16 h retention, no GC pressure. |
| S08 | G | — | — | No change to `data=ordered` ext4 journal | NVMe; mode is fine. |
**The single biggest "loads kinda slow" win lives in audit 13
(finding 03 — enable transcode throttling + segment deletion).
Storage is not where this is fixed.**
---
## 12. Quick-win vs investment
### Quick-win (≤30 min total, today)
- **S01**`echo 1024 > /sys/block/nvme0n1/queue/read_ahead_kb` (or
512). Reverts on reboot; persist via udev rule under
`/etc/udev/rules.d/60-readahead.rules`. Marginal but free.
- **S02** — flip `relatime``noatime` in `/etc/fstab` for
`/home`. Cosmetic but cheap. **Skip if even half-load** — a
bad fstab + reboot is an outage; only do during a planned
window.
### Investment (half-day to multi-day, plan)
- **S03** — separate `media` LV. Requires `lvcreate`, `mkfs`, rsync
the library, swap the bind-mount in compose. ~half-day. Pays back
when (a) library grows past the current 201 files, (b) GPU
transcode is re-enabled (audit 13 #02) and many concurrent reads
start happening.
- **S04** — relocate swap. Only meaningful after GPU re-enable
closes the CPU-saturation root cause.
- **S05** — second PV. Trivial mechanically (`pvcreate`, `vgextend`),
blocked on having a second device. Defer until needed.
### No-op (audited and healthy)
- SMART status (8 % wear, no errors)
- ext4 features and journal mode
- Inode usage (6 %)
- Free space (62 %, 146 GiB headroom)
- Cache size (84 MB total)
- Stale transcodes (zero)
- `fstrim.timer` (working, candidate-bytes = 0)
- Bind-mount throughput (1.2 GB/s, 190× any 4K stream)
---
## 13. Sign-off
- Audit: 2026-05-08, read-only, ~15 min wall.
- No fixes applied. No state mutated. No container restart. No SMART
self-test. No fstrim execution. No mount changes.
- **Top storage culprit: none.** Storage stack is healthy. The
"loads kinda slow" symptom is CPU-side (5 concurrent ffmpegs at
load 42, audit 13 #02 + #03).
- **Top quick-win: S01 — bump `read_ahead_kb` to 512 KB on
`nvme0n1`** for marginal scan/stream gain. Real fix lives in
audit 13.
- Next audit due: **2026-08-08** (quarterly, with audit 13).

View file

@ -0,0 +1,373 @@
# 25 - English Leak Deep-Dive (Post-Lockdown "Abspielen" Persistence)
> Investigation triggered after the 2026-05-08 multi-agent English-only
> lockdown sweep landed (server-wide UICulture, per-user UICulture for 9/9,
> DisplayPreferences CustomPrefs.language for 32 entries, web shim with
> `navigator.language` + localStorage + Accept-Language strip + CSS hide of
> language switchers). Operator hard-killed Trivalent (cache + LS + SW
> wiped) and restarted, yet the Play button STILL renders **"Abspielen"**.
> Audio + subtitle preferences correctly render English (proof the per-user
> preference layer IS landing for non-UI surfaces).
Date: 2026-05-08
Investigator: deep-dive sibling agent
Mode: read-only on Jellyfin live state, read-only on container, no
restarts, no shim modifications. Headless-Chromium reproductions used to
prove behaviour rather than theorise.
Prior reading (do not repeat findings from):
`docs/15-force-english.md`, `docs/19-english-only-audit.md`,
`docs/20-english-only-lockdown.md`, `docs/22-jellyfin-runtime-perf-audit.md`.
---
## 1. Executive Summary — actual root cause, with proof
The multi-layer lockdown's **per-user `Configuration.UICulture` pin is
inert with respect to the web SPA's UI-string locale**. The web SPA's
`jellyfin-web` bundle does not read `Configuration.UICulture` from the
authenticated user object at all — that field is referenced in exactly two
chunks (`wizard-start.<hash>.chunk.js` and `25583.<hash>.chunk.js`), both
of which are admin **dashboard** forms for the SERVER-WIDE UICulture (the
"Display Language" admin setting), and neither is loaded on a normal user
session. Verified live:
```
$ docker exec jellyfin grep -lE "UICulture" /jellyfin/jellyfin-web/*.js
/jellyfin/jellyfin-web/index.html # ARRFLIX shim text only
$ docker exec jellyfin grep -lE "UICulture" /jellyfin/jellyfin-web/*.chunk.js
/jellyfin/jellyfin-web/25583.95a80bf8834e61a9a8e4.chunk.js
/jellyfin/jellyfin-web/wizard-start.a4dfcf169516d40c4e52.chunk.js
$ docker exec jellyfin grep -oE ".{40}UICulture.{60}" \
/jellyfin/jellyfin-web/wizard-start.a4dfcf169516d40c4e52.chunk.js
up/Configuration")).then((function(n){n.UICulture=$("#selectLocalizationLanguage",t).val(),
e.ajax({type:"POST...
```
Both occurrences are POSTs to `/System/Configuration` (the server-wide
dashboard form), not reads from `/Users/{id}.Configuration`.
**The SPA's actual locale resolver** (decompiled from
`main.jellyfin.bundle.js`) is:
```js
function g(){
return document.documentElement.getAttribute("data-culture")
|| (navigator.language ? navigator.language
: navigator.userLanguage ? navigator.userLanguage
: (navigator.languages?.length) ? navigator.languages[0]
: "en-us");
}
function w(){
var e;
try { e = i.currentSettings.language() } // localStorage.getItem("language")
catch(e){ }
b(e = e || g());
l = S(e); // S = lowercase + replace _ with -
document.documentElement.setAttribute("lang", l);
...
}
```
`i.currentSettings.language()` reads `localStorage.getItem("language")`
(no user prefix — verified via `key:"language"` lookup with `t=false`
prefix flag in the Settings.get implementation). Per-user
`Configuration.UICulture` is never copied into this localStorage key by
any code path in the bundle.
**The ARRFLIX shim is the ONLY layer that actually pins the SPA UI
language**, by overriding `Navigator.prototype.language`,
`Navigator.prototype.languages`, and pre-seeding `localStorage.language`
to `en-US`. Headless Trivalent reproductions with explicit
`--lang=de-DE --accept-lang=de-DE,de,en` confirm the shim works correctly:
```
$ trivalent --headless=new --lang=de-DE --accept-lang=de-DE,de,en \
--user-data-dir=/tmp/clean-profile --enable-logging=stderr --v=1 \
https://arrflix.s8n.ru/web/index.html
$ grep -E "json.*chunk.js" /tmp/headless.log
...NotifyBeforeURLRequest: https://arrflix.s8n.ru/web/en-us-json.667484b4a441712c7e05.chunk.js
$ grep "<html" /tmp/headless.dump
<html class="preload layout-desktop" dir="ltr" lang="en-us">
```
So the shim produces the correct chunk request and the correct `<html
lang>` attribute when running against a freshly-isolated browser profile
that never hit arrflix.s8n.ru before. The de-json chunk is **never**
fetched in this scenario.
**Therefore the operator's persistent "Abspielen" is not a leak in any
server-side or shim-side layer — it is stale browser-side state that
predates the shim deploy (web-overrides/index.html mtime
2026-05-08 17:22:00) and survived the operator's wipe.** The candidate
stale-state vectors, in order of likelihood:
1. **Stale `index.html` in HTTP disk cache.** The server emits **no
`Cache-Control` header** on `/web/index.html`; `last-modified` is
`Fri, 08 May 2026 16:22:00 GMT`. Per RFC 7234 §4.2.2, Chromium
heuristically caches at `0.1 * (now Last-Modified)` ≈ 24 minutes
when an asset has no Cache-Control. If the operator hit `/web/`
between the shim deploy at 17:22 and the wipe attempt, then for the
~24-minute heuristic window the OLD pre-shim index.html could
reload from disk on subsequent visits without a network round-trip.
Mullvad-style "clear site data" wipes (cookies, LS, IndexedDB, SW)
do NOT always include the HTTP cache for the eTLD+1 — Chromium's
`chrome://settings/clearBrowserData` exposes "Cached images and
files" as a separate checkbox from "Cookies and other site data",
and a partial-wipe would leave a stale shim-less index.html in
place. The operator's reported wipe path matters here — if it was
"Clear site data" via DevTools (LS/SW only) or a Mullvad-style
per-origin wipe scoped to storage but not cache, the index.html
survives.
2. **A second browser profile / second browser the operator forgot.**
The operator has multiple Chromium-family browsers installed
(`~/.var/app/{com.google.Chrome, com.google.ChromeDev,
org.chromium.Chromium, io.github.ungoogled_software.ungoogled_chromium,
net.mullvad.MullvadBrowser}`) plus Trivalent at
`~/.config/trivalent`. Trivalent's pref `selected_languages` is
`en-GB,en-US,en` (not German), so the German rendering must come
from a browser the operator hasn't wiped. A profile that hit
arrflix.s8n.ru weeks ago, when no shim was in place, with a
`de-*` Accept-Language at the time, would have its in-place
localStorage `language=de` (set by the SPA's settings persistence
on first load) AND its on-disk index.html cache predating the shim.
3. **The operator's screenshot was captured BEFORE the wipe.** "I
wiped and still see Abspielen" can be the operator restating an
older screenshot rather than reproducing a fresh one. Verifiable
only by asking the operator to take a new screenshot post-wipe.
**Severity: LOW.** The shim is functioning correctly; this is a
deploy-day-only stale-cache window. The clean fix is two lines of
docker-compose / Traefik headers config to set proper `Cache-Control`
on `/web/index.html` so future shim deploys propagate without manual
operator wiping.
---
## 2. Per-Hypothesis Verdicts
| # | Hypothesis | Verdict | Probe + evidence |
|---|---|---|---|
| 1 | `<link rel="prefetch">` for the de chunk fires before the inline shim runs | **Ruled out** | `grep -ciE 'rel="?(prefetch\|preload\|modulepreload)' index_de.html``0`. The bundle uses `<script defer>`, which executes after parsing, AFTER the inline shim has already run. The shim is the first executable JS in the document. |
| 2 | Service Worker pre-cached the de chunk | **Ruled out** | `serviceworker.js` is 768 bytes and contains only a `notificationclick` handler + `clients.claim()`. No `fetch` event listener, no precache, no cache.put. Cannot intercept locale chunk loads. Source: `curl https://arrflix.s8n.ru/web/serviceworker.js`. |
| 3 | Bundle reads `<html lang>` attr on first paint | **Ruled out** | The bundle's locale resolver `g()` reads `document.documentElement.getAttribute("data-culture")` — NOT `lang`. The `lang` attribute is *set* by the bundle (via `document.documentElement.setAttribute("lang", l)` in `w()`), it is not read. The served HTML opens with `<html class="preload" dir="ltr">` — no `data-culture`, no `lang`. |
| 4 | Bundle reads `document.cookie` for locale | **Ruled out** | `grep -ciE 'document.cookie.{0,40}lang\|locale\|culture' main.bundle.js``0`. No cookie-based locale path in any bundle. |
| 5 | Hard-coded `de-DE` fallback in the bundle | **Ruled out** | The hard-coded fallback is `var f="en-us"` (decompiled from `main.bundle.js`) — used when `navigator.language`, `navigator.languages`, `navigator.userLanguage`, and `data-culture` are all absent. Falls back to English, not German. |
| 6 | Server sends `Content-Language: de` | **Ruled out** | `curl -sI https://arrflix.s8n.ru/web/index.html` returns no `Content-Language` header. |
| 7 | Traefik/upstream content-negotiates locale (`Vary: Accept-Language`) | **Ruled out** | `curl -sI` returns no `Vary` header. Both `Accept-Language: de-DE,de;q=0.9,en;q=0.5` and `Accept-Language: en-US` return byte-identical 65485-byte HTML (same etag `1dcdf06cc053bcd`). Confirmed via `diff -q` of two captures. |
| 8 | Per-user `DisplayPreferences.CustomPrefs.language` writes the wrong key | **Inconclusive (irrelevant)** | DisplayPreferences is read by the bundle for `chromecastVersion`, `dashboardTheme`, home-section ordering, etc. — not for UI locale. The locale-related code paths (`g()`, `w()`, `i.currentSettings.language()`) read from `Navigator.prototype.language`, `data-culture`, and `localStorage.getItem("language")` only. Per-user DisplayPreferences.CustomPrefs.language could be set to anything and the SPA UI would not change. The 32 entries written by sibling A2 are a no-op for the Abspielen bug. |
| 9 | Cineplex theme injects German strings via CSS `content:` | **Ruled out** | `grep -ciE 'content:.{0,80}(Abspielen\|Fortsetzen\|Anzeigen)' /opt/jellyfin/config/branding/*.css 2>&1` returns 0. Themes are CSS-only and Jellyfin's branding `CustomCss` is plain CSS, not capable of localising button labels. |
| 10 | Plugin contributes the Play string | **Ruled out** | `GET /Plugins` lists 6 plugins (AudioDB, MusicBrainz, OMDb, Open Subtitles, Studio Images, TMDb). All are metadata-source plugins with server-side string surfaces only; none ship web-bundle UI strings. Verified by inspecting each plugin's `Plugin.{xml,json}` for `web/` or `client/` resources — none. |
| 11 | Pre-auth chunk request races the shim | **Ruled out by reproduction** | Headless Trivalent run with `--lang=de-DE --accept-lang=de-DE,de,en` against a freshly-created `--user-data-dir`, capturing the full network log: the ONLY locale chunk requested is `en-us-json.667484b4a441712c7e05.chunk.js`. The de chunk URL is never touched. The shim's `Object.defineProperty(Navigator.prototype, 'language', …)` runs synchronously during HTML parsing (inline non-defer script), before any deferred bundle script executes (HTML5 spec §4.12.1 — defer scripts execute after parsing in document order; inline scripts execute when the parser reaches them). The locale resolver `g()` runs inside the deferred bundle, so the override is in effect by the time `g()` is called. |
| 12 | Browser sends `Accept-Language: de-DE` and the SPA reads it via fetch echo | **Ruled out** | The SPA's locale resolver does NOT make any pre-bundle network request to read its own Accept-Language. The resolver is purely synchronous and only reads `navigator.language`, `navigator.userLanguage`, `navigator.languages[0]`, plus the `data-culture` DOM attr and `localStorage.language`. Confirmed by full-text `grep -ciE 'fetch.{0,200}accept.language\|XMLHttpRequest.{0,200}accept.language' main.bundle.js` → matches only the ARRFLIX shim's STRIP code, no read. |
---
## 3. Concrete remediation, ranked by blast radius
### R1 — Add `Cache-Control: no-cache` on `/web/index.html` (Traefik header) — RECOMMENDED FIRST
Smallest blast radius. Forces every browser to revalidate the index.html
on every visit, so future shim updates propagate within one tab refresh
instead of a 1055 minute heuristic-cache window.
```yaml
# In /opt/docker/jellyfin/docker-compose.yml under the jellyfin service labels:
- "traefik.http.routers.jellyfin.middlewares=jellyfin-nocache-html@docker"
- "traefik.http.middlewares.jellyfin-nocache-html.headers.customresponseheaders.Cache-Control=no-cache, must-revalidate"
```
**Caveat:** this header would be applied to ALL responses on the
`jellyfin` router, including the immutable hashed chunk files. Chunks
SHOULD remain cacheable forever (they're hash-fingerprinted). Therefore
either:
- **Path A (simpler):** apply `no-cache` only to `/web/index.html` via
a path-scoped middleware, leaving everything else alone:
```yaml
- "traefik.http.middlewares.jellyfin-nocache-html.headers.customresponseheaders.Cache-Control=no-cache, must-revalidate"
- "traefik.http.routers.jellyfin-html.rule=Host(`arrflix.s8n.ru`) && Path(`/web/index.html`)"
- "traefik.http.routers.jellyfin-html.middlewares=jellyfin-nocache-html@docker"
- "traefik.http.routers.jellyfin-html.priority=100" # higher than the catch-all
- "traefik.http.routers.jellyfin-html.service=jellyfin@docker"
```
- **Path B (cleaner, better long-term):** apply `Cache-Control:
public, max-age=31536000, immutable` to all `/web/*.{js,css,chunk.js}`
(which Jellyfin upstream already fingerprints) AND `Cache-Control:
no-cache, must-revalidate` to `/web/index.html` and `/web/manifest.json`.
This is the conventional SPA cache strategy; we get the best of both
worlds (instant chunk load + always-fresh shim).
**Do NOT install yet** — operator decision required on Path A vs B.
### R2 — Operator-side: document the precise wipe procedure for shim updates
Add to `docs/20-english-only-lockdown.md` "Re-apply procedure" section:
> When updating the web shim (`web-overrides/index.html` or any file
> bind-mounted into `/jellyfin/jellyfin-web/`), every active browser
> session must be wiped with **"Clear browsing data" → tick BOTH
> "Cookies and other site data" AND "Cached images and files"** for
> the `arrflix.s8n.ru` origin. DevTools "Storage → Clear site data"
> alone does NOT clear HTTP disk cache in all Chromium variants; the
> all-time wipe via `chrome://settings/clearBrowserData` is required.
This closes the operator-process gap that left a stale index.html in
the operator's browser.
### R3 — Stop investing in per-user `Configuration.UICulture` POSTs
Per the proof in §1, this field has no effect on the web SPA's UI
language. It controls only:
- The user object the API returns (so the dashboard form for "edit
user" displays the correct value if anyone ever opens it).
- Server-side string surfaces that DO honour per-user culture
(Live TV EPG metadata for the API caller, some plugin responses),
but NOT the web client UI strings.
Keep `bin/force-english-all-users.sh` and the
`bin/add-jellyfin-user.sh` UICulture line for cosmetic consistency
and future-proofing (Jellyfin upstream might wire it up someday), but
**stop expecting it to fix UI-string leaks**. The shim is the only
thing pinning the UI.
### R4 — Defense-in-depth: bind-mount empty `de.json` chunk stubs
Doc 19 §"Files to Delete" (Path B) proposed this. Still valid as a
belt for Path R1, but high-maintenance (chunk hashes rotate on every
Jellyfin upgrade — currently `de-json.1afccc006ab8bb6c5953.chunk.js`
but a `jellyfin/jellyfin` image bump could change it). **Defer
indefinitely** unless the operator wants the German strings physically
unreachable for paranoia.
### R5 — `Accept-Language` rewrite at Traefik (doc 19 §"Path A — Traefik middleware")
```yaml
- "traefik.http.middlewares.arrflix-lang.headers.customrequestheaders.Accept-Language=en-US,en;q=0.9"
- "traefik.http.routers.jellyfin.middlewares=arrflix-lang"
```
**Useful but redundant** with the existing shim. The shim already
strips Accept-Language on outbound fetch/XHR (verified live in shim
source). The browser-issued INITIAL request to `/web/index.html` is
the only one that would ever carry Accept-Language, and the index.html
is byte-identical regardless of header (proven in hypothesis 7). So
this rewrite would prevent **future** Jellyfin upstream behaviour
changes that start using Accept-Language on the index.html response,
but doesn't fix anything currently broken.
---
## 4. Why prior audits (15, 19, 20) missed this
Doc 15 correctly diagnosed that the SPA "falls back to
`Accept-Language` when `UICulture` is unset" — a CONJECTURE based on
observing that German appeared and that `Configuration.UICulture` was
absent on every user. The conjecture was never tested by GREPPING THE
WEB BUNDLE for `UICulture`, which would have shown immediately that
the SPA never reads it. Doc 19 inherited the conjecture verbatim. Doc
20 codified it into the lockdown procedure. Three audits in a row,
all assuming a causal link that doesn't exist.
The actual causal layer (`Navigator.prototype.language` →
`<lang>-json` chunk selection) was correctly identified in doc 19's
§"Layer 18" remediation suggestion — which is what shipped as the
shim. The shim is the fix; the per-user UICulture pin is theatre.
After this deep dive, doc 20's "Layer 2" (per-user) and "Layer 1"
(server-wide) sections should be re-labelled as "metadata-affecting"
rather than "UI-affecting", and doc 19's primary-fix table-row should
be flipped from "Layer 5 (per-user UICulture) is the biggest impact"
to "Layer 18 (navigator.language shim) is the only impact on UI
strings".
A subtler lesson: when doc 19 said "all 8 users have `UICulture` absent
→ that's why German leaks", the audit *also* noted (table row 16)
that 92 non-English locale chunks are reachable and contain
`"Play":"Abspielen"` etc. That observation alone, combined with the
chunk-loading code, would have shown the actual mechanism. The fix
was correctly proposed (shim with `navigator.language` override),
but the diagnosis text emphasised the wrong layer.
---
## 5. New hypotheses uncovered during probe
### H13 — `index.html` is served with NO `Cache-Control` header
Already covered above (R1). Not a leak per se but the mechanism by which
ANY future shim deploy can fail to propagate without operator wiping.
Critical to fix before the next shim iteration to avoid this whole
"why is it still German" dance recurring.
### H14 — Operator may have multiple browser profiles/binaries with stale state
Operator has Trivalent (`~/.config/trivalent`), Chromium
(`~/.config/chromium` with profile `Default` and `Profile 1`),
`~/.config/google-chrome-for-testing`, plus Flatpak: Chrome,
ChromeDev, Chromium, ungoogled-Chromium, MullvadBrowser. Eight
Chromium-family installs. A wipe of "the browser" plausibly missed at
least one. **Probe to ask operator:** which exact browser binary +
which exact profile produced the screenshot? "Trivalent default
profile with all storage wiped including HTTP cache" yields a
different conclusion from "Mullvad ad-hoc surgical wipe targeting
storage only".
### H15 — Per-user `Configuration.UICulture` lockdown layer is doing literal nothing
Documented in R3. Worth flagging because the op cost (running
`bin/english-lockdown-runner.sh` weekly via systemd timer) is nonzero
and we now know the only layer that matters is the shim, which
doesn't need re-running because it's a static bind-mount.
### H16 — Chunk URLs in the require.context are immutable per Jellyfin upgrade
Verified: `n(73125)` maps `./en-us.json``[20233, 79754]` and
`./de.json``[99810, 89409]`. These ID pairs are baked into the
runtime.bundle.js at Jellyfin build time. So a Jellyfin image upgrade
WILL change the chunk hashes (filename, e.g.
`de-json.<hash>.chunk.js`) but NOT the chunk-id mapping in
runtime.bundle.js. Any defense-in-depth via 1-byte stub bind-mounts
(R4) MUST be regenerated after every image bump — not just the
filename, but if the chunk-id stub-content `(self.webpackChunk = …)
.push([[CHUNK_ID], {}])` also depends on the chunk-id, then those
lines need re-emission too. Treat as part of the upgrade runbook,
not a one-shot install.
### H17 — `localStorage.language` is shared across all Jellyfin users on the same browser
The settings store reads `localStorage.getItem("language")` with NO
user-prefix when the prefix flag is `false` (verified in `key:
"language"` getter signature with `t = false`). All other
preferences are stored as `<userId>-<key>`, but `language` is
specifically global. So if user A on a browser with German pref
loads the SPA pre-shim and the SPA writes `localStorage.language =
"de"` into the user-settings store, then user B on the same browser
inherits the German preference until either the shim runs (which
overwrites it on every load) or the storage is wiped. The shim's
`pinLocale()` belt re-pins on every visibility change, so this isn't
exploitable, but it IS the ONE persistence mechanism that survives
both server-side UICulture pinning and per-user-DisplayPreferences
writes.
---
## Sign-off
- **Mode:** read-only on Jellyfin live state (no POST/PATCH/PUT).
Read-only on container (zero `docker exec` writes). Shim file
unchanged. Headless Trivalent test runs used `/tmp/eng-deep-dive/cprofile{,2}`
isolated profiles, no production browser state touched.
- **Live evidence captures:**
- `/tmp/eng-deep-dive/index_de.html` (65485 bytes, has shim block)
- `/tmp/eng-deep-dive/runtime.bundle.js` + `main.bundle.js` +
`37869.bundle.js` (decompiled to confirm locale-resolver code path)
- `/tmp/eng-deep-dive/headless2.log` (only en-us-json chunk
requested under explicit German Accept-Language)
- **Recommendation order:** R1 (Cache-Control no-cache on index.html)
→ R2 (document wipe procedure) → R3 (stop investing in per-user
UICulture for UI). R4 and R5 are optional defense-in-depth, not
required to fix the screenshot.
- **Next-action owner:** operator decides Path A vs B for R1; web
agent then applies the chosen Traefik label diff in a single commit.
- **Severity:** LOW — shim is functioning, this is a stale-cache
process gap, not a continuous leak.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,131 @@
# 27 — ARRFLIX status snapshot (2026-05-09 02:15 UTC)
Point-in-time visual status after doc-26 incident. For ongoing roadmap see
`ROADMAP.md`. For incident detail see `docs/26-incident-2026-05-09-...md`.
```
┌─────────────────────────────────────────────────────────────────┐
│ ARRFLIX arrflix.s8n.ru · Jellyfin 10.10.3 · nullstone │
│ HEAD e1720e3 @ git.s8n.ru/s8n/ARRFLIX · 20Mbps cap · 12 users │
└─────────────────────────────────────────────────────────────────┘
```
## Symptoms killed this session (8/8)
```
[✓] Page Unresponsive INC1 index.html drift revert
[✓] No previews INC1 :has() transparent-scope
[✓] Posters black INC1
[✓] Abspielen German INC1 Cineplex CSS content: override
[✓] Backdrops black INC1+INC2+INC3 pin :fixed + sub-section transparent
[✓] Black band carousels INC4 .emby-scroller transparent
[✓] Slow first-frame (4K HDR) INC4 EnableTonemapping=false + 20Mbps cap
[✓] Grey scrollbar strip INC5 ::-webkit-scrollbar themed
[✓] MNS AV1 black INC5 re-encode H.264/AAC sources
[~] MNS fmp4-HLS black again INC6 Clear-Site-Data:"cache" — verify pending
```
## Roadmap
```
┌─ DONE this session ──────────────────────────────────────────────┐
│ ✓ docs/26 incident post-mortem (1500+ lines, 5 iterations) │
│ ✓ bin/headless-test.py + headless-test-v2.py (multi-user+Play) │
│ ✓ bin/apply-26-incident-fixes.sh (idempotent re-apply INC1-5) │
│ ✓ web-overrides/index.html INC5 fmp4=false shim │
│ ✓ branding.xml INC1-5 CustomCss patches │
│ ✓ encoding.xml throttling+segdeletion+tonemapping all off │
│ ✓ 12 user policies @20Mbps cap │
│ ✓ MNS S1E2/E4/E5 AV1→H.264 re-encode (originals @ /tmp .bak) │
│ ✓ 18-item don't-repeat checklist │
└──────────────────────────────────────────────────────────────────┘
┌─ PENDING verification ───────────────────────────────────────────┐
│ ⧗ INC6 Clear-Site-Data wipes user cache → fresh shim → MNS plays │
│ then: remove clear-cache-only middleware │
└──────────────────────────────────────────────────────────────────┘
┌─ HIGH-VALUE OPEN (next session) ─────────────────────────────────┐
│ H1 GPU transcode (nvidia driver + container toolkit + SecureBoot)│
│ → unlocks 4K HDR realtime instead of 0.5x │
│ H2 Off-host backup of /home/docker/jellyfin/config │
└──────────────────────────────────────────────────────────────────┘
┌─ MEDIUM-VALUE OPEN ──────────────────────────────────────────────┐
│ M1 Library AV1 sweep + Sonarr/Radarr penalty so future grabs │
│ don't re-trigger jellyfin#15646 │
│ M2 4K HDR pre-transcode batch (R&M masters → 1080p H.264 SDR) │
│ M3 v2 test allowlist: filter off-viewport (#reactRoot y=-490 │
│ and .mainDrawer x=-320 false-positives) │
│ M4 Promote /tmp/*-av1-original-*.mkv.bak to real archive dir │
│ M5 Per-library themes (Movies=Netflix, Anime=Crunchy, Music=Spo)│
│ M6 PWA manifest bind-mount (kill "Jellyfin" name on Android) │
└──────────────────────────────────────────────────────────────────┘
┌─ DEFERRED (with reason) ─────────────────────────────────────────┐
│ ⊘ Pixel-perfect Netflix/Crunchy/Spotify per-library — needs 3 │
│ separate Jellyfin instances, ~100x maintenance │
│ ⊘ Custom Jellyfin Docker image — bind-mount works │
│ ⊘ 4 TB HDD activation — wait for library > 500 GB │
│ ⊘ Jellyfin-Vue web client — would replace whole UI │
└──────────────────────────────────────────────────────────────────┘
┌─ STRATEGIC (separate planned migrations) ────────────────────────┐
│ ⚑ 10.11.8 upgrade (CVE coverage + TMDB scrape #14922 fix) │
│ Plan: dev first, EF Core DB migration snapshot, theme swap │
│ Cineplex→ElegantFin (10.11 supported), promote prod │
│ ⚑ FlexHub/Forgejo CI: lint compose, shellcheck bin/, render docs │
│ ⚑ ARRFLIX wordmark high-res for splash (currently 235x85 soft) │
└──────────────────────────────────────────────────────────────────┘
```
## Library
```
TV eps codec DirectPlay
Futurama S1-S4 72+9 1080p HEVC transcode→x264
American Dad S1-S4 58 1080p ✓
Rick&Morty S1 11 4K HEVC HDR transcode (slow until M2)
Maul S1 10 1080p ✓
Obi-Wan S1 6+4 1080p ✓
Mike Nolan S1 2-5 1080p H.264 ✓ (just re-encoded INC5)
Mandalorian S1-S3 18/24 - scrape in flight
Movies
Dark Knight 2008 4K HEVC HDR transcode
Hulk 2008 1080p ✓
Idiocracy 2006 1080p ✓
```
## Files in repo
```
ARRFLIX/
├── docker-compose.yml ← jellyfin/jellyfin:10.10.3 + Traefik labels
├── compose-dev/ ← jellyfin-dev sibling
├── web-overrides/
│ ├── index.html ← INC5 enableHlsFmp4=false shim + ARRFLIX brand
│ └── ENGLISH-LOCKDOWN.md
├── bin/
│ ├── add-jellyfin-user.sh ← canonical user creation
│ ├── apply-26-incident-fixes.sh ← idempotent INC1-5 re-apply ★ NEW
│ ├── force-english-all-users.sh ← (now superseded by Cineplex CSS fix)
│ ├── headless-test.py ← v1 smoke test
│ ├── headless-test-v2.py ← v2 multi-user+click-play+bg-sweep ★ NEW
│ └── inject-shim.py
├── docs/
│ ├── 00-overview.md
│ ├── 01..25-*.md ← prior audits + research docs
│ ├── 26-incident-2026-05-09-page-unresponsive-and-playback.md ★ NEW
│ └── 27-status-snapshot-2026-05-09.md ★ THIS DOC
├── ADMIN-GUIDE.md
├── ROADMAP.md
└── README.md
```
## Next click
```
1. Hard-reload browser → MNS S1E4 → confirm plays
2. Tell me: works → I remove INC6 Clear-Site-Data middleware
3. Plan B: 10.11.8 + ElegantFin migration on dev (~45 min)
```

View file

@ -0,0 +1,598 @@
# 28 — Prod vs Dev Playback Divergence (2026-05-09)
> Diff hunt: `arrflix.s8n.ru` (prod, BLACK SCREEN on high-quality video) vs `dev.arrflix.s8n.ru` (dev, plays fine). Same image `jellyfin/jellyfin:10.10.3`, same `/home/user/media:/media:ro`, same network `proxy`, same `userns_mode: host`, same `user: 1000:1000`. Difference is therefore in container env, bind-mounts, Traefik routing, server config XML, or per-user policy stored in `jellyfin.db`. This doc enumerates every divergence found and weights how likely each is to be the cause.
Status: **RESOLVED 2026-05-09 02:46Z** — root cause was Traefik `jellyfin-asset-immutable` pinning `/web/serviceworker.js` with `Cache-Control: immutable, max-age=31536000`, causing a stale Jellyfin PWA service worker to intercept `/Videos/*` and `/web/*` `fetch()` events and return cached/empty responses → MSE black screen. Patched in dynamic.yml (added `jellyfin-sw-nocache` router at priority 250 forcing `cache-no-store` on `/web/serviceworker.js` + `/web/sw.js`). Headless playback verified: MNS S1E4 plays 33s of currentTime advance, readyState 4, videoWidth 1920×1080, no errors. See "Final fix applied + verification" section at the bottom of this doc.
Sibling docs: 26 (incident chain INC1INC5), 12 (dev mirror setup), 17 (dev mirror + settings fix), 23 (perf audit).
---
## TL;DR — top suspects
| Rank | Suspect | Where | Why it could black-screen prod but not dev |
|------|---------|-------|---------------------------------------------|
| 1 (HIGH) | **Per-user `EnablePlaybackRemuxing = 0`** on every prod non-admin (USER-A/USER-F/USER-G/5/USER-B/USER-D/USER-C/Jayden/IX/ferghal/USER-E) | `jellyfin.db` Permissions table, Kind=10 | Forces a transcode for any container/codec mismatch even when client could direct-play. Combined with `HardwareAccelerationType=none` (CPU-only) and `RemoteClientBitrateLimit=8 Mbps` server-wide — high-bitrate 4K/HEVC content can't be re-encoded fast enough → blank frames. Dev `test` user has Kind 10 = 1 (remux ON) so it always direct-plays. |
| 2 (HIGH) | **`RemoteClientBitrateLimit = 8 000 000` (8 Mbps)** on prod server, `0` (unlimited) on dev | `/home/docker/jellyfin/config/config/system.xml` line 137 | Owner's reported symptom is *"high-quality video"* fails. 4K/H265 source bitrates routinely exceed 2060 Mbps. Server clamps to 8 Mbps for any "remote" session (anything not on prod LAN per server's view of client IP) → forces transcode to 8 Mbps → low-bitrate output that some browsers black-frame on HEVC profiles. Bizarrely, the per-user `Users.RemoteClientBitrateLimit` is `20000000` for ALL users — but server-wide cap and per-user cap interact via `min()`, so 8 Mbps wins. |
| 3 (HIGH) | **Traefik middleware `clear-cache-only` + `force-en-accept-lang` on `arrflix.s8n.ru`, NOT on `dev`** | `/opt/docker/traefik/config/dynamic.yml` lines 3043 | `clear-cache-only` middleware sends `Clear-Site-Data: "cache"` header on every `/`, `/web/`, `/web/index.html`, `/web/sw.js`, `/web/manifest.json` hit. This wipes the browser's HTTP cache but NOT IndexedDB or LocalStorage — except Chrome's `Clear-Site-Data: "cache"` interpretation **also evicts the Service Worker cache** on each navigation. Jellyfin's PWA SW caches the JS bundle. SW eviction mid-session can cause `MediaSource.appendBuffer` to fail mid-stream → black video. INC6 of doc 26 says this header was meant to be **temporary** ("REMOVE after owner confirms one fresh load"). It was never removed. |
| 4 (MED) | **Prod branding.xml has 285 extra lines of CSS** including `position: fixed; z-index: 0` on `.backdropContainer` / `.backgroundContainer` | `/home/docker/jellyfin/config/config/branding.xml` 110-258 (BLACK-PASS + INC1INC5) | INC2 pins backdrop containers at `position:fixed; top:0; left:0; width:100vw; height:100vh; z-index:0`. The HTML5 `<video>` lives in `.htmlVideoPlayerContainer` whose z-index is theme-dependent — if the prod backdrop pin happens to overlay it, the player renders behind the backdrop → black screen. Dev's branding.xml is minimal (only the `Abspielen` ::after override) so it can't occlude. |
| 5 (MED) | **Prod has `enableHlsFmp4=false` shim** in `/opt/docker/jellyfin/web-overrides/index.html`, dev shim has it too but order/timing may differ | INC5 shim block in prod (line 245-260 region of the diff) | Was introduced 2026-05-09 INC5 specifically to *fix* HEVC+fMP4 black-video. If the shim's `localStorage.setItem('enableHlsFmp4','false')` ran AFTER the player initialized, or if Cineplex/finity caches the value, fMP4 is still chosen → HEVC inside fMP4 black-screen on Chrome ~M120+. The shim must run on every fresh page load. |
| 6 (LOW) | **Prod env adds `JELLYFIN_UICulture=en-US`, `LANG=en_US.UTF-8`, `LC_ALL=en_US.UTF-8`**; dev does not | `docker inspect ... .Config.Env` | Locale env affects ffmpeg/jellyfin-ffmpeg's number formatting (decimal point in some locales). Unlikely to black-screen on its own but could change behavior of subtitle PGS rendering / x265 param parsing. |
| 7 (INFO) | **Prod index.html was REWRITTEN at 02:39 by root** mid-investigation | `stat /opt/docker/jellyfin/web-overrides/index.html` shows 02:39 mtime, owner=root, 9723 bytes (was 65789 at 01:54 owned by user) | A rollback or hot-patch happened during the diff hunt. Whoever did it wiped the giant base64 favicon block but kept the SHIM. Note: the file is now owned by root, the bind-mount is :ro inside the container so this is safe, but **uid 0 owning a file in a `user:user` directory means a privileged process did the write** — likely a forgotten root cron or a `sudo cp` from a recovery script. |
---
## a) docker-compose diff
| Field | Prod | Dev |
|-------|------|-----|
| service name | `jellyfin` | `jellyfin-dev` |
| container_name | `jellyfin` | `jellyfin-dev` |
| image | `jellyfin/jellyfin:10.10.3` | `jellyfin/jellyfin:10.10.3` (identical) |
| user | `1000:1000` | `1000:1000` (identical) |
| userns_mode | `host` | `host` (identical) |
| restart | `unless-stopped` | `unless-stopped` (identical) |
| network | `proxy` | `proxy` (identical) |
| TZ | `Europe/London` | `Europe/London` (identical) |
| JELLYFIN_PublishedServerUrl | `https://arrflix.s8n.ru` | `https://dev.arrflix.s8n.ru` |
| JELLYFIN_UICulture | `en-US` | (unset) |
| LANG | `en_US.UTF-8` | (unset — falls through to image default `en_US.UTF-8`) |
| LC_ALL | `en_US.UTF-8` | (unset — falls through to image default `en_US.UTF-8`) |
| /config bind | `/home/docker/jellyfin/config` | `/home/docker/jellyfin-dev/config` |
| /cache bind | `/home/docker/jellyfin/cache` | `/home/docker/jellyfin-dev/cache` |
| /media bind | `/home/user/media:ro` | `/home/user/media:ro` (**identical, both ro**) |
| /jellyfin/jellyfin-web/index.html | `/opt/docker/jellyfin/web-overrides/index.html:ro` | `/opt/docker/jellyfin-dev/web-overrides/index-dev.html:ro` |
| /jellyfin/jellyfin-web/cineplex.css | bind-mounted (md5 `01e95d49…`) | NOT bind-mounted (uses CDN `@import`, see branding.xml diff) |
| locale-en-only/*.chunk.js | **94 separate bind-mounts** of `/opt/docker/jellyfin/web-overrides/locale-en-only/<lang>-json.<hash>.chunk.js` over Jellyfin's stock locale chunks | **none** — dev serves Jellyfin's stock locale chunks as-shipped |
| Traefik labels | router=`jellyfin`, middlewares=`security-headers@file,compress@file,force-en-accept-lang@file` | router=`jellyfin-dev`, middlewares=`security-headers@file,no-USER-F@file` |
Result: 94 locale chunk overrides on prod, 0 on dev. None of these chunks affect playback — they're translation JSON for UI strings. Skip as a playback suspect.
## b) Traefik routing diff
Prod has **THREE routers** for `arrflix.s8n.ru` defined in `/opt/docker/traefik/config/dynamic.yml`, plus the docker-provider one from labels. Dev has only the docker-provider one.
| Route | Host | Path | Priority | Middlewares | Comment |
|-------|------|------|----------|-------------|---------|
| `jellyfin-html-nocache` | `arrflix.s8n.ru` | `/`, `/web/`, `/web/index.html`, `/web/sw.js`, `/web/manifest.json` | 100 | security-headers + compress + cache-no-store + force-en-accept-lang + **clear-cache-only** | Sends `Clear-Site-Data: "cache"` on every nav. Was meant to be **temporary** (INC6, "REMOVE after owner confirms"). |
| `jellyfin-locale-force-en` | `arrflix.s8n.ru` | regex locale-json chunks | 200 | security-headers + compress + cache-immutable + rewrite-to-en-us-json + force-en-accept-lang | Rewrites every locale-json chunk URL to en-us-json |
| `jellyfin-asset-immutable` | `arrflix.s8n.ru` | regex /web/*.{js,css,…} | 90 | security-headers + compress + cache-immutable | Cache lock for hashed assets |
| docker-provider router | `arrflix.s8n.ru` | (catch-all) | (no priority set) | security-headers + compress + force-en-accept-lang | The "default" jellyfin route |
| docker-provider router (dev) | `dev.arrflix.s8n.ru` | (catch-all) | (no priority set) | security-headers + **no-USER-F** | Single route, no per-asset caching, no Clear-Site-Data, no Accept-Language pinning |
Diff highlights for playback:
- **`clear-cache-only` (Clear-Site-Data: "cache") on prod only** — see suspect #3 above. HIGH likelihood: in Chrome, this header evicts the Service Worker cache on every navigation. Jellyfin's PWA registers `sw.js` and serves chunked JS from SW cache. If the SW cache is wiped while the user is mid-session and a re-fetch fails (rate-limited, or cache-immutable response served stale), `MediaSource.appendBuffer` can throw → silent black video.
- **`force-en-accept-lang` rewrites Accept-Language to en-US,en;q=0.9 on prod, not on dev** — affects only metadata strings, NOT playback.
- **`cache-immutable` (`max-age=31536000, immutable`) on prod's hashed JS/CSS** — fine in steady state, but combined with `clear-cache-only` on the index, you can get into a state where index says "fetch new chunks" but client has them locked under the immutable header. Browsers usually re-validate on hard reload only.
- **`rewrite-to-en-us-json` on prod only** — purely string-translation rewrite; not a playback factor.
- **`no-USER-F@file` on dev only**: blocks WAN, prod relies on its own no-USER-F somewhere else (router-level Pi-hole rules per CLAUDE.md memory `feedback_s8n_hosts_override.md`). Not a playback factor.
## c) branding.xml (CustomCss) diff
Prod = **401 lines**, dev = **116 lines**. 285-line delta is all the BLACK-PASS / INC1INC5 patches absent on dev.
| Block | Prod | Dev |
|-------|------|-----|
| `@import url("/web/cineplex.css")` | YES — local cineplex.css mounted in compose | NO — uses `https://cdn.jsdelivr.net/gh/MRunkehl/cineplex@v1.0.6/cineplex.css` |
| BLACK-PASS section (`:root` overrides + `.layout-desktop { background-color: #000 !important; }`) | YES (lines 110-180) | NO |
| INC1 transparent-scope `.itemDetailPage:has()` | YES | NO |
| INC2 `position:fixed; z-index:0` on `.backdropContainer`, `.backgroundContainer` (full viewport) | YES (lines 215-258) | NO |
| INC3 transparent-scope on `.detailPageContent`, `.detailVerticalSection`, `.itemsContainer`, etc. | YES | NO |
| INC4 transparent-scope on `.itemDetailPage .emby-scroller` | YES | NO |
| INC5 scrollbar palette overrides | YES | NO |
| `Abspielen``Play` ::after override | YES | YES (only this block on dev) |
Suspect #4 above: INC2's `position: fixed; z-index: 0` on `.backdropContainer` could overlap or stack above the video element wrapper depending on Cineplex/finity stacking context. The full-viewport pinned backdrop is the most aggressive layout change in the diff. Would not affect dev because dev has none of these rules.
## d) encoding.xml diff
Live `/encoding.xml`: **byte-identical** between prod and dev.
`encoding.xml.bak.1778285349` (older copies) shows historical divergence:
- Prod previously had `EnableThrottling=true`, `EnableSegmentDeletion=true`, `EnableTonemapping=true`
- Dev had all three `false`
- Both are now `false` — convergence happened during INC1-5 work.
Both servers run `HardwareAccelerationType = none` (no GPU hwaccel — known: GTX 1660 Ti driver broken on host per CLAUDE.md memory ref). CPU-only ffmpeg transcode on this host can keep up with H264 at 1080p but not with 4K/HEVC at >40 Mbps. This is the reason `RemoteClientBitrateLimit=8M` (suspect #2) is so dangerous on prod.
## e) bind-mount diff
Already covered in compose section. Net: **media is identical** (`/home/user/media:/media:ro` on both — same path, same `:ro`). All differences are in `/config`, `/cache`, and the `/jellyfin/jellyfin-web/*` overrides. Cache divergence cannot cause prod black-screen because each container has its own (Jellyfin transcode chunks land under `/cache/transcodes`, fully isolated).
## f) env-var diff
| Var | Prod | Dev |
|-----|------|-----|
| LANG | `en_US.UTF-8` (explicit) | `en_US.UTF-8` (image default) |
| LC_ALL | `en_US.UTF-8` (explicit) | `en_US.UTF-8` (image default) |
| LANGUAGE | `en_US:en` | `en_US:en` (identical) |
| TZ | `Europe/London` | `Europe/London` (identical) |
| JELLYFIN_PublishedServerUrl | `https://arrflix.s8n.ru` | `https://dev.arrflix.s8n.ru` |
| JELLYFIN_UICulture | `en-US` (explicit) | (unset — server reads `system.xml UICulture=en-US` instead) |
| All `JELLYFIN_*_DIR` paths | identical | identical |
| `NVIDIA_VISIBLE_DEVICES=all`, `NVIDIA_DRIVER_CAPABILITIES=compute,video,utility` | YES | YES (both — neither uses GPU because hwaccel=none in encoding.xml) |
| `MALLOC_TRIM_THRESHOLD_=131072` | YES | YES |
No env-var divergence is plausible as the playback root cause.
## g) web-overrides diff
```
PROD: DEV:
index.html 9723 bytes (root) index-dev.html 68349 bytes (user)
index.html.bak.eng-pre-2026-05-08 59757 b index-dev.html.bak.pre-middle-theme 65789 b
index.html.bak.pre-rollback-1778282871 69390 index-dev.html.bak.pre-mirror-1778289645 59757 b
cineplex.css 16143 b cineplex.css 16143 b
locale-en-only/ 94 chunks locale-en-only/ 94 chunks (mounted only on prod's container, not on dev's)
```
`md5sum` results:
- `cineplex.css` — IDENTICAL on both (`01e95d491d755ea3df39955af998d5f3`)
- `index.html` (prod) `5b212d7d60b8a2b910a2f47dd0470a09``index-dev.html` (dev) `9658933dfa069dce6f3cd58130249aa4`
**Anomaly**: prod `index.html` was rewritten at **02:39 today by root** (was `user:user` at 01:54, 65789 bytes; is `root:root` 9723 bytes now). Whoever did this stripped the giant base64 favicon block but kept the SHIM. Investigate who/what owns this — likely a rollback script or `sudo cp` from one of the `.bak` files.
The shim itself in current prod still contains:
- `localStorage.setItem('enableHlsFmp4', 'false')` (INC5 — disable fMP4 to dodge HEVC+fMP4 black bug)
- `Accept-Language` strip on outbound fetch/XHR
- `UICulture = 'en-US'` rewrite on user-config save
- Title rewrite to "ARRFLIX"
Dev's index-dev.html has the same shim (the SHIM-BEGIN/END markers are at offset 2774 → 10799 in dev). Difference: dev shim was last touched at 02:22 by user, prod's at 02:39 by root.
## h) per-user policy diff
Prod has 12 users (`5`, `USER-D`, `USER-B`, `ferghal`, `USER-F`, `USER-G`, `IX`, `Jayden`, `USER-A`, `USER-E`, `s8n`, `USER-C`). Dev has 1 (`test`).
`Users.RemoteClientBitrateLimit`:
- Prod: every user = `20000000` (20 Mbps)
- Dev: `test` = `0` (unlimited)
But the **server-wide cap in `system.xml`** is `8000000` (8 Mbps) on prod and `0` on dev. Jellyfin computes the effective cap per session as `min(server, user)` for non-LAN sessions → prod's 12 users are all clamped to **8 Mbps remote** (regardless of their per-user 20 Mbps allowance), dev's `test` is unlimited.
`Permissions` table (Kind = Jellyfin's `PermissionKind` enum: 0=IsAdministrator, 1=IsHidden, 2=IsDisabled, 3=EnableSharedDeviceControl, 4=EnableRemoteAccess, 5=EnableLiveTvManagement, 6=EnableLiveTvAccess, 7=EnableMediaPlayback, 8=EnableAudioPlaybackTranscoding, 9=EnableVideoPlaybackTranscoding, **10=EnablePlaybackRemuxing**, 11=ForceRemoteSourceTranscoding, …):
| User | Kind 0 (Admin) | Kind 9 (VideoTranscode) | Kind 10 (Remuxing) | Kind 11 (ForceTranscode) |
|------|----------------|-------------------------|---------------------|--------------------------|
| s8n (admin) | 1 | 1 | **1** | 1 |
| USER-A | 0 | 1 | **0** | 1 |
| USER-F | 0 | 1 | **0** | 1 |
| USER-G | 0 | 1 | **0** | 1 |
| 5 | 0 | 1 | **0** | 1 |
| (all other prod non-admin users — same pattern) | 0 | 1 | **0** | 1 |
| dev `test` | 1 | 1 | **1** | 1 |
**Smoking gun**: every prod non-admin has `EnablePlaybackRemuxing = 0` AND `ForceRemoteSourceTranscoding = 1`. Even when the client could perfectly direct-play an MKV by remuxing to MP4, the server has to fully transcode video. Combined with `HardwareAccelerationType=none` and `RemoteClientBitrateLimit=8M`, the server can't keep up on 4K/HEVC sources → empty segments → black-screen on the player.
Dev's `test` user has Remuxing=1 and is admin so the server-wide bitrate cap is bypassed (admin always direct-plays at full bitrate).
---
## Recommended fix order
1. **Remove the temporary `clear-cache-only` middleware** from `jellyfin-html-nocache` in `/opt/docker/traefik/config/dynamic.yml` (per INC6 it was supposed to be removed already). Reload Traefik. Have owner hard-reload arrflix.s8n.ru once. **(2 minutes, near-zero blast radius)**
2. **Bump `RemoteClientBitrateLimit` from 8000000 → 0** (or to 40000000) in `/home/docker/jellyfin/config/config/system.xml`, restart prod jellyfin. **(2 minutes)**
3. **Set `EnablePlaybackRemuxing = 1` for all non-admin prod users** via PATCH /Users/{id}/Policy or a direct UPDATE on `Permissions` SET Value=1 WHERE Kind=10. Restart not required.
4. Test the same high-quality file as `USER-A` from the same client that black-screened. If still bad → look at INC2 backdrop-pinning CSS in branding.xml (suspect #4) and Cineplex theme stacking context.
5. Investigate who/what rewrote `/opt/docker/jellyfin/web-overrides/index.html` at 02:39 as root. Permissions are now `root:root` instead of `user:user`. Even though the bind-mount is `:ro` so the container can still read it, future hot-patches by `user` will fail with EPERM.
Do NOT change at this stage:
- branding.xml (INC2 backdrop pinning) — defer until items 1-3 are tested. CSS-driven black would hit dev too once dev tries the same theme.
- The 94 locale-en-only chunk overrides — orthogonal to playback.
- encoding.xml — already identical to dev.
---
## Diff matrix
```
DIM PROD DEV
================================= ======================================================================== ========================================
docker image jellyfin/jellyfin:10.10.3 jellyfin/jellyfin:10.10.3 (=)
container user 1000:1000 1000:1000 (=)
userns_mode host host (=)
network proxy proxy (=)
restart unless-stopped unless-stopped (=)
hwaccel (encoding.xml) none none (=)
EnableThrottling (encoding.xml) false false (= now; PROD was true earlier per .bak)
EnableTonemapping (encoding.xml) false false (= now; PROD was true earlier per .bak)
EnableSegmentDeletion false false (= now; PROD was true earlier per .bak)
H264Crf / H265Crf 23 / 28 23 / 28 (=)
QuickConnectAvailable (system.xml) false true DIFF (cosmetic)
RemoteClientBitrateLimit (server) 8000000 (8 Mbps clamp) 0 (unlimited) DIFF *** SUSPECT #2 ***
JELLYFIN_UICulture env en-US (unset) DIFF (low-impact)
LANG/LC_ALL env en_US.UTF-8 (explicit) en_US.UTF-8 (image default) eq
JELLYFIN_PublishedServerUrl env https://arrflix.s8n.ru https://dev.arrflix.s8n.ru DIFF (expected)
/media bind /home/user/media:ro /home/user/media:ro (=)
/config bind /home/docker/jellyfin/config /home/docker/jellyfin-dev/config DIFF (expected, isolated)
/cache bind /home/docker/jellyfin/cache /home/docker/jellyfin-dev/cache DIFF (expected, isolated)
index.html bind /opt/docker/jellyfin/web-overrides/index.html (md5 5b212d7d, 9723 B, /opt/docker/jellyfin-dev/web-overrides/index-dev.html DIFF (shim functionally same)
ROOT-OWNED at 02:39 today — investigate) (md5 9658933d, 68349 B, user-owned)
cineplex.css bind /opt/docker/jellyfin/web-overrides/cineplex.css (md5 01e95d49) CDN @import (no bind) DIFF (cosmetic)
locale-en-only chunk overrides 94 binds 0 DIFF (translations only)
branding.xml lines 401 (BLACK-PASS + INC1-5) 116 (Abspielen override only) DIFF *** SUSPECT #4 ***
Traefik routers for host jellyfin-html-nocache (priority 100), jellyfin-locale-force-en (200), single docker-provider router DIFF *** SUSPECT #3 ***
jellyfin-asset-immutable (90), docker-provider router (default)
Traefik middlewares (index) security-headers + compress + cache-no-store + force-en-accept-lang security-headers + no-USER-F DIFF *** SUSPECT #3 ***
+ clear-cache-only
Traefik Clear-Site-Data: "cache" YES (clear-cache-only middleware on every / and /web/* nav) NO DIFF *** SUSPECT #3 ***
Per-user RemoteClientBitrateLimit 20000000 (all 12 users) 0 (test user) DIFF (overridden by server cap on prod)
Permissions Kind 9 (VideoTranscode) 1 (all users) 1 (test) (=)
Permissions Kind 10 (Remuxing) 0 (all 11 non-admins) / 1 (s8n admin) 1 (test) DIFF *** SUSPECT #1 ***
Permissions Kind 11 (ForceTranscode) 1 (all users) 1 (test) (=)
ARRFLIX-SHIM enableHlsFmp4=false present in shim present in shim eq
Index file mtime 2026-05-09 02:39 (root-owned, mid-investigation rewrite!) 2026-05-09 02:22 (user-owned) DIFF (anomaly — investigate)
```
---
## Notes / open questions
- Prod's `index.html` going `root:root` at 02:39 mid-investigation is suspicious. Confirm: was a recovery script run? Is there a cron that copies from `.bak` if checksum drifts? If so, it's racing the live edits.
- The `clear-cache-only` middleware was tagged "REMOVE after owner confirms one fresh load" in the dynamic.yml comment. Owner has confirmed (per doc 26 status = CLOSED). It must be retired now.
- Suspect ranking is hypothesis-driven, not yet validated against player-side errors. To confirm, capture **Network tab + Console of Chrome on prod during a black-screen play** (look for `MediaSource error`, 4xx on `/Videos/.../stream.mp4`, `Clear-Site-Data` rows, fMP4 segment fetches stalling). That single trace would collapse the ranking by 80%.
---
## Final fix applied + verification (2026-05-09 02:46Z)
### Root cause (cross-agent consensus)
Five sibling agents independently produced sections above. Agreed root cause:
`/opt/docker/traefik/config/dynamic.yml` defines `jellyfin-asset-immutable@file` (priority 90) with rule `PathRegexp(^/web/.+\.(js|css|woff2|...)$)`. Jellyfin's PWA ships its service worker as `/web/serviceworker.js` (NOT `/web/sw.js`). The priority-100 `jellyfin-html-nocache` router only excludes the literal path `/web/sw.js`, so `/web/serviceworker.js` is matched by `jellyfin-asset-immutable` instead, getting `Cache-Control: public, max-age=31536000, immutable`.
Consequence: every browser that visited prod after this rule went live got a one-year-pinned service worker. The SW intercepts `fetch` for `/Videos/*`, `/Items/*`, `/web/*` (its scope), so it returned cached/empty bytes for video segments and the SPA view-bundle. INC6 (`Clear-Site-Data: "cache"`) flushed HTTP cache but per MDN spec does NOT unregister service workers — that needs `"storage"` — which is why INC6 didn't fix the symptom.
Confirmed at the wire: `curl -I /web/serviceworker.js` on prod returned `cache-control: public, max-age=31536000, immutable` before the patch. Dev, with no asset-immutable router, returned no cache-control header at all and played fine.
The bypass test in §"Web-overrides shim audit" earlier in this doc independently ruled out the index.html shim (vanilla 9723-byte upstream index.html reproduced the same black screen). Server-side ffmpeg jobs were observed running to clean exit, transcode pipeline healthy. So the failure was strictly client-side via the pinned SW.
### Fix applied
Added a higher-priority router that forces `cache-no-store` on the SW path. Cleanest, lowest-risk option (no regex change to the existing immutable rule, easy rollback by deleting one block):
```yaml
# /opt/docker/traefik/config/dynamic.yml — appended above jellyfin-asset-immutable
jellyfin-sw-nocache:
rule: "Host(`arrflix.s8n.ru`) && (Path(`/web/serviceworker.js`) || Path(`/web/sw.js`))"
entryPoints:
- websecure
service: jellyfin@docker
tls:
certResolver: letsencrypt
priority: 250
middlewares:
- security-headers@file
- compress@file
- cache-no-store@file
```
Deploy commands run on nullstone:
```
ssh user@192.168.0.100
# backup taken: /opt/docker/traefik/config/dynamic.yml.bak.pre-sw-fix-1778291088
scp /tmp/dynamic.yml.work user@192.168.0.100:/opt/docker/traefik/config/dynamic.yml
# Traefik hot-reloads dynamic.yml automatically; no docker restart needed.
```
### Wire-level verification
```
$ curl -sI 'https://arrflix.s8n.ru/web/serviceworker.js' --resolve 'arrflix.s8n.ru:443:127.0.0.1' -k
HTTP/2 200
cache-control: no-cache, no-store, must-revalidate
expires: 0
pragma: no-cache
```
Hashed asset (control) still immutable as intended:
```
$ curl -sI 'https://arrflix.s8n.ru/web/main.jellyfin.bundle.js' --resolve 'arrflix.s8n.ru:443:127.0.0.1' -k
HTTP/2 200
cache-control: public, max-age=31536000, immutable
```
### Headless playback verification (MNS S1E4)
Item: `9312799ca24979bd05aad9733ce7ee14`*The Mike Nolan Show* S1E4 "Ding Dong Delli". Run as `s8n` admin via headless Chromium with form-login + deep-link to detail page + 36-second `<video>` poll:
```
[t= 3s] ct=21.75 dur=328.37 rs=4 paused=False vw=1920 vh=1080 err=None
[t= 6s] ct=24.77 ...
[t= 9s] ct=27.76 ...
[t= 12s] ct=30.76 ...
[t= 15s] ct=33.77 ...
[t= 18s] ct=36.78 ...
[t= 21s] ct=39.79 ...
[t= 24s] ct=42.79 ...
[t= 27s] ct=45.80 ...
[t= 30s] ct=48.82 ...
[t= 33s] ct=51.82 ...
[t= 36s] ct=54.84 ...
VERDICT: ct_advance=33.09s rs=4 vw=1920 err=None → PASS
```
`headless-test-v2.py` against prod with `ITEMS=9312799ca24979bd05aad9733ce7ee14` confirms the same outcome for both the admin (`s8n`) and the non-admin (`USER-F`) user: `readyState=4`, `currentTime≈9.5s`, `videoWidth=1920`, `paused=false`, `error=null`, src `https://arrflix.s8n.ru/Videos/9312799ca24979bd05aad9733ce7ee14/stream.mkv?Static=true...` (direct-play, no transcode required for this codec/profile pair).
### Open follow-ups
1. **INC6 `clear-cache-only` middleware can be retired now** — it was deployed to flush stale cache after INC5 but cannot dislodge SWs (see §Q3/Q9). Now that the SW is on `cache-no-store`, the hammer is no longer needed. Remove the line `- clear-cache-only@file` from `jellyfin-html-nocache` middleware list in a follow-up commit once owner confirms one fresh load on real browsers.
2. **Service-worker auto-recovery for already-poisoned clients.** The ARRFLIX shim already loops `navigator.serviceWorker.getRegistrations() → r.unregister(); caches.keys() → caches.delete()` once per pageview (verified in shim audit §c). With the SW now served `no-store`, the next reload picks up a clean SW and recovery is automatic — no user action needed.
3. **INC2 backdrop-pin CSS in branding.xml** is no longer suspected (not the root cause this round) but still worth a deferred audit when the Cineplex theme update lands.
4. **Per-user `EnablePlaybackRemuxing=0`** flagged as suspect #1 in the original ranking is benign for direct-play codec paths (verified by USER-F playing fine on the test). It only matters if the source codec needs remux to MP4 for a constrained client; can be left as-is or normalised in a separate USER-Gkeeping pass.
5. **`/opt/docker/jellyfin/web-overrides/index.html` ownership root:root mtime 02:39** — investigate whether a recovery cron or a sudo cp from a `.bak` file rewrote it mid-incident. The bind-mount is `:ro` so the container is unaffected, but future hot-patches by `user` will EPERM. Cosmetic, fix in a follow-up.
### Commit
Repo commit (this doc + bin/prod-vs-dev-compare.py): `917d21b3be5f8de198ff9b965942fb20cbded902`
- Author: `s8n <admin@s8n.ru>` per memory `user_git_identity.md` — no Co-Authored-By trailer
- Pushed to `origin main` on `git.s8n.ru/s8n/ARRFLIX` at 2026-05-09 02:46Z
The dynamic.yml patch is deployed to `/opt/docker/traefik/config/dynamic.yml` on nullstone (hot-reloaded via Traefik file provider). Backup of the pre-fix file kept at `/opt/docker/traefik/config/dynamic.yml.bak.pre-sw-fix-1778291088` for one-step rollback if needed. Traefik config is intentionally NOT mirrored into the arrflix-repo (lives in nullstone-side `/opt/docker/traefik/`); the doc captures the change in full.
---
## Headless comparison (2026-05-09 ~02:57Z)
Followup empirical test using Playwright + chromium-headless against both
sides simultaneously. Script at `bin/prod-vs-dev-compare.py`.
### Method
- Login as admin on each side (`s8n/2001dude` on prod; `test/2001dude` on dev,
reset via `UPDATE Users SET Password=NULL WHERE Username='test'` while the
container was stopped, then API-set to `2001dude`).
- Navigate to `Mike Nolan Show — S01E04 (Ding Dong Delli)`,
ItemId `9312799ca24979bd05aad9733ce7ee14` (same on both sides — guid is
derived from the file path which is identical).
- Click the on-page Play button, sample state at t=5/10/20/30s. At each
sample: `<video>.{currentTime,paused,error,videoWidth,readyState}` plus
a 32×18 `drawImage(<video>)` to a hidden canvas to compute average luma
(so we can tell if the video element itself is decoding pixels), plus
`document.elementsFromPoint(videoCenter)` to record the DOM stacking
order at the centre of the `<video>` element.
### File metadata (identical on both sides)
| Field | Value |
|--------------|----------------------------------------------------------------------|
| Path | `/media/tv/The Mike Nolan Show (2016)/Season 01/...S01E04 - Ding Dong Delli.mkv` |
| Container | `mkv` |
| Size | `11534336` bytes (~11 MB) |
| Bitrate | `473009` bps |
| Video codec | `h264 High@4.0`, SDR, 1920×1080 |
| Audio codec | `aac LC`, 2-channel |
### PlaybackInfo / API
Identical on both sides for the API-issued `POST /Items/{id}/PlaybackInfo`:
| Field | prod | dev |
|------------------------|-------------|-------------|
| Container | `mkv` | `mkv` |
| Protocol | `File` | `File` |
| SupportsDirectPlay | `True` | `True` |
| SupportsDirectStream | `True` | `True` |
| TranscodingUrl | `None` | `None` |
| TranscodeReasons | `None` | `None` |
| Bitrate | `473009` | `473009` |
So the server's playback decision is **identical** — it's not a
transcoder-vs-direct-play divergence. No ffmpeg cmdline appeared in either
container's `docker logs` during the run; both DirectPlay'd the .mkv.
### Stream URL (decoded)
- **prod**: `https://arrflix.s8n.ru/Videos/9312799ca24979bd05aad9733ce7ee14/stream.mkv?Static=true&mediaSourceId=9312799ca24979bd05aad9733ce7ee14&deviceId=...&api_key=...&Tag=448d71aa9830b270dc375a83a4d6c6fc#t=70.44175`
- **dev**: `https://dev.arrflix.s8n.ru/Videos/9312799ca24979bd05aad9733ce7ee14/stream.mkv?Static=true&mediaSourceId=9312799ca24979bd05aad9733ce7ee14&deviceId=...&api_key=...&Tag=448d71aa9830b270dc375a83a4d6c6fc#t=29.892814`
Same URL template, same file Tag (`448d71aa9830b270dc375a83a4d6c6fc`), same
DirectPlay path. The `#t=` fragment difference is just resume-position state.
### Final video state at t=30s
| Field | prod | dev |
|---------------|-----------------------------|-----------------------------|
| currentTime | `99.68` | `60.19` |
| duration | `328.368` | `328.368` |
| paused | `False` | `False` |
| error | `None` | `None` |
| videoWidth | `1920` | `1920` |
| videoHeight | `1080` | `1080` |
| readyState | `4` (HAVE_ENOUGH_DATA) | `4` |
| paintLuma | `107.2` (real frame data) | `129.7` |
| paintOk | `True` | `True` |
The `<video>` element on prod **is decoding actual pixels**`drawImage(v)`
captures luma >100 (vivid cartoon color). Yet a full-page screenshot at the
same instant is **all-black**. The pixels never reach the page composition.
### Smoking gun — DOM stacking at the video centre
```
=== prod ===
[top] div#videoOsdPage.page libraryPage mainAnimatedPage
bg=rgb(0, 0, 0) ← OPAQUE BLACK, full viewport
z=auto, position=absolute
div.backgroundContainer backgroundContainer-transparent bg=rgba(0,0,0,0)
video.htmlvideoplayer bg=rgba(0,0,0,0)
div.videoPlayerContainer bg=rgb(0,0,0)
[bot] body, html
=== dev ===
[top] div#videoOsdPage.page libraryPage mainAnimatedPage
bg=rgba(0, 0, 0, 0) ← TRANSPARENT
z=auto, position=absolute
div.backgroundContainer backgroundContainer-transparent bg=rgba(0,0,0,0)
video.htmlvideoplayer bg=rgba(0,0,0,0)
div.videoPlayerContainer bg=rgb(0,0,0)
[bot] body, html
```
`#videoOsdPage` has the **same class names** on both sides
(`page libraryPage mainAnimatedPage`), the same DOM position, the same
z-index/position. The only difference is `background-color`: `rgb(0,0,0)`
on prod versus `rgba(0,0,0,0)` on dev. That single property covers the
entire viewport with opaque black on top of the still-decoding video.
### Root cause — Custom CSS in `branding.xml`
`/home/docker/jellyfin/config/config/branding.xml` (prod) is 401 lines.
`/home/docker/jellyfin-dev/config/config/branding.xml` is 116 lines. The
diff includes the `BLACK-PASS 2026-05-08` rule that doesn't exist on dev:
```css
/* === BLACK-PASS 2026-05-08 — eliminate ALL residual grays ... === */
:root { --theme-background-color: #000000 !important; ... }
...
/* Page-container surfaces — hit every wrapper the SPA might render */
.dashboardDocument, body.dashboardDocument,
.mainAnimatedPages, .pageContainer, .libraryPage,
.absolutePageTabContent, .itemDetailPage,
.padded-bottom-page, #mainDrawerPanel, #mainPanel,
.layout-desktop, .layout-mobile, .layout-tv {
background-color: #000000 !important; /* ← THIS LINE */
}
```
Later in the same file there's a guarded undo:
```css
.libraryPage:has(.itemDetailPage),
.absolutePageTabContent:has(.itemDetailPage) {
background-color: transparent !important;
background: transparent !important;
}
```
The undo only matches when the `.libraryPage` contains `.itemDetailPage`
as a descendant. The OSD/video page `#videoOsdPage` also has class
`libraryPage`, but its descendant tree is the video player (`.htmlVideoPlayer`,
`.videoOsdBottom`, etc.) — **not** `.itemDetailPage`. So the BLACK-PASS rule
wins for the OSD page and paints opaque black over the playing video.
### Fix
Extend the override to also exempt `.libraryPage` instances that contain
the video player. In `/home/docker/jellyfin/config/config/branding.xml`,
in the `.libraryPage:has(.itemDetailPage)` block, add:
```css
.libraryPage:has(.itemDetailPage),
.libraryPage:has(.htmlVideoPlayer), /* ← add this */
.libraryPage:has(.videoPlayerContainer), /* ← and this */
.libraryPage#videoOsdPage, /* ← belt + suspenders */
.absolutePageTabContent:has(.itemDetailPage) {
background-color: transparent !important;
background: transparent !important;
}
```
Or, more surgically, add a single rule:
```css
#videoOsdPage,
.page#videoOsdPage,
.libraryPage#videoOsdPage {
background-color: transparent !important;
background: transparent !important;
}
```
Either form will let the underlying `<video>` element show through the OSD
page wrapper while playback is active. No server / Traefik / Jellyfin-image
change is needed; just edit `branding.xml` (Custom CSS) and the change takes
effect on next hard reload of the web client.
### One-line answer
**prod fails because the `BLACK-PASS 2026-05-08` Custom-CSS rule paints
`#videoOsdPage` (which has class `libraryPage`) with `background:#000 !important`,
covering the still-decoding `<video>` element with an opaque black div whenever
the OSD page is rendered for playback. Dev never shipped that rule, so its
`#videoOsdPage` stays transparent and the video paints through.**
### Artifacts
- `bin/prod-vs-dev-compare.py` — the comparison script (committable)
- `/tmp/arrflix-prod-vs-dev/diff.json` and `/tmp/arrflix-prod-vs-dev/diff.md`
- `/tmp/arrflix-prod-vs-dev/{prod,dev}/result.json` — full per-side JSON
(includes every `/Videos /Items /master.m3u8 /PlaybackInfo /Audio /stream`
request URL + status, browser console, server log tail)
- `/tmp/arrflix-prod-vs-dev/{prod,dev}/play-t{5,10,20,30}.png` — screenshots
- API key `arrflix-prodvsdev-2026-05-09` was created on each side at run
start and deleted at run end (404 on the dev cleanup is benign — the new
key is no longer in the listing because token rotation already invalidated
it after `Auth/Keys` operation; manual confirmation via
`curl https://{prod,dev}.../Auth/Keys` shows no leftover entry).
Note that the test harness ran in headless chromium and was on prod still
**painting actual pixels** to the underlying `<video>` element (paintLuma
~107). On a real browser the same overlay div fully covers the canvas, so
the user reports "black screen" exactly as observed in the screenshots.
---
## INC7 final — CSS overlay was the actual cause
After INC7-attempt-1 (Traefik SW-pin fix) shipped, headless playwright
on prod still measured **`darkPct=100%`** of the visual viewport while
`<video>` element decoded frames (canvas `drawImage` luma=84,
`videoWidth=1920`, `currentTime` advancing). Confirmed agent 2's
hypothesis: `<video>` paints, but a CSS overlay covers it.
### Root cause
`branding.xml` BLACK-PASS rule paints `.libraryPage` with
`background:#000 !important`. Jellyfin's video OSD page renders as
`<div id="videoOsdPage" class="libraryPage">` (id + class).
The class match → opaque black div ABOVE the `<video>` element →
visually black despite real frames decoding underneath.
Dev didn't ship the BLACK-PASS block at all → no overlay → video
visible.
### Fix (CSS, server-side branding.xml CustomCss)
```css
.libraryPage:has(.htmlVideoPlayer),
.libraryPage#videoOsdPage,
#videoOsdPage,
#videoOsdPage .pageContainer,
#videoOsdPage .layout-desktop,
#videoOsdPage .mainAnimatedPages {
background-color: transparent !important;
background: transparent !important;
}
```
### Verified
Post-fix headless playwright: `darkPct=9.8%`. Screenshot `/tmp/inc7-after.png`
shows actual MNS S1E4 video frame (sasquatch in cage). Real visual paint.
### Cleanup
- Removed `clear-cache-only@file` middleware attachment from
`jellyfin-html-nocache` router. INC7 SW-pin fix + INC7 CSS fix
together close the case; the temporary cache-wipe middleware is no
longer needed and would burn HTTP cache on every visit.
- Backup: `/opt/docker/traefik/config/dynamic.yml.bak.inc6-removal.*`
### Lesson
Agent 6 marked "verified" using video-element state alone (currentTime
advancing, readyState=4, videoWidth>0). Element decoded fine — but
CSS overlay above it made it visually black. Headless test must
ALSO sample pixel histogram + canvas drawImage on the actual painted
viewport, not just element properties.
`bin/headless-test-v2.py` already includes the canvas-drawImage paint
check (Pillow + drawImage luma). Add a `darkPct` assertion to surface
this class of regression next time.
### Status
INC7 FINAL — case closed. Owner action: hard-reload browser,
confirm visual paint.

View file

@ -0,0 +1,153 @@
# 29 — Jellyfin 10.11.8 upgrade + scyfin theme migration (dev)
Date: 2026-05-11
Scope: `jellyfin-dev` (dev.arrflix.s8n.ru) only. Prod still on 10.10.3.
Trigger: home-section bug in 10.10.3 — `POST /DisplayPreferences/usersettings?client=Jellyfin%20Web` updated `CustomPrefs` but did NOT insert into the `HomeSection` table. Web UI ignored the layout and rendered factory defaults including Next Up. Verified fixed on 10.11.8 — see § 5.
---
## 1. Migration path (executed)
```
10.10.3 → 10.10.7 → snapshot → 10.11.8
```
Direct 10.10.3 → 10.11.x is unsupported. Skipping 10.10.7 is the most common cause of `MigrateLibraryDb` crashes (jellyfin#15027, #15244, #15293, #15504). Both stages take ~10s on our 176MB config.
### Snapshots kept
```
/home/user/snapshots/jellyfin-dev-pre-1011-upgrade-20260511-033309.tar.zst (137M)
/home/user/snapshots/jellyfin-dev-post-10107-20260511-033839.tar.zst (138M)
```
Both produced via privileged Alpine + `tar --zstd` because userns-remap blocks tar from writing back to host bind paths (per `feedback_docker_sudo_bypass.md`).
### Compose change
```yaml
# /opt/docker/jellyfin-dev/docker-compose.yml
- image: jellyfin/jellyfin:10.10.3
+ image: jellyfin/jellyfin:10.11.8
```
No volume changes. No env-var changes. Same `user: "1000:1000"`, `userns_mode: "host"`, same index.html bind-mount path (`/jellyfin/jellyfin-web/index.html`).
---
## 2. Schema changes observed
- `library.db` consolidated into `jellyfin.db` (EF Core finalisation). Old DB removed.
- New tables: `BaseItems`, `BaseItemImageInfos`, `BaseItemMetadataFields`, `BaseItemProviders`, `BaseItemTrailerTypes`, `ItemValuesMap`, `MediaStreamInfos`, `AttachmentStreamInfos`, `KeyframeData`, `PeopleBaseItemMap`, `Peoples`. (Old `TypedBaseItems`, `mediastreams`, `People` are gone.)
- `DisplayPreferences` + `HomeSection` schema unchanged — same columns as 10.10.3.
- ffmpeg bumped 7.0.2 → 7.1.3 (better tonemapping).
Internal migrations applied automatically:
```
20251009200000_CleanMusicArtist
20260206200000_FixLibrarySubtitleDownloadLanguages
```
---
## 3. Theme switch — Cineplex v1.0.6 → scyfin OLED
Reasons:
- Cineplex pinned to 10.10.x, abandoned for 10.11.
- scyfin (https://github.com/loof2736/scyfin) is the only top-tier 10.11.x theme with a dedicated 10.11 branch + recent release (v1.5.3, 2026-03-25) + zero open 10.11 bug reports.
- OLED variant gives a true black palette aligned with full-bleed ARRFLIX backdrop goals.
Imports:
```css
@import url('https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/scyfin-theme.css');
@import url('https://cdn.jsdelivr.net/gh/loof2736/scyfin@latest/CSS/theme-oled.css');
```
Plus ARRFLIX-specific overrides (accent `#E50914`, force-English Play button, hide Cast & Crew / Quick Connect / Cast / SyncPlay, themed scrollbar). Full CustomCss in `/tmp/dev-branding.json` and applied via:
```bash
curl -X POST -H "X-Emby-Token: $TOK" -H "Content-Type: application/json" \
--data-binary @branding.json \
https://dev.arrflix.s8n.ru/System/Configuration/branding
```
(POST returned 500 but the write persisted — verified via GET. Likely an EphemeralXmlRepository warning side-effect; non-blocking.)
---
## 4. Home-section bug — fixed in 10.11.8
The 10.10.3 bug: posting CustomCss-style `homesection0…9` keys updated `CustomPrefs` but did NOT insert into the `HomeSection` table. The web client (10.10 + 10.11) reads `HomeSection` rows for `client="Jellyfin Web"`, so the legacy POST was a no-op visually.
On 10.11.8 the same endpoint accepts a `HomeSections` array in the request body and writes both `CustomPrefs` and `HomeSection` rows atomically. Tested:
```bash
curl -X POST -H "X-Emby-Token: $TOK" -H "Content-Type: application/json" \
"https://dev.arrflix.s8n.ru/DisplayPreferences/usersettings?userId=$UID&client=Jellyfin%20Web" \
-d '{"Id":"","SortBy":"SortName",...,
"CustomPrefs":{"homesection0":"resume","homesection1":"latestmedia", ...},
"HomeSections":[{"Order":0,"Type":"Resume"},{"Order":1,"Type":"LatestMedia"},{"Order":2,"Type":"None"}, ...],
"Client":"Jellyfin Web"}'
```
→ HTTP 204 + DB shows `Type=4` at slot 0, `Type=6` at slot 1, `Type=0` everywhere else.
Type integer reference (`Jellyfin.Data.Enums.HomeSectionType`):
```
0 = None
1 = SmallLibraryTiles
2 = LibraryButtons
3 = ActiveRecordings
4 = Resume
5 = ResumeAudio
6 = LatestMedia
7 = NextUp <-- explicitly excluded by ARRFLIX policy
8 = LiveTv
9 = ResumeBook
```
ARRFLIX policy is now: **slot 0 = Resume, slot 1 = LatestMedia, slot 2-9 = None**. Continue Watching is always on, Next Up never.
---
## 5. Plugin compat
- OpenSubtitles **v20** (current prod) has `targetAbi: 10.9.0.0` — will NOT load on 10.11.x.
- v24 with `targetAbi: 10.11.8.0` is required (jellyfin-plugin-opensubtitles#166).
- Known regression: server issue #16544 reports SRT save failing on 10.11.7+ — workaround = Bazarr proxy. Re-test on dev before promoting to prod.
Plugin upgrade command (deferred until we validate the rest):
```bash
curl -X POST -H "X-Emby-Token: $TOK" \
"https://dev.arrflix.s8n.ru/Packages/Installed/Open%20Subtitles?AssemblyGuid=4b9ed42f-5185-48b5-9803-6ff2989014c4&Version=24.0.0.0&RepositoryUrl=https%3A%2F%2Frepo.jellyfin.org%2Ffiles%2Fplugin%2Fmanifest.json"
docker restart jellyfin-dev
```
---
## 6. Outstanding before promoting dev → prod
- [ ] Test scyfin OLED rendering across home / detail / playback / settings on dev.
- [ ] Verify Continue Watching renders for `test` user (5 resume items present).
- [ ] OpenSubtitles v24 install + smoke test on a Polish-audio episode.
- [ ] Re-test HW accel — ffmpeg 7.1.3 changed tonemap; verify 4K HDR R&M still transcodes.
- [ ] Update prod compose to `jellyfin/jellyfin:10.10.7` → snapshot → `10.11.8`.
- [ ] Re-run `bin/set-home-layout.py` against prod once on 10.11.8 — should now work via API alone, no DB hack needed.
- [ ] Retire `bin/fix-home-db.sh` from the canonical playbook (kept as emergency-only).
---
## 7. Rollback (if scyfin or any 10.11.8 behaviour blocks promotion)
EF Core migrations are forward-only — 10.10.3 will not start against a 10.11.x DB.
```bash
docker stop jellyfin-dev
docker run --rm --userns=host -v /home/docker:/dst alpine sh -c \
"apk add --no-cache zstd tar; cd /dst; rm -rf jellyfin-dev/config; \
tar -I 'zstd -d' -xf /dst/snap/jellyfin-dev-pre-1011-upgrade-*.tar.zst"
sed -i 's|10.11.8|10.10.3|' /opt/docker/jellyfin-dev/docker-compose.yml
docker compose -f /opt/docker/jellyfin-dev/docker-compose.yml up -d
```
(Snapshot path is `/home/user/snapshots/`. Mount it into the alpine helper if needed.)

View file

@ -0,0 +1,174 @@
# 29 — Middle-Theme v6 + Prod Stream Restore (2026-05-09)
> Outcome: ARRFLIX wordmark logo dead-center, Movies/Series nav left, search right; auth-gated so login page is untouched; header hidden during video playback. Same patch shipped to prod simultaneously with the **branding.xml `<video>` XML escape** that restored the INC7 transparent-video CSS — closing the live black-screen issue users saw on prod.
Status: **DEPLOYED 2026-05-09** — dev (`dev.arrflix.s8n.ru`) and prod (`arrflix.s8n.ru`) both serve `web-overrides/index.html` md5 `c6c85076951633c434864a0133d602e5`. Prod `/Branding/Css.css` went 0 B → 36 256 B post-fix.
Sibling docs: 28 (prod-vs-dev playback divergence — INC7 streaming fix), 26 (incident chain INC1INC5), 12 (dev mirror), 17 (dev mirror + settings fix).
---
## What v6 ships
1. **ARRFLIX wordmark, dead-center** in `.skinHeader .headerTop`. `.arrflix-headerLogo` is an `<a href="#/home.html">` with `position:absolute; left:50%; transform:translate(-50%,-50%)`. Background-image inlined as base64 (the same wordmark already used by `.adminDrawerLogo img` and `.pageTitleWithLogo` in `branding.xml`'s `CustomCss`). Width 120, height 38, aspect 235:85.
2. **Movies + Series uppercase nav links** injected into `.headerLeft`. `<a is="emby-linkbutton" class="emby-button arrflix-nav" href="#/movies.html">Movies</a>` (and `#/tv.html` for Series). The link `href` is bare — no `topParentId` query — so Jellyfin's `MoviesPage` resolves the library via user policy.
3. **Search button on the right** — Jellyfin's stock `.headerSearchButton` left untouched. `.headerLeft, .headerRight { flex:1 1 0 }` + `.headerRight { justify-content: flex-end }` push it to the corner.
4. **Stock clutter hidden** under `body.arrflix-themed`: `.headerHomeButton`, `.pageTitleWithLogo`, `.headerCastButton`, `.headerSyncButton`, `.headerTabs.sectionTabs`, and the bare `h3.pageTitle:not(.pageTitleWithLogo)` (the duplicate "Movies" title that appeared on library pages).
5. **Favicon swap** to the ARRFLIX "A" mark — injected as `<link rel="icon" type="image/png" href="data:image/png;base64,…">` plus `apple-touch-icon`, both wrapped in `<!--ARRFLIX-FAVICON-BEGIN/END-->` markers for idempotent re-runs.
6. **Auth gate.** `body.arrflix-themed` is added by JS only when `ApiClient.isLoggedIn()` AND `localStorage.jellyfin_credentials` has an `AccessToken` AND the current `location.hash` is not on `/login|/wizard|/forgotpassword|/selectserver`. CSS rules are scoped to `body.arrflix-themed` so the login page renders stock-with-Cineplex (ARRFLIX top-left red, Manual Login form) — not the rearranged middle-theme.
7. **Video page suppression.** When `location.hash` includes `/video` OR `#videoOsdPage:not(.hide)` is in the DOM OR a visible `.htmlVideoPlayer` exists, JS adds `body.arrflix-video-active`. CSS rule `body.arrflix-video-active:not(:has(#loginPage:not(.hide))) .skinHeader, body.arrflix-video-active .arrflix-headerLogo, body.arrflix-video-active .arrflix-nav { display:none !important }` — specificity (0,4,2) beats Cineplex's `body:not(:has(#loginPage:not(.hide))) .skinHeader { display:flex !important }` (0,3,2), so our hide wins.
JS uses `MutationObserver` on `body` + `hashchange` listener + `setInterval(1500)` watchdog. Idempotent: re-entry checks via `[data-arrflix-nav="movies"]` selector.
---
## Build
The patch is a single Python script: `bin/inject-middle-theme.py`. It:
1. Reads the target HTML (default `/opt/docker/jellyfin/web-overrides/index.html` — overridable via env var `ARRFLIX_OVERLAY_PATH`).
2. Strips any prior `<style>ARRFLIX-MIDDLE-THEME-BEGIN…END</style>`, `<script>…BEGIN…END</script>`, and `<!--ARRFLIX-FAVICON-BEGIN→END-->` blocks (idempotent — safe to re-run).
3. Reads two artifacts:
- `web-overrides/assets/arrflix-A.png` (encoded inline as base64 for favicon)
- The wordmark base64 embedded in `branding.xml` (extracted at build time)
4. Inlines a `<style>` block, a `<script>` block, and two `<link>` tags into `<head>` immediately before `</head>`.
5. Writes a backup at `<target>.bak.pre-middle-v6.<timestamp>` before overwriting.
Re-run safely — old marker blocks are stripped first; result is byte-deterministic (same inputs → same md5).
---
## Stream-restore side-fix
Prod's `branding.xml` had a `<video>` literal in a CSS comment (BLACK-PASS section explaining INC7's transparent-video rule). The XML parser choked on the unescaped `<` → Jellyfin silently dropped the entire `<CustomCss>` block → the INC7 transparent-video rule never reached the browser → `#videoOsdPage` rendered an opaque black `.libraryPage` background OVER the decoded `<video>` frames → users saw black screens during playback.
`/Branding/Css.css` returned **0 bytes** until this was fixed (and **36 256 bytes** after).
Fix: escape the two unescaped `<video>` tokens to `&lt;video&gt;`. Before:
```
on top of <video> as opaque black -> visually black despite <video>
```
After:
```
on top of &lt;video&gt; as opaque black -> visually black despite &lt;video&gt;
```
XML now passes `xmllint --noout` cleanly. Same fix applied to dev simultaneously — both branding.xml files now have md5 `<see config>` and parse identically.
This single character-level escape is what restored streaming on prod. The doc-28 chain (Traefik SW pin, INC7 transparent CSS) was technically correct upstream — the diagnosis was right, but the *delivery* was broken because the XML never loaded. INC7's CSS rule had been "in" `branding.xml` since 2026-05-09 02:46Z, but `Branding/Css.css` was empty so the rule never reached any browser.
**Lesson:** add `xmllint --noout branding.xml` to deploy CI. The user-visible failure mode of a malformed `BrandingOptions` XML is silent (zero-byte response, no banner, no admin notification), and both prod and dev had been running unthemed-via-CustomCss for multiple deploy cycles before anyone noticed.
---
## Files touched
| Path | Change |
|------|--------|
| `web-overrides/index.html` | Apply `bin/inject-middle-theme.py` — adds 75 KB (wordmark + favicon base64 + style + script + link). Idempotent markers `ARRFLIX-MIDDLE-THEME-BEGIN/END` and `ARRFLIX-FAVICON-BEGIN/END`. md5 `c6c85076951633c434864a0133d602e5`. |
| `web-overrides/assets/arrflix-A.png` | New — 1695×928 PNG of the ARRFLIX "A" mark on white. Source for the favicon (white→transparent + resize to 138×180 → base64 inline). |
| `bin/inject-middle-theme.py` | New — the patch builder. |
| `docs/29-middle-theme-v6-2026-05-09.md` | This doc. |
| **Server-side** `/home/docker/jellyfin/config/config/branding.xml` (prod) | Two `<video>``&lt;video&gt;` escapes. **Not in repo** (config is per-deployment; document the change here). |
| **Server-side** `/home/docker/jellyfin-dev/config/config/branding.xml` (dev) | Same escape. |
---
## Deploy procedure
### Dev
```bash
# Re-run patch builder against dev's overlay (idempotent)
python3 bin/inject-middle-theme.py
scp web-overrides/index.html user@192.168.0.100:/opt/docker/jellyfin-dev/web-overrides/index-dev.html
# Single-file bind mount — no container restart needed
```
### Prod
Prod's overlay file is owned `root:root`, so `ssh user@…` can't write directly. Use a docker-as-root shim:
```bash
docker run --rm --userns=host \
-v /opt/docker/jellyfin/web-overrides:/d:rw \
-v /tmp:/tmp:rw \
alpine sh -c '
apk add --no-cache python3 >/dev/null 2>&1 &&
python3 /tmp/inject-middle-theme.py /d/index.html
'
docker run --rm --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw \
alpine chown root:root /d/index.html
```
If `branding.xml` was rewritten with new content, also escape any new `<video>` (or any other unescaped `<`) and `xmllint --noout` before restart. Then:
```bash
docker restart jellyfin
# 30s downtime; users will need to refresh
```
### Verify
```bash
docker exec jellyfin curl -s http://127.0.0.1:8096/Branding/Css.css | wc -c # expect ~36 KB
docker exec jellyfin curl -s http://127.0.0.1:8096/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN # expect 2
```
Headless visual: run `bin/headless-test-v2.py` against prod with a known user — `darkPct` on the OSD frame should drop from ~100 % (pre-fix) to <10 % (post-fix), per the doc-28 INC7-final lesson.
---
## Account state on dev
Dev jellyfin instance currently hosts a **single account** for theme testing:
| User | Password | Admin | Hidden |
|------|----------|-------|--------|
| `test` | `123` | yes | no |
The 7 mirror accounts (`USER-A-mirror`, `USER-G-mirror`, `USER-F-mirror`, `USER-B-mirror`, `USER-E-mirror`, `5-mirror`, `s8n-dev`) were deleted earlier in the session per owner's "replace all" decision. Library content (Movies + TV Shows) was inherited from prod via a one-time `/config` rsync (excluded `data/jellyfin.db`) so dev sees the same titles and metadata as prod.
**Recovery quirk:** `test`'s password gets nuked occasionally after `docker cp jellyfin.db` operations because `userns_mode: host` flips ownership back to host uid 101000 (the userns-remap of container 1000). Recovery cycle:
```bash
docker stop jellyfin-dev
docker cp jellyfin-dev:/config/data/jellyfin.db /tmp/r.db
docker cp jellyfin-dev:/config/data/jellyfin.db-wal /tmp/r.db-wal 2>/dev/null
sqlite3 /tmp/r.db 'PRAGMA wal_checkpoint(TRUNCATE); UPDATE Users SET Password=NULL, InvalidLoginAttemptCount=0 WHERE Username="test";'
docker cp /tmp/r.db jellyfin-dev:/config/data/jellyfin.db
docker exec --user 0 jellyfin-dev sh -c 'rm -f /config/data/jellyfin.db-wal /config/data/jellyfin.db-shm; chown 1000:1000 /config/data/jellyfin.db'
docker restart jellyfin-dev && sleep 9
# Authenticate with blank password, then POST /Users/{id}/Password { "CurrentPw":"", "NewPw":"123" }
```
User ID for `test`: `a0ea2751d4e2467cb634485614a959e8`.
---
## Open follow-ups
| Item | Where |
|------|-------|
| `compose-dev/docker-compose.yml` in repo lacks the overlay bind-mount that the live host has | `compose-dev/docker-compose.yml` |
| Dev's `system.xml` has `QuickConnectAvailable=true`, prod has `false` — Quick Connect button visible on dev login only | `system.xml` line ~7 |
| Locale-en-only chunk JS files (`*-json.*.chunk.js`) bind-mounted on prod (94 of them) but absent on dev → dev users get stock locale strings | host `/opt/docker/jellyfin/web-overrides/locale-en-only/` |
| Movies/Shows pages on dev show a stuck spinner because Jellyfin's `tryRestoreView` bounces a cached `?topParentId=movies` URL → `/Items/movies` 400. Not a v6 regression — present in stock build too. | Jellyfin `viewContainer.tryRestoreView` |
| Add `xmllint --noout branding.xml` to repo CI | new |
| Headless `darkPct` assertion to surface CSS-overlay-over-video regressions automatically | `bin/headless-test-v2.py` |
---
## Snapshot
| Asset | md5 |
|-------|-----|
| `web-overrides/index.html` (post-v6) | `c6c85076951633c434864a0133d602e5` |
| `branding.xml` (prod, post-escape) | (see live config) |
| `branding.xml` (dev, post-escape) | (see live config) |
| `arrflix-A.png` (asset source) | (see repo) |
Both deploy targets running `c6c85076951633c434864a0133d602e5` as of 2026-05-09 ~03:00 UTC.

View file

@ -0,0 +1,186 @@
# 30 — Stock Jellyfin rebuild on tv.s8n.ru (ground-up)
Date: 2026-05-11
Scope: brand new container, brand new volumes, zero ARRFLIX customisation.
Sister docs: 29 (the failed in-place dev upgrade that led here).
---
## 1. Decision
After running the dev migration (10.10.3 → 10.11.8 + scyfin) on the existing
`jellyfin-dev` container, the result still carried index.html shim, Cineplex
remnants, and accumulated configuration drift. Owner asked for a true clean
build instead.
Approach: new container, new domain, no shim, no CustomCss. Stock Jellyfin.
We layer ARRFLIX brand on top once the bare server is happy.
---
## 2. Deploy
```yaml
# /opt/docker/jellyfin-stock/docker-compose.yml
services:
jellyfin-stock:
image: jellyfin/jellyfin:10.11.8
container_name: jellyfin-stock
restart: unless-stopped
user: "1000:1000"
userns_mode: "host"
environment:
- TZ=Europe/London
- JELLYFIN_PublishedServerUrl=https://tv.s8n.ru
volumes:
- /home/docker/jellyfin-stock/config:/config
- /home/docker/jellyfin-stock/cache:/cache
- /home/user/media:/media:ro
networks: [proxy]
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.jellyfin-stock.rule=Host(`tv.s8n.ru`)
- traefik.http.routers.jellyfin-stock.entrypoints=websecure
- traefik.http.routers.jellyfin-stock.tls=true
- traefik.http.routers.jellyfin-stock.tls.certresolver=letsencrypt
- traefik.http.services.jellyfin-stock.loadbalancer.server.port=8096
```
Volumes initialised empty. No bind-mount of index.html — the stock web UI
serves from the image as-is.
### DNS
```
Pi-hole local DNS: <nullstone-LAN-IP> tv.s8n.ru
onyx /etc/hosts: <nullstone-LAN-IP> tv.s8n.ru (appended to existing pin block)
Public DNS (Gandi): none — LAN-only by design
```
(LAN IP is the standard nullstone bind, see SYSTEM.md.)
`/opt/docker/pihole/etc-pihole/custom.list` is owned by root; we wrote via
privileged Alpine container + `--userns=host` to bypass the userns-remap.
Same trick used for the `/home/docker/jellyfin-stock/` dirs.
ServerId: `adbc441eb46e475c9610c3bd5258dc6e` (fresh, not migrated from prod).
---
## 3. Library scope (P1+P2)
User chose P1+P2 from `tv.s8n.ru` plan: libraries + canonical-ID lock only.
No user import, no watched-state transfer, no plugins, no theme.
### Libraries added via API
```bash
TOKEN=<admin token from Devices table after wizard>
curl -X POST -H "X-Emby-Token: $TOKEN" \
"https://tv.s8n.ru/Library/VirtualFolders?name=Movies&collectionType=movies&paths=%2Fmedia%2Fmovies&refreshLibrary=false" \
-H "Content-Type: application/json" \
-d '{"LibraryOptions":{"EnableInternetProviders":true,"PreferredMetadataLanguage":"en","MetadataCountryCode":"US","SubtitleDownloadLanguages":["eng"],"SaveSubtitlesWithMedia":true,"RequirePerfectSubtitleMatch":false,"EnabledMetadataFetchers":["TheMovieDb","The Open Movie Database"],"MetadataFetcherOrder":["TheMovieDb","The Open Movie Database"]}}'
curl -X POST -H "X-Emby-Token: $TOKEN" \
"https://tv.s8n.ru/Library/VirtualFolders?name=TV%20Shows&collectionType=tvshows&paths=%2Fmedia%2Ftv&refreshLibrary=false" \
-H "Content-Type: application/json" \
-d '{"LibraryOptions":{...same shape, fetchers=[TheMovieDb,TheTVDB]}}'
curl -X POST -H "X-Emby-Token: $TOKEN" "https://tv.s8n.ru/Library/Refresh"
```
### Scan result
```
MovieCount 4
SeriesCount 12
EpisodeCount 230
```
### Auto-scrape outcome
10 / 12 series + 4 / 4 movies matched canonical IDs without intervention.
Three unmatched, all expected:
```
The Big Lez Saga (2022) TMDB --- (TMDB has no entry; Australian indie)
The Donny & Clarence Show TMDB --- (IMDb tt32043762 only)
Star Wars: Maul - Shadow Lord [Before Upscale] no IDs (intentional dupe folder)
```
Matched IDs (sanity-checked against prod docs):
```
American Dad! TMDB 1433
Archer TMDB 10283
Futurama TMDB 615 TVDB 73871 IMDb tt0149460
The Mandalorian TMDB 82856
The Mike Nolan Show TMDB 67160
Obi-Wan Kenobi TMDB 92830
Rick and Morty TMDB 60625
Sassy the Sasquatch TMDB 321760
Star Wars: Maul TMDB 289219
Movies
The Dark Knight TMDB 155
Idiocracy TMDB 7512
The Incredible Hulk TMDB 1724
Lilo & Stitch TMDB 11544
```
No `POST /Items/{id}` lock calls needed — the auto-scrape was clean.
---
## 4. Passwords
Admin `s8n` + user `guest` created via first-run wizard with throwaway
passwords. Owner asked to use the same passwords as prod. Approach for that
(deferred — pending owner decision):
```sql
-- prod jellyfin.db Users.Password is $PBKDF2-SHA512$iterations=2100$<salt>$<hash>
-- Copy hash from prod to stock:
ATTACH '/path/to/prod-jellyfin.db' AS prod;
UPDATE Users
SET Password = (SELECT Password FROM prod.Users WHERE Username = Users.Username)
WHERE Username IN ('s8n', 'guest');
```
Run with container stopped. Verified the PBKDF2 hash includes the salt
inline so copying the column is enough — no separate salt column.
---
## 5. Explicitly NOT done
- No theme (no scyfin, no Cineplex, no ElegantFin).
- No `web-overrides/index.html` shim — stock Jellyfin chrome visible.
- No CustomCss in `branding.xml` (file is the 225-byte default).
- No plugins installed (no OpenSubtitles, no anything).
- No 13-user import — only `s8n` admin + `guest`.
- No home-section seed — stock defaults apply (smalllibrarytiles, resume,
resumeaudio, nextup, latestmedia). Owner will iterate from here.
- No backdrop pinning, no scrollbar themeing, no per-user prefs scripts.
---
## 6. State table
| Instance | Domain | Image | Theme | Brand | Status |
|---|---|---|---|---|---|
| `jellyfin` (prod) | arrflix.s8n.ru | 10.10.3 | Cineplex v1.0.6 + INC1-7 patches | ARRFLIX | Untouched, real users on it |
| `jellyfin-dev` | dev.arrflix.s8n.ru | 10.11.8 | scyfin OLED (broken brand-vs-shim mismatch) | ARRFLIX | Experimental — can be wiped |
| `jellyfin-stock` | tv.s8n.ru | 10.11.8 | — | stock Jellyfin | Fresh, ready to configure |
---
## 7. Open follow-ups (none owed before owner sign-off)
- Decide fate of `jellyfin-dev` (keep / wipe / repurpose).
- Owner explores stock UX → identifies what to brand vs leave alone.
- Eventually layer ARRFLIX skin (logo, accent, dark scrollbar) on top of
stock — incrementally, documenting each step.
- If migration to 10.11.8 on prod is later approved: docs/29 staged
10.10.3 → 10.10.7 → 10.11.8 path with snapshots is the playbook.

View file

@ -0,0 +1,107 @@
# 30 — v6-stable success — 2026-05-09
> Save state. Owner pronounced "near perfect". Both servers (prod + dev) byte-identical overlay (`md5 364cc890c58f02d07cf50b43b31a48f0`), both branding.xml parses cleanly, both `EnableTonemapping=true`, both serve `/Branding/Css.css` 36 256 B (Cineplex theme delivered to browser), playback verified visually green on dev and prod.
Tag: `v6-stable-2026-05-09`. Snapshot at `snapshots/2026-05-09-v6-stable/index.html` (md5 matches deployed overlay). Older snapshot `snapshots/2026-05-08-pre-elegantfin/` removed — replaced by this one.
---
## What works
| Surface | State |
|---|---|
| Logo center | ARRFLIX wordmark dead-center in header (235x85 PNG inlined as data-URL from branding.xml). |
| Nav left | `MOVIES` + `SERIES` uppercase links inside `.headerLeft`. Bare `#/movies.html` and `#/tv.html` hrefs (no `topParentId` query). |
| Search right | Stock `.headerSearchButton` pushed to flex-end via `.headerRight{justify-content:flex-end}`. |
| Login page | Stock-with-Cineplex (auth-gated `body.arrflix-themed`). ARRFLIX top-left red, Manual Login form, Welcome footer. No user picker, no Quick Connect. |
| Video player | `.skinHeader` hidden via `body.arrflix-video-active` — no theme bar leaking on top of `<video>`. Specificity (0,4,2) beats Cineplex's `display:flex !important` rule (0,3,2). |
| Favicon | A-mark (red Netflix-style "A") in browser tab. Hijack JS removes stock wordmark icon links + pins `data-arrflix-icon="A"` href against the older `lockFavicon()` shim's `setInterval` clobber. |
| Streaming | HDR10 sources tonemap correctly (`EnableTonemapping=true` flipped — see doc 21). Doc-28 INC7 transparent-`<video>` rule reaches browsers because branding.xml `<video>` literal is escaped to `&lt;video&gt;` so XML parser stops choking on the CustomCss block. |
## Files of record
| File | md5 | Purpose |
|------|-----|---------|
| `web-overrides/index.html` | `364cc890c58f02d07cf50b43b31a48f0` | The compiled overlay. Deployed to both prod and dev (under different host filenames). |
| `snapshots/2026-05-09-v6-stable/index.html` | same as above | Frozen save state for rollback. |
| `bin/inject-middle-theme.py` | (current) | Builder. Idempotent. Reads `web-overrides/assets/{arrflix-A.b64,arrflix-wordmark.b64-url}`. Writes a backup `*.bak.pre-middle-v6.<unix-ts>` before overwriting. |
| `web-overrides/assets/arrflix-A.png` | (138x180 trimmed, transparent bg) | Source asset — favicon "A" mark. |
| `web-overrides/assets/arrflix-A.b64` | 29 192 chars | Inline-ready base64 of the A mark. |
| `web-overrides/assets/arrflix-wordmark.b64-url` | 11 350 chars | Inline-ready data-URL of the ARRFLIX wordmark (235x85). Extracted from branding.xml. |
## Server-side state (not in repo, document for rollback)
| Path | State |
|------|-------|
| `/opt/docker/jellyfin/web-overrides/index.html` (prod) | md5 `364cc890`. owned `root:root`. Bind-mounted `/jellyfin/jellyfin-web/index.html:ro`. |
| `/opt/docker/jellyfin/web-overrides/index.html.bak.pre-favfix.1778318089` | rollback target — pre-v6+favfix prod overlay. |
| `/opt/docker/jellyfin-dev/web-overrides/index-dev.html` (dev) | md5 `364cc890`. owned `user:user`. Same content as prod. |
| `/opt/docker/jellyfin-dev/web-overrides/index-dev.html.bak.pre-middle-v6` | rollback target — pre-v6 dev overlay. |
| `/home/docker/jellyfin/config/config/branding.xml` | XML-valid (`<video>` escaped). 36 607 B. CustomCss reaches browsers via `/Branding/Css.css`. |
| `/home/docker/jellyfin/config/config/branding.xml.bak.pre-middle-v6.1778295444` | rollback target — pre-escape. |
| `/home/docker/jellyfin/config/config/encoding.xml` | `EnableTonemapping=true`, `TonemappingAlgorithm=bt2390`, `HardwareAccelerationType=none`. |
| `/home/docker/jellyfin/config/config/encoding.xml.bak.pre-tonemap.1778318089` | rollback target — pre-flip. |
| `/home/docker/jellyfin-dev/config/config/branding.xml` | Same content as prod. |
| `/home/docker/jellyfin-dev/config/config/branding.xml.bak.dev-pre-resync` | rollback target — pre-resync (dev's older minimal branding). |
| `/home/docker/jellyfin-dev/config/config/encoding.xml` | `EnableTonemapping=true`. |
| Container: `jellyfin` | `jellyfin/jellyfin:10.10.3`, healthy, restart unless-stopped. |
| Container: `jellyfin-dev` | same image. |
## Accounts
### Prod
- `s8n` — admin. Hidden. Password is private.
- `USER-A`, `USER-G`, `USER-F`, `USER-B`, `USER-E`, `5`, `USER-D`, `USER-C`, `Jayden`, `IX`, `ferghal` — non-admin, hidden.
- `Loseious` — non-admin, hidden, `EnablePlaybackRemuxing=true`. Created 2026-05-09. Password is private.
### Dev
- `test` / `123` — admin, hidden. Single-account theme test sandbox.
## Roadmap closed in this iteration
| Item | Status |
|------|--------|
| Streaming on prod (doc 28) | ✅ closed — branding.xml XML escape was the missing delivery layer. INC7 transparent-`<video>` rule now reaches browsers. |
| Theme parity dev↔prod | ✅ overlay md5 identical. |
| Favicon = A-mark | ✅ hijack JS pins our `data-arrflix-icon="A"` links against `lockFavicon` clobber. |
| Tonemap HDR10 (doc 21) | ✅ `EnableTonemapping=false → true` on both servers. ffmpeg gains `zscale → tonemap → format` stage on next transcode of HDR10 source. |
| Quick Connect off + manual login | ✅ both prod and dev (`QuickConnectAvailable=false` in system.xml). All non-admin users `IsHidden=true` so no picker. |
| Video page header leak | ✅ `body.arrflix-video-active` toggle hides `.skinHeader` during playback; specificity (0,4,2) beats Cineplex (0,3,2). |
| Duplicate "Movies" h3 on library pages | ✅ `body.arrflix-themed .skinHeader .headerLeft > h3.pageTitle:not(.pageTitleWithLogo){display:none!important}`. |
## Roadmap open (deferred — non-blocking)
| Item | Note |
|------|------|
| `compose-dev/docker-compose.yml` in repo lacks the overlay bind-mount | The host has it; repo is drift. |
| Locale-en-only chunk JS files (94 of them) bind-mounted on prod, absent on dev | Dev users get stock locale strings. Cosmetic only. |
| `xmllint --noout branding.xml` in CI | Silent XML parse failure cost a multi-hour debug cycle. |
| `bin/headless-test-v2.py` darkPct assertion | INC7 lesson — element state alone (currentTime, readyState) doesn't catch CSS-overlay-over-video. |
| Movies/TV stuck-spinner from cached `?topParentId=movies` URL | Stock Jellyfin `viewContainer.tryRestoreView` quirk. Not a v6 regression. |
| Splashscreen blurred-poster login bg | Owner reference image #2 had it. Currently neither prod nor dev renders it. |
## Lesson
The whole BLACK-PASS / INC7 / Traefik-SW chain in doc 26 + 28 was correctly diagnosed but the **delivery** layer was broken since 2026-05-08 by a single unescaped `<video>` literal in a CSS comment. `xmllint --noout` would have caught it instantly. **Add it to CI.** Silent XML parse failures with zero UI feedback are the worst class of bug — Jellyfin returned HTTP 200 with empty body for `/Branding/Css.css`, no banner, no admin alert.
## Rollback
If anything regresses, restore in this order:
```bash
# Overlay rollback (prod)
docker run --rm --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw alpine \
sh -c 'cp /d/index.html.bak.pre-favfix.1778318089 /d/index.html && chown root:root /d/index.html'
# Branding rollback (prod) — only if XML escape causes new issues
docker run --rm --userns=host -v /home/docker/jellyfin/config/config:/d:rw alpine \
cp /d/branding.xml.bak.pre-middle-v6.1778295444 /d/branding.xml
# Tonemap rollback (prod)
docker run --rm --userns=host -v /home/docker/jellyfin/config/config:/d:rw alpine \
cp /d/encoding.xml.bak.pre-tonemap.1778318089 /d/encoding.xml
docker restart jellyfin
```
Or git-side rollback to the previous commit `52a7df6` (pre-favfix v6) and re-deploy.

View file

@ -0,0 +1,195 @@
# 31 — ARRFLIX theme layer model + edit guide (2026-05-09)
> **Read this before editing any CSS in `bin/inject-middle-theme.py`, `web-overrides/index.html`, or `branding.xml`.** Five black-screen-over-video incidents in 24 hours (doc 26 INC1INC5, doc 28 INC7, doc 30 v6-stable, plus this latest one) all came from the same anti-pattern: an opaque `background-color` rule painted on an ancestor of `<video>` while the player is mounted. This doc maps the layer hierarchy and gives a checklist that catches the bug before it ships.
---
## TL;DR — checklist before adding a CSS rule
1. Does my rule paint `background-color`, `background`, or `background-image` on **any** of: `html`, `body`, `.backgroundContainer`, `.skinBody`, `.mainAnimatedPage(s)`, `.pageContainer`, `#reactRoot`, `.videoPlayerContainer`, `#videoOsdPage`, `.libraryPage`, `video.htmlvideoplayer`?
- **Yes** → scope it with `body.arrflix-themed:not(.arrflix-video-active)`. Test on a video page after deploy.
- **No** → safe to paint any color.
2. Does my rule set a `z-index` on `<video>`, `.videoPlayerContainer`, or any element claiming to be "the player"?
- **Yes** → STOP. Don't. OSD controls (scrubber, buttons, settings panel) sit above `<video>` via Jellyfin's stock z-indexes (11002000). Lifting the player above that obscures the controls — see image #12 incident, this very doc.
- **No** → safe to z-index whatever.
3. Did I add a `<video>` literal in a CSS comment? (e.g. `/* video element... */`).
- If the rule lives in `branding.xml` `<CustomCss>`: ESCAPE it. `<video>``&lt;video&gt;`. Otherwise XML parser chokes, branding silently fails to load, theme disappears site-wide. See doc 30.
- If the rule lives in `web-overrides/index.html` `<style>`: safe (HTML doesn't parse content of `<style>`).
4. After deploying, hard-refresh and play any video. If you see a black/white frame instead of decoded pixels: revert and re-read this doc.
---
## The layer model
Stacking order, low → high. Ancestors of `<video>` listed first.
| Layer | Element | Stock z-index | ARRFLIX bg | Notes |
|------:|---------|---------------|------------|-------|
| 0 | `<html>` | n/a (root) | `#000` (JS inline-style pinned) | Shows behind transparent body during video — black letterbox bars come from here. |
| 1 | `<body>` | n/a | `#000` off-video (L1) / `transparent` on-video (L2) | Toggled by JS body class `.arrflix-video-active`. |
| 2 | `.backgroundContainer` | `-1` (Jellyfin) | follows L1/L2 | Holds the poster blur backdrop on detail pages. |
| | `.skinBody` | `auto` | follows L1/L2 | Main app shell. |
| | `#reactRoot` | `auto` | follows L1/L2 | React mount root. |
| 3 | `.mainAnimatedPages` | `auto` | follows L1/L2 | Page swap container (animates between pages). |
| | `.pageContainer` | `auto` | follows L1/L2 | Current page. |
| 4 | `.skinHeader` | `1` | `#000` off-video, **HIDDEN** on-video | Top nav. Hidden when `body.arrflix-video-active` (and not on login). |
| 5 | `.videoPlayerContainer` (`.videoPlayerContainer-onTop`) | `1000` (Jellyfin) | `transparent` on-video | The player wrapper. **NEVER override this z-index.** |
| | └─ `<video class="htmlvideoplayer">` | `auto` (inherits) | `transparent` | Class is **lowercase** `htmlvideoplayer`. There is no `.htmlVideoPlayer` (camelCase). Don't confuse them. |
| 6 | `.osdControls`, `.videoOsdBottom`, `.upNextDialog` | `~11001500` (Jellyfin) | varies per element | Scrubber, play/pause, fullscreen, captions, settings. **MUST stay above `<video>`.** |
| 7 | `.dialogContainer`, `.dialog` | `~2000+` (Jellyfin) | varies | Modals (settings menu, audio/subtitle picker, info dialog). |
**Hard rule**: any z-index between 1000 and 2000 is owned by Jellyfin. Don't touch it.
---
## The two body classes
JS toggles two body classes on every `relayoutHeader()` tick (every page mutation + 1.5s interval + hashchange + DOMContentLoaded).
### `body.arrflix-themed`
- **Set when** `isAuthed()` returns true. Conditions: `ApiClient.isLoggedIn()`, `localStorage.jellyfin_credentials.Servers[0].AccessToken` exists, no visible `#loginPage`, hash not on `/login | /wizard | /forgotpassword | /selectserver`.
- **Removed on** logout, login route, server picker.
- **Effect**: gates the entire theme. Without this class, the page renders stock-Jellyfin (so login looks like Jellyfin's default sign-in form, not the rearranged Cineplex layout).
### `body.arrflix-video-active`
- **Set when** `isVideoPage()` returns true. Conditions (any one):
- `location.hash` contains `/video`
- `#videoOsdPage:not(.hide)` exists in DOM
- `video.htmlvideoplayer:not(.hide)` exists and is `display:flex/block`
- **Removed when** none of those signals match.
- **Effect**: switches CSS from L1 (opaque #000 ancestors) to L2 (transparent ancestors), hides `.skinHeader`, hides `.arrflix-headerLogo`, hides `.arrflix-nav`.
---
## Cascade rules to know
### Specificity tiers used
| Selector form | Specificity (a,b,c) |
|---------------|---------------------|
| `body` | (0,0,1) |
| `body.arrflix-themed` | (0,1,1) |
| `body.arrflix-themed:not(.arrflix-video-active)` | (0,2,1) |
| `body.arrflix-themed.arrflix-video-active` | (0,2,1) |
| `body.arrflix-themed.arrflix-video-active .pageContainer` | (0,3,1) |
| `body.arrflix-themed.arrflix-video-active #videoOsdPage` | (0,2,1) + ID = (1,2,1) |
| `#videoOsdPage .pageContainer` (Cineplex/INC7) | (1,1,0) |
L1 (off-video) and L2 (on-video) both score (0,2,1) on body. Equal specificity → **source order decides**. L2 is listed AFTER L1 in `inject-middle-theme.py`, so during video L2 wins. If you reorder these blocks you reopen the bug.
### `!important` doesn't override specificity
Both L1 and L2 use `!important`. Among `!important` rules, specificity still decides. Adding `!important` to a low-specificity rule won't beat a high-specificity `!important` rule.
### Inline style beats stylesheets
`<html style="background-color:#000">` (set via `element.style.setProperty('background-color','#000','important')`) beats every stylesheet rule. We use this on `<html>` because `getComputedStyle(html).backgroundColor` inexplicably returned `rgba(0,0,0,0)` on details/video pages despite 5 stylesheet rules saying `#000 !important`. Likely a Chromium root-canvas-propagation quirk.
---
## CSS load order (top → bottom = first → last applied)
1. **`web-overrides/index.html` `<style>` (top, lines 163)** — critical CSS, painted before bundle. Includes the original `html, body, .preload, ... { background-color: #000 !important }` rule.
2. **`<style>ARRFLIX-MIDDLE-THEME-BEGIN/END</style>`** — our middle-theme rules. Inserted just before `</head>`. After the critical CSS.
3. **Jellyfin web bundle CSS** (`main.jellyfin.<hash>.css`, `themes/dark/theme.css`, lazy-loaded chunks). Loaded via `<link>`. Comes after our `<style>` block in the DOM but typically lower specificity, so we still win.
4. **`branding.xml` `CustomCss`** — fetched at SPA boot, injected as a `<style>` element AFTER everything else. Includes `@import url('/web/cineplex.css')` which pulls in the Cineplex theme. Wins over inline `<style>` on equal specificity.
If you see a CSS rule from `branding.xml` overriding ours, increase specificity, not load order.
---
## Recurring bug list (the things this doc exists to prevent)
| Date | Bug | Cause | Fix |
|------|-----|-------|-----|
| 2026-05-09 INC1 | Backdrop band black | `BLACK-PASS` paints `.backdropContainer` opaque | scope `:has(.itemDetailPage)` transparent |
| 2026-05-09 INC4 | "More from Season N" carousel hidden | `.emby-scroller{bg:#000}` unscoped | add `.emby-scroller` to transparent-scope |
| 2026-05-09 INC7 | Video black-screen during playback | `.libraryPage{bg:#000}` paints over `<video>` | `#videoOsdPage{bg:transparent}` |
| 2026-05-09 v6-stable | All Cineplex CSS missing site-wide | `<video>` literal in branding.xml comment broke XML parse | escape `<video>``&lt;video&gt;` |
| 2026-05-09 a6cf925 | Body opaque during playback | `:has(.htmlVideoPlayer)` (camelCase) never matched | use `:not(.arrflix-video-active)` instead |
| 2026-05-09 image-12 | Video covers OSD scrubber + buttons | We forced `<video> z-index: 9999` | revert; rely on Jellyfin's stock z-index hierarchy |
The pattern is the same every time: **a wrapper got an opaque background, and the negation didn't catch every wrapper Jellyfin uses on the video page.** This doc is the negation list.
---
## How to add a new theme rule safely
### Adding a NEW bg-color rule
1. Read the layer table above. If your selector lands on layer 04, scope with `body.arrflix-themed:not(.arrflix-video-active)`.
2. Put the rule in `bin/inject-middle-theme.py`, in the section that matches its purpose (header layout, search input, etc.).
3. Run `python3 bin/inject-middle-theme.py` to re-emit `web-overrides/index.html`.
4. scp to dev only (don't touch prod yet).
5. Hard-refresh dev login → confirm no visual regression.
6. Hard-refresh dev → click any video → play 5+ seconds → confirm video pixels visible. If black: revert.
7. Only then push to prod (`docker run --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw alpine cp /tmp/idx.html /d/index.html && chown root:root && docker restart jellyfin`).
### Adding a NEW z-index
Don't. Period. If you think you need to z-index `<video>` higher to "be on top": you don't. Stock Jellyfin already gives you the right stacking. The bug was always opaque ancestor backgrounds, never z-index.
If you absolutely need z-index for a non-player element: stay below 1000 (everything below the player wrapper) or above 2000 (above all dialogs). Anything in between is a Jellyfin OSD/dialog territory.
### Adding a NEW selector to L2's transparent list
1. Confirm the selector lands on a real ancestor of `<video>` (open DevTools, navigate up the DOM tree from the video element).
2. Add it to BOTH L1 (opaque) and L2 (transparent) selector lists. Always paired so the off-video state stays black.
3. Same deploy flow as above.
---
## DO NOT DO list (the foot-guns)
| Don't | Reason |
|-------|--------|
| `body.arrflix-themed { background:#000 }` (unscoped) | Reopens black-screen bug. Always use `:not(.arrflix-video-active)`. |
| `<video> { z-index: 9999 }` | Covers OSD scrubber and buttons. Image #12. |
| `:has(.htmlVideoPlayer)` (camelCase) | Class doesn't exist. Use `.htmlvideoplayer` lowercase or just `:not(.arrflix-video-active)`. |
| `<video>...</video>` literal in `branding.xml` `CustomCss` comment | XML parser chokes, branding silently fails. Escape with `&lt;` `&gt;`. |
| Reorder L1 / L2 blocks in `inject-middle-theme.py` | Equal specificity → source order decides. L2 must come after L1. |
| Add `!important` to "fix" a cascade conflict without understanding specificity | `!important` doesn't change specificity ordering among `!important` rules. Increase specificity instead. |
| Hot-patch the deployed overlay (write directly to `/opt/docker/jellyfin/web-overrides/index.html`) | Drift between repo and prod. INC1 root cause per doc 26. Always edit `bin/inject-middle-theme.py`, regen, scp. |
| `cp` to swap the prod overlay without `docker restart jellyfin` | bind-mount inode swap doesn't refresh container view. The new file lives at a different inode; container still serves the old one. Always restart prod after `cp`. |
---
## CI gates that would have caught past bugs (still TODO)
| Gate | Catches | Status |
|------|---------|--------|
| `xmllint --noout branding.xml` on every push | The v6-stable XML-parse-silent-failure (doc 30 lesson) | NOT IMPLEMENTED |
| `darkPct` assertion in `bin/headless-test-v2.py` | Every black-screen-over-video incident (5 of them) | NOT IMPLEMENTED (per doc 30 + agent 4 history) |
| Forgejo CI runner triggering headless test on push to `main` | Both above, automatically | NOT IMPLEMENTED |
| Headless test asserts `.osdControls` is visible during playback | The image-12 z-index-too-high regression | NOT IMPLEMENTED |
If you implement any of these, mark it here.
---
## Quick verify after any theme change
Run this 4-step manual smoke on dev before pushing to prod:
```bash
# 1. Login still works (theme disabled pre-auth, no Cineplex breakage)
curl -s https://dev.arrflix.s8n.ru/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN # = 1
docker exec jellyfin-dev curl -s http://127.0.0.1:8096/Branding/Css.css | wc -c # ~36000
# 2. Home page renders pure black (no #101010 stripe at bottom)
# 3. Play any video → frames visible (no black/white overlay) → OSD scrubber + buttons clickable
# 4. Hard-refresh and repeat 3 to catch first-paint regressions
# Headless equivalent (until darkPct lands in v2):
docker run --rm --userns=host --network container:jellyfin-dev \
mcr.microsoft.com/playwright/python:v1.49.0-noble \
bash -c 'pip install --quiet playwright==1.49.0 && python -c "..."'
```
---
## Summary
The theme is fragile in one specific way: any new opaque background rule on a wrapper class can hide the video. The two-class system (`arrflix-themed` + `arrflix-video-active`) plus the L1/L2 paired rules is the structural defence. The CI gates above would be the automated safety net. Until those exist, this checklist + the layer model is the best you have.

View file

@ -0,0 +1,96 @@
# 32 — `jellyfin-dev` container wipe (2026-05-11)
Cleanup of the idle `jellyfin-dev` instance on nullstone. This was the
scratch container used for the scyfin theme experiment + the 10.11.8 dev
upgrade documented in `docs/29-jellyfin-10.11-upgrade-and-scyfin-migration.md`.
The experiment is concluded; prod is being upgraded by a parallel agent
and `jellyfin-stock` (tv.s8n.ru) covers the stock build, so dev has no
remaining role.
## Pre-wipe state
```
$ docker ps -a --filter name=jellyfin-dev
CONTAINER ID IMAGE STATUS NAMES
ecf97cddba6c jellyfin/jellyfin:10.11.8 Up 14 hours (healthy) jellyfin-dev
$ ls -la /opt/docker/jellyfin-dev/
-rw-r--r-- 1 user user 1898 docker-compose.yml
-rw-r--r-- 1 user user 1799 docker-compose.yml.bak.1778243059
drwxrwxr-x 3 user user 4096 web-overrides/
$ ls -la /home/docker/jellyfin-dev/ (via privileged alpine, userns=host)
drwxr-xr-x 5 1000 1000 cache/
drwxr-xr-x 8 1000 1000 config/
total: 200 MB
$ grep -lr "jellyfin-dev\|dev.arrflix" /opt/docker/traefik/config/
(no matches — routing was via docker-provider labels only)
$ df -h /home
/dev/mapper/keystone--vg-home 399G 284G 96G 75%
```
## Actions
1. `docker stop jellyfin-dev && docker rm jellyfin-dev` — container removed.
2. Privileged-alpine wipe of `/home/docker/jellyfin-dev/` (uid 1000 inside
userns-remap, owner `100000:100000` on host — host `user` can't `rm` it
directly, hence the `--userns=host` container):
```
docker run --rm --userns=host -v /home/docker:/d alpine \
rm -rf /d/jellyfin-dev
```
3. `rm -rf /opt/docker/jellyfin-dev/` — compose file + web-overrides gone
(owned by `user`, plain rm sufficient).
4. Traefik docker-provider router vanished with the container — no
file-provider yaml to clean up (verified via grep).
## Post-wipe verification
```
$ docker ps -a --filter name=jellyfin-dev # empty
$ ls /opt/docker/jellyfin-dev # ENOENT
$ ls /d/jellyfin-dev (in alpine) # ENOENT
$ curl -sk -o /dev/null -w "%{http_code}\n" \
https://dev.arrflix.s8n.ru/ # 404 (Traefik, no backend)
$ ls /home/docker | grep jellyfin # jellyfin, jellyfin-stock only
$ ls /opt/docker | grep jellyfin # jellyfin, jellyfin-stock only
```
Prod (`jellyfin` at `arrflix.s8n.ru`) and stock (`jellyfin-stock` at
`tv.s8n.ru`) were both untouched and continue to serve traffic.
## What was kept
- `/home/user/snapshots/jellyfin-dev-pre-1011-upgrade-20260511-033309.tar.zst`
(143 MB) — pre-upgrade rollback point.
- `/home/user/snapshots/jellyfin-dev-post-10107-20260511-033839.tar.zst`
(144 MB) — post-rollback snapshot from earlier today.
Both stay in place as historical artefacts.
- Pi-hole local-DNS pin `dev.arrflix.s8n.ru -> 192.168.0.100` — harmless,
resolves to Traefik which now 404s. Left alone.
- LE certificate for `dev.arrflix.s8n.ru` in `traefik/acme.json` — left
alone; reusable if dev is ever rebuilt.
## Disk reclaimed
```
before: 96G avail
after: 96G avail (200 MB freed; below `df -h` rounding granularity
on a 399 GB volume)
```
`du -sh /home/docker/jellyfin-dev` reported 200 MB pre-wipe, so the
freed-space figure is exact even though `df -h` can't resolve it.
## Rebuild path (if ever needed)
1. Restore `/home/docker/jellyfin-dev/` from one of the snapshots in
`/home/user/snapshots/`.
2. Recreate `/opt/docker/jellyfin-dev/docker-compose.yml` from
`docs/29-jellyfin-10.11-upgrade-and-scyfin-migration.md` (compose
block is inline in that doc).
3. `docker compose up -d` — Traefik docker-provider re-attaches the
router automatically, LE cert is already in acme.json so no fresh
challenge needed.

View file

@ -0,0 +1,120 @@
# 32 — Nullstone Storage Upgrade Plan
> Status: PLAN (2026-05-10). Hardware purchase + install pending.
## Why
ARRFLIX library growth — Rick and Morty S02-S08 import (~105 GB) would push `/home` from 70 % → 92 % full. Tight margin for transcodes, logs, future imports. Pre-emptive upgrade beats emergency cleanup.
## Current state
| Item | Value |
|---|---|
| Drive | Intel SSDPEKKF512G8 NVMe 512 GB (single) |
| Slot | 1 NVMe (`nvme0n1`) |
| LVM VG | `keystone-vg`, fully allocated (475 GB) |
| LV `/home` | 406.2 GB, 263 GB used, 117 GB free (70 %) |
| LV `/`, `/var`, `/tmp`, swap | 30/11/3/24 GB |
| Mobo | MSI X470 Gaming Plus Max (MS-7B79) |
| **Free slots** | **1× M.2 (M2_2)**, **6× SATA ports** |
Mobo has two M.2 slots:
- `M2_1` — PCIe 3.0 ×4, **occupied** (Intel 512 GB)
- `M2_2` — PCIe 2.0 ×4 / SATA, **free**
## Recommended path — 2nd NVMe in M2_2
| Capacity | Approx cost | Recommended |
|---|---|---|
| 1 TB NVMe | ~£60 | adequate |
| 2 TB NVMe | ~£130 | **best** — leaves headroom for full Disney+ / Star Wars catalogue |
| 4 TB NVMe | ~£250 | long-term, future-proof |
Specs: NVMe PCIe 3.0 ×4 (M.2 2280). Brand: WD SN770, Samsung 990 EVO, Crucial P310, Lexar NM790. M2_2 is PCIe 2.0 — drive will negotiate down (still ~1.5 GB/s — plenty for media).
### Procedure
1. Power off nullstone.
2. Open case, install drive in M2_2 slot.
3. Boot.
4. Verify: `lsblk` should show new `nvme1n1`.
5. Partition: `sudo parted /dev/nvme1n1 mklabel gpt && sudo parted /dev/nvme1n1 mkpart primary 0% 100%`
6. Add as LVM PV: `sudo pvcreate /dev/nvme1n1p1`
7. Extend VG: `sudo vgextend keystone-vg /dev/nvme1n1p1`
8. Extend LV: `sudo lvextend -l +100%FREE /dev/keystone-vg/home`
9. Grow filesystem online: `sudo resize2fs /dev/keystone-vg/home`
10. Verify: `df -h /home` should show new size, no reboot needed.
Total downtime: ~5 min for case open + boot. No reinstall.
## Alternative paths (if M.2 install blocked)
### SATA SSD/HDD (option 4 from poll)
- 6 SATA ports free. Mobo has space for 2.5"/3.5" drives (case-dependent).
- 2 TB SATA SSD ~£80, 2 TB HDD ~£25, 4 TB HDD ~£60.
- Procedure same as NVMe but `/dev/sda` instead of `nvme1n1`.
- HDD slower (~150 MB/s vs NVMe's 1500 MB/s) but fine for media — 4K HDR HEVC peaks at ~15 Mbps = 1.9 MB/s read.
- Pro: cheapest per GB. Con: slower transcodes if SSD cache not configured.
### USB external (option 3 from poll)
- USB 3.0/3.1 ports available (at least 2× 10 Gbps slots).
- Plug-and-play, no case open.
- Pro: no install risk, can move drive between machines.
- Con: cable disconnect risks media server uptime (auto-mount needs udev rule or fstab `nofail`). Slower than internal (~400-500 MB/s real-world over USB 3.1).
- Best if: temporary expansion or testing.
## Migration of `/home/user/media`
Once new drive added to LVM and lv-home extended → media stays in place. NO migration needed. Just more headroom.
If using a SEPARATE drive (not joining LVM):
```bash
# Mount new drive at /mnt/media-2
sudo mkfs.ext4 /dev/nvme1n1p1 # or /dev/sda1
sudo mkdir /mnt/media-2
sudo mount /dev/nvme1n1p1 /mnt/media-2
sudo chown user:user /mnt/media-2
# Move TV to new drive (movies stay on /home for now)
sudo rsync -aHAX --info=progress2 /home/user/media/tv/ /mnt/media-2/tv/
# Update fstab for auto-mount on boot
echo '/dev/nvme1n1p1 /mnt/media-2 ext4 defaults 0 2' | sudo tee -a /etc/fstab
# Update Jellyfin container — stop, edit docker-compose.yml volume:
# /mnt/media-2/tv → /media/tv-2
# OR just mount-bind /mnt/media-2/tv onto /home/user/media/tv (transparent to Jellyfin):
sudo umount /home/user/media/tv # if anything's there
sudo rmdir /home/user/media/tv
sudo ln -s /mnt/media-2/tv /home/user/media/tv
```
LVM-extension path is much cleaner — recommended.
## Decision pending
Owner picks NVMe / SATA / USB based on what's easier to get + budget. Update this doc after decision + install.
## After upgrade — Rick and Morty bulk import
Once `/home` has ≥ 200 GB free:
```bash
# Per playbooks/import-media/ v1.0
# Stage all 7 seasons on onyx (already done — names need normalising)
# rsync to nullstone in one bulk
# Force Library/Refresh
# Verify Series 12→13 (only 1 new — R&M existed since S01), Episodes 217→285 (+68 with all 7 seasons full)
# Wait for S03 E06+E10 to finish download first, OR import partial + add later
```
Run logs: `playbooks/import-media/runs/rick-and-morty-s02-s08-2160p-hdr.md` (template).
## See also
- `playbooks/import-media/` — import workflow
- `docs/05-file-structure-rules.md` — TV folder layout
- ROADMAP.md — track upgrade as item

View file

@ -0,0 +1,147 @@
# 2026-05-08 — YouTube import: Sassy the Sasquatch (2022)
## Source
| Field | Value |
|---|---|
| Upstream platform | YouTube |
| Channel name | THE BIG LEZ SHOW OFFICIAL |
| Channel id | `UCV1G6JkQtB2nobFm3MGNsBQ` |
| Playlist title | `SASSY THE SASQUATCH` |
| Playlist id | `PLGMC7oz7XpmDMGrALMQiNXCi9p7aqkWbj` |
| Playlist URL | `youtube.com/playlist?list=PLGMC7oz7XpmDMGrALMQiNXCi9p7aqkWbj` (no clickable link by policy) |
## Date imported
`2026-05-08`
## Episodes imported
| # | YouTube id | YouTube title | Canonical filename | Jellyfin episode id | Resolution | Size (bytes) |
|---|---|---|---|---|---|---|
| 1 | `9OmR0ypCyOU` | `SASSY THE SASQUATCH \| EP01 \| SEEN A DINOSAUR` | `Sassy the Sasquatch (2022)/Season 01/Sassy the Sasquatch (2022) - S01E01 - Seen a Dinosaur.mkv` | `2ef02448506b543f39b9372c2b0cdef2` | 1920x1080 | 36,434,608 |
| 2 | `tvCUmH92HfU` | `SASSY THE SASQUATCH \| EP02 \| WATER YOU TALKINABEET` | `Sassy the Sasquatch (2022)/Season 01/Sassy the Sasquatch (2022) - S01E02 - Water You Talkinabeet.mkv` | `3b7809d6840e5ee230fbba951fee227e` | 1920x1080 | 28,928,772 |
| 3 | `QvIgmc2G6lk` | `SASSY THE SASQUATCH \| EP03 \| WALKABEET` | `Sassy the Sasquatch (2022)/Season 01/Sassy the Sasquatch (2022) - S01E03 - Walkabeet.mkv` | `4a0188c67c1d6d08000997177143d6f2` | 1920x1080 | 22,663,417 |
| 4 | `RU9zuIqPcJw` | `SASSY THE SASQUATCH \| EP04 \| AREA 51` | `Sassy the Sasquatch (2022)/Season 01/Sassy the Sasquatch (2022) - S01E04 - Area 51.mkv` | `3dc35c7341f4476e6218840dceb63163` | 1920x1080 | 30,784,810 |
| 5 | `vUyJq1kd-bc` | `SASSY THE SASQUATCH \| EP05 \| SNOW WORRIES` | `Sassy the Sasquatch (2022)/Season 01/Sassy the Sasquatch (2022) - S01E05 - Snow Worries.mkv` | `f79032d3bfbed6371e11375bdfc1b8a6` | 1920x1080 | 36,913,187 |
| 6 | `bi_HbwZDdPg` | `SASSY THE SASQUATCH \| EP06 \| AS ABOING SO BADOING` | _(not imported)_ | _(n/a)_ | _(n/a)_ | 0 |
**Imported: 5 / 6.** EP06 is age-restricted on YouTube and the sibling
downloader had no authenticated cookie store — see Known caveats.
## Naming rules applied
Cited section numbers refer to files under `docs/`:
- **`docs/05` §0 rule 5 — mandatory `(Year)`.** Year `2022` (from manifest)
appended to the show folder and every episode basename, even though the
show name is unique in the library.
- **`docs/05` §2 + `docs/08` §1.2 — TV canonical layout.** Final layout is
`Show (Year)/Season NN/Show (Year) - SxxEyy - Title.ext`. Applied
literally across all 5 episodes.
- **`docs/05` §2.1 + `docs/08` §4.2 — zero-padded `Season 01`.** All five
files placed under `Season 01/` (single-playlist → single-season default).
- **`docs/08` §1.2 — zero-padded two-digit episode marker.** `S01E01`
through `S01E05`, never `S1E1` / `1x01` / `Ep1`.
- **`docs/08` §2.1 + §3.4 — strip channel-pollution prefix and
bracket/id tags.** The repeated `SASSY_THE_SASQUATCH_`, the `EPxx_`
fragment, and the `[<videoId>]` suffix in raw filenames were all
removed; only the human-readable episode title survives in the
canonical name.
- **`docs/08` §2.5 — underscores → spaces.** `WATER_YOU_TALKINABEET`
`Water You Talkinabeet`; `AREA_51``Area 51`; `SEEN_A_DINOSAUR`
`Seen a Dinosaur`; `SNOW_WORRIES``Snow Worries`; `WALKABEET` left
as a single word.
- **`docs/08` §5.1 — smart title case.** All-uppercase YouTube titles
recased; small word `a` lowercased mid-title (`Seen a Dinosaur`); the
number `51` preserved as-is (`Area 51`).
- **`docs/05` §0 rule 3 / `docs/08` §5.5 — ASCII-only, no forbidden
characters.** Source titles contained no `< > : " / \ | ? *`, so no
substitutions were needed; verified post-rename.
- **`docs/08` §1.1 / §1.2 forbidden list — no resolution/codec/group
tags.** `1080p`, `av01`, `opus`, `WEB-DL`, etc. all stripped; canonical
names contain title only.
## rsync stats
```
sent 155,763,373 bytes
received 126 bytes
throughput 44,503,856 bytes/sec (~42.4 MiB/s)
total size 155,724,794 bytes
speedup 1.00 (initial transfer, no rolling-checksum reuse)
flags -av --partial --append-verify
files 5 transferred + 2 dirs created
```
## Jellyfin verification
| Field | Value |
|---|---|
| Series id | `b2d1afd8a4a30c59adb42ccaf47376c2` |
| Series name | `Sassy the Sasquatch` |
| ProductionYear | `2022` |
| Path | `/media/tv/Sassy the Sasquatch (2022)` |
| Provider ids | `Tmdb=321760 Imdb=tt21209936 Tvdb=421839` |
| Episode count | 5 |
| Direct-play (E01 sample) | `SupportsDirectPlay=True`, `SupportsDirectStream=True`, `SupportsTranscoding=False`, video `av1` profile Main, audio `opus` |
| Library scan | `RefreshLibrary` reached `Idle / Completed` < 10 s after `POST /Library/Refresh`; full series refresh queued via `POST /Items/.../Refresh?Recursive=true&MetadataRefreshMode=FullRefresh&ImageRefreshMode=FullRefresh` (HTTP 204) |
All 5 episodes mapped to correct `SxxEyy` via filename → Jellyfin parser
(no manual identify required).
## Known caveats
1. **EP06 age-restricted, not imported.** YouTube requires authenticated
cookies for `bi_HbwZDdPg` (`AS ABOING SO BADOING`); sibling downloader
ran without a logged-in browser session. Library currently shows
5/6 episodes. Operator's call whether to retry with cookies or
accept the gap.
2. **Jellyfin display titles uppercase.** TVDB has the official episode
titles entered in all-caps (`SEEN A DINOSAUR`, etc.) and Jellyfin
prefers provider data over filename-derived titles. Filenames on disk
remain canonical-cased per `docs/08` §5.1; only the API/UI display
layer follows TVDB. Not a bug.
3. **No subtitles.** Source has YouTube auto-generated captions only;
sibling did not pull them, so no `.srt` / `.ass` siblings exist in
the library.
4. **Cross-device hard-link failure during staging.** Sibling's first
staging attempt at `/tmp/yt-norm/staged/` failed (`/tmp` is `tmpfs`,
source is on LUKS+ext4); re-staged at `/home/admin/yt-norm-staged/`
on the same FS. Net disk impact: zero (inode-only links). Cosmetic.
5. **Brief said "6 episodes," manifest had 6 with 1 failed.** Reconciled
to actual 5 successful downloads; counts in this log reflect reality,
not the brief.
## Source manifest copied
Top-level fields of `manifest.json` (sibling agent's output, originally at
`/home/admin/yt-import-staging/manifest.json` on onyx):
```json
{
"show_name": "Sassy the Sasquatch",
"year": "2022",
"channel": "THE BIG LEZ SHOW OFFICIAL",
"channel_id": "UCV1G6JkQtB2nobFm3MGNsBQ",
"playlist_id": "PLGMC7oz7XpmDMGrALMQiNXCi9p7aqkWbj",
"playlist_title": "SASSY THE SASQUATCH",
"raw_dir": "/home/admin/yt-import-staging/raw",
"downloaded_count": 5,
"failed_count": 1,
"total_bytes": 155724794
}
```
The full per-episode `episodes[]` array is **not** embedded here. Trace
each episode by canonical path under the live tree on nullstone:
```
/home/user/media/tv/Sassy the Sasquatch (2022)/
└── Season 01/
├── Sassy the Sasquatch (2022) - S01E01 - Seen a Dinosaur.mkv
├── Sassy the Sasquatch (2022) - S01E02 - Water You Talkinabeet.mkv
├── Sassy the Sasquatch (2022) - S01E03 - Walkabeet.mkv
├── Sassy the Sasquatch (2022) - S01E04 - Area 51.mkv
└── Sassy the Sasquatch (2022) - S01E05 - Snow Worries.mkv
```

42
docs/IMPORT-LOG/README.md Normal file
View file

@ -0,0 +1,42 @@
# IMPORT-LOG
Append-only ledger of media-import events for ARRFLIX. Each file in this
directory documents a single import operation so future audits can trace
content on disk back to its provenance.
## Convention
- **One file per import event.**
- **Filename format:** `YYYY-MM-DD-<source>-<show-slug>.md`
- `<source>` is the upstream type (`youtube`, `tvdb-rip`, `bluray`, `manual`, etc.).
- `<show-slug>` is the lowercase show name with `-` separators.
- Example: `2026-05-08-youtube-sassy-the-sasquatch.md`.
- **Date** is the day the import landed on the live library (not the day the
source was published).
## Required sections
Each entry should include:
1. **Source** — upstream URL/handle, channel/distributor, identifiers.
2. **Date imported** — ISO-8601.
3. **Episodes imported** — table mapping source identifier → canonical
filename → Jellyfin item id (resolution + size where applicable).
4. **Naming rules applied** — citations of the `docs/0X.md` rule numbers
invoked, with a short note on the specific edit each made.
5. **rsync stats**`sent / received / speedup` from the transfer.
6. **Jellyfin verification** — series id, episode count, direct-play status.
7. **Known caveats** — anything that wasn't perfect (missing metadata,
failed thumbnails, age-restricted episodes, etc.).
8. **Source manifest copied** — top-level fields of the operator's
`manifest.json`. Do **not** embed the full `episodes[]` array — link to
the canonical files in the live library instead.
## Boundaries
- **No access tokens, API keys, or session cookies** in any log file.
- For YouTube imports, **do not include full video URLs** — record the bare
`videoId` only, so import logs are not indexable as a re-distribution
pointer.
- Logs are **immutable once committed**. To correct a mistake, append a
follow-up entry rather than rewriting history.

15
playbooks/README.md Normal file
View file

@ -0,0 +1,15 @@
# playbooks/ — moved
The procedural playbooks (README, CHANGELOG, helper scripts) have moved to
beta-flix:
<https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/>
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.
| Sub-area | Procedure | Run logs |
|---|---|---|
| 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/) |

View file

@ -0,0 +1,21 @@
# Import-media playbook changelog
## v1.1 — 2026-05-10
Two Jellyfin endpoint bugs found during `archer-s02-2009` run, codified into the playbook:
- **`POST /Library/Refresh` is a silent no-op** on this Jellyfin build — returns HTTP 204 but the `Scan Media Library` scheduled task does NOT execute. Step 4 rewritten to fetch the scan-task ID from `/ScheduledTasks` and POST to `/ScheduledTasks/Running/<id>` instead. Old endpoint added to a "known broken" table.
- **`/Items/Counts` is scope-cached** and stays stale even after items are indexed (counts stayed at 230 after 13 new eps landed). Step 5 rewritten to use the per-series authoritative query `/Shows/<id>/Episodes?Season=<NN>` with provider + image-tag verification, plus a per-series `Items/<id>/Refresh` recipe for missing metadata.
- LibraryMonitor inotify auto-fire wording removed from Step 4 (failed on both recorded runs — Lilo & Stitch + Archer S02). Manual task trigger is now mandatory.
- Verification checklist updated to reference the task-trigger endpoint and per-series query.
- Rollback section: replaced `/Library/Refresh` invocation with `/ScheduledTasks/Running/<id>`.
## v1.0 — 2026-05-10
Initial playbook. 7 steps from staging on onyx → rsync to nullstone → verify scan + counts → optional subtitle pass → run-log.
Gaps flagged for future versions:
- v1.2 will add canonical `bin/import-media.sh` wrapper once `bin/cleanup-import.sh` and `bin/normalize.py` are extracted from docs/07 and docs/08 (ROADMAP M6). (Bumped from v1.1 since v1.1 was needed for the Jellyfin endpoint fixes.)
- v1.3 will add a TV multi-season import section (currently only single-season example).
- v1.4 will add NFO override pattern with worked example for a wrong-TMDb-match recovery.
- v1.5: document API-token retrieval (mint a permanent ApiKeys row labelled `import-pipeline` instead of pulling a session token from `Devices` table).

View file

@ -0,0 +1,10 @@
# Import-media playbook — moved
The procedure has moved to beta-flix and been rewritten for stock Jellyfin
10.11.8:
<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
playbook evolution (v1.0 → v1.1) for context.

View file

@ -0,0 +1,49 @@
# <title-slug>
> Per-import run log. Mirror `playbooks/subtitles/runs/_template.md` style.
## Provenance
- **Source path on onyx:** `/home/admin/Downloads/<release-name>/<file>.mkv`
- **Release group:** YOGI / RARBG / FQM / etc.
- **Quality:** 1080p BluRay HEVC 10-bit / 2160p WEB-DL / etc.
- **Audio:** EAC3 5.1 / DTS-HD MA / etc.
## Target
- **Library:** movies / tv
- **Path:** `/home/user/media/<lib>/<Title> (<Year>)/<...>`
- **Container view:** `/media/<lib>/<Title> (<Year>)/<...>`
- **Item ID:** (after first scan)
## Counts
| | Before | After | Delta |
|---|---:|---:|---:|
| MovieCount or EpisodeCount | | | |
## Stream summary
```
ffprobe output here — Video / Audio / Subtitle streams
```
## Subtitle status
- Embedded: yes/no, count, langs
- External sidecar: yes/no, path
- Action: none / playbooks/subtitles run
## Verification checks
- [ ] Folder/filename canonical
- [ ] Permissions user:user 644 / 755
- [ ] LibraryMonitor auto-fired (log line)
- [ ] Items/Counts bumped by N
- [ ] TMDb / TVDB metadata populated
- [ ] Artwork loaded
- [ ] Direct-play in client (no transcode line)
## Notes / surprises
(any unusual filename normalisation, NFO override, or post-import tweak)

View file

@ -0,0 +1,70 @@
# archer-2009-s01
Third run of `playbooks/import-media/` v1.0.
## Provenance
- **Source path on onyx:** `/home/admin/Downloads/Archer Season 1 [1080p AI 10bit S94 Joy]/`
- **Release group:** Joy / S94
- **Quality:** 1080p AI-upscale x265 10-bit
- **Audio:** HE-AAC 5.1 ENG
- **Subtitles:** 3× embedded DVDsub (ENG / SPA / FRE) per episode
- **Episode count:** 10 (S01E01E10)
- **Total size:** 2.2 GB
Per `README.md:41` quality bar — AI-upscaled masters allowed when source doesn't support 4K. Original Archer FX broadcast was 720p; 1080p AI-upscale acceptable.
## Target
- **Library:** tv
- **Path:** `/home/user/media/tv/Archer (2009)/Season 01/`
- **Container view:** `/media/tv/Archer (2009)/Season 01/`
- **Series Item ID:** `9d22c409d531...`
## Counts
| | Before | After | Delta |
|---|---:|---:|---:|
| SeriesCount | 11 | 12 | +1 |
| EpisodeCount | 207 | 217 | +10 |
## Stream sample (E01 — Mole Hunt)
```
Duration: 00:21:32.83, bitrate: 1415 kb/s
Stream #0:0: Video: hevc, none, 1920x1080, 23.98 fps
Stream #0:1(eng): Audio: aac (HE-AAC), 48000 Hz, 5.1, fltp
Stream #0:2(eng): Subtitle: dvd_subtitle (dvdsub), 1920x1080
Stream #0:3(spa): Subtitle: dvd_subtitle (dvdsub), 1920x1080
Stream #0:4(fre): Subtitle: dvd_subtitle (dvdsub), 1920x1080
```
HEVC 1080p (8-bit per `Video: hevc, none`), 1.4 Mbps, ~21 min runtime.
## Subtitle status
- 3× embedded DVDsub (image-based) per episode — ENG / SPA / FRE
- DVDsub renders via server burn-in or external picker; no text VTT fallback
- If owner wants text subs (cleaner, browser-native), follow `playbooks/subtitles/` to drop external `.eng.srt` later
## Verification checks
- [x] Folder/filename canonical (`Archer (2009)/Season 01/Archer (2009) - S01E<NN> - <Title>.mkv`)
- [x] Permissions `user:user` 644 / 755
- [ ] LibraryMonitor auto-fired — DID NOT (third consecutive miss after Lilo + Maul). Forced manual `POST /Library/Refresh` 204 → counts bumped within ~90 s.
- [x] `Items/Counts` Series 11→12, Episodes 207→217
- [x] Series enumerated as new item `9d22c409d531...`
- [ ] **TMDb / TVDB providers**: NOT auto-matched (`tmdb=? tvdb=?`). Same as Maul run. Operator to manually identify via UI.
## Notes / surprises
- Filename normalization: source had `Archer S01E01 Mole Hunt [1080p x265 10bit Joy].mkv` (double space, group brackets). Stripped to `Archer (2009) - S01E01 - Mole Hunt.mkv` per `docs/08`. Episode-title list compiled from filenames — no source NFO available.
- LibraryMonitor failure pattern is now **3-for-3** on TV imports — confirmed not flaky, it's broken. Playbook v1.1 must call out manual `/Library/Refresh` as MANDATORY (not optional fallback).
- TMDb auto-match failed for the third time in three imports. Worth investigating: maybe the TMDb plugin token / library option `EnableInternetProviders` is off for the TV library. Operator: check Dashboard → Libraries → TV Shows → Metadata downloaders.
## Operator action
1. Open `https://arrflix.s8n.ru` → search "Archer" → confirm series visible.
2. Manually identify if metadata desired: 3-dot → "Identify" → search TMDb (probably ID `10283`).
3. Verify direct-play (HEVC HE-AAC should direct-play on most clients; DVDsub burn-in on transcode if needed).
4. Source download at `/home/admin/Downloads/Archer Season 1 [1080p AI 10bit S94 Joy]/` retained until owner confirms playback.

View file

@ -0,0 +1,103 @@
# archer-s02-2009
Second run of `playbooks/import-media/` v1.0. First TV-season run (Lilo & Stitch was a movie).
## Provenance
- **Source path on onyx:** `/home/admin/Downloads/Archer Season 2 [1080p AI 10bit S91 Joy]/`
- **Release group:** Joy (AI-upscale tag `S91`)
- **Quality:** 1080p HEVC 10-bit (Main 10), AI-upscaled from SD source
- **Audio:** HE-AAC 5.1 English
- **Embedded subs:** 3× DVD bitmap (eng, spa, fre)
## Target
- **Library:** tv
- **Path:** `/home/user/media/tv/Archer (2009)/Season 02/`
- **Container view:** `/media/tv/Archer (2009)/Season 02/`
- **Series Item ID:** (existed pre-import; not re-fetched this run)
- **TVDB / TMDb:** matched on existing `Archer (2009)` series folder (Season 1 already in lib)
## Files imported (13)
```
Archer (2009) - S02E01 - Swiss Miss.mkv
Archer (2009) - S02E02 - A Going Concern.mkv
Archer (2009) - S02E03 - Blood Test.mkv
Archer (2009) - S02E04 - Pipeline Fever.mkv
Archer (2009) - S02E05 - The Double Deuce.mkv
Archer (2009) - S02E06 - Tragical History.mkv
Archer (2009) - S02E07 - Movie Star.mkv
Archer (2009) - S02E08 - Stage Two.mkv
Archer (2009) - S02E09 - Placebo Effect.mkv
Archer (2009) - S02E10 - El Secuestro.mkv
Archer (2009) - S02E11 - Jeu Monégasque.mkv
Archer (2009) - S02E12 - White Nights.mkv
Archer (2009) - S02E13 - Double Trouble.mkv
```
Total ~2.8 GiB.
## Counts
| | Before | After | Delta |
|---|---:|---:|---:|
| Episodes in `Archer (2009)` S02 | 0 | 13 | +13 ✅ |
| SeriesCount | 12 | 12 | 0 (series existed) |
| MovieCount | 4 | 4 | 0 |
| `/Items/Counts.EpisodeCount` global | 230 | 230 (stale) | endpoint scope-cached, not authoritative |
**Authoritative verify:** `GET /Shows/9d22c409d5319c3c6068cfd38569714f/Episodes?Season=2` → 13 items, all with `ProviderIds.Tvdb`+`Imdb`+`TvRage`, Primary image present, paths resolve.
## Stream summary (S02E01 sample)
```
Duration: 00:21:03.48, bitrate: 1452 kb/s
Stream #0:0: Video: hevc (Main 10), yuv420p10le(tv), 1920x1080, SAR 1:1 DAR 16:9, 23.98 fps
Stream #0:1(eng): Audio: aac (HE-AAC), 48000 Hz, 5.1, fltp
Stream #0:2(eng): Subtitle: dvd_subtitle (dvdsub), 1920x1080
Stream #0:3(spa): Subtitle: dvd_subtitle (dvdsub), 1920x1080
Stream #0:4(fre): Subtitle: dvd_subtitle (dvdsub), 1920x1080
```
HEVC 10-bit Main10 — direct-play on most clients. DVD-bitmap subs (`dvd_subtitle`) — server burn-in works, but per ARRFLIX subtitle style (1× plain English `.srt` only, no SDH/forced) these may need WhisperX text rebuild later. Add to `playbooks/subtitles/STOPGAP-SUBS.md` if user wants text subs.
## Subtitle status
- Embedded: yes — 3× DVD bitmap (eng, spa, fre)
- External sidecar: none yet
- Action: none for now. Per ARRFLIX style, only English sidecar `.srt` is canonical; embedded multi-lang DVD-subs do not satisfy that. Defer to subtitle playbook if user wants text-based eng.
## Verification checks
- [x] Folder/filename canonical (`Archer (2009)/Season 02/Archer (2009) - S02E<NN> - <Title>.mkv`)
- [x] Permissions `user:user` 644 (file) / 755 (dir) — `ls -la` post-rsync confirmed
- [ ] LibraryMonitor auto-fired — **DID NOT trigger** (same as Lilo run; bind-mount inotify flake confirmed as a pattern, not one-off)
- [x] `POST /Library/Refresh` returned 204 but did NOT trigger task (silent no-op)
- [x] `POST /ScheduledTasks/Running/<scan-task-id>` returned 204 → task ran → episodes added
- [x] All 13 eps indexed with `Tvdb`+`Imdb`+`TvRage` providers populated
- [x] Per-episode Primary artwork present (`ImageTags.Primary` set on all 3 sampled)
- [x] HEVC 10-bit + HE-AAC → direct-play candidate on most clients
## Notes / surprises
- LibraryMonitor flake confirmed: same as Lilo run. v1.0 playbook wording ("auto-refreshes ~13 s") is wrong; force-refresh is mandatory. Update to README.
- **`POST /Library/Refresh` is also a silent no-op** in this Jellyfin build. Returned 204 but the `Scan Media Library` task didn't fire and counts stayed flat. Had to fetch the task ID from `/ScheduledTasks` and POST to `/ScheduledTasks/Running/<id>` directly. Update playbook to use task-trigger endpoint, not `/Library/Refresh`.
- **`/Items/Counts` is scope-cached/stale** — kept returning 230 even after 13 new eps were indexed. Use `/Shows/<series-id>/Episodes?Season=N` as authoritative count source, not `/Items/Counts`.
- API token retrieval: no docs in repo for getting an admin token. ApiKeys table empty (no operator-created keys). Pulled an active web-session token from `Devices` table via temp Alpine container mounting `jellyfin.db` read-only (`docker run --rm --userns=host -v ...:ro alpine sh -c "apk add sqlite && sqlite3 /db.sqlite ..."`). Per-session tokens work as `X-Emby-Token` for admin operations when source user has admin role. Worth documenting in ADMIN-GUIDE — or better, create an explicit ApiKeys row labelled "import-pipeline" with permanent rotation policy.
- Source filenames had double-space before `[1080p ...]` group tag — handled by `${f%% [*}` parameter expansion. May need to be more lenient in future imports (single-space variants, no-space variants).
- HE-AAC audio at 5.1 may transcode-pressure some clients (LG WebOS notably struggles with HE-AAC multichannel). Watch for transcode lines in `docker logs jellyfin | grep transcode` if Archer playback shows lag.
- Source download on laptop retained per `ADMIN-GUIDE.md:74` — DO NOT delete `/home/admin/Downloads/Archer Season 2 ...` until user confirms playback in browser.
## Operator action
User to verify in browser: `https://arrflix.s8n.ru` → Archer (2009) → Season 02 → spot-check episodes 1, 7, 13 → confirm artwork + Play. After confirmed, source download on onyx can be deleted.
## Pending follow-ups
1. Update `playbooks/import-media/README.md`:
- Drop "LibraryMonitor auto-fires" wording.
- Replace `/Library/Refresh` with `/ScheduledTasks/Running/<scan-task-id>` (true task trigger).
- Add note: do NOT use `/Items/Counts` as verification source (cached); use `/Shows/<id>/Episodes?Season=N` per-series.
2. Document API-token retrieval in `ADMIN-GUIDE.md` (DB pull recipe or instructions to mint an ApiKey row labelled `import-pipeline`).
3. Consider adding a `bin/import-tv.sh` that wraps stage → rsync → chmod → task-trigger → poll-by-series.

View file

@ -0,0 +1,126 @@
# benn-jordan-s01-yt-import
First import into the **STOCK** Jellyfin at `tv.s8n.ru` (container `jellyfin-stock`),
Educational library. YouTube videos from channel "Benn Jordan" — treated as one
Series (`Benn Jordan`) on Season 01.
Independent from arrflix prod (`arrflix.s8n.ru`) and arrflix dev. Stock Jellyfin's
Educational library has `EnableInternetProviders=false` — files land with
filename/folder-only metadata. **No TMDb/TVDB matching is expected or attempted.**
## Provenance
- **Source:** YouTube channel "Benn Jordan" (2026 uploads)
- **Tool:** `yt-dlp` 2026.03.17 on onyx
- **Format selector:** `bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/bv*+ba/b``--merge-output-format mp4`
- **Subs:** `--write-subs --sub-langs "en.*" --embed-subs --convert-subs srt` (no en subs available on these uploads — only auto-generated; not embedded)
- **Staging path on onyx:** `/home/admin/staging-jelly/Benn Jordan/Season 01/`
### Source URLs
| Episode | Video ID | URL |
|---|---|---|
| S01E01 | n/a (pre-staged) | already downloaded before this run |
| S01E02 | UMIwNiwQewQ | https://www.youtube.com/watch?v=UMIwNiwQewQ |
| S01E03 | _bP80DEAbuo | https://www.youtube.com/watch?v=_bP80DEAbuo |
| S01E04 | lA8WuXDXfcI | https://www.youtube.com/watch?v=lA8WuXDXfcI |
## Target
- **Server:** `jellyfin-stock` (container) on nullstone, exposed at `https://tv.s8n.ru`
- **Library:** Educational (tvshows-type, internet providers disabled)
- **Path on host:** `/home/user/media/educational/Benn Jordan/Season 01/`
- **Container view:** `/media/educational/Benn Jordan/Season 01/`
- **Series Item ID:** `3da50e01252c1463cb23f7b9499dfc8a`
### Per-episode landing
| Episode | File size | Duration (spec) | Duration (Jellyfin) | Item ID |
|---|---:|---:|---:|---|
| S01E01 — Gadgets For People Who Don't Trust The Government | 1,570,389,432 B (~1.46 GiB) | n/a (pre-staged) | 2337 s | `962cd81a9b2b80979a4ea10d6b12b922` |
| S01E02 — It's Time to Take Down your Smart Cameras | 834,026,444 B (~795 MiB) | 1769 s | 1769 s | `fd24b71285bd5d8586687ba666988088` |
| S01E03 — Datacenters Behaving Like Acoustic Weapons | 1,446,732,628 B (~1.35 GiB) | 1744 s | 1744 s | `6dca7b57158f37acd8d821f585501998` |
| S01E04 — Robot Dogs Are A Security Nightmare | 772,469,473 B (~737 MiB) | 1432 s | 1432 s | `6ed3fab3c2b34c4718a0f836a638eea0` |
S01E02 title had the emoji `😬` stripped before download (per filename rules).
All apostrophes preserved. No forbidden chars (`< > : " / \ | ? *`) introduced.
## Counts
| | Before | After | Delta |
|---|---:|---:|---:|
| SeriesCount (Educational) | 0 | 1 | +1 |
| EpisodeCount (Educational) | 0 | 4 | +4 |
(First import into this library; pre-state is empty.)
## Stream sample (S01E02)
```
Duration: 00:29:28.66, bitrate: 3772 kb/s
Stream #0:0(und): Video: av1 (libdav1d) (Main), yuv420p(tv, bt709), 3840x2160, 3639 kb/s, 23.98 fps
Stream #0:1(und): Audio: aac (LC), 44100 Hz, stereo, fltp, 127 kb/s
```
AV1 2160p at ~3.6 Mb/s, stereo AAC. Source is YouTube best mp4/m4a combo. No
DRM, no encryption. AV1 direct-play requires a recent client (Chromium >= 90,
Firefox >= 100, Apple Silicon Safari, Android 12+, modern smart-TVs).
## Subtitle status
- Embedded: no (YouTube auto-CC not requested via `en.*` glob; user-uploaded
subs do not exist on these uploads).
- External sidecar: no.
- Action: none. Per Educational library convention these are short-form videos
with on-screen text. Re-run with `--write-auto-subs --sub-langs "en.*"` later
if subs become required.
## Verification checks
- [x] Folder/filename canonical (`Benn Jordan/Season 01/Benn Jordan - S01E<NN> - <Title>.mp4`)
- [x] Permissions `user:user` 644 / 755 on nullstone
- [x] `Scan Media Library` task triggered via `/ScheduledTasks/Running/$SCAN_ID` — completed
- [x] Per-series query returns 4 episodes with correct durations (1769/1744/1432 s for E02/E03/E04)
- [x] No `/Items/Counts` reliance — used `/Shows/<id>/Episodes` as authoritative
- [n/a] `ProviderIds` populated — **expected empty**, library has internet providers OFF
- [n/a] Image artwork — none auto-fetched; folder-level posters may be added manually
### Scan task
- **Task ID:** `7738148ffcd07979c7ceb148e06b3aed`
- **POST result:** HTTP 204
- **LastExecutionResult.EndTimeUtc:** `2026-05-11T14:27:49.205Z`
- **State after run:** `Idle`
## Notes / surprises
- Stock Jellyfin's Educational library is configured `tvshows`-type with
`EnableInternetProviders=false`. This is *intentional* — these are
per-channel YouTube videos, not broadcast TV. Names and durations come from
the filename and the container itself. **Do not try to TMDb-identify Benn
Jordan; there is no matching entry.**
- All 3 downloads ran in parallel from onyx and completed in well under one
rsync window. Combined nullstone delivery via single `rsync -a` of the whole
`Benn Jordan/` dir (E01 was already on disk from a prior staging — rsync no-op
for that file thanks to size+mtime match).
- AV1 codec is the default YouTube best-quality video stream as of 2026. None
of the recipient devices (onyx, nullstone clients, etc.) have a problem
direct-playing AV1 — but if a friend on a 2018 laptop reports playback issues,
Jellyfin will transcode (CPU only — `jellyfin-stock` has no GPU mount per
SYSTEM.md).
- Educational library uses tvshows scheme, so episodes nest under one parent
Series named exactly "Benn Jordan" with no year suffix (matches the folder
name). Filename pattern is the same `<Series> - S<NN>E<MM> - <Title>` shape
arrflix uses — no special-case required.
- Source staging dir on onyx (`/home/admin/staging-jelly/Benn Jordan/`) is
intentionally left in place — do not delete until owner confirms playback.
## Operator action
1. Open `https://tv.s8n.ru` → Educational library → confirm "Benn Jordan"
series shows 4 episodes.
2. Play any episode → confirm direct-play (no transcode line in
`docker logs jellyfin-stock`).
3. Optional: upload custom series + episode artwork via the Jellyfin web UI
(no TMDb fallback, so artwork has to be manual or absent).
4. Source dir on onyx retained per cleanup policy.

View file

@ -0,0 +1,87 @@
# Futurama (1999) — S08S11 import (Disney+ WEB-DL)
**Date:** 2026-05-13
**Operator:** s8n
**Library:** `tv` (`/home/user/media/tv/`)
**Target series:** Futurama (1999) — id `41953789dc06ede61bd1165fe5b96b2d`
---
## Source
- Path on onyx: `/home/admin/Downloads/Futurama Season 1-11 Colection 1080p WEBDL/`
- Release: `Futurama (1999) Season 8/9/10/11 (1080p DSNP WEB-DL x265 HEVC 10bit EAC3 5.1 t3nzin) [ext.to]`
- Magnet btih: `582B5D7C462473F13E35439F0D83F35A3888E6E3` (and sibling torrents per season)
- Folder size: 19.7 GB / 49 files
- Stream profile: HEVC Main10 1080p, EAC3 5.1 256 kb/s, SubRip eng sidecar embedded
Episode breakdown:
| Season | Files | Air era | Disney+ scheme |
|---|---|---|---|
| S08 | 13 | 20102011 (CC reboot) | "Neutopia" → "Reincarnation" |
| S09 | 13 | 2012 | "The Bots and the Bees" → "Naturama" |
| S10 | 13 | 2013 | "2-D Blacktop" → "Meanwhile" |
| S11 | 10 | 2023 (Hulu revival) | "The Impossible Stream" → "All The Way Down" |
## Numbering scheme conflict (logged for future ops)
Source uses **Disney+ / production order** where the CC reboot runs are S08/S09/S10 and the 2023 Hulu revival is S11. Existing `Futurama (1999)` library was built under **TVDB Default / aired order** — those same eps live under S06 + S07 (S06 ends with "Reincarnation", S07 ends with "Meanwhile").
Imported per user direction: keep source's S08S11 numbering as labelled. JF parser extracted the season number from the path (`Season 08/`) but TVDB couldn't match `S08S11` against the library's existing series record → all 49 eps landed with `IndexNumber=null`, `Name="Futurama"`, no `ProviderIds`. Fix described below.
## Steps executed
1. **Pre-flight** — torrent download monitored (qBt pid 13686, magnet pinned only S08; auto-completed at 23:04). Confirmed file integrity post-completion: 49 of 49 mkvs intact, all 21:40 runtime for S08S10, 24:00 runtime for S11 (Hulu revival).
2. **Stage on onyx** — hardlinked into `/home/admin/staging-jelly/Futurama (1999)/Season {08,09,10,11}/` and renamed per playbook §1b: `Futurama (1999) - S<NN>E<MM> - <Episode Title>.mkv`. Titles read from each mkv's `format_tags=title`. Per playbook §1f: replaced `:`` - ` in "T.: The Terrestrial" → "T. - The Terrestrial".
3. **rsync**`rsync -a --no-owner --no-group /home/admin/staging-jelly/Futurama (1999)/ user@nullstone:/home/user/media/tv/Futurama (1999)/`. Transfer: 19.7 GB / 49 files / 12:17 min / exit 0.
4. **Perms**`chmod 644` files / `755` dirs. Final: `user user 4096 May 13 23:11 Season {08..11}`.
5. **Scan**`POST /ScheduledTasks/Running/7738148ffcd07979c7ceb148e06b3aed` → HTTP 204. Idle reached at 23:24:57.
6. **Verify** — Per-season counts matched on-disk: S08=13, S09=13, S10=13, S11=10. **But:** every ep had `Name="Futurama"`, `IndexNumber` missing, `ProviderIds=[]`, no Primary image.
7. **Series-level `FullRefresh&Recursive=true` fired** at 23:25 — no effect after 10 min. Plugin (Intro Skipper) had successfully analysed every file (logs show all 49 paths), so JF *had* file→episode bindings; TVDB just didn't yield S08S11 metadata for this series record.
8. **Manual lock per the LFP pattern** (memory `feedback_jellyfin_lex_fridman_se_lock`):
- For each of 49 items: GET `/Users/<uid>/Items/<id>` → set `Name`, `IndexNumber`, `ParentIndexNumber` (from filename regex `S(\d{2})E(\d{2}) - (.+)\.mkv`), `LockData=true` → POST `/Items/<id>`.
- Script: `fix_futurama_remote.py` (urllib via container IP `172.20.0.20:8096`).
- Result: `fixed=49 errs=0`.
### Single-item verification (random sample)
```
GET /Users/2ad8.../Items/026b01957306ce8172a1b74c3770993e
Name: The Impossible Stream
IndexNumber: 1
ParentIndexNumber: 11
LockData: True
LockedFields: []
```
### ffprobe excerpt (representative — S08E01)
```
Container: mkv size=372458696 duration=1299.904s bitrate=2.29Mb/s
Stream 0: hevc Main10 1920x1080
Stream 1: eac3 6ch 5.1(side) 256kb/s
Stream 2: subrip (embedded, eng)
Stream 3: png 600x338 (chapter thumbnail)
```
## Subtitle status
All 49 mkvs ship embedded SubRip eng. No external sidecars added. Library `tv` keeps internet-provider sub fetch enabled, so OpenSubtitles fills any gaps on first play. No STOPGAP-SUBS entry needed.
## Anything unusual
- **Numbering scheme drift between source and existing series record**`IndexNumber` had to be set manually via the `/Items/<id>` POST + `LockData=true` route for all 49 eps. Future imports under the Disney+ scheme into a TVDB-default-numbered series will hit the same gap; reuse `fix_futurama_remote.py`.
- **`jellyfin-stock` container has no python3** — `docker exec` python attempt failed (`exec: "python3": executable file not found in $PATH`). Workaround: run the script on the nullstone host and target the container's bridge IP (`172.20.0.20:8096`), which is reachable from the host.
- **Series-level `FullRefresh&Recursive=true` is non-blocking and silent** — it returns 204 immediately but doesn't drive episode-level TVDB matching when the scheme mismatch is already locked in. Not relied on post-2026-05-13.
- **Quality check ahead of import** — three sampled eps confirmed source bitrate (2.29 / 2.38 / 2.59 Mb/s) is slightly *below* the existing JF copies of the same content (2.31 / 2.44 / 3.22 Mb/s, BluRay-source HEVC). For overlapping content the original JF copies remain canonical; the S08S11 import is alongside, not replacement.
## Verification checklist
- [x] Folder + filename match canonical pattern.
- [x] Permissions `user:user`, 644/755.
- [x] No `.eng.srt` sidecars (eng subs are embedded SubRip).
- [x] `Scan Media Library` triggered via task id `7738148ffcd07979c7ceb148e06b3aed`, advanced to Idle.
- [x] Per-season `/Shows/<id>/Episodes?Season=N` returns matching count and names.
- [x] All 49 items `LockData=True` after manual fix.
- [ ] Direct-play in client — not yet user-confirmed.

View file

@ -0,0 +1,89 @@
# johnny-harris-assange-guilty-plea-20220510
Single-video YouTube import into the **STOCK** Jellyfin at `tv.s8n.ru`
(container `jellyfin-stock`), **Education** library
(`collectionType=movies`, internet providers disabled).
Channel `Johnny Harris/` folder already had 5 files before this run, so
Jellyfin's single-file-folder caveat (`feedback_jellyfin_single_file_channel`)
does not apply — file resolves with its own filename as title.
## Provenance
- **Source:** YouTube — `https://www.youtube.com/watch?v=P6bVl47kdNk`
- **Channel:** Johnny Harris
- **Tool:** `yt-dlp` on onyx
- **Format selector:** `bv*[height<=1080][ext=mp4]+ba[ext=m4a]/b[height<=1080][ext=mp4]/bv*[height<=1080]+ba/b[height<=1080]/b``--merge-output-format mp4` (source 2160p, capped to 1080p per playbook §1e)
- **Subs:** `--write-subs --sub-langs 'en' --embed-subs --convert-subs srt` — English user-uploaded subs embedded AND sidecar `.en.srt`
- **Thumbnail:** `--write-thumbnail --convert-thumbnails jpg` → sidecar `.jpg`, Primary via Local Posters
- **Staging path on onyx:** `/home/admin/staging-jelly/Johnny Harris/`
### Filename normalisation
Raw YouTube title:
> `Why Julian Assange's guilty plea will change journalism forever`
Apostrophe is the smart quote `'` (U+2019). Playbook §1f passes smart quotes
through unchanged — no rename needed. Final filename:
`Why Julian Assange's guilty plea will change journalism forever — 20220510.mp4`
## Target
- **Server:** `jellyfin-stock` on nullstone, `https://tv.s8n.ru`
- **Library:** Education
- **Path on host:** `/home/user/media/education/Johnny Harris/Why Julian Assange's guilty plea will change journalism forever — 20220510.mp4`
- **Container view:** same under `/media/education/`
- **Item ID:** `6d2f161823995d547795615245dbdf94`
### Sidecar files
| Kind | File |
|---|---|
| Media | `… — 20220510.mp4` (207,130,253 B, ~197 MiB) |
| Subtitle | `… — 20220510.en.srt` (57,288 B) |
| Thumbnail | `… — 20220510.jpg` (110,333 B) — Primary via Local Posters |
## Counts
| | Before | After | Delta |
|---|---:|---:|---:|
| Education / Johnny Harris items | 5 | 6 | +1 |
## Stream summary
```
Duration: 00:32:07.28, bitrate: 859 kb/s
Stream #0:0[0x1](und): Video: av1 (libdav1d) (Main), yuv420p(tv, bt709), 1920x1080, 726 kb/s, 23.98 fps
Stream #0:1[0x2](eng): Audio: aac (LC), 44100 Hz, stereo, fltp, 127 kb/s
Stream #0:2[0x3](eng): Subtitle: mov_text (tx3g)
```
AV1 1080p23.98 + stereo AAC + embedded English mov_text + external .en.srt
sidecar.
## Subtitle status
- Embedded: yes — one English `mov_text` track.
- External sidecar: yes — `.en.srt`.
- Action: none.
## Verification checks
- [x] Folder/filename canonical (`<Channel>/<Title><YYYYMMDD>.mp4`).
- [x] Smart apostrophe preserved per §1f; no forbidden chars in path.
- [x] Permissions `user:user` 644.
- [x] Single `Scan Media Library` invocation indexed it on first pass
(no re-trigger needed — folder pre-existing, not new).
- [x] `/Items?searchTerm=Assange` returns single expected item with
`ImageTags.Primary` present, `ProviderIds` empty (expected for
Education library).
- [x] `Name` resolved from filename — no folder-name fallback (≥2 files in
channel folder).
- [ ] Direct-play in mobile / Smart-TV client not exercised.
## Notes / surprises
None — clean reference run. Validates that the single-file caveat
(`feedback_jellyfin_single_file_channel`) is strictly tied to one-file
channel folders; pre-populated folders parse correctly from filename.

View file

@ -0,0 +1,83 @@
# johnny-harris-why-us-deporting-20251031
Single-video YouTube import into the **STOCK** Jellyfin at `tv.s8n.ru`
(container `jellyfin-stock`), **Education** library
(`collectionType=movies`, internet providers disabled).
Channel "Johnny Harris" folder already existed with 4 prior videos. This run
adds the 2025-10-31 release "Why the US is deporting so many people".
## Provenance
- **Source:** YouTube — `https://www.youtube.com/watch?v=aDbtrdfYqBc`
- **Channel:** Johnny Harris
- **Tool:** `yt-dlp` on onyx
- **Format selector:** `bv*[height<=1080][ext=mp4]+ba[ext=m4a]/b[height<=1080][ext=mp4]/bv*[height<=1080]+ba/b[height<=1080]/b``--merge-output-format mp4` (source available up to 2160p, capped to 1080p per playbook §1e)
- **Subs:** `--write-subs --sub-langs 'en' --embed-subs --convert-subs srt` — user-uploaded English subs present, embedded into mp4 AND written as sidecar `.en.srt`
- **Thumbnail:** `--write-thumbnail --convert-thumbnails jpg` → sidecar `.jpg` used as Primary by Local Posters plugin
- **Staging path on onyx:** `/home/admin/staging-jelly/Johnny Harris/`
## Target
- **Server:** `jellyfin-stock` on nullstone, public URL `https://tv.s8n.ru`
- **Library:** Education (`collectionType=movies`, `EnableInternetProviders=false`)
- **Path on host:** `/home/user/media/education/Johnny Harris/Why the US is deporting so many people — 20251031.mp4`
- **Container view:** `/media/education/Johnny Harris/Why the US is deporting so many people — 20251031.mp4`
- **Item ID:** `6ba95c8325213da65c2d6f3c26a35a08`
### Sidecar files
| Kind | File |
|---|---|
| Media | `Why the US is deporting so many people — 20251031.mp4` (271,042,755 B, ~258 MiB) |
| Subtitle | `Why the US is deporting so many people — 20251031.en.srt` (83,161 B) |
| Thumbnail | `Why the US is deporting so many people — 20251031.jpg` (70,137 B) — Primary image via Local Posters |
## Counts
| | Before | After | Delta |
|---|---:|---:|---:|
| Education / Johnny Harris items | 4 | 5 | +1 |
## Stream summary
```
Duration: 00:45:26.32, bitrate: 795 kb/s
Stream #0:0[0x1](und): Video: av1 (libdav1d) (Main), yuv420p(tv, bt709), 1920x1080, 662 kb/s, 23.98 fps
Stream #0:1[0x2](eng): Audio: aac (LC), 44100 Hz, stereo, fltp, 128 kb/s
Stream #0:2[0x3](eng): Subtitle: mov_text (tx3g)
```
AV1 1080p at ~0.66 Mb/s + stereo AAC + embedded English mov_text subs.
## Subtitle status
- Embedded: yes — one English `mov_text` track from yt-dlp `--embed-subs`.
- External sidecar: yes — `.en.srt` next to the mp4 (Jellyfin will register it
as a second selectable subtitle track).
- Action: none. Plain English, no SDH/MT/AI tag per ARRFLIX subtitle style.
## Verification checks
- [x] Folder/filename canonical (`<Channel>/<Title><YYYYMMDD>.mp4`, date as suffix).
- [x] No forbidden chars in path.
- [x] Permissions `user:user` 644 / 755 (chmod safety net run server-side).
- [x] `Scan Media Library` triggered via `/ScheduledTasks/Running/<id>`,
`State` returned to `Idle`.
- [x] `/Items?searchTerm=Why+the+US+is+deporting` returns the single expected
item with `ImageTags.Primary` present, `ProviderIds` empty (expected for
Education library).
- [x] Direct-play in client browser (AV1 supported by Chromium >= 90).
- [ ] Mobile / Smart-TV direct-play not exercised.
## Notes / surprises
- Source upload available in 2160p AV1; 1080p cap per playbook §1e mandatory
for long-form (~45min) content.
- No metadata refresh needed — Local Posters picked up `<basename>.jpg` as
Primary on first scan; no Screen Grabber fallback.
- Single-file channel folder caveat does **not** apply here because the
Johnny Harris folder already contained 4 prior files; Jellyfin's
"movie-in-own-folder" heuristic only fires when there's exactly one media
file. See `the-guardian-snowden-2013-20130709.md` for the workaround when
importing the first video into a brand-new channel folder.

View file

@ -0,0 +1,151 @@
# lex-fridman-podcast-s01-yt-import
Second YouTube import into the **STOCK** Jellyfin at `tv.s8n.ru` (container
`jellyfin-stock`), this time targeting the **Podcasts** library. Five episodes
of the Lex Fridman Podcast — treated as one Series (`Lex Fridman Podcast`) on
Season 01, with the podcast episode number used directly as the episode index
(E400, E461, E478, E479, E481).
Independent from arrflix prod (`arrflix.s8n.ru`) and arrflix dev. Stock
Jellyfin's Podcasts library has `EnableInternetProviders=false` — files land
with filename/folder-only metadata. **No TMDb/TVDB matching is expected or
attempted.** Mirrors the pattern set by `benn-jordan-s01-yt-import.md`
(commit `6e336d1`).
## Provenance
- **Source:** YouTube channel "Lex Fridman" (podcast back catalogue picks)
- **Tool:** `yt-dlp` 2026.03.17 on onyx
- **Format selector (1080p cap):** `bv*[height<=1080][ext=mp4]+ba[ext=m4a]/b[height<=1080][ext=mp4]/bv*[height<=1080]+ba/b[height<=1080]/b``--merge-output-format mp4`
- **Subs:** `--write-subs --sub-langs "en.*" --embed-subs --convert-subs srt` — user-uploaded en subs embedded as `mov_text` when present (4/5 eps); E478 had no en track on YouTube.
- **Staging path on onyx:** `/home/admin/staging-jelly/Lex Fridman Podcast/Season 01/`
- **Parallel downloads:** 5 jobs spawned simultaneously, master wrapper `wait`-blocked until ALL exited 0 before rsync (lesson from prior run — never race rsync against in-flight downloads).
### Source URLs
| Episode | Video ID | URL |
|---|---|---|
| S01E400 | JN3KPFbWCy8 | https://www.youtube.com/watch?v=JN3KPFbWCy8 |
| S01E461 | tNZnLkRBYA8 | https://www.youtube.com/watch?v=tNZnLkRBYA8 |
| S01E478 | jdCKiEJpwf4 | https://www.youtube.com/watch?v=jdCKiEJpwf4 |
| S01E479 | HsLgZzgpz9Y | https://www.youtube.com/watch?v=HsLgZzgpz9Y |
| S01E481 | SvKv7D4pBjE | https://www.youtube.com/watch?v=SvKv7D4pBjE |
Original YouTube titles had ` | Lex Fridman Podcast #XXX` suffix and a
`Guest:` colon — stripped/replaced before filename construction per
playbook filename rules (no forbidden chars `< > : " / \ | ? *`). Ampersand,
comma, apostrophe, hyphen all preserved.
## Target
- **Server:** `jellyfin-stock` (container) on nullstone, exposed at `https://tv.s8n.ru`
- **Library:** Podcasts (tvshows-type, internet providers disabled)
- **Path on host:** `/home/user/media/podcasts/Lex Fridman Podcast/Season 01/`
- **Container view:** `/media/podcasts/Lex Fridman Podcast/Season 01/`
- **Series Item ID:** `6c01ab0084d87b94c124948f64f87c15`
- **Season Item ID:** `67d2aaba01fe73f2ba90e36514823632`
### Per-episode landing
| Episode | File size | Duration (spec) | Duration (Jellyfin) | Item ID |
|---|---:|---:|---:|---|
| S01E400 — Elon Musk - War, AI, Aliens, Politics, Physics, Video Games, and Humanity | 419,097,052 B (~400 MiB) | 8206 s | 8206 s | `5266b338705003d6fd04e315a01cd7fe` |
| S01E461 — ThePrimeagen - Programming, AI, ADHD, Productivity, Addiction, and God | 1,196,404,821 B (~1.11 GiB) | 19208 s | 19207 s | `b68e7628784ebdfafa21c3412bcb31f0` |
| S01E478 — Scott Horton - The Case Against War and the Military Industrial Complex | 1,830,927,069 B (~1.70 GiB) | 37591 s | 37590 s | `9baab6a35e3c0f32f4776e9aa379745d` |
| S01E479 — Dave Plummer - Programming, Autism, and Old-School Microsoft Stories | 583,179,948 B (~556 MiB) | 6628 s | 6628 s | `f33bf1d068c3c4771c8744f655256829` |
| S01E481 — Norman Ohler - Hitler, Nazis, Drugs, WW2, Blitzkrieg, LSD, MKUltra & CIA | 933,193,939 B (~890 MiB) | 15944 s | 15944 s | `b5946af6a55919391b227c7893a73059` |
Total on disk ~4.74 GB across 5 mp4s. The 1080p cap kept the 10.4-hour E478
to 1.7 GB — at 4K this would have ballooned past 50 GB.
Jellyfin's ffprobe is off by 1 s on E461/E478 (rounding-down vs YouTube's
declared seconds) — within tolerance, no correction needed.
## Counts
| | Before | After | Delta |
|---|---:|---:|---:|
| SeriesCount (Podcasts) | 0 | 1 | +1 |
| EpisodeCount (Podcasts) | 0 | 5 | +5 |
(First import into the Podcasts library; pre-state empty.)
## Stream sample (S01E479)
```
major_brand : isom
Duration: 01:50:28.48, start: 0.000000, bitrate: 703 kb/s
Stream #0:0[0x1](und): Video: av1 (libdav1d) (Main) (av01 / 0x31307661), yuv420p(tv, bt709), 1920x1080, 568 kb/s, 30 fps
Stream #0:1[0x2](eng): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 127 kb/s
Stream #0:2[0x3](eng): Subtitle: mov_text (tx3g / 0x67337874), 0 kb/s
```
AV1 1080p30 ~568 kb/s + AAC stereo ~127 kb/s + embedded `mov_text` en subs.
Source is YouTube's best 1080p mp4/m4a combo. AV1 direct-play requires a
recent client (Chromium ≥ 90, Firefox ≥ 100, Apple Silicon Safari, Android
12+, modern smart-TVs); otherwise `jellyfin-stock` will CPU-transcode (no GPU
mount per SYSTEM.md).
## Subtitle status
- Embedded `mov_text` (en): **yes** for E400 / E461 / E479 / E481 (user-uploaded en track present on the YouTube upload — yt-dlp embedded via `--embed-subs`).
- E478: no en track available on YouTube — no embedded sub. Player will fall back to no subs unless auto-CC sidecar is fetched later (`--write-auto-subs --sub-langs "en.*"`).
- External sidecar: none.
- Action: leave as-is for now. If E478 subs become required, re-fetch auto-CC and drop a `.eng.srt` next to the mp4 per `playbooks/subtitles/`.
## Verification checks
- [x] Folder/filename canonical (`Lex Fridman Podcast/Season 01/Lex Fridman Podcast - S01E<NNN> - <Title>.mp4`)
- [x] Permissions `user:user` 644 / 755 on nullstone
- [x] `Scan Media Library` task triggered via `/ScheduledTasks/Running/$SCAN_ID` (HTTP 204) — completed at 2026-05-11T14:43:12Z
- [x] **Note:** initial scan created Series + Season stubs but ChildCount=0. A follow-up `/Items/$SERIES_ID/Refresh?MetadataRefreshMode=FullRefresh&Recursive=true` (HTTP 204) was required to actually pull the 5 episodes into the index. This was *not* required in the benn-jordan run — possibly because Lex's filenames include forbidden-looking characters (`,` `&` `-`) and Jellyfin's series-stub-first heuristic is slower to reconcile when the discovery probe is racing the scan. Documented here so the next operator knows the second-pass refresh is sometimes load-bearing.
- [x] Per-series query `/Shows/$SERIES_ID/Episodes?Season=1` returns 5 episodes with correct durations
- [x] No `/Items/Counts` reliance — used `/Shows/<id>/Episodes` as authoritative
- [n/a] `ProviderIds` populated — **expected empty**, Podcasts library has internet providers OFF
- [x] `ImageTags.Primary` populated on all 5 — Jellyfin extracted thumbnail from mp4 itself
### Scan task
- **Task ID:** `7738148ffcd07979c7ceb148e06b3aed`
- **POST result:** HTTP 204
- **StartTime (initial scan):** `2026-05-11T14:43:01.031Z`
- **EndTime (initial scan):** `2026-05-11T14:43:12.285Z` (11 s)
- **Follow-up series refresh:** POST `/Items/$SERIES_ID/Refresh` returned HTTP 204; episodes appeared in season within ~3 s.
- **State after run:** `Idle`
## Notes / surprises
- Stock Jellyfin's Podcasts library is configured `tvshows`-type with
`EnableInternetProviders=false`. This matches the Educational library set
up for Benn Jordan — same pattern, different folder. **Do not try to
TMDb-identify Lex Fridman Podcast episodes; the Podcasts library is
deliberately offline.**
- Used podcast episode number as the season-1 episode index. E400/E461/E478/E479/E481
is consciously sparse — Jellyfin handles non-contiguous episode numbers fine,
and using the canonical podcast number means there's no ambiguity when an
operator looks at the UI and matches "Lex #481" to a file.
- All 5 downloads ran in parallel from onyx via a wrapper script
(`/tmp/lex-download.sh`) which `wait`-blocked on every job PID before
exiting. The wrapper's exit code (0) gated the rsync step — addressing the
"rsync raced partial downloads" failure mode from a prior YouTube import.
- E478 is 10.4 hours (`Scott Horton - The Case Against War and the Military
Industrial Complex`). Capped at 1080p it weighs in at 1.7 GB / ~590 kb/s
total. At 4K it would have exceeded 50 GB and absolutely buried disk.
The format selector `bv*[height<=1080]` is now the standing rule for any
podcast-style long-form import.
- rsync ran at ~61 MB/s onyx → nullstone over the 1G LAN (4.7 GB in ~80 s).
No `--info=progress2` surprises; resumable on disconnect via rsync defaults.
- Source staging dir on onyx (`/home/admin/staging-jelly/Lex Fridman Podcast/`)
is intentionally left in place — do not delete until owner confirms
playback.
## Operator action
1. Open `https://tv.s8n.ru` → Podcasts library → confirm "Lex Fridman Podcast"
series shows 5 episodes (numbered 400 / 461 / 478 / 479 / 481).
2. Play any episode → confirm direct-play on a modern AV1-capable client (no
transcode line in `docker logs jellyfin-stock`). On older clients expect
CPU transcode.
3. Optional: upload custom series poster + per-episode artwork via the
Jellyfin web UI (no TMDb fallback, so artwork is manual or absent).
4. Source dir on onyx retained per cleanup policy.

View file

@ -0,0 +1,127 @@
# lex-fridman-podcast-s01e491-openclaw
Single-episode YouTube import into the **STOCK** Jellyfin at `tv.s8n.ru`
(container `jellyfin-stock`), **Podcasts** library
(`collectionType=tvshows`, internet providers disabled).
Extends the existing `Lex Fridman Podcast` Season 01 series — sparse numbering
already in use (400 / 461 / 478 / 479 / 481). Adds episode 491.
## Provenance
- **Source:** YouTube — `https://www.youtube.com/watch?v=YFjfBk8HI5o`
- **Channel / Series:** Lex Fridman Podcast
- **Episode:** #491 "OpenClaw: The Viral AI Agent that Broke the Internet — Peter Steinberger"
- **Tool:** `yt-dlp` on onyx
- **Format selector:** `bv*[height<=1080][ext=mp4]+ba[ext=m4a]/b[height<=1080][ext=mp4]/bv*[height<=1080]+ba/b[height<=1080]/b``--merge-output-format mp4` (source native 1080p)
- **Subs:** `--write-subs --sub-langs 'en' --embed-subs --convert-subs srt` — user-uploaded English subs embedded AND sidecar `.en.srt`
- **Thumbnail:** `--write-thumbnail --convert-thumbnails jpg` → sidecar `.jpg`, Primary via Local Posters
- **yt-dlp output template:** `-o "Lex Fridman Podcast - S01E491 - OpenClaw - The Viral AI Agent that Broke the Internet - Peter Steinberger.%(ext)s"` (downloaded straight to canonical filename — no post-download rename needed)
- **Staging path on onyx:** `/home/admin/staging-jelly/Lex Fridman Podcast/Season 01/`
### Filename normalisation
Raw YouTube title:
> `OpenClaw: The Viral AI Agent that Broke the Internet - Peter Steinberger | Lex Fridman Podcast #491`
Applied playbook §1f rules:
- Dropped suffix ` | Lex Fridman Podcast #491` (redundant with `Lex Fridman Podcast - S01E491 -` prefix).
- Replaced ASCII `:` after `OpenClaw` with ` - ` (forbidden char).
- Pipe `|` not present in episode-title portion after suffix drop.
Final filename (per playbook §1c numbered-podcast pattern):
`Lex Fridman Podcast - S01E491 - OpenClaw - The Viral AI Agent that Broke the Internet - Peter Steinberger.mp4`
## Target
- **Server:** `jellyfin-stock` on nullstone, `https://tv.s8n.ru`
- **Library:** Podcasts (`collectionType=tvshows`, `EnableInternetProviders=false`)
- **Series Item ID:** `6c01ab0084d87b94c124948f64f87c15`
- **Season Item ID:** `67d2aaba01fe73f2ba90e36514823632`
- **Episode Item ID:** `fbeeffb256a04f103987f9b22d0bd442`
- **Path on host:** `/home/user/media/podcasts/Lex Fridman Podcast/Season 01/Lex Fridman Podcast - S01E491 - OpenClaw - The Viral AI Agent that Broke the Internet - Peter Steinberger.mp4`
### Sidecar files
| Kind | File |
|---|---|
| Media | `… - S01E491 - … .mp4` (749,120,258 B, ~715 MiB) |
| Subtitle | `… - S01E491 - … .en.srt` (270,915 B) |
| Thumbnail | `… - S01E491 - … .jpg` (64,605 B) — Primary via Local Posters |
## Counts
| | Before | After | Delta |
|---|---:|---:|---:|
| Lex Fridman Podcast / Season 01 episodes | 5 | 6 | +1 |
## Stream summary
```
Duration: 03:15:51.67, bitrate: 509 kb/s
Stream #0:0[0x1](und): Video: av1 (libdav1d) (Main), yuv420p(tv, bt709), 1920x1080, 375 kb/s, 29.97 fps
Stream #0:1[0x2](eng): Audio: aac (LC), 44100 Hz, stereo, fltp, 127 kb/s
Stream #0:2[0x3](eng): Subtitle: mov_text (tx3g)
```
AV1 1080p29.97 + stereo AAC + embedded English mov_text + external .en.srt
sidecar. 3:15:52 runtime, ~715 MiB — well under the 1080p cap budget for
long-form content.
## Subtitle status
- Embedded: yes — one English `mov_text` track from `--embed-subs`.
- External sidecar: yes — `.en.srt`.
- Action: none. WhisperX rebuild not required (channel-published subs trusted
for podcast transcripts; per `feedback_subtitle_accuracy_priority` only
auto-CC is rejected — these are author-provided).
## Verification checks
- [x] Folder/filename canonical (`Lex Fridman Podcast - S01E491 - <Title>.mp4` per playbook §1c).
- [x] No forbidden chars in path.
- [x] Permissions `user:user` 644.
- [x] `Scan Media Library` triggered, `State=Idle`, episode appeared in
`/Shows/<id>/Episodes?Season=1`.
- [x] `/Items?searchTerm=OpenClaw` returns the expected single Episode item.
- [x] `ImageTags.Primary` present (Local Posters from sidecar `.jpg`).
- [x] `Type=Episode`, `SeriesId` / `SeasonId` correctly attached.
- [x] `ParentIndexNumber=1`, `IndexNumber=491` populated (see Notes — required
manual override, JF scan did not parse SxxEyy from filename despite
the filename containing `S01E491`).
- [x] `LockData=true` set so future series refresh cannot revert the SE.
- [ ] Direct-play in mobile / Smart-TV client not exercised.
## Notes / surprises
### JF MovieResolver did not parse `S01E491` from filename — manual SE override required
After the `Scan Media Library` pass, the episode resolved as `Type=Episode`
attached to the correct Series + Season, but `ParentIndexNumber` and
`IndexNumber` were both `null`. The other 5 episodes in Season 01 (E400 /
E461 / E478 / E479 / E481) all have the same `Lex Fridman Podcast - S<NN>E<NN>
- <Title>.mp4` pattern and parsed correctly — root cause unclear.
Tried fixes that did **not** work:
1. Series-level `POST /Items/<seriesId>/Refresh?MetadataRefreshMode=FullRefresh&Recursive=true` → state did not change.
2. Item-level `POST /Items/<episodeId>/Refresh?MetadataRefreshMode=FullRefresh&ReplaceAllMetadata=true` → state did not change.
Working fix — direct API override:
```bash
EP_ID=fbeeffb256a04f103987f9b22d0bd442
curl -sf -H "X-Emby-Token: $TOK" \
"$SERVER_URL/Users/$USER_ID/Items/$EP_ID" \
| jq '.ParentIndexNumber = 1 | .IndexNumber = 491 | .LockData = true' \
| curl -sf -X POST -H "X-Emby-Token: $TOK" -H 'Content-Type: application/json' \
--data @- "$SERVER_URL/Items/$EP_ID" # → HTTP 204
```
Inspecting the 5 known-good episodes, **all of them** already have
`LockData=true`, so this is the established pattern for the `Lex Fridman
Podcast` series — every new episode appears to need the override. Generalise
to a post-import step in the playbook §1c "numbered podcast" section.
Possible upstream cause: episode-resolver in JF 10.11.8 may bail when the
filename contains additional hyphen-separated segments that resemble more
SxxEyy tokens. Investigate after second occurrence.

View file

@ -0,0 +1,63 @@
# lilo-stitch-2002
First run of `playbooks/import-media/` v1.0.
## Provenance
- **Source path on onyx:** `/home/admin/Downloads/Lilo & Stitch (2002) (1080p BluRay x265 HEVC 10bit EAC3 5.1 YOGI)/Lilo & Stitch (2002) (1080p BluRay x265 YOGI).mkv`
- **Release group:** YOGI
- **Quality:** 1080p BluRay HEVC 10-bit
- **Audio:** EAC3 5.1 English
## Target
- **Library:** movies
- **Path:** `/home/user/media/movies/Lilo & Stitch (2002)/Lilo & Stitch (2002).mkv`
- **Container view:** `/media/movies/Lilo & Stitch (2002)/Lilo & Stitch (2002).mkv`
- **Item ID:** `c2f4aff133c1b9631500fadf293b0b2f`
- **TMDb:** `11544`
- **IMDb:** `tt0275847`
## Counts
| | Before | After | Delta |
|---|---:|---:|---:|
| MovieCount | 3 | 4 | +1 |
## Stream summary
```
Duration: 01:25:22.18, bitrate: 3698 kb/s
Stream #0:0: Video: hevc (Main 10), yuv420p10le(tv, bt709), 1816x1080, 23.98 fps
Stream #0:1(eng): Audio: eac3, 48000 Hz, 5.1(side), 640 kb/s
Stream #0:2(eng): Subtitle: hdmv_pgs_subtitle (pgssub)
Stream #0:3(eng): Subtitle: hdmv_pgs_subtitle (pgssub), 1920x1080
```
Color tagged BT.709 (SDR) — no tonemap path needed. Direct-play-friendly HEVC.
## Subtitle status
- Embedded: yes — 2× English PGS (image-based, burnt-in style)
- External sidecar: none yet
- Action: none for now. PGS works on most clients via server burn-in. If text subs preferred, run `playbooks/subtitles/` later.
## Verification checks
- [x] Folder/filename canonical (`Lilo & Stitch (2002)/Lilo & Stitch (2002).mkv`)
- [x] Permissions `user:user` 644 (file) / 755 (dir) — verified via `ls -la` post-rsync
- [ ] LibraryMonitor auto-fired — DID NOT trigger on this import (file landed but no log line). Forced manual `POST /Library/Refresh` returned 204 → ffprobe ran within seconds → MovieCount bumped. **Possible cause:** rsync over many seconds may break the inotify watch's debounce; bind-mount FS event delivery from host into container can be flaky on userns-remap setups. Manual refresh always works. Flagged for v1.1 of playbook to recommend always running manual refresh after rsync.
- [x] `Items/Counts.MovieCount` bumped 3 → 4
- [x] TMDb match: `11544` populated
- [x] Artwork: PrimaryImage `15330b2e...` + 1 backdrop fetched
- [x] Direct-play candidate (HEVC 10-bit, BT.709, EAC3 5.1)
## Notes / surprises
- LibraryMonitor didn't auto-pick the new file — had to force `POST /Library/Refresh`. Updated the playbook to make this an unconditional step rather than "optional fallback".
- Filename didn't need NFO override — TMDb matched correctly on first try via folder + year.
- Source download at `/home/admin/Downloads/Lilo & Stitch (2002) (1080p BluRay x265 HEVC 10bit EAC3 5.1 YOGI)/` retained per `ADMIN-GUIDE.md:74` (don't delete until confirmed playing in app).
## Operator action
User to verify in browser: `https://arrflix.s8n.ru` → search "Lilo" → confirm artwork + Play. After that, source download on laptop can be deleted.

View file

@ -0,0 +1,124 @@
# more-perfect-union-palantir-20250417
Single-video YouTube import into the **STOCK** Jellyfin at `tv.s8n.ru`
(container `jellyfin-stock`), **Education** library
(`collectionType=movies`, internet providers disabled, fresh path).
First import for channel `More Perfect Union` — creates the channel folder
under `/media/education/`.
## Provenance
- **Source:** YouTube — `https://www.youtube.com/watch?v=DZ95Gmvg_D4`
- **Channel:** More Perfect Union
- **Title:** "I Worked At Palantir: The Tech Company Reshaping Reality"
- **Upload date:** 2025-04-17
- **Duration:** 16:31
- **Tool:** `yt-dlp` on onyx
- **Format selector:** `bv*[height<=1080][ext=mp4]+ba[ext=m4a]/b[height<=1080][ext=mp4]/bv*[height<=1080]+ba/b[height<=1080]/b``--merge-output-format mp4` (source native 1080p AV1+AAC)
- **Subs:** `--write-subs --sub-langs 'en' --embed-subs --convert-subs srt` — author-provided English subs embedded AND sidecar `.en.srt`
- **Thumbnail:** `--write-thumbnail --convert-thumbnails jpg` → sidecar `.jpg`, Primary via Local Posters
- **yt-dlp output template:** `-o "%(title)s — %(upload_date)s.%(ext)s"`
- **Staging path on onyx:** `/home/admin/staging-jelly/More Perfect Union/`
### Filename normalisation
Raw yt-dlp output (with fullwidth colon substitute):
`I Worked At Palantir The Tech Company Reshaping Reality — 20250417.mp4`
Applied playbook §1f rules:
- Replaced U+FF1A FULLWIDTH COLON (yt-dlp's safe substitute for `:`) with
` - `. Playbook §1f forbids ASCII `:`; the fullwidth fallback is
cosmetically ugly and breaks search.
Final filename (per playbook §1d Education pattern — date as suffix, em-dash):
`I Worked At Palantir - The Tech Company Reshaping Reality — 20250417.mp4`
## Target
- **Server:** `jellyfin-stock` on nullstone, `https://tv.s8n.ru`
- **Library:** Education (`collectionType=movies`, `EnableInternetProviders=false`)
- **Library Item ID:** `484cf52875118e03bd7effc72621bec0`
- **Movie Item ID:** `d9127cf53df5f81565bc217305179962`
- **Path on host:** `/home/user/media/education/More Perfect Union/I Worked At Palantir - The Tech Company Reshaping Reality — 20250417.mp4`
### Sidecar files
| Kind | File |
|---|---|
| Media | `… — 20250417.mp4` (89,599,403 B, ~85 MiB) |
| Subtitle | `… — 20250417.en.srt` (22,955 B) |
| Thumbnail | `… — 20250417.jpg` (56,398 B) — Primary via Local Posters |
## Counts
| | Before | After | Delta |
|---|---:|---:|---:|
| Education library items | 11 | 12 | +1 |
| `More Perfect Union/` channel folder | — | created | new channel |
## Stream summary
```
Container: mp4 Size: 85.5 MiB Duration: 16:31
Video av1 und 1080p AV1 SDR
Audio aac eng English AAC stereo
Subtitle mov_text eng English — Default — MOV_TEXT (embedded)
Subtitle subrip eng English — SUBRIP — External (.en.srt)
```
AV1 1080p source — direct-play in any AV1-capable client (Chromium 100+,
recent VLC, mpv).
## Subtitle status
- Embedded: yes — one English `mov_text` track from `--embed-subs`.
- External sidecar: yes — `.en.srt`.
- Source: channel-published (author-provided) — yt-dlp `--sub-langs 'en'`
fetches manual subs only, never auto-CC.
- Action: none. No WhisperX rebuild needed.
## Verification checks
- [x] Folder/filename canonical (playbook §1d — date suffix, em-dash, no Season dir).
- [x] No forbidden chars in path.
- [x] Permissions `user:user` 644 / 755.
- [x] `Scan Media Library` triggered via `/ScheduledTasks/Running/<id>`,
`LastExecutionResult.Status=Completed`.
- [x] Item resolved as `Type=Movie` in Education library.
- [x] `ImageTags.Primary` present (Local Posters from sidecar `.jpg`).
- [x] Embedded + external subtitle streams both registered.
- [x] `LockData=true` set after manual Name override (see Notes).
- [ ] Direct-play in mobile / Smart-TV client not exercised.
## Notes / surprises
### JF single-file-in-channel-folder leaked folder name as title
First import for a brand-new channel folder produces exactly **one** file in
`/media/education/More Perfect Union/`. JF's movie resolver applied the
folder-name-as-title heuristic and registered the item as
`Name="More Perfect Union"` — wiping the actual episode title.
This matches the documented pattern in
`feedback_jellyfin_single_file_channel`: movie-in-own-folder → folder name
wins. Fix: PUT `/Items/<id>` with corrected `Name` + `LockData=true` so
future scans don't revert it.
Working fix:
```bash
TOK=<admin>
ITEM=d9127cf53df5f81565bc217305179962
USER_ID=2ad8033c4f97486788d4a4b4915b9c0f
curl -sf -H "X-Emby-Token: $TOK" \
"$SERVER_URL/Users/$USER_ID/Items/$ITEM" \
| jq '.Name = "I Worked At Palantir - The Tech Company Reshaping Reality — 20250417" | .LockData = true' \
| curl -sf -X POST -H "X-Emby-Token: $TOK" -H 'Content-Type: application/json' \
--data @- "$SERVER_URL/Items/$ITEM" # → HTTP 204
```
Generalises to all single-file YouTube imports: drop a second file into the
channel folder before scanning, OR accept the post-import `Name`+`LockData`
override. Once the channel has ≥2 files, JF parses filenames correctly.

View file

@ -0,0 +1,154 @@
# Multi-import + nullstone cleanup — 2026-05-14
**Operator:** s8n
**Libraries touched:** `tv`, `movies`
This run bundles three things into one session-level log:
1. Cross-ref + delete of three Futurama duplicate dirs on onyx
2. Import of The Inbetweeners (2008) — TV + 2 movies
3. Delete of Mandalorian + Obi-Wan Kenobi from JF to free space
4. Import of Mr. Robot (2015) + Kim Possible (2002) — TV + 2 KP movies
5. Skipped: Rick and Morty S02-S03 + Mr Robot (per the original conditional rule — see below)
---
## 1. Futurama cleanup (3 duplicate sources deleted from onyx)
Cross-referenced each Futurama dir in `~/Downloads` against the existing `Futurama (1999)` series in JF. **All three were duplicates of content already on JF.** Stream probes confirmed.
| Source | Size | Verdict | Action |
|---|---|---|---|
| `Futurama (1999) Season 1-7 S01-S07 + Extras (Mixed x265 HEVC 10bit AAC 5.1 RCVR) REPACK` | 48G | 480p DVD HEVC — **same as JF S01-S07** (706x480 hevc Main10 + AC3 192kb/s, byte-for-byte stream match) | `rm -rf` |
| `Futurama (1999) Season 8 S08 (1080p DSNP WEB-DL x265 HEVC 10bit EAC3 5.1 t3nzin)` | 3.9G | Hulu revival 10-ep set — same content as today's earlier JF S11 import | `rm -rf` |
| `Futurama Season 1-11 Colection 1080p WEBDL` | 24G | DSNP S08-S11 set — same source as today's earlier JF S08-S11 import | `rm -rf` |
**Freed on onyx Downloads:** ~76G. qBt fastresume + .torrent records also removed for the 3 hashes (`471bb22...`, `582b5d7...`, `e4b2918...`) — qBt was throwing `file_open ... No such file or directory` errors and pausing those torrents on missing files.
## 2. The Inbetweeners (2008)
**Source:** `~/Downloads/The Inbetweeners 2008 S01-S03 Complete 1080p WEB-DL HEVC x265 BONE/` (8.3G)
**Target series:** `/home/user/media/tv/The Inbetweeners (2008)/` (new in JF)
**Target movies:** `/home/user/media/movies/The Inbetweeners Movie (2011)/` + `/home/user/media/movies/The Inbetweeners 2 (2014)/`
Stream profile: HEVC Main10 1080p, AAC stereo ~1.76 Mb/s, SubRip eng embedded.
Renamed per playbook §1b: `The Inbetweeners (2008) - S<NN>E<MM> - <Episode Title>.mkv` (titles parsed from torrent filenames).
| Season | Eps |
|---|---|
| S01 | 6 |
| S02 | 6 |
| S03 | 6 |
Plus two movies: `The Inbetweeners Movie (2011)` and `The Inbetweeners 2 (2014)` (BluRay HEVC 5.1).
rsync: 5.8G TV + 3.0G movies / exit 0.
Verify: 18 episodes + 2 movies indexed. JF auto-replaced my filename-derived titles with TVDB canonical (e.g. `Girlfriend``Will Gets a Girlfriend`). No manual LockData step needed.
## 3. Delete Mandalorian + Obi-Wan Kenobi from JF
User-requested storage cleanup. Deleted from nullstone:
| Path | Size freed |
|---|---|
| `/home/user/media/tv/The Mandalorian (2019)/` | 45G |
| `/home/user/media/tv/Obi-Wan Kenobi (2022)/` | 16G |
`/home` free jumped 32G → **92G** post-delete. Triggered JF scan to drop indexes.
## 4. Mr. Robot (2015) + Kim Possible (2002)
### Decision matrix
Free on nullstone post-Inbetweeners = 92G after Mando/Obi-Wan deletes. User picked: **Mr Robot + Kim Possible** (76.6G → 15.4G headroom). Original conditional rule ("if Mr Robot fits, also import R&M S02-S03; if not, skip both") was satisfied by Mr Robot fitting comfortably, but R&M was de-scoped this round in favor of KP — see § 5.
### Mr. Robot
**Source:** `~/Downloads/Mr Robot (2015) Complete Series S01-S04 (1080p BluRay x265 HEVC 10bit AAC 5.1 Vyndros)/` (44G)
**Target:** `/home/user/media/tv/Mr. Robot (2015)/Season {01..04}/`
| Season | Eps | Notes |
|---|---|---|
| S01 | 10 | |
| S02 | 11 | E01 is a double-episode (`S02E01-E02`) — kept as single file per Vyndros release |
| S03 | 10 | |
| S04 | 13 | |
Sidecars: 44 `.eng.srt` per ep (renamed from `.srt` to match playbook §1e canonical pattern).
### Kim Possible (2002)
**Source:** `~/Downloads/KIM POSSIBLE (2002-2019) - Complete TV Series, Season 1,2,3,4 ... 1080p AMZN Web-DL x264/` (30G)
**Target TV:** `/home/user/media/tv/Kim Possible (2002)/Season {01..04}/`
**Target movies:**
- `/home/user/media/movies/Kim Possible Movie - So the Drama (2005)/`
- `/home/user/media/movies/Kim Possible (2019)/` (live-action film)
| Season | Eps |
|---|---|
| S01 | 21 |
| S02 | 33 |
| S03 | 12 |
| S04 | 23 |
**Skipped from source:** `d. Season 3.5 - Crossover (2005)` contains a Lilo & Stitch episode (`S02E20 - Rufus`), not a Kim Possible episode — different series, dropped. Also dropped: `Other CARTOONS with MARTIAL ARTS Themes`, `OTHER Martial Arts Movies and Shows` (Bruce Lee + Jet Li bundled in the torrent — unrelated content).
### rsync + scan + verify
Combined rsync to nullstone: 71.5 GB TV + 4.7 GB movies / `to-chk=0/187` / exit 0.
Perms pass: 755 dirs / 644 files / `user:user`.
Scan via `POST /ScheduledTasks/Running/7738148ffcd07979c7ceb148e06b3aed` → HTTP 204, Idle reached.
Verify (via `/Items?Recursive=true&IncludeItemTypes=Episode&fields=Path`):
- Mr. Robot: **44 eps** indexed, all `IndexNumber` + `ParentIndexNumber` set, names from TVDB.
- Kim Possible: **89 eps** indexed.
- 2 KP movies + 2 Inbetweeners movies + series records created.
Note: `/Shows/<sid>/Episodes?Season=N` returned 0 for both series initially — JF's per-show endpoint appears to lag behind the recursive Items index after a fresh scan; the recursive query is authoritative.
## 5. Rick and Morty + Mr Robot conditional (resolved)
Original instruction was: import Inbetweeners + R&M S02-S03 + Mr Robot if Mr Robot fits; if not, skip both R&M and Mr Robot. With the post-Mando/Obi-Wan delete, Mr Robot did fit. R&M was set aside in favor of importing Kim Possible (user's revised choice). R&M S02-S08 (4K Mesc upscales, ~107 GB) still seeding on onyx, available for the next storage upgrade. Nothing deleted.
## 6. Cleanup + qBt hygiene
Deleted sources from onyx Downloads:
- `The Inbetweeners 2008 S01-S03 Complete 1080p WEB-DL HEVC x265 BONE`
- `Mr Robot (2015) Complete Series S01-S04 (1080p BluRay x265 HEVC 10bit AAC 5.1 Vyndros)`
- `KIM POSSIBLE (2002-2019) ... 1080p AMZN Web-DL x264`
qBt stopped (SIGTERM PID 159610), three .fastresume + .torrent pairs removed from `~/.local/share/qBittorrent/BT_backup/` (hashes `4badc62...`, `1ab3a02...`, `19223321...`), qBt restarted on port 64817 (listening verified).
`/home/admin/staging-jelly` cleaned.
## 7. Final state
| Resource | Before | After |
|---|---|---|
| nullstone `/home` free | 41G | 21G |
| onyx Downloads | 465G | 297G (≈) |
| JF tv items | + 0 | + Inbetweeners (18 eps), Mr. Robot (44 eps), Kim Possible (89 eps) |
| JF movies | 13 → 17 | + Inbetweeners Movie, Inbetweeners 2, KP: So the Drama, KP (2019) |
## 8. Unusual things to note
- The Futurama RCVR repack on disk has byte-identical streams to JF's S01-S07 (706x480 HEVC + AC3 192kb/s). Confirms an earlier import of this exact release. **JF's S01-S07 is 480p DVD**, not 1080p. Worth an upgrade to a true 1080p BluRay HEVC source on the next storage refresh.
- Mr. Robot S02E01-E02 ships as a single file (double-episode aired together). Kept as `S02E01-E02` — JF handles the dual-ep mapping natively.
- The Kim Possible torrent bundles unrelated martial-arts content as "OTHER" dirs; not part of the series, skipped.
- JF endpoint `/Shows/<sid>/Episodes?Season=N` lags after a fresh series import; use the recursive `/Items` endpoint with `IncludeItemTypes=Episode` for authoritative verification.
## Verification checklist
- [x] Folder + filename canonical pattern (§1b/§1f).
- [x] Permissions `user:user`, 644/755.
- [x] Sidecar `.eng.srt` for Mr Robot (44).
- [x] Scan triggered + Idle.
- [x] Per-show ep counts via recursive Items query match on-disk counts (44, 89, 18).
- [x] Series + movie records created with correct `ProductionYear`.
- [x] Source dirs deleted from onyx, fastresumes removed, qBt restarted clean.

View file

@ -0,0 +1,98 @@
# star-wars-maul-shadow-lord-2026-2160p
Second run of `playbooks/import-media/` — first multi-episode TV import + first replace-with-comparison flow.
## Provenance
- **Source path on onyx:** `/home/admin/Downloads/Star.Wars.Maul.Shadow.Lord.S01.2160p.DSNP.WEB-DL.DDP5.1.ENG.Atmos.ITA.SDR.H265-TheDarkLord/`
- **Release group:** TheDarkLord
- **Quality:** 2160p WEB-DL (Disney+) HEVC SDR
- **Audio:** EAC3 5.1 ENG Atmos + ITA dub
- **Subtitles:** 4× embedded subrip per episode (ITA forced + ITA + ENG + ENG)
- **Episode count:** 10 (S01E01E10)
- **Total size:** ~21 GB
## Replace-with-comparison flow (first time)
User wanted the prior 1080p upscale kept side-by-side as a quality reference rather than overwritten. Approach:
1. Renamed existing folder `Star Wars - Maul - Shadow Lord (2026)``Star Wars - Maul - Shadow Lord [Before Upscale] (2026)` on nullstone.
2. Renamed all 10 existing episode files to embed `[Before Upscale]` in the show name segment (canonical filename pattern preserved otherwise).
3. Dropped `tvshow.nfo` in the renamed folder with `<title>Star Wars: Maul - Shadow Lord [Before Upscale]</title>` and `<lockdata>true</lockdata>` to prevent Jellyfin from merging the two folders via TMDb match.
4. Imported new 2160p as the canonical `Star Wars - Maul - Shadow Lord (2026)` per playbook v1.0.
Result: two distinct Series items in Jellyfin, both visible at `searchTerm=Maul`. User can compare playback between them.
## Targets
| Variant | Path | Item ID |
|---|---|---|
| New canonical (2160p) | `/home/user/media/tv/Star Wars - Maul - Shadow Lord (2026)/Season 01/` | `e993ccc0544638a2f4973b9e9f0dfe87` |
| Old [Before Upscale] | `/home/user/media/tv/Star Wars - Maul - Shadow Lord [Before Upscale] (2026)/Season 01/` | `dcc1205a6ed760e4cf21fdd2d8eaf7f8` |
Container view: `/media/tv/Star Wars - Maul - Shadow Lord (2026)/...` and `/media/tv/Star Wars - Maul - Shadow Lord [Before Upscale] (2026)/...`.
## Counts
| | Before | After | Delta |
|---|---:|---:|---:|
| SeriesCount | 10 | 11 | +1 |
| EpisodeCount | 197 | 207 | +10 |
The +1 series is the `[Before Upscale]` clone of an existing entry (folder-rename re-creates it as a fresh Series); the +10 episodes are the new 2160p canonical S01.
## Stream sample (E01 — Chapter 1: The Dark Revenge)
```
Duration: 00:28:18.91, bitrate: 12483 kb/s
Stream #0:0: Video: hevc (Main), yuv420p(tv), 3840x2160, 23.81 fps ← TRUE 4K UHD
Stream #0:1(ita): Audio: eac3, 48000 Hz, 5.1(side), 256 kb/s
Stream #0:2(eng): Audio: eac3 (Dolby Digital Plus + Dolby Atmos), 48000 Hz, 5.1(side), 768 kb/s
Stream #0:3(ita): Subtitle: subrip (forced)
Stream #0:4(ita): Subtitle: subrip
Stream #0:5(eng): Subtitle: subrip
Stream #0:6(eng): Subtitle: subrip
```
3840×2160 8-bit SDR HEVC, 12 Mbps. Direct-play candidate on capable clients.
## Subtitle status
- 4× embedded text subrip per episode (ITA + ENG, plus forced ITA) — no external sidecar needed.
- No action — embedded text subs work natively in browser via WebVTT.
## Verification checks
- [x] Folder/filename canonical for both old + new
- [x] Permissions `user:user` 644 (file) / 755 (dir) — verified post-rsync chmod
- [ ] LibraryMonitor auto-fired — DID NOT (same as Lilo run). Forced manual `POST /Library/Refresh` returned 204. Per-item `Refresh?MetadataRefreshMode=FullRefresh` also fired on both series.
- [x] `Items/Counts` bumped Series 10→11, Episodes 197→207
- [x] Both series enumerated as separate items
- [x] New series confirmed has 10 episodes via `/Shows/{id}/Episodes`
- [ ] **TMDb / TVDB providers**: NOT auto-matched on either series (`tmdb=? tvdb=?`). Likely cause: 2026 Disney+ Star Wars animated series may be too recent / non-canonical title format for the TMDb provider's auto-match. Operator can manually attach the right TMDb match via the Jellyfin UI (3-dot menu → "Identify").
- [x] 1 backdrop image fetched on new canonical (poster auto-grabbed from local artwork or default).
- [x] HEVC 4K stream confirmed via ffprobe; direct-play candidate.
## Notes / surprises
- LibraryMonitor failed twice in a row to detect new media (Lilo + this run). Playbook v1.0 already calls for unconditional manual `/Library/Refresh` — confirmed correct.
- Inter-series merging is suppressed by `tvshow.nfo` `<lockdata>true</lockdata>` on the [Before Upscale] folder — without that, Jellyfin would merge both folders to the same TMDb match and present one collapsed Series. Tested — separation holds.
- TMDb auto-match failed for both. This may also need a `[tmdbid-NNNN]` token in the folder name per `docs/05:54-56` if user wants pre-mapped IDs in the future. For now, manual UI match is the fix.
- Initial rsync was interrupted by the spawning agent's session timeout — second rsync resumed from 4/10 → 10/10 successfully. `rsync -a` is idempotent so the resume worked. Playbook v1.1 should call out: "if rsync fails partway, just re-run the same command — `rsync -a` skips already-transferred files".
- 21 GB transferred in two passes; second pass was the bulk (6 episodes / ~13 GB).
## Operator action
1. Open `https://arrflix.s8n.ru` → search "Maul".
2. Confirm BOTH series visible:
- "Star Wars: Maul - Shadow Lord" (the new canonical 2160p — the "real" version)
- "Star Wars: Maul - Shadow Lord [Before Upscale]" (the old upscaled — comparison reference)
3. If TMDb metadata desired: open each, click 3-dot → "Identify" → search TMDb manually.
4. Compare quality: play same episode (S01E01 Chapter 1) on both items — should see clear quality difference (4K vs upscaled 1080p).
5. Once happy, source download at `/home/admin/Downloads/Star.Wars.Maul.Shadow.Lord.S01.2160p.DSNP.WEB-DL.DDP5.1.ENG.Atmos.ITA.SDR.H265-TheDarkLord/` can be deleted.
## Playbook updates needed (v1.1 candidate)
- Document the "replace with [Before Upscale] comparison" pattern: rename + tvshow.nfo lockdata + new canonical folder.
- Document rsync resume idempotency: large multi-file transfers may interrupt; just re-run same command.
- Recommend `[tmdbid-NNNN]` folder-name token for any title where auto-match historically fails (recent Disney+ animated, niche releases).

View file

@ -0,0 +1,122 @@
# the-guardian-snowden-2013-20130709
Single-video YouTube import into the **STOCK** Jellyfin at `tv.s8n.ru`
(container `jellyfin-stock`), **Education** library
(`collectionType=movies`, internet providers disabled).
First import into a brand-new "The Guardian" channel folder. Exposed a
single-file folder caveat in Jellyfin movie parsing — see Notes.
## Provenance
- **Source:** YouTube — `https://www.youtube.com/watch?v=0hLjuVyIIrs`
- **Channel:** The Guardian
- **Tool:** `yt-dlp` on onyx
- **Format selector:** `bv*[height<=1080][ext=mp4]+ba[ext=m4a]/b[height<=1080][ext=mp4]/bv*[height<=1080]+ba/b[height<=1080]/b``--merge-output-format mp4` (source native 1080p)
- **Subs:** `--write-subs --sub-langs 'en' --embed-subs --convert-subs srt`**no user-uploaded English subs available**; `[EmbedSubtitle] There aren't any subtitles to embed`
- **Thumbnail:** `--write-thumbnail --convert-thumbnails jpg` → sidecar `.jpg` used as Primary
- **Staging path on onyx:** `/home/admin/staging-jelly/The Guardian/`
### Filename normalisation
Raw YouTube title:
> `NSA whistleblower Edward Snowden: 'I don't want to live in a society that does these sort of things'`
yt-dlp's safe-name pass replaced the ASCII `:` with the fullwidth `` (U+FF1A).
Per playbook §1f the canonical replacement is ASCII ` - `, so the file was
renamed before rsync to:
`NSA whistleblower Edward Snowden - 'I don't want to live in a society that does these sort of things' — 20130709.mp4`
Apostrophes preserved (playbook §1f: smart quotes and apostrophes pass).
## Target
- **Server:** `jellyfin-stock` on nullstone, public URL `https://tv.s8n.ru`
- **Library:** Education (`collectionType=movies`, `EnableInternetProviders=false`)
- **Path on host:** `/home/user/media/education/The Guardian/NSA whistleblower Edward Snowden - 'I don't want to live in a society that does these sort of things' — 20130709.mp4`
- **Container view:** same under `/media/education/`
- **Item ID:** `578da493fdcff4e8fde5137adbcaebdb`
### Sidecar files
| Kind | File |
|---|---|
| Media | `… — 20130709.mp4` (46,324,047 B, ~44 MiB) |
| Subtitle | none |
| Thumbnail | `… — 20130709.jpg` (34,895 B) — Primary via Local Posters |
## Counts
| | Before | After | Delta |
|---|---:|---:|---:|
| Education / The Guardian items | 0 | 1 | +1 |
| Education / The Guardian channel folders | 0 | 1 | +1 |
## Stream summary
```
Duration: 00:12:34.30, bitrate: 491 kb/s
Stream #0:0[0x1](und): Video: av1 (libdav1d) (Main), yuv420p(tv, bt709), 1920x1080, 358 kb/s, 25 fps
Stream #0:1[0x2](eng): Audio: aac (LC), 44100 Hz, stereo, fltp, 127 kb/s
```
AV1 1080p25 + stereo AAC. No subtitle streams.
## Subtitle status
- Embedded: no (channel does not publish user-uploaded en captions on this
video; auto-CC not requested by playbook).
- External sidecar: no.
- Action: deferred. If subs become required, follow `playbooks/subtitles/`
WhisperX pipeline (do **not** fall back to YouTube auto-CC; see
`feedback_subtitle_accuracy_priority`).
## Verification checks
- [x] Folder/filename canonical (`<Channel>/<Title><YYYYMMDD>.mp4`).
- [x] No forbidden chars in path (fullwidth ``` - ` rename in staging).
- [x] Permissions `user:user` 644 / 755.
- [x] `Scan Media Library` triggered, `State=Idle`, ran twice (initial scan
did not pull file in until folder was visible to library monitor — see
Notes).
- [x] `/Items?searchTerm=NSA+whistleblower&IncludeItemTypes=Movie` returns
the expected single item after `Name` lock.
- [x] `ImageTags.Primary` present.
- [ ] Direct-play in mobile / Smart-TV client not exercised.
## Notes / surprises
### Single-file channel folder → Jellyfin parses folder name as title
Jellyfin's MovieResolver applies the "movie-in-own-folder" heuristic when a
folder under a `collectionType=movies` library contains **exactly one** media
file. The resulting `BaseItem.Name` is taken from the folder name, not the
filename — so the Snowden mp4 initially indexed with `Name="The Guardian"`
instead of the actual video title.
Workaround used (no playbook change required for this run):
```bash
# Pull current item, set Name explicitly, LockData=true, POST back
ID=578da493fdcff4e8fde5137adbcaebdb
curl -sf -H "X-Emby-Token: $TOK" \
"$SERVER_URL/Users/$USER_ID/Items/$ID" \
| jq '.Name = "NSA whistleblower Edward Snowden - ..." | .LockData = true' \
| curl -sf -X POST -H "X-Emby-Token: $TOK" -H 'Content-Type: application/json' \
--data @- "$SERVER_URL/Items/$ID"
# expect HTTP 204; Name now locked so future refresh cannot revert it.
```
Generalises to any first-video import into a brand-new channel folder.
Recommend documenting in `playbooks/import-media/README.md` §1d as a known
caveat with the LockData fix; will propose a `playbooks/` PR after the
second occurrence to confirm reproducibility.
### Two scan passes required
First `POST /ScheduledTasks/Running/<scan-task-id>` returned to `Idle` while
the file was already on disk but reported no new Education item. A second
identical invocation indexed the file. Root cause unclear — possible
`LibraryMonitor` race during inotify pickup of the freshly created folder.
Documenting per playbook §5d / known-broken-trigger guidance.

View file

@ -0,0 +1,144 @@
# Subtitle process — changelog
## v1 — 2026-05-09
Initial recipe. Drafted while running on American Dad. Distilled from doc
03-subtitles.md (Futurama work) and the actual AD run.
Approach: Jellyfin RemoteSearch/Subtitles/eng → pick best non-HI/non-MT match
via Python filter → POST download → docker cp metadata cache → media folder →
delete cache dupes → validation refresh.
Scope: works on shows whose library season/episode numbering matches
OpenSubtitles' indexed numbering. Verified passing on AD S01 (7/7 episodes).
### Known break — added 2026-05-09 same day
After S01 passed, S02 returned 0 results for every episode probed (E01, E02,
E08, E13). Quota was fine (13 downloads remaining). Cause:
> Jellyfin metadata for American Dad uses **Hulu/DSP season ordering**
> (S1=7, S2=16, S3=19, S4=16). OpenSubtitles indexes by **Fox original-airing
> order** where S1 has 23 episodes. The plugin queries OS by
> `(parent_imdb_id, season_number, episode_number)`. For library S02E01
> "Bullocks to Stan" the plugin sends `S=2,E=1` but OS catalogues that
> episode as `S=1,E=8`. Result: 0 hits.
Each library episode has its own correct per-episode IMDB id (e.g.
`tt0511631` for "Bullocks to Stan") which would resolve directly via OS REST
`imdb_id=` parameter, but the plugin doesn't expose that path.
## v2 — 2026-05-09
Approach **A** chosen: direct OpenSubtitles REST API, per-episode `imdb_id`
lookup, bypass the Jellyfin plugin entirely. New helper at
`lib/sub-rest-fetch.py`.
- API key file: `~/.config/arrflix-opensubtitles-api.txt` (mode 600)
- Account: `Caveman5` (free tier, 20 downloads/day)
- Saves sidecars directly to nullstone media folder via `ssh ... cat >`
- No more docker-cp from `/config/metadata/library` cache (plugin path)
Recipe upgrade:
- Step 4 swaps `lib/sub-fetch.sh``lib/sub-rest-fetch.py` for shows with
non-standard season ordering.
- Picker logic identical: filter HI/MT/AI/Forced (renamed
`foreign_parts_only` in OS REST), prefer 23.976fps, sort by
`download_count` desc.
### v2 known quirks
- **OpenSubtitles `/download` endpoint rejects urllib** — consistent HTTP 503
via Python `urllib.request`, HTTP 200 via `curl` with same headers/body.
`_curl()` shim added; all OS API calls go through it. **Each 503 still
consumes 1 download-quota slot**, so this had to be fixed before retrying
large batches.
- `download_count` of `0` and `fps` of `0.0` appear on some catalogue
entries; treat as informational, not exclusionary.
- Some hits have `file_name` mismatching the `imdb_id` searched (OS metadata
drift). Recipe Step 6 visual-sync check is the catch.
### v2 known limits
- Free-tier 20/day still in force (REST and plugin share the counter).
- Recipe Step 6 (sync verification) is still manual — no automated check
that the picked .srt actually aligns with audio.
## v3 — 2026-05-09
Approach **Addic7ed via subliminal** added as a quota-free fallback. New
helper at `lib/sub-a7d-fetch.py`. Runs alongside v2; pick whichever fits.
- `subliminal` Python lib drives `addic7ed` provider, anonymous
- OS REST is still consulted (search-only, no quota cost) to translate
library Hulu numbering to the show's primary catalogue numbering, since
Addic7ed and OS feature_details appear to align for at least the test
show (American Dad)
- Sidecar written direct to nullstone via `ssh ... cat >`
### v3 picker / matching
- subliminal returns ordered candidates by match score; takes first
- "!" in series name breaks subliminal's matcher; recipe strips it before
building the synthetic filename for `Video.fromname()`
- Synthetic filename pattern: `Series.Name.Year.SXXEYY.HDTV.x264.mkv`
### v3 known quirks
- Some episodes return 0 hits at addic7ed for the OS-feat-details S/E we
pass — likely cases where addic7ed indexes by Fox airing order while OS
uses DVD-compressed (or vice versa). On American Dad, ~9 of 58 episodes
missed via this path. Fall back to v2 OS REST when quota allows.
- One episode (`Black Mystery Month`) had a hit but downloaded empty
content — addic7ed-side cataloguing error or temp 0-byte upload.
- Per-show coverage varies: Addic7ed has near-complete English on broadcast
US shows but spotty for animated specials and obscure titles.
### v3 known limits
- English coverage best; non-English near-empty
- Anonymous downloads work but heavy bursts may trigger Addic7ed's
bot detection and short IP throttle (~1 hour). The script makes no
effort at jittering / backoff
- No automated sync-quality check; recipe Step 6 still manual
## v3.5 — 2026-05-10 (stop-gap path for niche YouTube-distributed shows)
For shows that distribute on YouTube and have no community subs anywhere
(verified by parallel research agents covering OS REST / OS legacy /
Addic7ed / SubDL / SubSource / Podnapisi for 5 niche shows), pull the
YouTube auto-CC track via yt-dlp and clean it.
- New helper: `lib/sub-yt-fetch.sh` (yt-dlp wrapper) + `lib/yt-clean.py`
(rolling-window VTT → flat SRT cleaner)
- First applied to **Sassy the Sasquatch (2022)**, S01 5/5 episodes
- Reusable for the rest of the Big Lez universe (same channel hosts
Donny & Clarence, Mike Nolan, Big Lez Saga)
### v3.5 known limits — explicitly violates STYLE.md "best quality"
- Lowercase, no punctuation, no sentence segmentation
- Proper-noun mishears (Sassy → "sasha", Big Lez → "Big Less")
- Profanity censored as `[ __ ]` by YouTube's ASR
- Will be replaced wholesale by v4 WhisperX (see ROADMAP H5)
### v3.5 also discovered
- **OpenSubtitles VIP would not have helped.** Verified: VIP is download-cap
relief and ad removal, not coverage unlock. Same catalog as free.
- **Mike Nolan special-case**: a YouTube upload titled
"MIKE NOLAN SHOW | COMPLETE SEASON | SUBTITLES" (Oct 2025) carries
hand-typed CCs. When subbing Mike Nolan, prefer ripping that single
upload over the per-episode auto-CC playlist path.
## v4 — planned (see ROADMAP H5)
Path: **WhisperX large-v3 on friend RTX 4080 node** (`100.64.0.3`).
- Replaces v3.5 stop-gap with full-quality auto-transcription
- Per-show proper-noun prompt at `playbooks/subtitles/prompts/<show>.yaml`
- New helper: `lib/sub-whisperx-fetch.py` (TBD)
- Expected WER: 46% on noisy / animated dialogue (vs ~12% YT auto-CC)
- Restores STYLE.md "one clean English sub per ep" bar for niche shows
- Cloud fallback: ElevenLabs Scribe v2 (~$0.40/hr, ~2.2% WER) for any
episode WhisperX still misses

View file

@ -0,0 +1,82 @@
# ARRFLIX subtitle coverage
_Generated 2026-05-10 05:09 UTC by `playbooks/subtitles/lib/audit-coverage.py`._
_Re-run: `JELLYFIN_TOKEN=<admin-token> playbooks/subtitles/lib/audit-coverage.py`._
Legend: `█` eng sidecar · `▒` eng embedded only · `▓` other-lang embedded · `·` none
## TV shows
```
Show Eps sc emb none Status
──────────────────────────────────────────────────────────────────────────────
American Dad! 58 49 0 9 PARTIAL (84%)
██████████████████████████·████████·█████······███
██·█████
Archer 23 0 23 0 OK-EMBED (no sidecars)
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Futurama 72 0 72 0 OK-EMBED (no sidecars)
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Obi-Wan Kenobi 6 0 6 0 OK-EMBED (no sidecars)
▒▒▒▒▒▒
Rick and Morty 11 0 11 0 OK-EMBED (no sidecars)
▒▒▒▒▒▒▒▒▒▒▒
Sassy the Sasquatch 5 5 0 0 OK (100%)
█████
Star Wars: Maul - Shadow Lord 10 0 10 0 OK-EMBED (no sidecars)
▒▒▒▒▒▒▒▒▒▒
Star Wars: Maul - Shadow Lord [Before Upscale] 10 0 10 0 OK-EMBED (no sidecars)
▒▒▒▒▒▒▒▒▒▒
The Big Lez Saga (2022) 3 3 0 0 OK (100%)
███
The Donny & Clarence Show (2024) 5 5 0 0 OK (100%)
█████
The Mandalorian 24 0 24 0 OK-EMBED (no sidecars)
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
The Mike Nolan Show 3 3 0 0 OK (100%)
███
```
## Movies
```
Title sc emb Status
──────────────────────────────────────────────────────────────────────────────
Idiocracy 0 2 OK (embedded)
Lilo & Stitch 1 2 OK (sidecar)
The Dark Knight 0 1 OK (embedded)
The Incredible Hulk 0 1 OK (embedded)
```
## Aggregate
| Metric | Count | % |
|---|---:|---:|
| Episodes total | 230 | — |
| eng sidecar | 65 | 28% |
| eng embedded only | 156 | 67% |
| other-lang embedded only | 0 | 0% |
| no subs anywhere | 9 | 3% |
| Movies total | 4 | — |
| Movies with any eng sub | 4 | 100% |
## Status meanings
- **OK** — every episode has an external `.eng.srt` sidecar (STYLE.md happy path)
- **OK-EMBED** — all eps playable in English but no sidecars; `SaveSubtitlesWithMedia` won't trigger fetch since Jellyfin sees an eng track already
- **PARTIAL (X %)** — some sidecars, some gaps
- **NEEDS SUBS** — zero subs of any language; v3 / v3.5 / v4 fetch required
- **OTHER-LANG ONLY** (movies) — embedded subs exist but none in English

View file

@ -0,0 +1,14 @@
# Subtitles playbook — moved
The procedure, STYLE.md, COVERAGE.md, STOPGAP-SUBS.md and the helper
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:
<https://git.s8n.ru/s8n/beta-flix/src/branch/main/playbooks/subtitles/>
Per-show fetch logs stay here under [`runs/`](runs/) — they're history,
not procedure. [`CHANGELOG.md`](CHANGELOG.md) preserves the ARRFLIX recipe
evolution (v1 → v3.5) for context. The local [`lib/`](lib/) copies remain
as the ARRFLIX-host-specific reference (hardcoded Jellyfin container
name, internal URL, and SSH target).

View file

@ -0,0 +1,56 @@
# Stop-gap subs — pending Whisper cross-ref
Shows whose current subtitles ship from a path that explicitly violates
[`STYLE.md`](STYLE.md). Quality is "acceptable, not great" (~85 %). When
v4 WhisperX (ROADMAP H5) lands on the friend RTX 4080 node, **regenerate
every show on this list** with proper-noun-prompted transcription and
replace the sidecars in place. Keep this file as the v4 worklist.
**NOT a stop-gap** (do NOT log here): embedded original-release bitmap
subs (PGS, VobSub, `dvd_subtitle`). Per [`STYLE.md`](STYLE.md) tier 2,
those are first-class — they're the original studio render and ship
as-is. Examples currently in library that are correct, not stop-gap:
- Lilo & Stitch (2002) — 2× embedded English PGS
- Archer (2009) S02 — 3× embedded DVD-bitmap (eng/spa/fre)
Optional `pgsrip` OCR sidecar for those is a UX nicety, not a
correctness fix — see STYLE.md "OCR bitmap → text".
## Active stop-gaps
| Show | Eps subbed | Source path | Why stop-gap | Owner verdict | Logged |
|---|---|---|---|---|---|
| Sassy the Sasquatch (2022) | S01 5/5 | v3.5 YouTube auto-CC | lowercase, no punctuation, names mangled (`Sassy → sasha`), profanity = `[ __ ]` | "85 % the way there, acceptable, fine" — keep until v4 | 2026-05-10 |
## When more Big Lez universe shows ship via v3.5
Same channel hosts these — when subbed via the v3.5 yt-dlp path, append
to the table above:
- The Donny & Clarence Show (2024)
- The Big Lez Saga (2022)
- The Mike Nolan Show (2016) — but **try the YT "COMPLETE SEASON | SUBTITLES"
upload first** for hand-typed CCs before falling back to auto-CC
## v4 WhisperX rebuild plan
When the friend node (`100.64.0.3`, per memory `project_friend_gpu.md`) is
back online:
1. Install WhisperX on the node (CUDA 12 + cuDNN 9 + faster-whisper +
pyannote VAD).
2. For each show in the table above, write
`playbooks/subtitles/prompts/<show>.yaml` with the recurring proper
nouns the YT auto-CC mangled.
3. Run `lib/sub-whisperx-fetch.py` (TBD, ROADMAP H5) per show. Each
episode: pull mkv → ffmpeg extract 16k mono wav → WhisperX large-v3
with `--initial_prompt` from the yaml → SRT → SSH push to nullstone
with library filename, **overwriting the v3.5 sidecar in place**.
4. Tick off the row from the table; move it to a "Cleared via v4" archive
section below this one (kept as record).
5. Library scan; verify Jellyfin still reports 1 external eng sub stream
per ep (no dupes from v3.5 + v4 stacking).
## Cleared via v4 (archive)
(empty — populate as v4 rebuilds land)

View file

@ -0,0 +1,134 @@
# Subtitle USER-G style — ARRFLIX
The bar every fetch should hit. If a recipe step would violate any of these,
stop and ask before proceeding.
## Source priority (highest → lowest)
Accuracy beats format. Use this tier ladder before reaching for OCR/AI:
1. **Original release text subs** (`.srt`/`.ass` from disc/streamer rip,
embedded or sidecar). Ground truth — ship as-is.
2. **Original release bitmap subs** (PGS, VobSub, `dvd_subtitle`,
embedded). **Acceptable in their native form** — they ARE the original
words from the source master, just rendered as images. Jellyfin server
burns them in for clients that can't render natively. Optionally
OCR-extract a `.srt` sidecar alongside (do NOT replace the embedded
stream) when client-side styling, search, or mobile rendering matters.
3. **Trusted text rips** from OpenSubtitles (verified uploads, hash-match
or high-download-count + frame-rate-match).
4. **WhisperX rebuild** with `--initial_prompt` proper-nouns yaml — only
when no original exists (e.g. user-uploaded YT content with auto-CC).
Logged in [`STOPGAP-SUBS.md`](STOPGAP-SUBS.md) until cleared.
Tier 1 and 2 are first-class. Tier 3 is a fallback. Tier 4 is a stop-gap.
## What lands on disk
- **At least one** English subtitle stream per episode (embedded OR
sidecar — not both required).
- Sidecar filename when used: `<videobasename>.eng.srt` — no
language-region tags (`en-US`), no flag stack on regular subs (no
`.sdh`, no `.forced`, no `.cc` unless there genuinely is no
plain-English option).
- Sidecar format: `.srt` (SubRip text). For embedded: keep the original
codec (`subrip`, `ass`, `pgs`, `vobsub`, `dvd_subtitle`) — do NOT
re-mux just to convert format. Convert only when extracting to disk:
text codecs via `ffmpeg -map 0:s:0 -c:s srt`, bitmap codecs via OCR
(see "OCR bitmap → text" below).
- Encoding (sidecar): UTF-8. Re-encode with `iconv` if a sidecar comes
back as cp1252 / windows-1250.
## What gets picked
In order:
1. **English** language — `eng` / `en`. Never auto-pick `en-US`/`en-GB`
variants over plain `en`; treat them equivalent for matching.
2. **No SDH / Hearing Impaired** — drop any sub flagged `hearing_impaired`,
`sdh`, `cc`. Only fall back to SDH if no plain-English option exists.
3. **No machine / AI translation** — drop `machine_translated`,
`ai_translated`. Hand-authored subs only.
4. **No forced subtitles** — drop `foreign_parts_only` / `Forced` unless the
episode has English audio with foreign-language scenes that need
translation (rare for US shows).
5. **Frame-rate match** — prefer entries whose declared fps matches the
source video (typically 23.976 for our masters). Treat `0.0` as unknown
and fall through to step 6.
6. **Highest download count** within the surviving candidates — proxies for
"the version everyone agreed was best."
After fetch, **eyeball-verify one sample episode per show** plays in sync
(±1 s on a known dialogue line) before declaring the show done.
## What doesn't ship
- Multiple language tracks per episode (no German/French alternatives —
English-only library). Drop non-English embedded streams via
`mkvpropedit` only if user complains about client picker clutter; do
NOT silently strip them on import.
- Director's commentary, behind-the-scenes, song-only subs.
- Subs that cover only a partial runtime (the partial-cover heuristic isn't
scripted yet; spot-check duration vs episode runtime if a srt looks short).
- "All-episodes-in-one" mega-packs treated as a single episode's sidecar.
## OCR bitmap → text (optional, tier-2 augmentation)
Embedded PGS/VobSub/`dvd_subtitle` are acceptable as-is (tier 2). OCR
becomes worthwhile when: (a) client repeatedly transcodes due to bitmap
burn-in (CPU pressure on nullstone — no GPU transcode available), (b)
user wants to restyle font/size on a specific show, (c) mobile client
renders bitmap subs poorly.
Recipe (`pgsrip`, batch-friendly, Tesseract-backed):
```bash
pip install pgsrip
# PGS: extract embedded to .sup
ffmpeg -i input.mkv -map 0:s:0 -c copy subs.sup
pgsrip --language eng subs.sup # -> subs.srt
# VobSub/dvd_subtitle: extract to .idx + .sub
mkvextract input.mkv tracks 2:subs.idx
pgsrip subs.idx # -> subs.srt
```
OCR accuracy ~9095 % raw, ~9598 % after Subtitle Edit cleanup. Source
words are correct (it's transcription of original render, not Whisper
hallucination) — only font recognition fights you. Resulting `.srt`
ships as sidecar **alongside** the embedded bitmap stream, not as a
replacement.
## How the UI presents subs
The detail-page subtitle dropdown is shimmed via
`web-overrides/index.html` (markers `SUB-LABEL-SHIM-BEGIN/END`). Stock
Jellyfin shows e.g. `English - SUBRIP - External - Default`; the shim
collapses to `English`, with `(Forced)` / `(SDH)` / `(Hearing Impaired)`
suffixes only when those flags actually apply. `Default` is dropped — it's
redundant when there's only one stream per language.
Revert: `bin/revert-sub-label-shim.sh`.
## Why these rules
- Boutique-release-group quality bar from
[`README.md`](../../README.md): "every show and film is the best version
I could put together."
- **Original-release subs > pretty format.** The DVD/BD/streamer master
is the canonical script — bitmap or text, those are the words the
studio shipped. An OCR'd or AI-rebuilt sidecar is a derivative that
introduces error (font confusion, mistranscription); the original
doesn't. Especially true for older shows (Futurama S1S3, Archer
early seasons) where the master is the only authoritative source and
upscale artifacts already dominate the visual experience — bitmap
subs match the source vibe.
- One-language library = one stream per ep = no need to expose codec or
source in UI.
- SDH/CC adds `[door slams]`, `[music]` etc. — distracting on first watch
and not what someone reaches for unless they specifically need it.
- Machine / AI translations are inconsistent and often wrong on slang or
show-specific terms (esp. animated comedies).
- Frame-rate-matched subs sync without manual offset on first try; mismatch
generally still works on NTSC (29.97 vs 23.976 are the same elapsed time)
but hash-match or fps-match removes that gamble.

View file

@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""ARRFLIX subtitle coverage audit — read-only.
Queries Jellyfin live (via SSH+curl into the nullstone container), classifies
every TV episode and movie by the source of its English subtitle (sidecar /
embedded / none), and renders a Markdown report. Designed to be regenerated
on demand and committed alongside the recipe so the repo always has a
current view of what's subbed and what isn't.
Usage:
JELLYFIN_TOKEN=<admin-token> \\
playbooks/subtitles/lib/audit-coverage.py [--out PATH]
Default output path: playbooks/subtitles/COVERAGE.md (relative to repo root).
With --stdout, prints to stdout instead of writing the file.
Env (required):
JELLYFIN_TOKEN X-Emby-Token for nullstone Jellyfin
Env (optional):
NULLSTONE SSH target, default user@192.168.0.100
Classification (per episode):
eng sidecar STYLE.md happy path
eng embedded only playable but doesn't satisfy "1 .eng.srt per ep"
other-lang embedded no English at all, only foreign subs muxed
· none nothing fetch needed
"""
from __future__ import annotations
import argparse
import collections
import datetime as _dt
import json
import os
import shlex
import subprocess
import sys
import urllib.parse
NULLSTONE = os.environ.get("NULLSTONE", "user@192.168.0.100")
JF_BASE = "http://localhost:8096"
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
DEFAULT_OUT = os.path.join(REPO_ROOT, "playbooks", "subtitles", "COVERAGE.md")
def die(msg: str, code: int = 1) -> None:
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(code)
def jellyfin(path: str, params: dict | None = None) -> dict:
tok = os.environ.get("JELLYFIN_TOKEN") or die("JELLYFIN_TOKEN not set")
qs = "?" + urllib.parse.urlencode(params, safe=",") if params else ""
url = JF_BASE + path + qs
cmd = ["ssh", NULLSTONE,
f"docker exec jellyfin curl -s -H 'X-Emby-Token: {tok}' {shlex.quote(url)}"]
return json.loads(subprocess.check_output(cmd, text=True))
def stream_summary(item: dict) -> dict:
out = {"eng_sidecar": 0, "eng_embed": 0, "other_sidecar": 0,
"other_embed": 0, "embedded_any": 0, "sub_total": 0}
for st in item.get("MediaStreams", []) or []:
if st.get("Type") != "Subtitle":
continue
out["sub_total"] += 1
lang = (st.get("Language") or "").lower()
if st.get("IsExternal"):
if lang in ("eng", "en"):
out["eng_sidecar"] += 1
else:
out["other_sidecar"] += 1
else:
out["embedded_any"] += 1
if lang in ("eng", "en"):
out["eng_embed"] += 1
else:
out["other_embed"] += 1
return out
def ep_status_char(s: dict) -> str:
if s["eng_sidecar"]: return ""
if s["eng_embed"]: return ""
if s["embedded_any"]: return ""
if s["sub_total"] == 0: return "·"
return "?"
def render_show_block(name: str, eps: list[dict]) -> tuple[str, dict]:
eps.sort(key=lambda e: (e.get("ParentIndexNumber", 0), e.get("IndexNumber", 0)))
counts = {"eng_sc": 0, "eng_emb": 0, "embed_other": 0, "none": 0}
bar = []
for e in eps:
sm = stream_summary(e)
if sm["eng_sidecar"]: counts["eng_sc"] += 1
elif sm["eng_embed"]: counts["eng_emb"] += 1
elif sm["embedded_any"]: counts["embed_other"] += 1
else: counts["none"] += 1
bar.append(ep_status_char(sm))
n = len(eps)
pct = counts["eng_sc"] * 100 // n if n else 0
if counts["eng_sc"] == n:
status = f"OK ({pct}%)"
elif counts["eng_sc"] + counts["eng_emb"] == n:
status = "OK-EMBED (no sidecars)"
elif counts["none"] == n:
status = "NEEDS SUBS"
else:
status = f"PARTIAL ({pct}%)"
line = (f"{name:<42} {n:>4} {counts['eng_sc']:>6} "
f"{counts['eng_emb']:>7} {counts['none']:>4} {status}")
bar_lines = []
for i in range(0, len(bar), 50):
bar_lines.append(" " + "".join(bar[i:i+50]))
return line + "\n" + "\n".join(bar_lines), counts
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--out", default=DEFAULT_OUT)
ap.add_argument("--stdout", action="store_true")
args = ap.parse_args()
print("[audit] querying Jellyfin…", file=sys.stderr)
series = jellyfin("/Items", {
"IncludeItemTypes": "Series",
"Recursive": "true",
"Fields": "Path",
"SortBy": "SortName",
})["Items"]
eps = jellyfin("/Items", {
"IncludeItemTypes": "Episode",
"Recursive": "true",
"Fields": "Path,MediaStreams,SeriesName,ParentIndexNumber,IndexNumber",
})["Items"]
movies = jellyfin("/Items", {
"IncludeItemTypes": "Movie",
"Recursive": "true",
"Fields": "Path,MediaStreams",
"SortBy": "SortName",
})["Items"]
by_series = collections.defaultdict(list)
for e in eps:
by_series[e.get("SeriesId") or e.get("SeasonId", "???")].append(e)
now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
out = []
out.append("# ARRFLIX subtitle coverage")
out.append("")
out.append(f"_Generated {now} by `playbooks/subtitles/lib/audit-coverage.py`._")
out.append(f"_Re-run: `JELLYFIN_TOKEN=<admin-token> playbooks/subtitles/lib/audit-coverage.py`._")
out.append("")
out.append("Legend: `█` eng sidecar · `▒` eng embedded only · "
"`▓` other-lang embedded · `·` none")
out.append("")
out.append("## TV shows")
out.append("")
out.append("```")
out.append(f"{'Show':<42} {'Eps':>4} {'sc':>6} {'emb':>7} {'none':>4} Status")
out.append("" * 78)
agg = {"eng_sc": 0, "eng_emb": 0, "embed_other": 0, "none": 0, "total": 0}
for s in sorted(series, key=lambda x: x["Name"].lower()):
sid = s["Id"]
block, counts = render_show_block(s["Name"], by_series.get(sid, []))
out.append(block)
out.append("")
for k in agg:
if k == "total": continue
agg[k] += counts[k]
agg["total"] += sum(counts.values())
out.append("```")
out.append("")
out.append("## Movies")
out.append("")
out.append("```")
out.append(f"{'Title':<58} {'sc':>6} {'emb':>7} Status")
out.append("" * 78)
m_eng = 0
for m in sorted(movies, key=lambda x: x["Name"].lower()):
sm = stream_summary(m)
if sm["eng_sidecar"]:
status = "OK (sidecar)"
elif sm["eng_embed"]:
status = "OK (embedded)"
elif sm["embedded_any"]:
status = "OTHER-LANG ONLY"
elif sm["sub_total"] == 0:
status = "NEEDS SUBS"
else:
status = "?"
if sm["eng_sidecar"] or sm["eng_embed"]:
m_eng += 1
name = m["Name"]
if len(name) > 56:
name = name[:55] + ""
out.append(f"{name:<58} {sm['eng_sidecar']:>6} {sm['eng_embed']:>7} {status}")
out.append("```")
out.append("")
out.append("## Aggregate")
out.append("")
n = agg["total"] or 1
out.append("| Metric | Count | % |")
out.append("|---|---:|---:|")
out.append(f"| Episodes total | {agg['total']} | — |")
out.append(f"| eng sidecar | {agg['eng_sc']} | {agg['eng_sc']*100//n}% |")
out.append(f"| eng embedded only | {agg['eng_emb']} | {agg['eng_emb']*100//n}% |")
out.append(f"| other-lang embedded only | {agg['embed_other']} | {agg['embed_other']*100//n}% |")
out.append(f"| no subs anywhere | {agg['none']} | {agg['none']*100//n}% |")
out.append(f"| Movies total | {len(movies)} | — |")
out.append(f"| Movies with any eng sub | {m_eng} | "
f"{m_eng*100//max(len(movies),1)}% |")
out.append("")
out.append("## Status meanings")
out.append("")
out.append("- **OK** — every episode has an external `.eng.srt` sidecar (STYLE.md happy path)")
out.append("- **OK-EMBED** — all eps playable in English but no sidecars; `SaveSubtitlesWithMedia` won't trigger fetch since Jellyfin sees an eng track already")
out.append("- **PARTIAL (X %)** — some sidecars, some gaps")
out.append("- **NEEDS SUBS** — zero subs of any language; v3 / v3.5 / v4 fetch required")
out.append("- **OTHER-LANG ONLY** (movies) — embedded subs exist but none in English")
rendered = "\n".join(out) + "\n"
if args.stdout:
sys.stdout.write(rendered)
else:
with open(args.out, "w") as f:
f.write(rendered)
print(f"[audit] wrote {args.out}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""Subtitle fetcher v3 — Addic7ed via subliminal.
Free, no daily quota. Uses OpenSubtitles REST (search-only, no downloads,
no quota burn) to translate library S/E numbering to the show's primary
catalogue numbering (e.g. HuluFox for American Dad), then drives
subliminal's addic7ed provider for the actual download.
Why v3: OS REST `/download` is capped at 20/day on free tier. Addic7ed
serves anonymous downloads with no daily limit. v2 (lib/sub-rest-fetch.py)
remains the right tool when quota isn't the bottleneck — addic7ed has
narrower coverage than OpenSubtitles (English only, mostly).
Picker: subliminal's own scoring against the matched Video (filename, S/E,
year). For AD, addic7ed catalogues by Fox airing order, so the script
remaps library Hulu numbering via per-ep IMDB id lookup on OS REST.
Usage:
sub-a7d-fetch.py <series-id> --season N [--start E] [--end E]
sub-a7d-fetch.py <series-id> --all
Env (required):
JELLYFIN_TOKEN X-Emby-Token for nullstone Jellyfin
OPENSUBTITLES_API_KEY Path to file holding the OS REST key (search only)
Env (optional):
NULLSTONE SSH target, default user@192.168.0.100
DRY_RUN=1 search + remap only, no download
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shlex
import subprocess
import sys
import tempfile
import urllib.parse
from babelfish import Language
from subliminal import (Video, region, list_subtitles, download_subtitles,
save_subtitles)
OS_BASE = "https://api.opensubtitles.com/api/v1"
USER_AGENT = "arrflix v1.0.0"
JF_BASE = "http://localhost:8096"
NULLSTONE = os.environ.get("NULLSTONE", "user@192.168.0.100")
region.configure("dogpile.cache.memory")
def die(msg: str, code: int = 1) -> None:
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(code)
def env_or_die(name: str) -> str:
v = os.environ.get(name)
if not v:
die(f"{name} not set")
return v
def load_api_key() -> str:
path = env_or_die("OPENSUBTITLES_API_KEY")
with open(path) as f:
return f.read().strip()
def jellyfin(path: str, params: dict | None = None) -> dict:
tok = env_or_die("JELLYFIN_TOKEN")
qs = "?" + urllib.parse.urlencode(params, safe=",") if params else ""
url = JF_BASE + path + qs
cmd = ["ssh", NULLSTONE,
f"docker exec jellyfin curl -s -H 'X-Emby-Token: {tok}' {shlex.quote(url)}"]
return json.loads(subprocess.check_output(cmd, text=True))
def list_episodes(series_id: str) -> list[dict]:
d = jellyfin("/Items", {
"ParentId": series_id,
"IncludeItemTypes": "Episode",
"Recursive": "true",
"Fields": "Path,ParentIndexNumber,IndexNumber,ProviderIds",
"SortBy": "ParentIndexNumber,IndexNumber",
})
return d["Items"]
def imdb_strip(s: str | None) -> str | None:
if not s:
return None
return s[2:] if s.startswith("tt") else s
def os_search_imdb(api_key: str, imdb_no_tt: str) -> tuple[int, int] | None:
"""Look up the show's primary catalogue (season, episode) by per-ep IMDB id.
Uses OS feature_details S/E (which appears to align with what Addic7ed
indexes for at least the test shows). Search calls do not consume the
daily quota. If the resulting download mismatches expected dialogue,
consider re-running with the v2 OS REST path which uses imdb_id directly."""
cmd = ["curl", "-sSf",
"-H", f"Api-Key: {api_key}",
"-H", f"User-Agent: {USER_AGENT}",
f"{OS_BASE}/subtitles?imdb_id={imdb_no_tt}&languages=en&per_page=5"]
raw = subprocess.check_output(cmd)
j = json.loads(raw.decode())
for h in j.get("data", []):
fd = h.get("attributes", {}).get("feature_details", {})
s, e = fd.get("season_number"), fd.get("episode_number")
if s and e:
return int(s), int(e)
return None
def episode_to_paths(ep: dict) -> tuple[str, str]:
"""Return (remote_dir, base_filename) for sidecar placement on nullstone."""
container_path = ep["Path"]
host_path = container_path.replace("/media/", "/home/user/media/")
return os.path.dirname(host_path), os.path.splitext(os.path.basename(host_path))[0]
def addic7ed_safe_name(series: str, year: int | None, fox_s: int, fox_e: int) -> str:
"""Build filename that subliminal+addic7ed match. Strip '!' (breaks matcher)
and other punctuation; keep year if known."""
cleaned = re.sub(r"[!?:]", "", series).replace(" ", ".")
yearbit = f".{year}" if year else ""
return f"{cleaned}{yearbit}.S{fox_s:02d}E{fox_e:02d}.HDTV.x264.mkv"
def write_sidecar_remote(content: bytes, remote_path: str) -> None:
p = subprocess.Popen(["ssh", NULLSTONE, f"cat > {shlex.quote(remote_path)}"],
stdin=subprocess.PIPE)
p.communicate(content)
if p.returncode != 0:
die(f"failed writing {remote_path}")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("series_id")
ap.add_argument("--season", type=int, default=None)
ap.add_argument("--start", type=int, default=1)
ap.add_argument("--end", type=int, default=10**6)
ap.add_argument("--all", action="store_true")
args = ap.parse_args()
if args.season is None and not args.all:
die("pass --season N or --all")
api_key = load_api_key()
dry = os.environ.get("DRY_RUN") == "1"
eps = list_episodes(args.series_id)
work = []
for ep in eps:
s, n = ep["ParentIndexNumber"], ep["IndexNumber"]
if not args.all and s != args.season:
continue
if not (args.start <= n <= args.end):
continue
work.append(ep)
if not work:
die("no episodes selected")
print(f"[plan] {len(work)} episodes selected", file=sys.stderr)
ok = 0
fail = []
for ep in work:
s, n = ep["ParentIndexNumber"], ep["IndexNumber"]
label = f"libS{s:02}E{n:02} {ep['Name']}"
imdb = imdb_strip(ep.get("ProviderIds", {}).get("Imdb"))
if not imdb:
print(f"[skip] {label} — no IMDB id", file=sys.stderr)
fail.append((label, "no-imdb"))
continue
try:
fox = os_search_imdb(api_key, imdb)
except subprocess.CalledProcessError as e:
print(f"[skip] {label} — OS search err {e.returncode}", file=sys.stderr)
fail.append((label, "os-search"))
continue
if fox is None:
print(f"[skip] {label} — OS has no S/E for imdb={imdb}", file=sys.stderr)
fail.append((label, "no-fox-se"))
continue
fox_s, fox_e = fox
# series name + year — pull from path or item
series_name = ep.get("SeriesName") or "Show"
year = None
ymatch = re.search(r"\((\d{4})\)", ep.get("Path", ""))
if ymatch:
year = int(ymatch.group(1))
v_name = addic7ed_safe_name(series_name, year, fox_s, fox_e)
v = Video.fromname(v_name)
try:
hits = list_subtitles([v], {Language("eng")},
providers=["addic7ed"]).get(v, [])
except Exception as e:
print(f"[skip] {label} — addic7ed list err: {type(e).__name__}",
file=sys.stderr)
fail.append((label, "a7d-list"))
continue
if not hits:
print(f"[skip] {label} — addic7ed 0 subs (foxS{fox_s:02}E{fox_e:02})",
file=sys.stderr)
fail.append((label, "a7d-no-hits"))
continue
pick = hits[0] # subliminal returns ordered; take first
print(f"[pick] {label} -> foxS{fox_s:02}E{fox_e:02} a7d={pick.id}",
file=sys.stderr)
if dry:
ok += 1
continue
try:
download_subtitles([pick])
except Exception as e:
print(f"[fail] {label} — addic7ed dl err: {type(e).__name__}: {e}",
file=sys.stderr)
fail.append((label, "a7d-dl"))
continue
if not pick.content:
print(f"[fail] {label} — empty content", file=sys.stderr)
fail.append((label, "empty"))
continue
remote_dir, base = episode_to_paths(ep)
dest = f"{remote_dir}/{base}.eng.srt"
write_sidecar_remote(pick.content, dest)
print(f"[ok] {label} -> {dest}", file=sys.stderr)
ok += 1
print(f"\n[done] ok={ok}/{len(work)} failures={len(fail)}", file=sys.stderr)
for lab, why in fail:
print(f" - {lab}: {why}", file=sys.stderr)
if ok:
try:
subprocess.run([os.path.join(os.path.dirname(__file__),
"audit-coverage.py")],
check=False)
except Exception as e:
print(f"[warn] coverage refresh skipped: {e}", file=sys.stderr)
return 0 if ok else 2
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Subtitle fetch helper — recipe v1 Step 4.
#
# Single-episode loop body. Runs against a Jellyfin instance reachable from
# nullstone via `docker exec jellyfin curl ...`. Driver loops should source or
# call this per episode.
#
# Picker: highest DownloadCount among results that are NOT
# (HearingImpaired|MachineTranslated|AiTranslated|Forced); 23.976fps preferred.
# Falls back to all results if every candidate is HI/MT/AI/Forced.
#
# Side effects:
# - POSTs RemoteSearch download (consumes 1 of 20 daily free-tier slots)
# - docker cp's the resulting metadata-cache srt to MEDIA_DIR
#
# Caller env:
# TOK Jellyfin admin X-Emby-Token
# EP Jellyfin episode item id
# MEDIA_DIR destination dir on nullstone, e.g.
# '/home/user/media/tv/American Dad! (2005)/Season 01'
# MEDIA_BASE filename without extension, must match the .mkv basename
#
# Exits non-zero on no-subs (1) or download HTTP != 204 (2).
# Output to stdout: "OK <ep-id> -> <dest path>".
# Output to stderr: chosen sub release name + fps + DownloadCount, or error.
set -euo pipefail
: "${TOK:?TOK required}"
: "${EP:?EP required}"
: "${MEDIA_DIR:?MEDIA_DIR required}"
: "${MEDIA_BASE:?MEDIA_BASE required}"
NULLSTONE="${NULLSTONE:-user@192.168.0.100}"
RAW=$(ssh "$NULLSTONE" "docker exec jellyfin curl -s -H 'X-Emby-Token: $TOK' \
'http://localhost:8096/Items/$EP/RemoteSearch/Subtitles/eng'")
SUBID=$(printf '%s' "$RAW" | python3 -c "
import json, sys
subs = json.load(sys.stdin)
clean = [s for s in subs
if not (s.get('HearingImpaired') or s.get('MachineTranslated')
or s.get('AiTranslated') or s.get('Forced'))]
if not clean:
clean = subs
fps2398 = [s for s in clean if abs(s.get('FrameRate', 0) - 23.976) < 0.01]
pool = fps2398 if fps2398 else clean
pool.sort(key=lambda s: -s.get('DownloadCount', 0))
if pool:
print(pool[0]['Id'])
print(pool[0]['Name'], pool[0].get('FrameRate'),
pool[0].get('DownloadCount'), file=sys.stderr)
")
if [[ -z "$SUBID" ]]; then
echo "NO-SUBS for $EP" >&2
exit 1
fi
HTTP=$(ssh "$NULLSTONE" "docker exec jellyfin curl -s -o /dev/null -X POST \
-H 'X-Emby-Token: $TOK' \
'http://localhost:8096/Items/$EP/RemoteSearch/Subtitles/$SUBID' \
-w '%{http_code}'")
if [[ "$HTTP" != "204" ]]; then
echo "DL-FAIL HTTP=$HTTP for $EP $SUBID" >&2
exit 2
fi
SHARD="${EP:0:2}"
SRC_IN_CONTAINER="/config/metadata/library/$SHARD/$EP/$MEDIA_BASE.eng.srt"
DEST="$MEDIA_DIR/$MEDIA_BASE.eng.srt"
ssh "$NULLSTONE" "docker cp \"jellyfin:$SRC_IN_CONTAINER\" \"$DEST\"" >/dev/null
echo "OK $EP -> $DEST"

View file

@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""Subtitle fetcher v2 — direct OpenSubtitles REST API.
Bypasses the Jellyfin OpenSubtitles plugin to dodge season/episode numbering
mismatches. Looks each library episode up by its per-episode IMDB id, picks
the best English match, downloads via the REST endpoint, and writes the
sidecar straight onto nullstone next to the media file (via SSH).
Why v2 exists: see ../CHANGELOG.md "Known break" American Dad library
uses Hulu season numbering, OS catalogues by Fox airing order; the plugin
queries by (parent_imdb_id, season, episode) so library S02E01 OS S01E08
returned 0 hits even though the per-episode IMDB id (tt0511631) is real.
Picker: highest download_count among non-HI, non-MT, non-AI, non-Forced
candidates; 23.976fps preferred. Falls back to all candidates if every match
is HI/MT/AI/Forced.
Usage:
sub-rest-fetch.py <series-id> --season <N> [--start <ep>] [--end <ep>]
sub-rest-fetch.py <series-id> --all
Env (required):
JELLYFIN_TOKEN X-Emby-Token for nullstone Jellyfin
OPENSUBTITLES_API_KEY Path to file holding the API key
OPENSUBTITLES_USER OS account username
OPENSUBTITLES_PASS OS account password
Env (optional):
NULLSTONE SSH target, default user@192.168.0.100
DRY_RUN=1 search + pick only, no download
"""
from __future__ import annotations
import argparse
import json
import os
import shlex
import subprocess
import sys
import time
import urllib.parse
OS_BASE = "https://api.opensubtitles.com/api/v1"
USER_AGENT = "arrflix v1.0.0"
JF_BASE = "http://localhost:8096"
NULLSTONE = os.environ.get("NULLSTONE", "user@192.168.0.100")
def die(msg: str, code: int = 1) -> None:
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(code)
def env_or_die(name: str) -> str:
v = os.environ.get(name)
if not v:
die(f"{name} not set")
return v
def load_api_key() -> str:
path = env_or_die("OPENSUBTITLES_API_KEY")
with open(path) as f:
return f.read().strip()
def _curl(url: str, method: str = "GET", headers: dict | None = None,
body: dict | None = None, binary: bool = False) -> bytes:
"""OpenSubtitles' frontend rejects urllib (consistent 503 on /download).
curl works against the same endpoint and headers. Use curl uniformly."""
cmd = ["curl", "-sSf", "-X", method, url]
for k, v in (headers or {}).items():
cmd += ["-H", f"{k}: {v}"]
if body is not None:
cmd += ["--data", json.dumps(body)]
return subprocess.check_output(cmd)
def http_json(url: str, method: str = "GET", headers: dict | None = None,
body: dict | None = None) -> dict:
raw = _curl(url, method, headers, body)
return json.loads(raw.decode())
def http_get_bytes(url: str) -> bytes:
return _curl(url, "GET", headers={"User-Agent": USER_AGENT})
def jellyfin(path: str, params: dict | None = None) -> dict:
"""Run Jellyfin API call inside the container on nullstone via SSH."""
tok = env_or_die("JELLYFIN_TOKEN")
qs = ""
if params:
qs = "?" + urllib.parse.urlencode(params, safe=",")
url = JF_BASE + path + qs
cmd = ["ssh", NULLSTONE,
f"docker exec jellyfin curl -s -H 'X-Emby-Token: {tok}' {shlex.quote(url)}"]
out = subprocess.check_output(cmd, text=True)
return json.loads(out)
def list_episodes(series_id: str) -> list[dict]:
d = jellyfin(f"/Items", {
"ParentId": series_id,
"IncludeItemTypes": "Episode",
"Recursive": "true",
"Fields": "Path,ParentIndexNumber,IndexNumber,ProviderIds",
"SortBy": "ParentIndexNumber,IndexNumber",
})
return d["Items"]
def os_login(api_key: str, user: str, password: str) -> str:
res = http_json(f"{OS_BASE}/login", "POST", headers={
"Api-Key": api_key,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
}, body={"username": user, "password": password})
return res["token"]
def os_user_info(api_key: str, bearer: str) -> dict:
return http_json(f"{OS_BASE}/infos/user", headers={
"Api-Key": api_key,
"Authorization": f"Bearer {bearer}",
"User-Agent": USER_AGENT,
})["data"]
def os_search(api_key: str, imdb_id: str) -> list[dict]:
"""imdb_id without the 'tt' prefix per OS convention."""
res = http_json(
f"{OS_BASE}/subtitles?imdb_id={imdb_id}&languages=en",
headers={"Api-Key": api_key, "User-Agent": USER_AGENT})
return res.get("data", [])
def pick_best(hits: list[dict]) -> dict | None:
"""Filter HI/MT/AI/Forced, prefer 23.976fps, sort by download_count desc."""
def attr(h, k):
return h["attributes"].get(k)
clean = [h for h in hits
if not attr(h, "hearing_impaired")
and not attr(h, "machine_translated")
and not attr(h, "ai_translated")
and not attr(h, "foreign_parts_only")]
if not clean:
clean = hits
fps2398 = [h for h in clean if abs((attr(h, "fps") or 0) - 23.976) < 0.01]
pool = fps2398 if fps2398 else clean
pool.sort(key=lambda h: -(attr(h, "download_count") or 0))
return pool[0] if pool else None
def os_download(api_key: str, bearer: str, file_id: int) -> dict:
return http_json(f"{OS_BASE}/download", "POST", headers={
"Api-Key": api_key,
"Authorization": f"Bearer {bearer}",
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
}, body={"file_id": file_id})
def write_sidecar_remote(content: bytes, remote_path: str) -> None:
"""ssh redirect file content to nullstone."""
cmd = ["ssh", NULLSTONE, f"cat > {shlex.quote(remote_path)}"]
p = subprocess.Popen(cmd, stdin=subprocess.PIPE)
p.communicate(content)
if p.returncode != 0:
die(f"failed writing {remote_path}")
def imdb_strip(s: str | None) -> str | None:
if not s:
return None
return s[2:] if s.startswith("tt") else s
def episode_to_paths(ep: dict) -> tuple[str, str]:
"""Return (remote_dir, base_filename) for sidecar placement."""
container_path = ep["Path"] # /media/tv/Show/Season XX/Show - SxxExx - Title.mkv
host_path = container_path.replace("/media/", "/home/user/media/")
remote_dir = os.path.dirname(host_path)
base = os.path.splitext(os.path.basename(host_path))[0]
return remote_dir, base
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("series_id")
ap.add_argument("--season", type=int, default=None)
ap.add_argument("--start", type=int, default=1)
ap.add_argument("--end", type=int, default=10**6)
ap.add_argument("--all", action="store_true")
args = ap.parse_args()
if args.season is None and not args.all:
die("pass --season N or --all")
api_key = load_api_key()
user = env_or_die("OPENSUBTITLES_USER")
pw = env_or_die("OPENSUBTITLES_PASS")
dry = os.environ.get("DRY_RUN") == "1"
bearer = os_login(api_key, user, pw)
info = os_user_info(api_key, bearer)
print(f"[quota] remaining={info['remaining_downloads']}/{info['allowed_downloads']}, "
f"resets in {info['reset_time']}", file=sys.stderr)
eps = list_episodes(args.series_id)
work = []
for ep in eps:
s = ep["ParentIndexNumber"]
n = ep["IndexNumber"]
if not args.all and s != args.season:
continue
if not (args.start <= n <= args.end):
continue
work.append(ep)
if not work:
die("no episodes selected")
print(f"[plan] {len(work)} episodes selected", file=sys.stderr)
if not dry and len(work) > info["remaining_downloads"]:
print(f"[warn] {len(work)} > quota {info['remaining_downloads']}; "
f"will halt mid-run", file=sys.stderr)
ok = 0
fail = []
for ep in work:
s, n = ep["ParentIndexNumber"], ep["IndexNumber"]
label = f"S{s:02}E{n:02} {ep['Name']}"
imdb = imdb_strip(ep.get("ProviderIds", {}).get("Imdb"))
if not imdb:
print(f"[skip] {label} — no IMDB id", file=sys.stderr)
fail.append((label, "no-imdb"))
continue
hits = os_search(api_key, imdb)
pick = pick_best(hits)
if not pick:
print(f"[skip] {label} — 0 hits for imdb={imdb}", file=sys.stderr)
fail.append((label, "no-hits"))
continue
a = pick["attributes"]
f = a["files"][0]
print(f"[pick] {label} imdb={imdb} fid={f['file_id']} dl={a.get('download_count')} "
f"fps={a.get('fps')} fname={f.get('file_name')}", file=sys.stderr)
if dry:
ok += 1
continue
try:
dl = os_download(api_key, bearer, f["file_id"])
except subprocess.CalledProcessError as e:
print(f"[fail] {label} download (curl exit {e.returncode})", file=sys.stderr)
fail.append((label, f"dl-curl-{e.returncode}"))
break # may be quota; stop run
link = dl.get("link")
if not link:
print(f"[fail] {label} no download link in response: {dl}", file=sys.stderr)
fail.append((label, "no-link"))
break
content = http_get_bytes(link)
remote_dir, base = episode_to_paths(ep)
dest = f"{remote_dir}/{base}.eng.srt"
write_sidecar_remote(content, dest)
print(f"[ok] {label} -> {dest} (remaining={dl.get('remaining')})",
file=sys.stderr)
ok += 1
time.sleep(0.5) # be polite
print(f"\n[done] ok={ok}/{len(work)} failures={len(fail)}", file=sys.stderr)
for lab, why in fail:
print(f" - {lab}: {why}", file=sys.stderr)
if ok:
try:
subprocess.run([os.path.join(os.path.dirname(__file__),
"audit-coverage.py")],
check=False)
except Exception as e:
print(f"[warn] coverage refresh skipped: {e}", file=sys.stderr)
return 0 if ok else 2
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Subtitle fetcher v3.5 — YouTube auto-captions via yt-dlp + cleaner.
#
# For shows that distribute on YouTube and have no community subs anywhere
# else (e.g. Big Lez Show universe: Sassy the Sasquatch, Donny & Clarence,
# Mike Nolan, Big Lez Saga). yt-dlp pulls the en-orig auto-CC track, the
# rolling-window VTT goes through yt-clean.py to deduplicate into a flat
# SRT, and the result is dropped on nullstone with the library filename.
#
# Quality caveats (per playbooks/subtitles/STYLE.md fallback policy):
# - lowercase, no punctuation
# - YouTube ASR mishears proper nouns (e.g. "Sassy" → "sasha")
# - profanity is censored as "[ __ ]"
# - capitalisation / sentence segmentation is absent
#
# These subs ship as a stop-gap. v4 (WhisperX large-v3 on the 4080 friend
# node) replaces them with full-quality transcriptions; see ROADMAP.
#
# Usage:
# sub-yt-fetch.sh <playlist-or-channel-url> <out-dir> <name-template>
#
# Example (Sassy):
# sub-yt-fetch.sh \
# 'https://www.youtube.com/playlist?list=PLGMC7oz7XpmDMGrALMQiNXCi9p7aqkWbj' \
# /tmp/sassy-yt \
# 'Sassy the Sasquatch (2022) - S01E%(playlist_index)02d - %(title)s'
#
# After fetch: rename / copy each .en.srt to nullstone with the canonical
# library filename (`<videobasename>.eng.srt`). For now this is manual —
# automate when the next show comes through.
set -euo pipefail
PLAYLIST="${1:?playlist or channel URL required}"
OUTDIR="${2:?output directory required}"
NAMETMPL="${3:-S%(playlist_index)02d - %(title)s}"
mkdir -p "$OUTDIR"
if ! command -v yt-dlp >/dev/null; then
echo "ERROR: yt-dlp not installed (pip install yt-dlp)" >&2
exit 1
fi
# Pull raw VTT auto-CC, no video, en-orig only (matches en bytewise but is the
# canonical track to request).
yt-dlp --skip-download --write-auto-subs --sub-langs "en-orig" \
--sub-format vtt \
--sleep-requests 1 --sleep-subtitles 2 \
-o "$OUTDIR/${NAMETMPL}-raw.%(ext)s" \
"$PLAYLIST"
CLEANER="$(dirname "$0")/yt-clean.py"
if [[ ! -x "$CLEANER" ]]; then
echo "ERROR: $CLEANER not found / not executable" >&2
exit 2
fi
# Convert each raw VTT to clean SRT
shopt -s nullglob
for vtt in "$OUTDIR"/*-raw.en-orig.vtt; do
out="${vtt%-raw.en-orig.vtt}.en.srt"
python3 "$CLEANER" "$vtt" "$out"
echo "OK $out"
done
echo
echo "next: copy each .en.srt to nullstone with library filename, then library scan."

View file

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Clean YouTube auto-caption VTT into a flat SRT with no rolling-window dupes."""
import re, sys, pathlib
def parse_vtt(text):
"""Yield (start, end, line) tuples, dropping inline timing tags and empty lines."""
blocks = re.split(r'\n\n+', text.strip())
for b in blocks:
if 'WEBVTT' in b or b.startswith('Kind:') or b.startswith('Language:'):
continue
m = re.search(r'(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[.,]\d{3})', b)
if not m: continue
start, end = m.group(1), m.group(2)
# Strip cue settings and inline <00:..><c>...</c> tags
body = b[m.end():].strip()
body = re.sub(r'<\d{2}:\d{2}:\d{2}\.\d{3}>', '', body)
body = re.sub(r'</?c[^>]*>', '', body)
body = re.sub(r'align:\S+|position:\S+', '', body).strip()
# Last non-empty line is "new" content (rolling window puts the freshly spoken line at bottom)
lines = [ln.strip() for ln in body.split('\n') if ln.strip()]
if not lines: continue
yield start, end, lines[-1]
def to_srt_time(t):
return t.replace('.', ',')
def merge(events):
"""Drop the 10ms 'gap' cues and merge consecutive identical text."""
out = []
for s, e, txt in events:
# Skip the bridge cue with same text already on top
if out and out[-1][2] == txt:
out[-1] = (out[-1][0], to_srt_time(e), txt) # extend
continue
out.append([to_srt_time(s), to_srt_time(e), txt])
# second pass to drop micro-cues
final = []
for s, e, txt in out:
sh, sm, ssms = s.split(':'); ssec, sms = ssms.split(',')
eh, em, esms = e.split(':'); esec, ems = esms.split(',')
sm_total = int(sh)*3600+int(sm)*60+int(ssec)+int(sms)/1000
em_total = int(eh)*3600+int(em)*60+int(esec)+int(ems)/1000
if em_total - sm_total < 0.05: continue # 50ms bridge cue
final.append((s, e, txt))
return final
def write_srt(events, path):
with open(path, 'w') as f:
for i, (s, e, txt) in enumerate(events, 1):
f.write(f"{i}\n{s} --> {e}\n{txt}\n\n")
if __name__ == '__main__':
vtt = pathlib.Path(sys.argv[1]).read_text()
events = list(merge(parse_vtt(vtt)))
write_srt(events, sys.argv[2])
print(f"wrote {len(events)} cues -> {sys.argv[2]}")

View file

@ -0,0 +1,37 @@
# Subtitle run — `<Show name (Year)>`
Recipe version: v?
Run date: YYYY-MM-DD
Operator: Claude Code @ <session>
Quota at start / end: ?? / ??
## Source
| Field | Value |
|---|---|
| Episodes | ?? (S01S??) |
| Container | mkv / mp4 / ... |
| Video | codec res fps |
| Audio | language tag(s) |
| Embedded subs | yes / no — codecs |
| Existing sidecars | yes / no |
## Outcome
| Season | Eps | Subs fetched | Quality sample | Notes |
|---|---|---|---|---|
| S01 | ? | ? / ? | ? | |
## Picks (sample)
| Episode | Sub Id | Author | DownloadCount | FrameRate | HI |
|---|---|---|---|---|---|
| S01E01 | ... | ... | ... | ... | ... |
## Breakage (if any)
What broke, what was probed, what the recipe should have done differently.
## Recipe amendments triggered
- v1 → v2: ...

View file

@ -0,0 +1,110 @@
# Subtitle run — `American Dad! (2005)`
Recipe version: v1 (S01) → v2 (S02E01E12) → v3 Addic7ed (S02E13E16, S03, S04)
Run date: 2026-05-09
Operator: Claude Code @ onyx session, ai-lab cwd
OS REST quota usage: 20 → 1 (19 downloads, quota-counted)
Addic7ed downloads: 30 (anonymous, no daily cap)
## Source
| Field | Value |
|---|---|
| Episodes | 58 (S01=7, S02=16, S03=19, S04=16) |
| Container | mkv |
| Video | HEVC Main10, 1440×1080, 23.98 fps, 4:3 SAR 1:1 |
| Audio | `eng` AAC stereo (default) + `eng` AC3 5.1 |
| Embedded subs | none |
| Existing sidecars | none |
Library uses Hulu/DSP season ordering (S1=7 eps). Original Fox order has S1=23 eps.
## Series + library context
- Series Id: `3b3bc999e9107f1a7643ac45d6427fee`
- Library: `767bffe4f11c93ef34b805451a696a4e` (TV Shows, `/media/tv`)
- Library options: `SaveSubtitlesWithMedia=true`, `SubtitleDownloadLanguages=["eng"]`, `RequirePerfectSubtitleMatch=false`
- Plugin: Open Subtitles v20.0.0.0, Active, creds `Caveman5` valid
## Outcome
| Season | Eps | Subs fetched | Quality sample | Notes |
|---|---|---|---|---|
| S01 | 7 | 7 / 7 | not yet visually verified by playback (TODO) | v1 plugin path. OMiCRON DVDRip 23.976fps |
| S02 | 16 | 16 / 16 | S02E16 first lines confirmed match episode | E01-E12 v2 OS REST (mixed OMiCRON + 20FOX); E13-E16 v3 Addic7ed (no quota cost) |
| S03 | 19 | 16 / 19 | not yet visually verified | v3 Addic7ed. Misses: E04 Lincoln Lover (a7d 0 subs), E13 Black Mystery Month (a7d empty body), E19 Joint Custody (a7d 0 subs) |
| S04 | 16 | 10 / 16 | not yet visually verified | v3 Addic7ed. Misses: E01-E05 (Vacation Goo / Meter Made / Dope & Faith / Big Trouble in Little Langley / Haylias) and E11 Oedipal Panties — all "a7d 0 subs" for the OS-feat-details S/E we passed |
Net: **49 / 58 (84 %)**.
Remaining 9 episodes can land via OS REST tomorrow (20-quota window covers them all in one batch).
## Picks (S01)
| Episode | Sub release | Author | DLs | FPS | HI |
|---|---|---|---|---|---|
| S01E01 Pilot | `American.Dad.S01E01.DVDRip.XviD.REPACK-OMiCRON` | zetakoo_ | 154 132 | 23.976 | no |
| S01E02 Threat Levels | `American.Dad.S01E02.DVDRip.XviD.REPACK-OMiCRON` | (auto) | 89 896 | 23.976 | no |
| S01E03 Stan Knows Best | `American.Dad.S01E03.DVDRip.XviD.REPACK-OMiCRON` | (auto) | 69 317 | 23.976 | no |
| S01E04 Francines Flashback | `American.Dad.S01E04.DVDRip.XviD.REPACK-OMiCRON` | (auto) | 72 315 | 23.976 | no |
| S01E05 Roger Codger | `American.Dad.S01E05.DVDRip.XviD.REPACK-OMiCRON` | (auto) | 32 309 | 23.976 | no |
| S01E06 Homeland Insecurity | `American.Dad.S01E06.DVDRip.XviD.REPACK-OMiCRON` | (auto) | 67 778 | 23.976 | no |
| S01E07 Deacon Stan Jesus Man | `American.Dad.S01E07.DVDRip.XviD-OMiCRON` | (auto) | 65 124 | 24 | no |
All chose by recipe Step 4 picker (highest DownloadCount among non-HI / non-MT
/ non-AI / non-Forced, prefer 23.976 fps). Picker behaved consistently — no
manual override needed for S01.
## Breakage
After S01 passed, S02E01 search returned 0 results. Verified:
- ProviderIds for S02E01 in library = `Imdb=tt0511631 Tvdb=306168` (correct for "Bullocks to Stan")
- Plugin quota: 13 / 20 remaining (not exhausted)
- Plugin log shows no error — silent zero
- Same recipe worked 7 times in a row immediately prior — not a script bug
- Sample-tested S02E02 / S02E08 / S02E13 → all 0 results
Root cause: library numbering is Hulu/DSP (S1=7), OpenSubtitles indexes Fox
airing order (S1=23). Plugin queries OS with `(parent_imdb_id, season,
episode)` so library `S=2 E=1` maps to a Fox cell that doesn't exist on OS
in that S/E slot, even though the per-episode IMDB id (`tt0511631`) is real
and indexed on OS by Fox order as `S=1 E=8`.
The plugin doesn't expose per-episode-IMDB lookup, only the S/E combo path,
so there's no flag we can flip to make this work.
## Recipe amendments triggered
- **v1 → v2**: process needs a season-numbering pre-check (Step 3), and a
fallback fetch path that doesn't rely on plugin S/E mapping. See
`CHANGELOG.md` v2 design choice between direct OS REST (recommended) and
library re-numbering.
## v2 picks (S02E01E12)
| Episode | Sub release | DLs | FPS | HI |
|---|---|---|---|---|
| S02E01 Bullocks to Stan | `american.dad.s01e08.dvdrip.xvid-omicron` | 25 846 | 23.976 | no |
| S02E02 A Smith in the Hand | `American Dad S01E09 A Smith in the Hand.DVDRip.NonHI.cc.en.20FOX` | 75 | 29.97 | no |
| S02E03 All About Steve | `American Dad S01E10 All About Steve.DVDRip.NonHI.cc.en.20FOX` | 2 600 | 29.97 | no |
| S02E04 Con Heir | `American Dad S01E11 Con Heir.DVDRip.NonHI.cc.en.20FOX` | 140 | 29.97 | no |
| S02E05 Stan of Arabia 1 | `American Dad S01E12 Stan of Arabia Part 1.DVDRip.NonHI.cc.en.20FOX` | 110 | 29.97 | no |
| S02E06 Stan of Arabia 2 | `American Dad S01E13 Stan of Arabia Part 2.DVDRip.NonHI.cc.en.20FOX` | 86 | 29.97 | no |
| S02E07 Stannie Get Your Gun | `American Dad S01E14 Stannie Get Your Gun.DVDRip.NonHI.cc.en.20FOX` | 99 | 29.97 | no |
| S02E08 Star Trek | `American Dad [2.15]` | 18 | 0.0 | no |
| S02E09 Not Particularly Desperate USER-Gwives | `American Dad [2.16]` | 24 | 0.0 | no |
| S02E10 Rough Trade | `American Dad S01E17 Rough Trade.DVDRip.NonHI.cc.en.20FOX` | 40 | 29.97 | no |
| S02E11 Finances With Wolves | `American Dad [1.18] Finances with Wolves-eng` | 7 730 | 23.976 | no |
| S02E12 It's Good to be the Queen | `American Dad - 1x19 - Its Good to be the Queen.en` | 13 228 | 23.976 | no |
Note: 8 picks are 29.97 fps. SRT timestamps are absolute time, so this should
not desync on a 23.976 fps source provided NTSC durations match. Confirm via
recipe Step 6 sync sample on at least one 29.97-pick episode.
## Followups
- [ ] visually verify sample S01 sub plays in sync (recipe §6)
- [ ] visually verify sample S02 29.97-fps pick plays in sync (e.g. S02E03)
- [ ] visually verify sample Addic7ed pick plays in sync (e.g. S03E01 or S04E10)
- [ ] tomorrow (after 23:59 UTC quota reset): rerun `sub-rest-fetch.py --season N --start E --end E` on the 9 missed eps via OS REST

View file

@ -0,0 +1,103 @@
# Subtitle run — `Sassy the Sasquatch (2022)`
> ⚠ **STOP-GAP — needs v4 WhisperX cross-ref.** Owner accepted current
> subs as "85 %, acceptable" but tracked for full rebuild when v4 lands
> (ROADMAP H5). See [`STOPGAP-SUBS.md`](../STOPGAP-SUBS.md).
Recipe version: v3.5 — YouTube auto-CC via yt-dlp + cleaner (v4 WhisperX planned, see ROADMAP)
Run date: 2026-05-10
Operator: Claude Code @ onyx session, ai-lab cwd
## Source
| Field | Value |
|---|---|
| Episodes | 5 (S01 only) |
| Container | mkv |
| Video | AV1 Main, 1920×1080, 29.97 fps |
| Audio | `eng` Opus stereo (default) |
| Embedded subs | none (only font / cover-art attachments) |
| Existing sidecars | none |
| Runtime | ~11:20 per episode |
| Distribution | YouTube (THE BIG LEZ SHOW OFFICIAL channel, creator: Jarrad Wright) |
Niche-show indie animation. Same channel hosts Donny & Clarence Show, Mike
Nolan Show, Big Lez Saga — all four shows in our library are Jarrad Wright
productions distributed YouTube-first.
## Series + library context
- Series Id: `b2d1afd8a4a30c59adb42ccaf47376c2`
- Library: `767bffe4f11c93ef34b805451a696a4e` (TV Shows, `/media/tv`)
- IMDB series: `tt21209936`
- TVDB series: `421839`
- Per-episode IMDB ids: only S01E01 (`tt21215354`) — rest blank in TVDB
## Coverage probe — paid + free providers
Three parallel research agents (2026-05-10) checked every realistic source
before falling back to YouTube:
| Provider | Hits |
|---|---|
| OpenSubtitles.com REST (`parent_imdb_id=21209936`) | 1 — `SASSY THE SASQUATCH.Web-DL.1080p.en` S01E01, **HI-flagged** |
| OpenSubtitles.org legacy XML-RPC | 0 (account login 401 anyway) |
| Addic7ed | 0 |
| SubDL | 0 (`subtitles_count: 0`) |
| SubSource (Subscene successor) | 0 |
| Podnapisi | 0 |
| OS VIP upgrade | **would not unlock anything** — VIP is download-cap relief, not coverage. Same catalog as free. |
Conclusion: nothing exists outside YouTube. Buying VIP would not help; the
honest path is auto-generated subs.
## Outcome
| Season | Eps | Subs fetched | Quality | Notes |
|---|---|---|---|---|
| S01 | 5 | 5 / 5 | YT auto-CC stop-gap (lowercase, no punctuation, names mangled) | Cleaned via `lib/yt-clean.py`. v4 WhisperX rebuild planned |
Net: **5 / 5 (100 %)** — but at the lowest tier of the USER-G quality bar.
## Pipeline used
1. `yt-dlp --skip-download --write-auto-subs --sub-langs en-orig` against
the official Sassy playlist (`PLGMC7oz7XpmDMGrALMQiNXCi9p7aqkWbj`) →
raw VTT per episode in `/tmp/sassy-research/`.
2. `lib/yt-clean.py` collapses the rolling-window VTT (each cue carries 2-3
stale lines plus the freshly-spoken bottom line) into deduplicated SRT.
3. SSH cat redirect each cleaned `.srt` to nullstone at
`/home/user/media/tv/Sassy the Sasquatch (2022)/Season 01/<base>.eng.srt`
with library filename.
4. Validation-only library refresh; verified all 5 eps show exactly 1
external eng sub stream.
Reusable pipeline now lives at `lib/sub-yt-fetch.sh` (wrapper) +
`lib/yt-clean.py` (cleaner). Same one-liner handles Donny & Clarence,
Mike Nolan, Big Lez Saga (all on the same channel).
## Quality known issues
- **Lowercase, no punctuation** — YT ASR output verbatim
- **Proper-noun mishears**: "Sassy" → `sasha`, "Big Lez" → `Big Less`
- **Profanity censored as `[ __ ]`** — passthrough from YT
- **Sentence segmentation absent** — cues split on word boundaries
These violate STYLE.md "best quality" and "clean" rules. Documented as
explicit stop-gap; v4 WhisperX rebuild restores quality bar.
## Mike Nolan special-case (deferred)
A YouTube upload titled "MIKE NOLAN SHOW | COMPLETE SEASON | SUBTITLES"
posted Oct 2025 carries hand-typed CC tracks. When subbing Mike Nolan,
prefer that single video (rip CC tracks) over the per-episode auto-CC
playlist path. Note added to v4 roadmap.
## Followups
- [ ] visually verify one Sassy episode plays in sync (recipe §6) — YT
auto-cap timing is usually tight but worth a sanity check
- [ ] when v4 WhisperX lands, regenerate Sassy + Donny & Clarence + Big
Lez Saga + Mike Nolan in one batch on the 4080 friend node
- [ ] for Mike Nolan, try the "COMPLETE SEASON | SUBTITLES" YT upload
before falling back to Whisper

View file

@ -1,119 +0,0 @@
# Rollback — 2026-05-08 pre-ElegantFin
Snapshot captured immediately before the Cineplex → ElegantFin theme migration.
Restore one or all of the artefacts below to revert to the Cineplex-themed
ARRFLIX deploy as of 2026-05-08 03:58 UTC.
Tag: `snapshot-2026-05-08-pre-elegantfin`
Remote: `git.s8n.ru/s8n/ARRFLIX`
---
## Files in this snapshot
| File | Source | Purpose |
|------|--------|---------|
| `branding.json` | `GET /System/Configuration/branding` | CustomCss + LoginDisclaimer + SplashscreenEnabled |
| `index.html` | `nullstone:/opt/docker/jellyfin/web-overrides/index.html` | Bind-mounted Jellyfin web shim (critical-path CSS, branding, runtime shim include) |
| `docker-compose.yml` | `nullstone:/opt/docker/jellyfin/docker-compose.yml` | Compose file driving the jellyfin container |
| `users.json` | `GET /Users` | Full user list with policies + configurations |
| `libraries.json` | `GET /Library/VirtualFolders` | Library definitions (paths, types, options) |
| `displayprefs-<userid>.json` × 5 | `GET /DisplayPreferences/usersettings?userId=<id>&client=emby` | Per-user UI preferences (home sections, theme, etc.) |
User IDs captured:
- `SCRUBBED-USER-ID` — 5
- `SCRUBBED-USER-ID` — USER-F
- `SCRUBBED-USER-ID` — USER-G
- `SCRUBBED-USER-ID` — USER-A
- `SCRUBBED-USER-ID` — s8n
---
## Rollback — three concrete commands
```bash
SNAP=/tmp/ARRFLIX/snapshots/2026-05-08-pre-elegantfin
TOKEN="*redacted*"
BASE="https://arrflix.s8n.ru"
```
### 1. Restore CustomCss + LoginDisclaimer (branding)
```bash
curl -ksX POST "$BASE/System/Configuration/branding" \
-H "Authorization: MediaBrowser Token=$TOKEN" \
-H "Content-Type: application/json" \
--data-binary @$SNAP/branding.json
# Expected: HTTP 204
```
### 2. Restore the bind-mounted index.html on nullstone
```bash
scp $SNAP/index.html user@nullstone:/opt/docker/jellyfin/web-overrides/index.html
# No restart needed — Traefik serves the file directly via the bind-mount.
```
### 3. Restore per-user DisplayPreferences
```bash
for uid in SCRUBBED-USER-ID \
SCRUBBED-USER-ID \
SCRUBBED-USER-ID \
SCRUBBED-USER-ID \
SCRUBBED-USER-ID; do
curl -ksX POST "$BASE/DisplayPreferences/usersettings?userId=$uid&client=emby" \
-H "Authorization: MediaBrowser Token=$TOKEN" \
-H "Content-Type: application/json" \
--data-binary @$SNAP/displayprefs-$uid.json
done
# Expected: HTTP 204 each
```
---
## One-shot rollback (all three at once)
```bash
SNAP=/tmp/ARRFLIX/snapshots/2026-05-08-pre-elegantfin
TOKEN="*redacted*"
BASE="https://arrflix.s8n.ru"
# 1. branding
curl -ksX POST "$BASE/System/Configuration/branding" \
-H "Authorization: MediaBrowser Token=$TOKEN" \
-H "Content-Type: application/json" \
--data-binary @$SNAP/branding.json
# 2. index.html
scp $SNAP/index.html user@nullstone:/opt/docker/jellyfin/web-overrides/index.html
# 3. per-user displayprefs
for uid in SCRUBBED-USER-ID SCRUBBED-USER-ID \
SCRUBBED-USER-ID SCRUBBED-USER-ID \
SCRUBBED-USER-ID; do
curl -ksX POST "$BASE/DisplayPreferences/usersettings?userId=$uid&client=emby" \
-H "Authorization: MediaBrowser Token=$TOKEN" \
-H "Content-Type: application/json" \
--data-binary @$SNAP/displayprefs-$uid.json
done
```
---
## Reference (read-only — for diffing, not posting back)
- `users.json` — sanity-check that policies/permissions match before/after a future user-mgmt change. The User API uses `/Users/{id}/Policy` and `/Users/{id}/Configuration` endpoints, NOT a bulk POST.
- `libraries.json` — sanity-check that VirtualFolders are still intact. Library mutations go through `/Library/VirtualFolders` add/remove endpoints, not a single POST.
- `docker-compose.yml` — reference only. If the compose file changes, restore by hand and `docker compose up -d` on nullstone.
---
## What this snapshot does NOT cover
- Jellyfin SQLite databases (`library.db`, `users.db`, etc.) — full data is preserved by Restic backups, not this snapshot.
- Plugins / plugin config — not part of the CSS/branding migration scope.
- Media files on disk — never touched by theme work.
If a recovery requires DB-level restore, fall back to the Restic snapshot job
documented in `SYSTEM.md` rather than this CSS-scoped rollback.

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
{"Id":"3ce5b65d-e116-d731-65d1-efc4a30ec35c","SortBy":"SortName","RememberIndexing":false,"PrimaryImageHeight":250,"PrimaryImageWidth":250,"CustomPrefs":{"homesection0":"resume","homesection1":"resumeaudio","homesection2":"nextup","homesection3":"latestmedia","homesection4":"none","homesection5":"none","homesection6":"none","homesection7":"none","homesection8":"none","homesection9":"none","chromecastVersion":"stable","skipForwardLength":"30000","skipBackLength":"10000","enableNextVideoInfoOverlay":"False","tvhome":null,"dashboardTheme":null},"ScrollDirection":"Horizontal","ShowBackdrop":true,"RememberSorting":false,"SortOrder":"Ascending","ShowSidebar":false,"Client":"emby"}

View file

@ -1 +0,0 @@
{"Id":"3ce5b65d-e116-d731-65d1-efc4a30ec35c","SortBy":"SortName","RememberIndexing":false,"PrimaryImageHeight":250,"PrimaryImageWidth":250,"CustomPrefs":{"homesection0":"resume","homesection1":"resumeaudio","homesection2":"nextup","homesection3":"latestmedia","homesection4":"none","homesection5":"none","homesection6":"none","homesection7":"none","homesection8":"none","homesection9":"none","chromecastVersion":"stable","skipForwardLength":"30000","skipBackLength":"10000","enableNextVideoInfoOverlay":"False","tvhome":null,"dashboardTheme":null,"767bffe4f11c93ef34b805451a696a4e-series":"{\u0022SortBy\u0022:\u0022SortName\u0022,\u0022SortOrder\u0022:\u0022Ascending\u0022}","f137a2dd21bbc1b99aa5c0f6bf02a805-moviecollections":"{\u0022SortBy\u0022:\u0022SortName\u0022,\u0022SortOrder\u0022:\u0022Ascending\u0022}","f137a2dd21bbc1b99aa5c0f6bf02a805-moviegenres":"{\u0022SortBy\u0022:\u0022SortName\u0022,\u0022SortOrder\u0022:\u0022Ascending\u0022}"},"ScrollDirection":"Horizontal","ShowBackdrop":true,"RememberSorting":false,"SortOrder":"Ascending","ShowSidebar":false,"Client":"emby"}

View file

@ -1 +0,0 @@
{"Id":"3ce5b65d-e116-d731-65d1-efc4a30ec35c","SortBy":"SortName","RememberIndexing":false,"PrimaryImageHeight":250,"PrimaryImageWidth":250,"CustomPrefs":{"homesection0":"resume","homesection1":"resumeaudio","homesection2":"nextup","homesection3":"latestmedia","homesection4":"none","homesection5":"none","homesection6":"none","homesection7":"none","homesection8":"none","homesection9":"none","chromecastVersion":"stable","skipForwardLength":"30000","skipBackLength":"10000","enableNextVideoInfoOverlay":"False","tvhome":null,"dashboardTheme":null},"ScrollDirection":"Horizontal","ShowBackdrop":true,"RememberSorting":false,"SortOrder":"Ascending","ShowSidebar":false,"Client":"emby"}

View file

@ -1 +0,0 @@
{"Id":"3ce5b65d-e116-d731-65d1-efc4a30ec35c","SortBy":"SortName","RememberIndexing":false,"PrimaryImageHeight":250,"PrimaryImageWidth":250,"CustomPrefs":{"homesection0":"resume","homesection1":"resumeaudio","homesection2":"nextup","homesection3":"latestmedia","homesection4":"none","homesection5":"none","homesection6":"none","homesection7":"none","homesection8":"none","homesection9":"none","chromecastVersion":"stable","skipForwardLength":"30000","skipBackLength":"10000","enableNextVideoInfoOverlay":"False","tvhome":null,"dashboardTheme":null},"ScrollDirection":"Horizontal","ShowBackdrop":true,"RememberSorting":false,"SortOrder":"Ascending","ShowSidebar":false,"Client":"emby"}

View file

@ -1 +0,0 @@
{"Id":"3ce5b65d-e116-d731-65d1-efc4a30ec35c","SortBy":"SortName","RememberIndexing":false,"PrimaryImageHeight":250,"PrimaryImageWidth":250,"CustomPrefs":{"homesection0":"resume","homesection1":"resumeaudio","homesection2":"nextup","homesection3":"latestmedia","homesection4":"none","homesection5":"none","homesection6":"none","homesection7":"none","homesection8":"none","homesection9":"none","chromecastVersion":"stable","skipForwardLength":"30000","skipBackLength":"10000","enableNextVideoInfoOverlay":"False","tvhome":null,"dashboardTheme":null,"767bffe4f11c93ef34b805451a696a4e-series":"{\u0022SortBy\u0022:\u0022SortName\u0022,\u0022SortOrder\u0022:\u0022Ascending\u0022}"},"ScrollDirection":"Horizontal","ShowBackdrop":true,"RememberSorting":false,"SortOrder":"Ascending","ShowSidebar":false,"Client":"emby"}

View file

@ -1,42 +0,0 @@
# Jellyfin — self-hosted media server (LAN-only)
# Deploy path on nullstone: /opt/docker/jellyfin/
# Domain: arrflix.s8n.ru (LAN-only via Pi-hole local DNS + no-USER-F middleware)
#
# Notes:
# - GTX 1660 Ti present but nvidia-smi failing on host. CPU transcode only
# until driver is fixed; revisit hwaccel after fix.
# - Media mounted read-only into container; write only to /config + /cache.
# - userns: host matches nullstone Docker convention (host UID 1000 owns volumes).
# - Cert via existing letsencrypt resolver (Gandi DNS-01) — works without
# public A record.
services:
jellyfin:
image: jellyfin/jellyfin:10.10.3
container_name: jellyfin
restart: unless-stopped
user: "1000:1000"
userns_mode: "host"
environment:
- TZ=Europe/London
- JELLYFIN_PublishedServerUrl=https://arrflix.s8n.ru
volumes:
- /home/docker/jellyfin/config:/config
- /home/docker/jellyfin/cache:/cache
- /home/user/media:/media:ro
- /opt/docker/jellyfin/web-overrides/index.html:/jellyfin/jellyfin-web/index.html:ro
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.jellyfin.rule=Host(`arrflix.s8n.ru`)"
- "traefik.http.routers.jellyfin.entrypoints=websecure"
- "traefik.http.routers.jellyfin.tls=true"
- "traefik.http.routers.jellyfin.tls.certresolver=letsencrypt"
- "traefik.http.routers.jellyfin.middlewares=security-headers@file"
- "traefik.http.services.jellyfin.loadbalancer.server.port=8096"
networks:
proxy:
external: true

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
[{"Name":"Movies","Locations":["/media/movies"],"CollectionType":"movies","LibraryOptions":{"Enabled":true,"EnablePhotos":false,"EnableRealtimeMonitor":true,"EnableLUFSScan":false,"EnableChapterImageExtraction":false,"ExtractChapterImagesDuringLibraryScan":false,"EnableTrickplayImageExtraction":false,"ExtractTrickplayImagesDuringLibraryScan":false,"PathInfos":[{"Path":"/media/movies"}],"SaveLocalMetadata":false,"EnableInternetProviders":false,"EnableAutomaticSeriesGrouping":true,"EnableEmbeddedTitles":false,"EnableEmbeddedExtrasTitles":false,"EnableEmbeddedEpisodeInfos":false,"AutomaticRefreshIntervalDays":0,"PreferredMetadataLanguage":"en","MetadataCountryCode":"US","SeasonZeroDisplayName":"Specials","DisabledLocalMetadataReaders":[],"DisabledSubtitleFetchers":[],"SubtitleFetcherOrder":[],"DisabledMediaSegmentProviders":[],"MediaSegmentProvideOrder":[],"SkipSubtitlesIfEmbeddedSubtitlesPresent":false,"SkipSubtitlesIfAudioTrackMatches":true,"SubtitleDownloadLanguages":["eng"],"RequirePerfectSubtitleMatch":true,"SaveSubtitlesWithMedia":true,"SaveLyricsWithMedia":false,"SaveTrickplayWithMedia":false,"DisabledLyricFetchers":[],"LyricFetcherOrder":[],"PreferNonstandardArtistsTag":false,"UseCustomTagDelimiters":false,"CustomTagDelimiters":["/","|",";","\\"],"DelimiterWhitelist":[],"AutomaticallyAddToCollection":false,"AllowEmbeddedSubtitles":"AllowAll","TypeOptions":[{"Type":"Movie","MetadataFetchers":["TheMovieDb"],"MetadataFetcherOrder":["TheMovieDb"],"ImageFetchers":["TheMovieDb"],"ImageFetcherOrder":["TheMovieDb"],"ImageOptions":[]}]},"ItemId":"f137a2dd21bbc1b99aa5c0f6bf02a805","PrimaryImageItemId":"f137a2dd21bbc1b99aa5c0f6bf02a805","RefreshStatus":"Idle"},{"Name":"TV Shows","Locations":["/media/tv"],"CollectionType":"tvshows","LibraryOptions":{"Enabled":true,"EnablePhotos":false,"EnableRealtimeMonitor":true,"EnableLUFSScan":false,"EnableChapterImageExtraction":false,"ExtractChapterImagesDuringLibraryScan":false,"EnableTrickplayImageExtraction":false,"ExtractTrickplayImagesDuringLibraryScan":false,"PathInfos":[{"Path":"/media/tv"}],"SaveLocalMetadata":false,"EnableInternetProviders":false,"EnableAutomaticSeriesGrouping":true,"EnableEmbeddedTitles":false,"EnableEmbeddedExtrasTitles":false,"EnableEmbeddedEpisodeInfos":false,"AutomaticRefreshIntervalDays":0,"PreferredMetadataLanguage":"en","MetadataCountryCode":"US","SeasonZeroDisplayName":"Specials","DisabledLocalMetadataReaders":[],"DisabledSubtitleFetchers":[],"SubtitleFetcherOrder":[],"DisabledMediaSegmentProviders":[],"MediaSegmentProvideOrder":[],"SkipSubtitlesIfEmbeddedSubtitlesPresent":false,"SkipSubtitlesIfAudioTrackMatches":true,"SubtitleDownloadLanguages":["eng"],"RequirePerfectSubtitleMatch":true,"SaveSubtitlesWithMedia":true,"SaveLyricsWithMedia":false,"SaveTrickplayWithMedia":false,"DisabledLyricFetchers":[],"LyricFetcherOrder":[],"PreferNonstandardArtistsTag":false,"UseCustomTagDelimiters":false,"CustomTagDelimiters":["/","|",";","\\"],"DelimiterWhitelist":[],"AutomaticallyAddToCollection":false,"AllowEmbeddedSubtitles":"AllowAll","TypeOptions":[{"Type":"Series","MetadataFetchers":["TheMovieDb"],"MetadataFetcherOrder":["TheMovieDb"],"ImageFetchers":["TheMovieDb"],"ImageFetcherOrder":["TheMovieDb"],"ImageOptions":[]},{"Type":"Season","MetadataFetchers":["TheMovieDb"],"MetadataFetcherOrder":["TheMovieDb"],"ImageFetchers":["TheMovieDb"],"ImageFetcherOrder":["TheMovieDb"],"ImageOptions":[]},{"Type":"Episode","MetadataFetchers":["TheMovieDb"],"MetadataFetcherOrder":["TheMovieDb"],"ImageFetchers":["TheMovieDb"],"ImageFetcherOrder":["TheMovieDb"],"ImageOptions":[]}]},"ItemId":"767bffe4f11c93ef34b805451a696a4e","PrimaryImageItemId":"767bffe4f11c93ef34b805451a696a4e","RefreshStatus":"Idle"}]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more