Compare commits

..

44 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
75 changed files with 10659 additions and 53 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 }}"

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. - **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 - **Disk**: nullstone /home 109G free
- **Theme**: ElegantFin v25.12.31 - **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) - **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) - **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/>.

View file

@ -1,6 +1,6 @@
# Roadmap — ARRFLIX # Roadmap — ARRFLIX
Last revised: **2026-05-08** Last revised: **2026-05-11**
--- ---
@ -24,10 +24,9 @@ Last revised: **2026-05-08**
| # | Item | Effort | Blocker | | # | Item | Effort | Blocker |
|---|---|---|---| |---|---|---|---|
| H1 | OpenSubtitles credentials (auth fixes log spam too — doc 13 win 2) | S | **owner signs up at opensubtitles.com** | | H1 | GPU transcode (nvidia driver kernel module + container toolkit + SecureBoot signing) | L | **owner sudo + reboot** |
| H2 | 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 | Apply `bin/force-english-all-users.sh` (German Play button breaks UX for non-English browsers) | S | none — owner runs | | H3 | Library AV1 sweep + Sonarr/Radarr penalty (kills jellyfin#15646 future) | M | post-doc-26 |
| H4 | Backup `/home/docker/jellyfin/config/` off-host (no automated backup yet) | M | strategy decision |
## 🟨 Open — Medium value ## 🟨 Open — Medium value
@ -56,7 +55,6 @@ Last revised: **2026-05-08**
| Item | Blocker | Action owner | | Item | Blocker | Action owner |
|---|---|---| |---|---|---|
| OpenSubtitles auth | account signup at .com | **s8n** |
| Nvidia GPU | sudo + reboot decision | **s8n** | | Nvidia GPU | sudo + reboot decision | **s8n** |
| WAN public access | home router port-forward 80/443 → 192.168.0.100 | **s8n** | | WAN public access | home router port-forward 80/443 → 192.168.0.100 | **s8n** |
@ -87,7 +85,7 @@ Last revised: **2026-05-08**
- ✅ Detail-page backdrop full-bleed gradient fix (was 17vw black band; now Netflix-style) - ✅ Detail-page backdrop full-bleed gradient fix (was 17vw black band; now Netflix-style)
### UI hides + tweaks (CSS in CustomCss) ### UI hides + tweaks (CSS in CustomCss)
- ✅ Cast & Crew + USER-F Stars sections (`#castCollapsible, #USER-FCastCollapsible`) - ✅ Cast & Crew + Guest Stars sections (`#castCollapsible, #guestCastCollapsible`)
- ✅ Quick Connect button + server-side disable (`.btnQuick`, `QuickConnectAvailable=false`) - ✅ 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) - ✅ 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`) - ✅ Header icons: SyncPlay group, Cast, User menu (`.headerSyncButton`, `.headerCastButton`, `.headerUserButton`)
@ -111,7 +109,7 @@ Last revised: **2026-05-08**
- ✅ Polish set replaced with English; libraries flipped `pl/PL``en/US` - ✅ Polish set replaced with English; libraries flipped `pl/PL``en/US`
### Users + access ### Users + access
- ✅ 9 users (`s8n` admin, `5`, `USER-D`, `USER-B`, `USER-F`, `USER-G`, `USER-A`, `USER-E`, `USER-C`) - ✅ 9 users (`s8n` admin, `5`, `64bitpotato`, `aloy`, `guest`, `house`, `marco`, `pet`, `yummyhunny`)
- ✅ All non-admin policies: `IsAdministrator=false`, `EnableContentDeletion=false`, `EnableUserPreferenceAccess=false`, `LoginAttemptsBeforeLockout=5` - ✅ 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) - ✅ 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) - ✅ Home layout per-user: resume → resumeaudio → nextup → latestmedia (My Media tile row dropped)
@ -121,7 +119,7 @@ Last revised: **2026-05-08**
- ✅ Repo rename: `jellyfin-stack``NASFLIX`**`ARRFLIX`** at `git.s8n.ru/s8n/ARRFLIX` - ✅ Repo rename: `jellyfin-stack``NASFLIX`**`ARRFLIX`** at `git.s8n.ru/s8n/ARRFLIX`
- ✅ Pi-hole local DNS for `arrflix.s8n.ru` + `dev.arrflix.s8n.ru` - ✅ Pi-hole local DNS for `arrflix.s8n.ru` + `dev.arrflix.s8n.ru`
- ✅ LE certs via Gandi DNS-01 for both prod + dev - ✅ LE certs via Gandi DNS-01 for both prod + dev
- ✅ WAN window: Gandi public A record `arrflix.s8n.ru → 82.31.156.86`, no-USER-F middleware dropped, lockout=5 baked in (router port-forward pending) - ✅ 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 - ✅ 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 - ✅ Snapshot tag `snapshot-2026-05-08-pre-elegantfin` for one-command rollback

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"

View file

@ -33,34 +33,243 @@ wordmark_url = (ASSETS / "arrflix-wordmark.b64-url").read_text(encoding="utf-8")
START = "/* ARRFLIX-MIDDLE-THEME-BEGIN */" START = "/* ARRFLIX-MIDDLE-THEME-BEGIN */"
END = "/* ARRFLIX-MIDDLE-THEME-END */" END = "/* ARRFLIX-MIDDLE-THEME-END */"
CSS = ( CSS = r"""
"body.arrflix-themed .skinHeader .headerTop{display:flex!important;align-items:center;position:relative;min-height:48px}\n" /* ===========================================================================
"body.arrflix-themed .skinHeader .headerLeft,body.arrflix-themed .skinHeader .headerRight{flex:1 1 0;display:flex;align-items:center}\n" * ARRFLIX MIDDLE-THEME v6 CSS layer model
"body.arrflix-themed .skinHeader .headerLeft{justify-content:flex-start;gap:.4em}\n" * ===========================================================================
"body.arrflix-themed .skinHeader .headerRight{justify-content:flex-end}\n" *
"body.arrflix-themed .skinHeader .headerHomeButton,body.arrflix-themed .skinHeader .pageTitleWithLogo{display:none!important}\n" * STACKING ORDER (low high) DO NOT VIOLATE:
"body.arrflix-themed .skinHeader .headerLeft > h3.pageTitle:not(.pageTitleWithLogo){display:none!important}\n" *
"body.arrflix-themed .skinHeader .headerCastButton,body.arrflix-themed .skinHeader .headerSyncButton{display:none!important}\n" * layer 0 <html> bg #000 (set via JS inline style; see start())
"body.arrflix-themed .headerTabs.sectionTabs{display:none!important}\n" * black letterbox bars on video page come from here
"/* Hide entire header during video playback */\n" * layer 1 <body> bg #000 off-video (L1), transparent on-video (L2)
"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}\n" * layer 2 .backgroundContainer Jellyfin backdrop (poster blur), bg propagated from L1/L2
".arrflix-headerLogo{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:120px;height:38px;" * .skinBody main app shell
"background:center/contain no-repeat url('" + wordmark_url + "');" * #reactRoot
"z-index:1;display:block;text-indent:-9999px;overflow:hidden}\n" * layer 3 .mainAnimatedPages page swap container
".arrflix-headerLogo:hover{filter:brightness(1.15)}\n" * .pageContainer current page
".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}\n" * layer 4 .skinHeader top nav (HIDDEN during video see :not(:has(#loginPage)))
".arrflix-nav:hover{color:#E50914!important}\n" * 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 = """ 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(){
function isVideoPage(){ 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{ try{
var h=(location.hash||'').toLowerCase(); var h=(location.hash||'').toLowerCase();
if (h.indexOf('/video') !== -1) return true; if (h.indexOf('/video') !== -1) return true;
var osd = document.querySelector('#videoOsdPage:not(.hide)'); var osd = document.querySelector('#videoOsdPage:not(.hide)');
if (osd) return true; if (osd) return true;
var v = document.querySelector('.htmlVideoPlayer:not(.hide), video.htmlvideoplayer:not(.hide)'); var v = document.querySelector('video.htmlvideoplayer:not(.hide)');
if (v && getComputedStyle(v).display !== 'none') return true; if (v && getComputedStyle(v).display !== 'none') return true;
}catch(e){} }catch(e){}
return false; return false;
@ -106,8 +315,14 @@ JS = """
var right=top.querySelector('.headerRight'); var right=top.querySelector('.headerRight');
top.insertBefore(a, right || null); 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(){ function start(){
try{ document.documentElement.style.setProperty('background-color','#000','important'); }catch(e){}
relayoutHeader(); relayoutHeader();
try{ new MutationObserver(relayoutHeader).observe(document.body,{childList:true,subtree:true}); }catch(e){} try{ new MutationObserver(relayoutHeader).observe(document.body,{childList:true,subtree:true}); }catch(e){}
window.addEventListener('hashchange', relayoutHeader); window.addEventListener('hashchange', relayoutHeader);

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

@ -317,7 +317,7 @@ Plugin logs: `docker logs jellyfin 2>&1 | grep -i opensubtitles`.
| User `s8n` `SubtitleMode` | `Always` | | User `s8n` `SubtitleMode` | `Always` |
| User `s8n` `SubtitleLanguagePreference` | `eng` | | User `s8n` `SubtitleLanguagePreference` | `eng` |
| User `s8n` `AudioLanguagePreference` | `pol` | | User `s8n` `AudioLanguagePreference` | `pol` |
| OpenSubtitles **credentials** | **PENDING — user signs up at <https://www.opensubtitles.com>** | | OpenSubtitles **credentials** | **SET** — user `Caveman5`, `CredentialsInvalid=false` (verified 2026-05-11) |
| Series refresh to fetch all 44 | **PENDING — after creds entered** | | 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`. 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

@ -61,7 +61,7 @@ action). Effort: **S** ≤ 30 min, **M** half-day, **L** > 1 day.
| 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 | | 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 | | 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 | | 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 | **R** | `Username`/`Password` empty in `Jellyfin.Plugin.OpenSubtitles.xml`; **102** `Error downloading subtitles from Open Subtitles` lines / 6 h | Set creds via UI, OR disable the provider on both libraries (`EnableInternetProviders=false` already; subtitle search still runs). Doc 03-subtitles.md already calls this out as pending | 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 | | 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 | | 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 | | 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 |

View file

@ -46,10 +46,9 @@ Point-in-time visual status after doc-26 incident. For ongoing roadmap see
└──────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────┘
┌─ HIGH-VALUE OPEN (next session) ─────────────────────────────────┐ ┌─ HIGH-VALUE OPEN (next session) ─────────────────────────────────┐
│ H1 OpenSubtitles creds (owner sign up at .com) │ │ H1 GPU transcode (nvidia driver + container toolkit + SecureBoot)│
│ H2 GPU transcode (nvidia driver + container toolkit + SecureBoot)│
│ → unlocks 4K HDR realtime instead of 0.5x │ │ → unlocks 4K HDR realtime instead of 0.5x │
│ H3 Off-host backup of /home/docker/jellyfin/config │ │ H2 Off-host backup of /home/docker/jellyfin/config │
└──────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────┘
┌─ MEDIUM-VALUE OPEN ──────────────────────────────────────────────┐ ┌─ MEDIUM-VALUE OPEN ──────────────────────────────────────────────┐

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,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,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

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

File diff suppressed because one or more lines are too long

74
testing/DEPLOY.md Normal file
View file

@ -0,0 +1,74 @@
# DEPLOY — dev → prod promotion workflow
> Strict order. Skip a step → break prod.
## Pre-flight
- [ ] testing/SMOKE-TEST.md passed on dev
- [ ] You can name the change in one sentence
- [ ] You have a rollback target (the current prod md5 — capture before deploy)
## Step 1 — capture current prod md5 (rollback anchor)
```bash
PROD_BEFORE=$(ssh user@nullstone 'md5sum /opt/docker/jellyfin/web-overrides/index.html' | awk '{print $1}')
echo "rollback anchor: $PROD_BEFORE"
```
If anything goes wrong: `cp /opt/docker/jellyfin/web-overrides/index.html.bak.<latest> /opt/docker/jellyfin/web-overrides/index.html` + restart.
## Step 2 — backup prod overlay + branding
```bash
ssh user@nullstone 'set -e
TS=$(date +%s)
docker run --rm --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw alpine cp /d/index.html /d/index.html.bak.deploy.$TS
docker run --rm --userns=host -v /home/docker/jellyfin/config/config:/d:rw alpine cp /d/branding.xml /d/branding.xml.bak.deploy.$TS
'
```
## Step 3 — copy dev overlay to prod via docker shim (root-owned dest)
```bash
ssh user@nullstone 'docker run --rm --userns=host -v /opt/docker/jellyfin-dev/web-overrides:/dev:ro -v /opt/docker/jellyfin/web-overrides:/prod:rw alpine sh -c "cp /dev/index-dev.html /prod/index.html && chown root:root /prod/index.html && md5sum /prod/index.html"'
```
## Step 4 — bind-mount inode swap requires restart
```bash
ssh user@nullstone 'docker restart jellyfin && sleep 14'
```
## Step 5 — verify
```bash
ssh user@nullstone 'docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html' # should match dev's md5
ssh user@nullstone 'docker exec jellyfin curl -s http://127.0.0.1:8096/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN' # = 1
ssh user@nullstone 'docker exec jellyfin curl -s http://127.0.0.1:8096/Branding/Css.css | wc -c' # ~36000
ssh user@nullstone 'docker ps --format "{{.Names}} {{.Status}}" | grep "^jellyfin "' # healthy
```
## Step 6 — manual smoke on prod
Open arrflix.s8n.ru in incognito. Run testing/SMOKE-TEST.md manual checklist.
## Step 7 — commit + push to repo
```bash
cd /tmp/arrflix-recon
cp web-overrides/index.html snapshots/2026-05-09-v6-stable/index.html # update snapshot
git add bin/inject-middle-theme.py web-overrides/index.html snapshots/2026-05-09-v6-stable/index.html docs/ # whatever changed
git -c user.name=s8n -c user.email=admin@s8n.ru commit -m "<one-line summary>"
git push origin main
```
## If verify fails
Run `testing/ROLLBACK.md` immediately. Don't try to fix forward on prod.
## Common deploy gotchas
- Forget `docker restart jellyfin` after `cp` → bind-mount inode swap → container serves stale
- Forget `chown root:root` → user can't write but root needs to own per host config
- Forget snapshot bump → next "what's deployed" question gets wrong answer
- Forget commit → repo drifts from prod (per doc 26 INC1 root cause)

215
testing/ERROR-PATTERNS.md Normal file
View file

@ -0,0 +1,215 @@
# ERROR-PATTERNS — recurring theme/deploy/playback bugs
> Bookmark this. Every pattern below has happened multiple times. Read before debugging.
## Index of past errors
| # | Pattern | First seen | Last seen | Recurrences |
|---|---------|------------|-----------|-------------|
| 1 | Black screen over video (CSS overlay) | 2026-05-09 INC1 | 2026-05-10 image-12 | 6+ |
| 2 | Video covers OSD controls (z-index too high) | 2026-05-10 image-12 | 2026-05-10 image-12 | 1 |
| 3 | Bind-mount inode swap (container serves stale) | 2026-05-09 backHide | 2026-05-10 v4-selector | 3+ |
| 4 | branding.xml XML parse silent fail | 2026-05-09 v6-stable | 2026-05-09 v6-stable | 1 |
| 5 | test/123 password nuked after docker cp | 2026-05-09 multi | 2026-05-10 multi | 5+ |
| 6 | DB readonly after docker cp (uid 101000) | 2026-05-09 multi | 2026-05-10 multi | 4+ |
| 7 | camelCase class typo (.htmlVideoPlayer) | 2026-05-09 a6cf925 | 2026-05-09 a6cf925 | 1 |
| 8 | Wrong Jellyfin class assumption | 2026-05-09 multi | 2026-05-10 multi | 3+ |
| 9 | HDR10 grey wash (tonemap off) | 2026-05-08 doc 21 | 2026-05-09 fix | 1 |
| 10 | Favicon clobbered by lockFavicon shim | 2026-05-09 favfix | 2026-05-09 favfix | 1 |
| 11 | Backdrop residue / carousel black band | 2026-05-09 multi | 2026-05-10 multi | 2+ |
| 12 | Quick Connect bypass + login mismatch | 2026-05-09 dev | 2026-05-09 dev | 1 |
## ERROR 1 — Black screen over video (CSS overlay)
**Symptom.** `<video>` decodes (`currentTime` advances, `readyState=4`, `videoWidth=1920`, `error=null`, `drawImage` luma >100) but viewport is opaque black. `darkPct=100%`. Doc 28: *"`<video>` is decoding actual pixels — yet a screenshot is all-black. Pixels never reach page composition."*
**Root cause.** Opaque `background-color` on a `<video>` ancestor while `body.arrflix-video-active` set OR `.htmlvideoplayer` in DOM. Offenders: `#videoOsdPage`, `.libraryPage`, `.layout-desktop`, `.pageContainer`, `.skinBody`, `.emby-scroller`.
**Diagnostic.** Probe DOM stack at video centre via `elementsFromPoint`; log `getComputedStyle(el).backgroundColor` per ancestor. `drawImage` luma >50 + screenshot all-black = overlay bug. See doc 28 §"Headless comparison".
**Fix.** Pair L1 (off-video opaque) / L2 (on-video transparent), scoped on body class:
```css
body.arrflix-video-active #videoOsdPage,
body.arrflix-video-active .libraryPage:has(.htmlvideoplayer) {
background: transparent !important;
}
```
**Prevention.** Any new bg-color rule on layer 04 ancestors MUST scope `:not(.arrflix-video-active)`. Add `darkPct` assertion to `bin/headless-test-v2.py` (TODO doc 30/31). Ref docs/26 INC7-final, docs/28 INC7-final, docs/31 layer model.
## ERROR 2 — Video covers OSD controls (z-index hack)
**Symptom.** Frames visible, OSD scrubber/buttons clipped or unclickable.
**Root cause.** Forced `<video> { z-index: 9999 }` to "lift" above an unknown overlay. Stock OSD sits at z 11001500; lifting `<video>` buries the controls.
**Diagnostic.** DevTools → click where scrubber should be → if click target is `<video>`, z-index is wrong.
**Fix.** Revert. Delete the override. Real bug is always opaque ancestors (Error 1). Commit `d4ddf6f` reverted.
**Prevention.** Stock Jellyfin owns z 10002000. Never override. Docs/31: *"If you think you need to z-index `<video>` higher: you don't."*
## ERROR 3 — Bind-mount inode swap
**Symptom.** Host file md5 changed after `cp`/`scp`, but `docker exec md5sum` returns old hash.
**Root cause.** Single-file Docker bind mount tracks inode at container start, not path. `cp src dest` (or scp) creates a NEW inode; container keeps the old one. Docs/31: *"bind-mount inode swap doesn't refresh container view."*
**Diagnostic.**
```bash
ssh user@nullstone 'md5sum /opt/docker/jellyfin/web-overrides/index.html'
ssh user@nullstone 'docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
# Differ → inode swap
```
**Fix.** `docker restart jellyfin` (or `jellyfin-dev`) after every `cp`/`scp` of a single-file bind.
**Prevention.** Deploy: scp → restart → curl-verify md5. Ref docs/26 §A, docs/31 DO-NOT-DO.
## ERROR 4 — branding.xml XML parse silent fail
**Symptom.** Theme partially loads. `curl /Branding/Css.css` returns HTTP 200 with **0 bytes**. No log, no banner. Doc 30: *"Silent XML parse failures with zero UI feedback are the worst class of bug."*
**Root cause.** Unescaped `<` in `<CustomCss>`. CSS comment with `<video>` literal makes XML parser treat it as a child element. Branding loader catches the exception, serves empty CSS.
**Diagnostic.**
```bash
docker run --rm --userns=host -v /home/docker/jellyfin/config/config:/d:ro alpine sh -c \
"apk add --no-cache libxml2-utils >/dev/null 2>&1 && xmllint --noout /d/branding.xml"
curl -s https://arrflix.s8n.ru/Branding/Css.css | wc -c # expect ~36000
```
**Fix.** Escape: `<video>``&lt;video&gt;` for any `<tag>` literal in CSS comments.
**Prevention.** Add `xmllint --noout branding.xml` to CI gate (TODO doc 30/31). Smoke-test `/Branding/Css.css` byte count.
## ERROR 5 — test/123 password nuked after docker cp
**Symptom.** Dev login rejects `test/123` with 401, no password change requested.
**Root cause.** `docker cp` (or in-container cp) on `jellyfin.db` either replaced it with an older copy or triggered SQLite Error 8 readonly that rolled back the password write. Userns-remap leftovers (uid 101000) loop this.
**Diagnostic.**
```bash
ssh user@nullstone 'ls -ln /home/docker/jellyfin-dev/config/data/jellyfin.db' # uid 1000
docker logs jellyfin-dev 2>&1 | grep -iE "readonly|sqlite.*error 8"
```
**Fix.** Stop container; sqlite3 `UPDATE Users SET Password=NULL WHERE Username='test'`; restart; API-set password (doc 28 "Headless comparison" recipe).
**Prevention.** Never `docker cp` a live SQLite file. Stop first. `chown 1000:1000` after any cp into config volume.
## ERROR 6 — DB readonly after docker cp (uid 101000)
**Symptom.** POST `/Users/{id}/Configuration` returns 204 but GET shows field unchanged.
**Root cause.** `jellyfin.db` owned by uid 101000 (Docker userns subuid leftover); container runs as 1000. SQLite throws Error 8 readonly; API returns 204 anyway. Doc 26 §B: *"EVERY user-config save silently fails (HTTP 204 success, value not persisted)."*
**Diagnostic.**
```bash
ls -ln /home/docker/jellyfin/config/data/jellyfin.db # uid must be 1000
docker logs jellyfin 2>&1 | grep -i "readonly\|error 8"
```
**Fix.** `sudo chown -R 1000:1000 /home/docker/jellyfin/config /home/docker/jellyfin/cache && docker restart jellyfin`.
**Prevention.** Never trust 204 — always GET-verify (doc 26 forbidden #4, post-mortem #3). Init-container chowning to 1000:1000 on boot.
## ERROR 7 — camelCase class name typo
**Symptom.** `:has(.htmlVideoPlayer)` (camelCase V) never matches. Body stays opaque → black screen (Error 1).
**Root cause.** Jellyfin's class is **lowercase** `.htmlvideoplayer`. Commit `a6cf925` shipped the typo. Docs/31: *"There is no `.htmlVideoPlayer` (camelCase). Don't confuse them."*
**Diagnostic.** DevTools → search selector. "0 results" while video plays = wrong casing.
**Fix.** Use `.htmlvideoplayer` lowercase OR rely on `body.arrflix-video-active` toggled by JS (preferred — class-on-body robust to DOM changes).
**Prevention.** Read docs/31 layer model BEFORE writing `:has()`. Inspect live DOM, never guess casing.
## ERROR 8 — Wrong Jellyfin class assumption
**Symptom.** CSS rule appears correct but does nothing. e.g. `body.itemDetailPage { ... }` — body's actual class is `libraryDocument` in 10.10.3.
**Root cause.** Jellyfin web class names aren't stable. Doc 26 post-mortem #4: *"Body class on detail pages is `libraryDocument`, not `itemDetailPage`. Use `.itemDetailPage` directly or `:has(.itemDetailPage)`."* Same trap with `.skinHeader` (z:1) vs `.videoPlayerContainer` (z:1000).
**Diagnostic.** `document.body.className`; `document.querySelectorAll('.itemDetailPage').length`.
**Fix.** Use `:has()` on ancestors: `.layout-desktop:has(.itemDetailPage) { ... }`.
**Prevention.** Inspect live DOM in 10.10.3 — never trust forum/older-doc selectors. Ref doc 26 post-mortem #4, doc 31 layer model.
## ERROR 9 — HDR10 grey wash (tonemap off)
**Symptom.** HDR10 source (4K Rick & Morty) renders desaturated grey. NOT pure black — distinct from Error 1.
**Root cause.** `EnableTonemapping=false` while serving HDR10 (`smpte2084` / `bt2020nc` / `yuv420p10le`). ffmpeg passes HDR pixels to SDR transcode without zscale→tonemap→format → wrong colorspace → grey wash. Doc 21 traced for R&M Pilot.
**Diagnostic.** `grep -E "EnableTonemapping|TonemappingAlgorithm" /home/docker/jellyfin/config/config/encoding.xml` — expect `true` + `bt2390`.
**Fix.** `sed -i s|<EnableTonemapping>false|<EnableTonemapping>true|` then `docker restart jellyfin`. Or Dashboard → Playback → Tone Mapping. Commit `1168ba6`.
**Prevention.** Tonemap ON for any library with HDR10 sources. Don't toggle off as "perf fix" — grey wash is worse than slow encode.
## ERROR 10 — Favicon clobbered by lockFavicon shim
**Symptom.** Browser tab shows stock Jellyfin purple-swirl despite ARRFLIX overlay shipping A-mark icon link.
**Root cause.** Jellyfin's `lockFavicon()` runs on `setInterval`, re-pinning its `<link rel="icon">` and overwriting our overlay's link. Two shims race; Jellyfin wins.
**Diagnostic.** `[...document.querySelectorAll('link[rel*=icon]')].map(l => l.href)` — our `data-arrflix-icon="A"` element gone or href swapped.
**Fix.** ARRFLIX shim stamps `data-arrflix-icon="A"` and runs its own re-pin loop on the same interval. Removes stock wordmark links every tick. Commit `1168ba6`.
**Prevention.** Never one-shot DOM writes for elements Jellyfin actively manages (favicon, body class, drawer). Use observe + reapply, or class-on-body.
## ERROR 11 — Backdrop residue / carousel black band
**Symptom.** Backdrop missing on detail-page mid-scroll → black band behind "More from Season N". Or previous item's blurhash sticks after navigation.
**Root cause.** `.backdropContainer` defaults to non-fixed positioning — scrolls out of view (INC2). Sections below paint against body's `#000`. Separately, opaque `.emby-scroller { background:#000 !important }` (originally for home grey strips) leaks into detail-page carousel wrappers (INC4).
**Diagnostic.** DOM-walk every `.scrollSlider` in `.itemDetailPage`, log ancestors with non-transparent computed bg. Locator pattern in doc 26 §INC4.
**Fix.** Pin backdrop `position:fixed; top:0; height:100vh; z-index:0` (INC2). Transparent-scope `.itemDetailPage` wrappers: `.emby-scroller`, `.scrollSliderContainer`, `.detailVerticalSection*`, `.padded-bottom-page`, `.itemsContainer` (INC3+INC4).
**Prevention.** Any new `background:#000 !important` MUST be scoped from day one — never bare `.emby-scroller` (INC4 lesson). Headless test takes top + scrolled (50%) screenshots.
## ERROR 12 — Quick Connect bypass + login mismatch
**Symptom.** Login shows Quick Connect button + user-picker tiles instead of curated ARRFLIX manual-login.
**Root cause.** `system.xml` has `QuickConnectAvailable=true` AND non-admin `IsHidden=false` so picker enumerates. Theme expects QC off and all non-admin hidden.
**Diagnostic.**
```bash
grep QuickConnectAvailable /home/docker/jellyfin/config/config/system.xml
docker exec jellyfin sqlite3 /config/data/jellyfin.db "SELECT Username,IsHidden FROM Users"
```
**Fix.** `QuickConnectAvailable=false` in `system.xml`, restart. `UPDATE Users SET IsHidden=1 WHERE Username != 's8n'`.
**Prevention.** Closed in v6-stable (doc 30). Headless test asserts no `.btnQuickConnect` and no `.cardBox-login` tiles on login.
## Pattern recognition cheat sheet
| If you see... | Likely # |
|---|---|
| black video, audio plays, element decoding | 1 |
| video clipped, OSD controls hidden | 2 |
| local file changed, live page unchanged | 3 |
| empty `/Branding/Css.css` | 4 |
| test/123 401 / sqlite readonly logs | 5+6 |
| selector typo, "rule not applied" | 7+8 |
| HDR content washed-out grey | 9 |
| wrong logo in browser tab | 10 |
| black band behind carousel | 11 |
| Quick Connect / user picker on login | 12 |
## When to add a new error
After ANY incident:
1. Add to index table with date + recurrence count.
2. Add full Symptom / Root cause / Diagnostic / Fix / Prevention.
3. Update cheat sheet if symptom phrasing is novel.
4. If recurrence ≥ 3: add CI gate to `testing/SMOKE-TEST.md`.

209
testing/HEADLESS-PROBE.md Normal file
View file

@ -0,0 +1,209 @@
# HEADLESS-PROBE — playwright + DOM recipes
> Copy-paste these to verify any theme/playback change. All use `mcr.microsoft.com/playwright/python:v1.49.0-noble` with `--userns=host --network container:jellyfin-dev` (or `jellyfin` for prod).
## Setup (one-time per session)
```bash
ssh user@192.168.0.100 'docker pull mcr.microsoft.com/playwright/python:v1.49.0-noble' >/dev/null
mkdir -p /tmp/arrflix-probes
# Run pattern (on nullstone):
docker run --rm --userns=host --network container:jellyfin-dev \
-v /tmp/arrflix-probes:/out -v /tmp/probe-X.py:/probe.py:ro \
mcr.microsoft.com/playwright/python:v1.49.0-noble python /probe.py
```
## RECIPE 1 — auth + pre-seed credentials
Boilerplate every recipe imports. `/Users/AuthenticateByName` returns `{AccessToken, User.Id, ServerId}`. Jellyfin web reads `localStorage['jellyfin_credentials']` on boot — pre-seeding via `add_init_script` skips login.
```python
import asyncio, json, urllib.request
from playwright.async_api import async_playwright
URL='http://127.0.0.1:8096'
USER,PW='test','123'
def auth():
req=urllib.request.Request(f"{URL}/Users/AuthenticateByName",
data=json.dumps({"Username":USER,"Pw":PW}).encode(),
headers={"Content-Type":"application/json","Authorization":'MediaBrowser Client="probe", Device="x", DeviceId="probe-1", Version="1.0"'},method="POST")
return json.loads(urllib.request.urlopen(req,timeout=15).read())
a=auth(); token=a["AccessToken"]; uid=a["User"]["Id"]; sid=a["ServerId"]
```
Pre-seed creds via `add_init_script`:
```python
await page.add_init_script(f"""
localStorage.setItem('jellyfin_credentials', JSON.stringify({{Servers:[{{ManualAddress:'{URL}',Id:'{sid}',Name:'D',UserId:'{uid}',AccessToken:'{token}',DateLastAccessed:Date.now(),UserLinkType:'LinkedUser'}}]}}));
""")
```
If auth returns 401 + `sqlite-readonly` in `docker logs jellyfin-dev`, test password got nuked. Recovery: `docker exec jellyfin-dev sqlite3 /config/data/jellyfin.db "UPDATE Users SET Password=NULL,EasyPassword=NULL WHERE Username='test'"` then `docker restart jellyfin-dev` and POST `/Users/{uid}/Password` with `{NewPw:"123"}`.
## RECIPE 2 — bg-color of every ancestor of `<video>`
Tests L1/L2 transparent rules. Run during playback.
```js
() => {
const v = document.querySelector('video.htmlvideoplayer'); if (!v) return {found:false};
const chain=[];
for (let el=v; el; el=el.parentElement) {
chain.push({tag:el.tagName, cls:String(el.className).slice(0,80), id:el.id, bg:getComputedStyle(el).backgroundColor, z:getComputedStyle(el).zIndex});
}
return {found:true, chain};
}
```
Expect every ancestor `rgba(0,0,0,0)` except `<html>` = `rgb(0,0,0)`.
## RECIPE 3 — darkPct on rendered viewport
Detects "video decodes but is visually black" (doc 28 INC7).
```js
() => {
const v = document.querySelector('video.htmlvideoplayer'); if (!v) return null;
const c = document.createElement('canvas'); c.width=320; c.height=180;
const ctx = c.getContext('2d'); ctx.drawImage(v, 0, 0, 320, 180);
const data = ctx.getImageData(0, 0, 320, 180).data;
let dark=0, total=320*180;
for (let i=0; i<data.length; i+=4) {
const max = Math.max(data[i], data[i+1], data[i+2]);
if (max < 32) dark++;
}
return {darkPct: dark/total, currentTime:v.currentTime, videoWidth:v.videoWidth};
}
```
Expect `darkPct < 0.2` during playback. `> 0.7` = black overlay.
## RECIPE 4 — md5 chain (host → container → served)
```bash
ssh user@nullstone 'md5sum /opt/docker/jellyfin-dev/web-overrides/index-dev.html'
ssh user@nullstone 'docker exec jellyfin-dev md5sum /jellyfin/jellyfin-web/index.html'
ssh user@nullstone 'docker exec jellyfin-dev curl -s http://127.0.0.1:8096/web/index.html | md5sum'
```
All three must match. If host ≠ container: bind-mount inode swap (ERROR-PATTERNS#3) — `docker restart jellyfin-dev`.
## RECIPE 5 — computed style of selector
```js
() => {
const el = document.querySelector('TARGET-SELECTOR-HERE');
if (!el) return {found:false};
const cs = getComputedStyle(el);
const props = ['backgroundColor', 'color', 'outline', 'border', 'zIndex', 'display', 'visibility'];
return {found:true, computed:Object.fromEntries(props.map(p=>[p, cs[p]]))};
}
```
## RECIPE 6 — dump CSS rules matching selector token (cascade debug)
```js
(token) => {
const rules=[];
for (const s of document.styleSheets) {
try { for (const r of s.cssRules) {
if (r.style && r.selectorText && r.selectorText.indexOf(token)>=0)
rules.push({sel:r.selectorText.slice(0,180), css:r.style.cssText.slice(0,200), src:(s.href||'inline').slice(-80)});
}} catch(e){ rules.push({err:String(e).slice(0,40), src:s.href}); }
}
return rules; // later sheets override earlier; arrflix overrides should appear last
}
// page.evaluate("(t)=>{...}", "htmlVideoPlayer")
```
## RECIPE 7 — open dropdown, sample selected listItem
Audio/subtitle picker theme check.
```js
async () => {
const btn = document.querySelector('.btnAudio, .audioMenuButton'); btn?.click();
await new Promise(r => setTimeout(r, 600));
const sel = document.querySelector('.actionSheet .listItem.selected, .actionSheet .listItem-button.selected, .actionSheet .listItem.focused');
if (!sel) return {found:false};
const cs = getComputedStyle(sel);
return {found:true, computed:{outline:cs.outline, bg:cs.backgroundColor, color:cs.color}};
}
```
## RECIPE 8 — full playback smoke (auth + play + sample @5/10/15s)
Reuses Recipe 1 boilerplate. Save as `testing/recipes/smoke-playback.py`:
```python
# (prelude from Recipe 1: auth(), token/uid/sid)
ITEM='324f75b84f394a5d9b0749c0679f23b9'
SAMPLE = """()=>{const v=document.querySelector('video.htmlvideoplayer');
if(!v)return{hasVideo:false};
const c=document.createElement('canvas');c.width=320;c.height=180;
const x=c.getContext('2d');x.drawImage(v,0,0,320,180);
const d=x.getImageData(0,0,320,180).data;let dark=0;
for(let i=0;i<d.length;i+=4)if(Math.max(d[i],d[i+1],d[i+2])<32)dark++;
return{hasVideo:true,t:v.currentTime,w:v.videoWidth,darkPct:dark/57600};}"""
async def main():
async with async_playwright() as p:
b=await p.chromium.launch(headless=True,args=["--ignore-certificate-errors","--autoplay-policy=no-user-gesture-required"])
page=await (await b.new_context(viewport={"width":1280,"height":720})).new_page()
await page.add_init_script(f"localStorage.setItem('jellyfin_credentials',JSON.stringify({{Servers:[{{ManualAddress:'{URL}',Id:'{sid}',Name:'D',UserId:'{uid}',AccessToken:'{token}',DateLastAccessed:Date.now(),UserLinkType:'LinkedUser'}}]}}))")
await page.goto(f"{URL}/web/index.html#/details?id={ITEM}",wait_until="networkidle",timeout=30000)
await page.wait_for_timeout(4000)
try: await page.click('button.btnPlay,.mainDetailButtons .btnPlay,button[title="Play"]',timeout=5000)
except: pass
out=[]
for t in (5,10,15):
await page.wait_for_timeout(5000)
out.append({"at_s":t, **(await page.evaluate(SAMPLE))})
await page.screenshot(path="/out/smoke.png"); print("SMOKE",json.dumps(out,indent=1))
await b.close()
asyncio.run(main())
```
Pass: all three samples `hasVideo:true`, `t` advancing, `darkPct < 0.2`.
## RECIPE 9 — compare two overlays (visual diff)
```bash
# Nullstone: swap+render each, scp screenshots back.
for V in baseline candidate; do
ssh user@nullstone "docker cp /tmp/$V-index.html jellyfin-dev:/jellyfin/jellyfin-web/index.html && docker restart jellyfin-dev"; sleep 8
ssh user@nullstone "docker run --rm --userns=host --network container:jellyfin-dev -v /tmp/probe-real.py:/p.py:ro -v /tmp/arrflix-probes:/out mcr.microsoft.com/playwright/python:v1.49.0-noble python /p.py"
scp user@nullstone:/tmp/arrflix-probes/dev-real-vid.png /tmp/$V.png
done
# Onyx: Pillow diff
python -c "from PIL import Image,ImageChops as C
a=Image.open('/tmp/baseline.png').convert('RGB');b=Image.open('/tmp/candidate.png').convert('RGB')
d=C.difference(a,b);h=d.histogram();print(f'changed={sum(h[1:256])+sum(h[257:512])+sum(h[513:768])} bbox={d.getbbox()}');d.save('/tmp/overlay-diff.png')"
```
## RECIPE 10 — favicon shim (lockFavicon) verify
```js
() => Array.from(document.querySelectorAll('link[rel*="icon"]')).map(l => ({
rel:l.rel, sizes:l.sizes?.value, dataAttr:l.getAttribute('data-arrflix-icon'),
isArrflixA:l.href.indexOf('iVBORw0KGgoAAAANSUhEUgAAAIo')>0,
hrefHead:l.href.slice(0,80)
}))
```
Expect `dataAttr:"1"` + arrflix base64 prefix on every icon link after ~5s (poll interval).
## RECIPE 11 — force `arrflix-video-active` (theme isolation, no real playback)
```js
() => {
const v=document.createElement('div'); v.className='htmlVideoPlayer';
v.style.cssText='position:fixed;inset:0;z-index:5;'; document.body.appendChild(v);
document.body.classList.add('arrflix-video-active');
const els=['body','#reactRoot','.skinBody','.backgroundContainer','.mainAnimatedPages','.pageContainer','.videoPlayerContainer','.htmlVideoPlayer'];
return Object.fromEntries(els.map(s=>{const el=document.querySelector(s); return [s, el?{bg:getComputedStyle(el).backgroundColor,z:getComputedStyle(el).zIndex}:null];}));
}
```
## Where to store probe scripts
`testing/snipUSER-Es/` — one-liners + bash. `testing/recipes/` — full python (recipes 1, 8, 9 live here).

32
testing/README.md Normal file
View file

@ -0,0 +1,32 @@
# testing/
Manual + automated verification + recovery for the ARRFLIX overlay (web-overrides/index.html) + branding.xml + system.xml. Read this folder before any theme edit, deployment, or recovery.
## Index
| File | Purpose |
|------|---------|
| THEMING.md | Safe-edit checklist + layer model + specificity rules (links to docs/31) |
| ERROR-PATTERNS.md | Catalog of every theme/deploy error so far + fixes |
| HEADLESS-PROBE.md | Playwright + DOM-probe recipes for verifying changes |
| ROLLBACK.md | Emergency revert procedures (overlay, branding, full prod) |
| SMOKE-TEST.md | Manual 4-step verify checklist before/after deploy |
| DEPLOY.md | Dev → prod promotion workflow (overlay swap + restart + verify) |
| snipUSER-Es/ | Reusable bash, sqlite, playwright, ssh snipUSER-Es |
| recipes/ | Step-by-step recipes for common tasks (apply theme variant, fix pw, etc) |
| incidents/ | Post-mortems for past + future bugs (referenced from docs/26, 28, 30, 31) |
## Quickstart for "I want to edit the theme"
1. Read `THEMING.md` — pay attention to the layer model + L1/L2 paired rules
2. Edit `bin/inject-middle-theme.py`. Keep it dev-only, scoped under `body.arrflix-themed`
3. `python3 bin/inject-middle-theme.py` to regenerate `web-overrides/index.html`
4. scp the regenerated file to dev's overlay path (NOT prod)
5. `docker restart jellyfin-dev` — bind-mount inode swap requires restart
6. Run `SMOKE-TEST.md` checklist on dev
7. Hard-refresh the browser (Ctrl+Shift+R) — defeats Service Worker + HTTP cache
8. If green: promote per `DEPLOY.md`. If red: revert per `ROLLBACK.md`
## Why this exists
Five+ regressions in 24 hours during 2026-05-09 (docs/26 INC1-5, docs/28 INC7, docs/30 v6-stable). Each one was the same anti-pattern: an opaque CSS rule painted over the video. Combined with bind-mount inode + browser-cache + XML-parse-silent-failures + Cineplex CSS shadow specificity → debugging nightmare. This folder is the institutional memory.

154
testing/ROLLBACK.md Normal file
View file

@ -0,0 +1,154 @@
# ROLLBACK — emergency revert procedures
> When something breaks, follow these recipes. Each is one shell block + verify step.
## When to use this
Any of:
- Live users report black screens, missing UI, can't login
- Headless probe shows `darkPct > 50%` during playback
- `/Branding/Css.css` returns 0 bytes
- Container won't start or stays unhealthy
- `curl -s https://arrflix.s8n.ru/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN` returns 0
- Owner says "rollback" — don't debate, restore first, diagnose after
## ROLLBACK 1 — overlay (most common)
Symptom: theme regression, wrong colors, missing features after a deploy. Login or home page renders but looks wrong.
Source: every prod deploy creates a `.bak.pre-<reason>.<unix-ts>` file in `/opt/docker/jellyfin/web-overrides/`. Pick the most recent (or the one matching the last-known-good state — e.g. `index.html.bak.pre-favfix.1778318089` is pre-v6+favfix).
```bash
ssh user@nullstone 'set -e
TS=$(ls /opt/docker/jellyfin/web-overrides/index.html.bak.* 2>/dev/null | sort -V | tail -1)
echo "Restoring from: $TS"
docker run --rm --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw alpine sh -c "cp $TS /d/index.html && chown root:root /d/index.html && md5sum /d/index.html"
docker restart jellyfin && sleep 12
docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
```
Verify: `curl -s https://arrflix.s8n.ru/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN` returns `1`. Then hard-refresh the browser (`Ctrl+Shift+R`) — defeats Service Worker + HTTP cache.
## ROLLBACK 2 — branding.xml
Symptom: `/Branding/Css.css` returns 0 bytes (Cineplex theme stops loading site-wide). Usually caused by an unescaped `<video>` literal or other XML parse failure inside `<CustomCss>` (silent failure — HTTP 200 empty body, no admin alert).
```bash
ssh user@nullstone 'set -e
TS=$(ls /home/docker/jellyfin/config/config/branding.xml.bak.* 2>/dev/null | sort -V | tail -1)
echo "Restoring from: $TS"
docker run --rm --userns=host -v /home/docker/jellyfin/config/config:/d:rw alpine sh -c "cp $TS /d/branding.xml"
docker restart jellyfin && sleep 12
docker exec jellyfin curl -s http://127.0.0.1:8096/Branding/Css.css | wc -c'
```
Expect: `> 30000` bytes (v6-stable serves 36 256 B). 0 bytes = still broken — the backup itself was corrupt; pick an older `.bak.*` and retry.
## ROLLBACK 3 — encoding.xml (HDR / tonemap)
Symptom: HDR10 playback looks washed out / grey, or transcode fails after flipping `EnableTonemapping`. Backup created pre-flip as `encoding.xml.bak.pre-tonemap.1778318089`.
```bash
ssh user@nullstone 'set -e
TS=$(ls /home/docker/jellyfin/config/config/encoding.xml.bak.* 2>/dev/null | sort -V | tail -1)
echo "Restoring from: $TS"
docker run --rm --userns=host -v /home/docker/jellyfin/config/config:/d:rw alpine sh -c "cp $TS /d/encoding.xml"
docker restart jellyfin && sleep 12
docker exec jellyfin grep -E "EnableTonemapping|TonemappingAlgorithm|HardwareAccelerationType" /config/config/encoding.xml'
```
Verify: values match the last-known-good (v6-stable: `EnableTonemapping=true`, `TonemappingAlgorithm=bt2390`, `HardwareAccelerationType=none`). Stop any in-flight HDR10 transcode and re-start it from the client.
## ROLLBACK 4 — full prod = exact state from repo HEAD
When you don't trust the live state (drift, tampering, multiple bad deploys), force prod to match `git origin/main`:
```bash
cd /tmp/arrflix-recon
git fetch origin && git checkout origin/main
md5sum web-overrides/index.html
scp web-overrides/index.html user@nullstone:/tmp/repo-overlay.html
ssh user@nullstone 'set -e
docker run --rm --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw -v /tmp:/src:ro alpine sh -c "cp /src/repo-overlay.html /d/index.html && chown root:root /d/index.html && md5sum /d/index.html"
docker restart jellyfin && sleep 12
docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
```
Verify: container md5 == repo md5 (v6-stable: `364cc890c58f02d07cf50b43b31a48f0`). `curl -s https://arrflix.s8n.ru/web/index.html | md5sum` should match too.
## ROLLBACK 5 — dev = exact clone of prod
When dev has drifted and you want to reset it to live prod state (for a clean theme test sandbox):
```bash
ssh user@nullstone 'set -e
docker run --rm --userns=host \
-v /opt/docker/jellyfin/web-overrides:/p:ro \
-v /opt/docker/jellyfin-dev/web-overrides:/d:rw \
alpine sh -c "cp /p/index.html /d/index-dev.html && chown 1000:1000 /d/index-dev.html && md5sum /p/index.html /d/index-dev.html"
docker restart jellyfin-dev && sleep 12
docker exec jellyfin-dev md5sum /jellyfin/jellyfin-web/index.html'
```
Note dev's overlay filename is `index-dev.html` (NOT `index.html`) and ownership is `user:user` (1000:1000), unlike prod's `root:root`. Also resync `branding.xml` if needed: source `/home/docker/jellyfin/config/config/branding.xml` → dest `/home/docker/jellyfin-dev/config/config/branding.xml` (backup as `branding.xml.bak.dev-pre-resync` first).
## ROLLBACK 6 — git revert last commit
When the bad change is in repo HEAD and you want it gone from history (then redeploy cleanly):
```bash
cd /tmp/arrflix-recon
git log --oneline -5
git revert HEAD --no-edit
git push origin main
```
Then redeploy via ROLLBACK 4 to push the reverted state to prod. If the bad commit is more than one back, use `git revert <sha>` for each, or `git revert <bad-sha>..HEAD --no-edit` for a range.
## ROLLBACK 7 — recover dev `test`/`123` password
Symptom: dev login broken, can't auth as `test`/`123`. Standard sqlite password-reset cycle (dev only — never prod).
```bash
ssh user@nullstone 'set -e
docker stop jellyfin-dev
DB=/home/docker/jellyfin-dev/config/data/jellyfin.db
cp $DB ${DB}.bak.pre-pwreset.$(date +%s)
docker run --rm -v /home/docker/jellyfin-dev/config/data:/d:rw alpine sh -c "apk add --no-cache sqlite >/dev/null && sqlite3 /d/jellyfin.db \"UPDATE Users SET Password=NULL, EasyPassword=NULL WHERE Username=\x27test\x27;\""
chown -R 1000:1000 /home/docker/jellyfin-dev/config/data
docker start jellyfin-dev && sleep 12'
```
Then in browser: log in as `test` with **blank** password → admin → user `test` → set password to `123`. Verify `curl -X POST https://dev.arrflix.s8n.ru/Users/AuthenticateByName -H 'Content-Type: application/json' -d '{"Username":"test","Pw":"123"}'` returns a token.
## ROLLBACK 8 — bind-mount inode swap (just restart)
Symptom: you changed an overlay file and the served bytes don't match the file on disk. `docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html` differs from `md5sum /opt/docker/jellyfin/web-overrides/index.html`. Bind-mount captured the old inode; container is serving stale.
```bash
ssh user@nullstone 'docker restart jellyfin && sleep 12 && docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
# or for dev:
ssh user@nullstone 'docker restart jellyfin-dev && sleep 12 && docker exec jellyfin-dev md5sum /jellyfin/jellyfin-web/index.html'
```
This is not a "rollback" per se — it's the cure for any `cp`-without-restart that left the container out of sync.
## Backups directory layout
| Path | Purpose |
|------|---------|
| `/opt/docker/jellyfin/web-overrides/index.html.bak.*` | prod overlay (root:root) |
| `/opt/docker/jellyfin-dev/web-overrides/index-dev.html.bak.*` | dev overlay (user:user, note `-dev` suffix) |
| `/home/docker/jellyfin/config/config/branding.xml.bak.*` | prod branding |
| `/home/docker/jellyfin/config/config/encoding.xml.bak.*` | prod encoding |
| `/home/docker/jellyfin-dev/config/config/branding.xml.bak.*` | dev branding |
| `/home/docker/jellyfin-dev/config/data/jellyfin.db.bak.*` | dev sqlite (user db) |
Keep the **most recent** `.bak` per file; older ones can be deleted (per doc 30 cleanup). Never delete a `.bak.*` you haven't verified is older than the current good state.
## After any rollback
1. **Notify users** — restart drops in-flight stream sessions; if anyone was mid-playback they'll get bumped.
2. **Open an incident** in `testing/incidents/` (post-mortem) — what broke, what backup was used, container md5 before/after, owner-visible impact.
3. **Add the failure mode to `testing/ERROR-PATTERNS.md`** if novel.
4. **Verify v6-stable invariants** — overlay md5 prod==dev, `/Branding/Css.css` > 30 000 B, `EnableTonemapping=true`, login + playback both green via `testing/SMOKE-TEST.md`.

74
testing/SMOKE-TEST.md Normal file
View file

@ -0,0 +1,74 @@
# SMOKE-TEST — pre/post-deploy verify checklist
> Run this on dev BEFORE every prod promotion. Run again on prod AFTER deploy.
## Manual (5 min, browser)
1. **Login pristine** — open dev.arrflix.s8n.ru in a fresh incognito window. Hard-refresh.
- [ ] ARRFLIX red logo top-left
- [ ] No Movies/Series links visible (gated by auth)
- [ ] User+Password fields, red Sign In button, "Welcome to ARRFLIX" footer
- [ ] Background pure black, no #101010 stripe
2. **Login + home** — sign in as `test/123` (dev) or your account (prod). After login:
- [ ] Wordmark logo dead-center in header
- [ ] MOVIES + SERIES uppercase nav links left
- [ ] 🔍 search icon right
- [ ] No My Media row (.section0 hidden)
- [ ] Continue Watching / Next Up / Recently Added rows render
- [ ] No grey stripe at bottom of page when scrolled
3. **Movies / Series navigation** — click MOVIES.
- [ ] MOVIES link gets red glow + bold (variant E active state)
- [ ] No back arrow visible on header
- [ ] No duplicate "Movies" h3 title
- [ ] Library renders or shows spinner (Jellyfin viewContainer)
4. **Search** — click 🔍 icon.
- [ ] Search input has red bottom underline on focus (NOT cyan ring)
- [ ] Suggestions list red text on black
5. **Playback** — click any movie's Play button. Wait 10 seconds.
- [ ] Video frame visible (not black, not white, not grey washed out)
- [ ] OSD scrubber + play/pause buttons + settings icon click-able above video
- [ ] Header bar HIDDEN during playback
- [ ] Letterbox bars (top/bottom of video) are BLACK not white
- [ ] Seek with scrubber works
- [ ] Click settings → audio/subtitle dropdowns show red hairline ring on selected
6. **Browser tab favicon** — check tab.
- [ ] Red ARRFLIX "A" mark (not Jellyfin triangle, not wordmark)
## Headless (1 min, automated)
```bash
ssh user@nullstone '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 /tmp/smoke.py"'
```
(`/tmp/smoke.py` lives in testing/recipes/smoke-headless.py — TODO)
Expected diag output:
```
{
"loginRedSignIn": true,
"wordmarkCenter": true,
"myMediaHidden": true,
"darkPctOnVideoFrame": 0.10,
"osdControlsClickable": true,
"letterboxBlack": true,
"favIconAMark": true,
"selectorOutlineRed": true
}
```
If ANY false: rollback (testing/ROLLBACK.md) and check testing/ERROR-PATTERNS.md for the matching pattern.
## md5 chain check
```bash
ssh user@nullstone 'md5sum /opt/docker/jellyfin/web-overrides/index.html /opt/docker/jellyfin-dev/web-overrides/index-dev.html'
ssh user@nullstone 'docker exec jellyfin md5sum /jellyfin/jellyfin-web/index.html'
ssh user@nullstone 'docker exec jellyfin-dev md5sum /jellyfin/jellyfin-web/index.html'
```
Expected on dev-only deploy: prod ≠ dev (intentional). Container view = host file (else inode swap, restart). Once prod-promoted: all 4 should match.

123
testing/THEMING.md Normal file
View file

@ -0,0 +1,123 @@
# THEMING — how to edit the ARRFLIX theme without breaking it
> Short, actionable companion to `docs/31-theme-layer-model-and-edit-guide.md`.
> Read 31 once for the why; come back here for the checklist every edit.
## TL;DR — checklist before EVERY theme edit
1. Read `docs/31-theme-layer-model-and-edit-guide.md` (canonical layer model).
2. Decide: **am I painting an ancestor of `<video>`?** (see layer table below).
3. If yes → scope with `body.arrflix-themed:not(.arrflix-video-active)` AND add the matching transparent rule under `body.arrflix-themed.arrflix-video-active`.
4. Use the specificity table to predict what wins. `!important` does NOT promote specificity.
5. Edit `bin/inject-middle-theme.py` — NEVER hot-patch the deployed overlay.
6. `python3 bin/inject-middle-theme.py``scp web-overrides/index.html dev:/opt/docker/jellyfin-dev/web-overrides/``docker restart jellyfin-dev`.
7. Hard-refresh browser (`Ctrl+Shift+R`) — bind-mount serves stale otherwise.
8. Run `testing/SMOKE-TEST.md` (login, home, video, OSD).
9. Green? Promote per `testing/DEPLOY.md`. Red? `testing/ROLLBACK.md`.
## The layer model (condensed)
| Layer | Element | bg ownership |
|------:|---------|--------------|
| 0 | `<html>` | `#000` (JS inline-style pinned via `setProperty(...,'important')`) |
| 1 | `<body>` | L1 `#000` off-video / L2 `transparent` on-video |
| 2 | `.backgroundContainer` / `.skinBody` / `#reactRoot` | follows L1/L2 |
| 3 | `.mainAnimatedPages` / `.pageContainer` | follows L1/L2 |
| 4 | `.skinHeader` | `#000` off-video, `display:none` on-video |
| 5 | `.videoPlayerContainer` (z:1000) → `<video.htmlvideoplayer>` | transparent on-video |
| 6 | `.osdControls` / `.videoOsdBottom` (z:~11001500) | DO NOT touch — Jellyfin owns |
| 7 | `.dialogContainer` / `.actionSheet` (z:~2000+) | DO NOT touch — Jellyfin owns |
## Specificity quick reference
| Selector | (a,b,c) | When to use |
|----------|---------|-------------|
| `body` | (0,0,1) | almost never |
| `body.arrflix-themed` | (0,1,1) | base theme rule, off/on video both |
| `body.arrflix-themed:not(.arrflix-video-active)` | (0,2,1) | **L1**: off-video bg paint |
| `body.arrflix-themed.arrflix-video-active` | (0,2,1) | **L2**: on-video transparent |
| `body.arrflix-themed.arrflix-video-active #videoOsdPage` | (1,2,1) | beats Cineplex `#videoOsdPage .pageContainer` (1,1,0) |
| `body.arrflix-video-active:not(:has(#loginPage:not(.hide))) .skinHeader` | (0,4,2) | beats Cineplex `display:flex` on header |
L1 and L2 tie on (0,2,1). **Source order decides** — L2 must come AFTER L1 in `inject-middle-theme.py`. Reordering reopens the black-screen bug.
## DO NOT DO
- Set `z-index` on `<video>` or `.videoPlayerContainer` above 1000 → covers OSD scrubber/buttons (image-12 incident).
- Add `background-color` rules without `:not(.arrflix-video-active)` gate → black-screen-over-video.
- Hot-patch `/opt/docker/jellyfin/web-overrides/index.html` in place → repo↔prod drift, INC1 root cause.
- `cp` overlay then skip `docker restart jellyfin` → bind-mount inode swap, container serves stale.
- Use `:has(.htmlVideoPlayer)` (camelCase) — class is `.htmlvideoplayer` lowercase. The selector silently never matches.
- Drop a `<video>` literal into `branding.xml` `<CustomCss>` (even in a comment) without escaping → XML parse fails silently, theme disappears site-wide. Use `&lt;video&gt;`.
- Add `!important` hoping it beats a higher-specificity rule. Among `!important` rules, specificity still wins.
## When to add to L1/L2 paired rules
If your rule paints `background`, `background-color`, or `background-image` on **any** of:
```
body, html, .backgroundContainer, .skinBody, .mainAnimatedPage, .mainAnimatedPages,
.pageContainer, #reactRoot, .videoPlayerContainer, #videoOsdPage, .libraryPage,
video.htmlvideoplayer, .emby-scroller, .backdropContainer
```
→ Add the selector to **BOTH** lists in `bin/inject-middle-theme.py`:
- **L1 list** — under `/* --- L1: PURE-BLACK BG (off-video only) ------ */`, prefixed with `body.arrflix-themed:not(.arrflix-video-active)`.
- **L2 list** — under the L2 transparent block, prefixed with `body.arrflix-themed.arrflix-video-active`, value `background:transparent !important`.
Always paired. Off-video must stay opaque black; on-video must be transparent so `<video>` pixels show through.
## Safe-edit recipe — "make the search input focus ring red"
```bash
# 1. Edit the injector (NOT the deployed overlay)
$EDITOR /tmp/arrflix-recon/bin/inject-middle-theme.py
# Add inside the CSS string, search-input section:
# body.arrflix-themed .searchFields input:focus {
# border-color: #E50914 !important;
# box-shadow: 0 0 0 2px rgba(229,9,20,.35) !important;
# }
# Specificity (0,2,1) — does NOT touch a <video> ancestor → no L1/L2 pairing needed.
# 2. Regenerate the overlay
cd /tmp/arrflix-recon && python3 bin/inject-middle-theme.py
# 3. Sanity: exactly one marker block
grep -c ARRFLIX-MIDDLE-THEME-BEGIN web-overrides/index.html # = 1
# 4. Push to dev only
scp web-overrides/index.html nullstone:/opt/docker/jellyfin-dev/web-overrides/index.html
ssh nullstone 'docker restart jellyfin-dev'
# 5. Verify served
curl -s https://dev.arrflix.s8n.ru/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN # = 1
# 6. Hard-refresh browser, run testing/SMOKE-TEST.md, then promote per testing/DEPLOY.md.
```
## How to add a new skin variant
Skin variants are alternative CSS blocks that swap a single visual concern (selector highlight, header logo treatment, etc.) without forking the whole theme.
- Location: `web-overrides/skins/`.
- Naming: `<concern>-variant-<NN>-<short-slug>.css` (e.g. `selector-variant-02-red-underline.css`).
- Format: file-level comment header explaining what concern it replaces, which variant is currently active in `inject-middle-theme.py`, and a "drop into the CSS string" instruction. Body is plain CSS scoped under `body.arrflix-themed …`.
- Activation: copy the rules into the matching section of `bin/inject-middle-theme.py`, regen overlay, deploy. Skins are NOT auto-loaded — the file is a parking spot.
## Common pitfalls
- **camelCase vs lowercase classes**`.htmlVideoPlayer` does NOT exist; the real class is `.htmlvideoplayer`. Same trap on `.videoOsdBottom` (correct) vs `.videoosdbottom` (wrong).
- **Cineplex CSS load order**`branding.xml``@import url('/web/cineplex.css')` is injected as a `<style>` AFTER our inline block. On equal specificity Cineplex wins. Bump specificity, do NOT reorder.
- **`branding.xml` XML parse** — `<CustomCss>` content must be XML-safe. Escape `<` `>` in any CSS comment that mentions HTML tags. Silent failure = whole branding skipped.
- **iframes / shadow DOM** — Jellyfin web does not currently use either. N/A; skip.
- **`backdrop-filter: blur()`** — only renders if there's content scrolling/painted behind the fixed element. On a pure-black bg the blur is invisible (no pixel diff). Test on a page with a poster backdrop.
- **`getComputedStyle(html).backgroundColor` returns `rgba(0,0,0,0)`** despite stylesheet rules — Chromium root-canvas quirk. We pin `<html>` via JS `style.setProperty('background-color','#000','important')`. Don't fight it from CSS.
## See also
- `docs/31-theme-layer-model-and-edit-guide.md` — canonical layer model and history of past incidents.
- `testing/ERROR-PATTERNS.md` — catalog of past mistakes (INC1INC7, v6-stable, image-12).
- `testing/SMOKE-TEST.md` — 4-step manual verify after any theme change.
- `testing/HEADLESS-PROBE.md` — Playwright recipes for DOM / `darkPct` / OSD-visible assertions.
- `testing/DEPLOY.md` / `testing/ROLLBACK.md` — promote-to-prod and revert procedures.

View file

0
testing/recipes/.gitkeep Normal file
View file

View file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,37 @@
# Next-episode popup designs
Side-by-side preview of the 4 candidate popup designs evaluated 2026-05-10.
Owner picked **A · Cinematic Strip** for prod-bound shim. **B · Terminal**
and **C · Minimal Bar** archived as standalone files for future re-evaluation
(e.g. when ARRFLIX visual direction shifts or owner gets bored of A).
## Files
- `preview.html` — full 4-up preview page (drop into `python3 -m http.server`
to compare). Designed at 1920×1080 in a faux Star-Wars-credits backdrop.
- `a-strip.html` — standalone A. Full-bleed bottom strip, big countdown
ring, white "Start Now" CTA, "Hide" secondary. Netflix-grade muscle
memory. **Currently shipped to dev.**
- `b-terminal.html` — standalone B. Bottom-right card with JetBrains
Mono, dashed dividers, red accent line. Edgy, ARRFLIX-distinct.
- `c-minimal.html` — standalone C. Thin progress bar across bottom + small
text strip, no card. Disappears into UX. Power-user.
D · Poster Card was discarded — too similar to Jellyfin stock to justify
shipping.
## Wiring (current state)
Design A is shipped to **dev only** (`dev.arrflix.s8n.ru`) as a CSS+JS
shim bracketed in `/opt/docker/jellyfin-dev/web-overrides/index-dev.html`
between `NEXT-EP-POPUP-BEGIN` and `NEXT-EP-POPUP-END` markers. The shim
keeps Jellyfin's `.upNextDialog` DOM intact (so its countdown timer keeps
ticking and clicking the underlying buttons stays wired) and overlays
Design A's visual via CSS + a small countdown-ring SVG that mirrors
`.upNextDialog-countdownText`.
Promote to prod when satisfied: copy the shim block into prod's
`web-overrides/index.html` between the same markers, then deploy via
the same nsenter trick documented in `bin/revert-sub-label-shim.sh`.
Revert (dev): `bin/revert-next-ep-popup.sh`.

View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><title>ARRFLIX popup — A · Cinematic Strip</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet">
<style>
:root { --arrflix-red:#E50914; --ink:#fff; --ink-dim:rgba(255,255,255,0.55); --ink-faint:rgba(255,255,255,0.3); }
*{box-sizing:border-box;margin:0;padding:0;}
body{background:#000;color:#fff;font-family:'Geist',system-ui,sans-serif;height:100vh;overflow:hidden;position:relative;}
.bg{position:absolute;inset:0;background:radial-gradient(ellipse 60% 40% at 50% 50%,rgba(20,20,40,0.4) 0%,transparent 70%),black;}
.popup{position:absolute;bottom:0;left:0;right:0;height:26%;background:linear-gradient(to top,rgba(0,0,0,0.95) 50%,rgba(0,0,0,0.7) 80%,transparent);padding:28px 56px 32px;display:flex;align-items:center;gap:36px;}
.ring{position:relative;width:88px;height:88px;flex-shrink:0;}
.ring svg{transform:rotate(-90deg);}
.ring circle{fill:none;stroke-width:3;}
.ring .track{stroke:rgba(255,255,255,0.1);}
.ring .progress{stroke:var(--arrflix-red);stroke-dasharray:264;stroke-dashoffset:67;stroke-linecap:round;filter:drop-shadow(0 0 8px rgba(229,9,20,0.5));}
.ring .num{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:'JetBrains Mono',monospace;font-size:28px;font-weight:500;}
.info{flex:1;min-width:0;}
.label{font-size:10px;letter-spacing:0.32em;text-transform:uppercase;color:var(--arrflix-red);margin-bottom:8px;font-weight:600;}
.title{font-size:28px;font-weight:600;letter-spacing:-0.02em;margin-bottom:6px;line-height:1.1;}
.episode-title{font-size:16px;color:var(--ink-dim);margin-bottom:4px;}
.meta{font-size:12px;color:var(--ink-faint);letter-spacing:0.04em;}
.actions{display:flex;gap:10px;flex-shrink:0;}
.btn{border:none;background:white;color:black;padding:14px 28px;font-family:inherit;font-size:13px;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:10px;}
.btn:hover{background:rgba(255,255,255,0.85);}
.btn-secondary{background:rgba(255,255,255,0.08);color:white;backdrop-filter:blur(10px);}
.btn-secondary:hover{background:rgba(255,255,255,0.16);}
</style></head><body>
<div class="bg"></div>
<div class="popup">
<div class="ring"><svg width="88" height="88" viewBox="0 0 88 88"><circle class="track" cx="44" cy="44" r="42"/><circle class="progress" cx="44" cy="44" r="42"/></svg><div class="num">17</div></div>
<div class="info">
<div class="label">Up Next</div>
<div class="title">Star Wars: Maul · Shadow Lord</div>
<div class="episode-title">S1·E3 — Chapter 3: The Crucible</div>
<div class="meta">22 min · Ends at 2:49 AM</div>
</div>
<div class="actions">
<button class="btn">▶ Start Now</button>
<button class="btn btn-secondary">Hide</button>
</div>
</div>
</body></html>

View file

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><title>ARRFLIX popup — B · Terminal Card</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root{--arrflix-red:#E50914;--arrflix-red-dark:#B00710;--ink:#fff;--ink-dim:rgba(255,255,255,0.55);--ink-faint:rgba(255,255,255,0.3);}
*{box-sizing:border-box;margin:0;padding:0;}
body{background:#000;color:#fff;font-family:'JetBrains Mono',monospace;height:100vh;overflow:hidden;position:relative;}
.bg{position:absolute;inset:0;background:radial-gradient(ellipse 60% 40% at 50% 50%,rgba(20,20,40,0.4) 0%,transparent 70%),black;}
.popup{position:absolute;bottom:32px;right:32px;width:380px;background:rgba(5,5,5,0.92);backdrop-filter:blur(14px);border:1px solid rgba(255,255,255,0.12);border-left:2px solid var(--arrflix-red);padding:20px 22px;}
.top-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;font-size:10px;letter-spacing:0.18em;text-transform:uppercase;}
.tag{color:var(--arrflix-red);font-weight:700;}
.countdown{color:var(--ink-dim);}
.countdown strong{color:var(--ink);font-weight:700;}
hr{border:none;border-top:1px dashed rgba(255,255,255,0.1);margin:12px 0;}
.show{font-family:'Geist',sans-serif;font-size:11px;letter-spacing:0.18em;text-transform:uppercase;color:var(--ink-faint);margin-bottom:4px;}
.ep{font-family:'Geist',sans-serif;font-size:15px;font-weight:500;margin-bottom:4px;color:var(--ink);}
.ep-meta{font-size:11px;color:var(--ink-faint);letter-spacing:0.06em;margin-bottom:16px;}
.progress-bar{height:2px;background:rgba(255,255,255,0.08);margin-bottom:18px;position:relative;}
.progress-bar::after{content:'';position:absolute;top:0;left:0;height:100%;width:75%;background:var(--arrflix-red);box-shadow:0 0 8px rgba(229,9,20,0.6);}
.actions{display:flex;gap:8px;}
.btn{flex:1;background:transparent;border:1px solid rgba(255,255,255,0.18);color:white;padding:9px 14px;font-family:inherit;font-size:11px;letter-spacing:0.18em;text-transform:uppercase;cursor:pointer;}
.btn-primary{background:var(--arrflix-red);border-color:var(--arrflix-red);}
.btn-primary:hover{background:var(--arrflix-red-dark);}
.btn:hover{background:rgba(255,255,255,0.06);border-color:rgba(255,255,255,0.3);}
</style></head><body>
<div class="bg"></div>
<div class="popup">
<div class="top-row"><span class="tag">▎ Up Next</span><span class="countdown"><strong>17</strong>s</span></div>
<hr>
<div class="show">Star Wars: Maul · Shadow Lord · S1E3</div>
<div class="ep">Chapter 3: The Crucible</div>
<div class="ep-meta">22m / ends 02:49</div>
<div class="progress-bar"></div>
<div class="actions">
<button class="btn btn-primary">▶ Start now</button>
<button class="btn">Hide</button>
</div>
</div>
</body></html>

View file

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><title>ARRFLIX popup — C · Minimal Bar</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=JetBrains+Mono:wght@500&family=Bebas+Neue&display=swap" rel="stylesheet">
<style>
:root{--arrflix-red:#E50914;--ink:#fff;--ink-dim:rgba(255,255,255,0.55);}
*{box-sizing:border-box;margin:0;padding:0;}
body{background:#000;color:#fff;font-family:'Geist',system-ui,sans-serif;height:100vh;overflow:hidden;position:relative;}
.bg{position:absolute;inset:0;background:radial-gradient(ellipse 60% 40% at 50% 50%,rgba(20,20,40,0.4) 0%,transparent 70%),black;}
.popup{position:absolute;bottom:0;left:0;right:0;}
.progress-line{height:3px;background:rgba(255,255,255,0.1);position:relative;}
.progress-line::after{content:'';position:absolute;top:0;left:0;height:100%;width:75%;background:var(--arrflix-red);}
.row{background:linear-gradient(to bottom,transparent,rgba(0,0,0,0.85) 30%);padding:24px 56px 22px;display:flex;align-items:center;gap:24px;}
.label{font-family:'Bebas Neue',sans-serif;font-size:13px;letter-spacing:0.32em;color:var(--arrflix-red);flex-shrink:0;}
.text{flex:1;font-size:14px;color:var(--ink-dim);letter-spacing:0.02em;}
.text strong{color:var(--ink);font-weight:500;margin-right:8px;}
.text .countdown{color:var(--arrflix-red);font-family:'JetBrains Mono',monospace;font-weight:500;margin-left:8px;}
.actions{display:flex;gap:16px;}
.btn{background:transparent;border:none;color:white;font-family:inherit;font-size:12px;letter-spacing:0.2em;text-transform:uppercase;font-weight:600;cursor:pointer;padding:8px 0;position:relative;}
.btn::after{content:'';position:absolute;left:0;bottom:0;height:1px;width:100%;background:currentColor;opacity:0.3;}
.btn:hover::after{opacity:1;}
.btn-primary{color:var(--arrflix-red);}
</style></head><body>
<div class="bg"></div>
<div class="popup">
<div class="progress-line"></div>
<div class="row">
<div class="label">UP NEXT</div>
<div class="text"><strong>Star Wars: Maul · Shadow Lord</strong>S1·E3 — Chapter 3: The Crucible<span class="countdown">00:17</span></div>
<div class="actions">
<button class="btn btn-primary">Start now ▶</button>
<button class="btn">Hide</button>
</div>
</div>
</div>
</body></html>

View file

@ -0,0 +1,760 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ARRFLIX — Next-Episode popup designs</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&family=Bebas+Neue&family=Anton&display=swap" rel="stylesheet">
<style>
:root {
--arrflix-red: #E50914;
--arrflix-red-dark: #B00710;
--arrflix-bg: #0a0a0a;
--ink: #fff;
--ink-dim: rgba(255,255,255,0.55);
--ink-faint: rgba(255,255,255,0.3);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: #050505;
color: var(--ink);
font-family: 'Geist', system-ui, sans-serif;
font-feature-settings: "ss01", "ss02";
overflow-x: hidden;
}
.header {
position: sticky; top: 0; z-index: 100;
background: rgba(5,5,5,0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255,255,255,0.08);
padding: 18px 28px;
display: flex; align-items: center; justify-content: space-between;
}
.header h1 {
font-family: 'Bebas Neue', sans-serif;
letter-spacing: 0.1em;
font-size: 20px;
color: var(--arrflix-red);
}
.header .meta {
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-dim);
}
.picker {
display: flex; gap: 10px;
}
.picker button {
background: transparent;
border: 1px solid rgba(255,255,255,0.15);
color: var(--ink-dim);
padding: 8px 14px;
font-family: inherit;
font-size: 11px;
letter-spacing: 0.15em;
text-transform: uppercase;
cursor: pointer;
transition: all 0.15s ease;
}
.picker button:hover {
border-color: var(--arrflix-red);
color: var(--ink);
}
.picker button.active {
background: var(--arrflix-red);
border-color: var(--arrflix-red);
color: white;
}
.stage {
position: relative;
width: 100%;
aspect-ratio: 16/9;
max-height: calc(100vh - 80px);
background: black;
overflow: hidden;
}
.stage::before {
/* Star Wars credits backdrop */
content: '';
position: absolute; inset: 0;
background:
radial-gradient(ellipse 60% 40% at 50% 50%, rgba(20,20,40,0.4) 0%, transparent 70%),
black;
}
.stars {
position: absolute; inset: 0;
background-image:
radial-gradient(1px 1px at 20% 30%, white, transparent),
radial-gradient(1px 1px at 65% 50%, white, transparent),
radial-gradient(1px 1px at 80% 20%, rgba(255,255,255,0.7), transparent),
radial-gradient(2px 2px at 10% 70%, white, transparent),
radial-gradient(1px 1px at 85% 80%, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 45% 90%, white, transparent),
radial-gradient(1px 1px at 30% 15%, rgba(255,255,255,0.5), transparent),
radial-gradient(1.5px 1.5px at 70% 75%, white, transparent),
radial-gradient(1px 1px at 5% 45%, white, transparent),
radial-gradient(1px 1px at 95% 60%, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 25% 85%, white, transparent),
radial-gradient(1px 1px at 55% 25%, white, transparent),
radial-gradient(2px 2px at 50% 50%, rgba(255,255,255,0.4), transparent),
radial-gradient(1px 1px at 15% 92%, white, transparent),
radial-gradient(1px 1px at 88% 35%, white, transparent);
background-size: 100% 100%;
}
.credits {
position: absolute;
top: 18%;
left: 50%;
transform: translateX(-50%);
color: #4488dd;
font-family: 'Geist', sans-serif;
font-weight: 500;
font-size: 14px;
letter-spacing: 0.04em;
text-align: center;
line-height: 1.7;
opacity: 0.55;
text-shadow: 0 0 4px rgba(68,136,221,0.2);
}
.credits .row {
display: grid; grid-template-columns: 1fr 1fr; gap: 32px;
text-align: left;
margin-bottom: 18px;
}
.credits .row .role { text-align: right; opacity: 0.85; }
.credits .row .name { text-transform: uppercase; }
.credits .title-block { text-align: center; margin-bottom: 18px; }
/* Stop the star backdrop from comUSER-Eing */
.stage .popup-host {
position: absolute; inset: 0; z-index: 50;
pointer-events: none;
}
.stage .popup-host > * { pointer-events: auto; }
/* === DESIGN A — CINEMATIC STRIP === */
.design-a {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 26%;
background: linear-gradient(to top, rgba(0,0,0,0.95) 50%, rgba(0,0,0,0.7) 80%, transparent);
padding: 28px 56px 32px;
display: flex;
align-items: center;
gap: 36px;
transform: translateY(0);
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideUp {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.design-a .ring {
position: relative;
width: 88px; height: 88px;
flex-shrink: 0;
}
.design-a .ring svg { transform: rotate(-90deg); }
.design-a .ring circle {
fill: none;
stroke-width: 3;
}
.design-a .ring .track { stroke: rgba(255,255,255,0.1); }
.design-a .ring .progress {
stroke: var(--arrflix-red);
stroke-dasharray: 264;
stroke-dashoffset: 67;
stroke-linecap: round;
transition: stroke-dashoffset 1s linear;
filter: drop-shadow(0 0 8px rgba(229, 9, 20, 0.5));
}
.design-a .ring .num {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-family: 'JetBrains Mono', monospace;
font-size: 28px;
font-weight: 500;
letter-spacing: -0.02em;
}
.design-a .info { flex: 1; min-width: 0; }
.design-a .label {
font-size: 10px;
letter-spacing: 0.32em;
text-transform: uppercase;
color: var(--arrflix-red);
margin-bottom: 8px;
font-weight: 600;
}
.design-a .title {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.02em;
margin-bottom: 6px;
line-height: 1.1;
}
.design-a .episode-title {
font-size: 16px;
color: var(--ink-dim);
margin-bottom: 4px;
}
.design-a .meta {
font-size: 12px;
color: var(--ink-faint);
letter-spacing: 0.04em;
}
.design-a .actions {
display: flex; gap: 10px;
flex-shrink: 0;
}
.design-a .btn {
border: none;
background: white;
color: black;
padding: 14px 28px;
font-family: inherit;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: all 0.15s ease;
}
.design-a .btn:hover { background: rgba(255,255,255,0.85); }
.design-a .btn-secondary {
background: rgba(255,255,255,0.08);
color: white;
backdrop-filter: blur(10px);
}
.design-a .btn-secondary:hover { background: rgba(255,255,255,0.16); }
/* === DESIGN B — TERMINAL CARD === */
.design-b {
position: absolute;
bottom: 32px; right: 32px;
width: 380px;
background: rgba(5,5,5,0.92);
backdrop-filter: blur(14px);
border: 1px solid rgba(255,255,255,0.12);
border-left: 2px solid var(--arrflix-red);
padding: 20px 22px;
font-family: 'JetBrains Mono', monospace;
animation: slideRight 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideRight {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.design-b .top-row {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 14px;
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.design-b .tag {
color: var(--arrflix-red);
font-weight: 700;
}
.design-b .countdown {
color: var(--ink-dim);
}
.design-b .countdown strong {
color: var(--ink);
font-weight: 700;
}
.design-b hr {
border: none;
border-top: 1px dashed rgba(255,255,255,0.1);
margin: 12px 0;
}
.design-b .ep {
font-family: 'Geist', sans-serif;
font-size: 15px;
font-weight: 500;
margin-bottom: 4px;
color: var(--ink);
letter-spacing: -0.01em;
}
.design-b .ep-meta {
font-size: 11px;
color: var(--ink-faint);
letter-spacing: 0.06em;
margin-bottom: 16px;
}
.design-b .progress-bar {
height: 2px;
background: rgba(255,255,255,0.08);
margin-bottom: 18px;
position: relative;
}
.design-b .progress-bar::after {
content: '';
position: absolute;
top: 0; left: 0;
height: 100%;
width: 75%;
background: var(--arrflix-red);
box-shadow: 0 0 8px rgba(229,9,20,0.6);
animation: fillBar 17s linear forwards;
}
@keyframes fillBar {
to { width: 100%; }
}
.design-b .actions {
display: flex; gap: 8px;
}
.design-b .btn {
flex: 1;
background: transparent;
border: 1px solid rgba(255,255,255,0.18);
color: white;
padding: 9px 14px;
font-family: inherit;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
cursor: pointer;
transition: all 0.15s ease;
}
.design-b .btn-primary {
background: var(--arrflix-red);
border-color: var(--arrflix-red);
}
.design-b .btn-primary:hover { background: var(--arrflix-red-dark); }
.design-b .btn-secondary:hover {
background: rgba(255,255,255,0.06);
border-color: rgba(255,255,255,0.3);
}
/* === DESIGN C — MINIMAL BAR === */
.design-c {
position: absolute;
bottom: 0; left: 0; right: 0;
pointer-events: auto;
animation: fadeUp 0.4s ease;
}
@keyframes fadeUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.design-c .progress-line {
height: 3px;
background: rgba(255,255,255,0.1);
position: relative;
}
.design-c .progress-line::after {
content: '';
position: absolute;
top: 0; left: 0;
height: 100%;
width: 75%;
background: var(--arrflix-red);
animation: fillBar 17s linear forwards;
}
.design-c .row {
background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.85) 30%);
padding: 24px 56px 22px;
display: flex;
align-items: center;
gap: 24px;
}
.design-c .label {
font-family: 'Bebas Neue', sans-serif;
font-size: 13px;
letter-spacing: 0.32em;
color: var(--arrflix-red);
flex-shrink: 0;
}
.design-c .text {
flex: 1;
font-size: 14px;
color: var(--ink-dim);
letter-spacing: 0.02em;
}
.design-c .text strong {
color: var(--ink);
font-weight: 500;
margin-right: 8px;
}
.design-c .text .countdown {
color: var(--arrflix-red);
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
margin-left: 8px;
}
.design-c .actions {
display: flex; gap: 16px;
}
.design-c .btn {
background: transparent;
border: none;
color: white;
font-family: inherit;
font-size: 12px;
letter-spacing: 0.2em;
text-transform: uppercase;
font-weight: 600;
cursor: pointer;
padding: 8px 0;
position: relative;
transition: color 0.15s ease;
}
.design-c .btn::after {
content: '';
position: absolute;
left: 0; bottom: 0;
height: 1px; width: 100%;
background: currentColor;
opacity: 0.3;
transition: opacity 0.15s ease;
}
.design-c .btn:hover::after { opacity: 1; }
.design-c .btn-primary { color: var(--arrflix-red); }
/* === DESIGN D — POSTER CARD === */
.design-d {
position: absolute;
bottom: 28px; right: 28px;
width: 460px;
background: linear-gradient(135deg, rgba(15,15,15,0.95), rgba(5,5,5,0.95));
backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 4px;
overflow: hidden;
display: flex;
box-shadow: 0 20px 60px rgba(0,0,0,0.6),
0 0 0 1px rgba(229,9,20,0.15);
animation: slideRight 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.design-d .poster {
width: 160px;
flex-shrink: 0;
background:
linear-gradient(135deg, rgba(229,9,20,0.3), transparent 60%),
radial-gradient(circle at 30% 30%, #2a1a1a, #050505);
position: relative;
display: flex; align-items: flex-end;
padding: 16px;
}
.design-d .poster::before {
content: '';
position: absolute; inset: 0;
background-image:
radial-gradient(1px 1px at 20% 30%, white, transparent),
radial-gradient(1px 1px at 70% 50%, white, transparent),
radial-gradient(1px 1px at 40% 70%, white, transparent),
radial-gradient(1px 1px at 80% 80%, white, transparent),
radial-gradient(1px 1px at 50% 20%, white, transparent),
radial-gradient(1.5px 1.5px at 60% 60%, white, transparent);
opacity: 0.4;
}
.design-d .poster .ep-num {
position: relative;
font-family: 'Anton', sans-serif;
font-size: 64px;
line-height: 0.9;
color: var(--arrflix-red);
letter-spacing: -0.04em;
text-shadow: 0 4px 20px rgba(229,9,20,0.5);
}
.design-d .body {
flex: 1;
padding: 18px 20px 16px;
display: flex;
flex-direction: column;
}
.design-d .top {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 10px;
}
.design-d .label {
font-size: 9.5px;
letter-spacing: 0.32em;
text-transform: uppercase;
color: var(--arrflix-red);
font-weight: 700;
}
.design-d .timer {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--ink-dim);
}
.design-d .timer strong {
color: var(--ink);
font-weight: 700;
}
.design-d .show {
font-size: 13px;
color: var(--ink-faint);
margin-bottom: 4px;
letter-spacing: 0.02em;
}
.design-d .ep-title {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.2;
margin-bottom: 12px;
}
.design-d .meta {
font-size: 11px;
color: var(--ink-faint);
letter-spacing: 0.06em;
margin-bottom: 14px;
text-transform: uppercase;
}
.design-d .meta .dot { margin: 0 8px; opacity: 0.5; }
.design-d .actions {
display: flex; gap: 8px;
margin-top: auto;
}
.design-d .btn {
flex: 1;
border: none;
padding: 11px 12px;
font-family: inherit;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
transition: all 0.15s ease;
border-radius: 2px;
}
.design-d .btn-primary {
background: var(--arrflix-red);
color: white;
box-shadow: 0 4px 14px rgba(229,9,20,0.3);
}
.design-d .btn-primary:hover { background: var(--arrflix-red-dark); }
.design-d .btn-secondary {
background: transparent;
color: var(--ink-dim);
border: 1px solid rgba(255,255,255,0.15);
}
.design-d .btn-secondary:hover {
color: var(--ink);
border-color: rgba(255,255,255,0.4);
}
/* hidden helper */
.hidden { display: none !important; }
/* design label */
.design-label {
position: absolute;
top: 24px; left: 28px;
z-index: 60;
font-family: 'Bebas Neue', sans-serif;
letter-spacing: 0.18em;
font-size: 13px;
color: rgba(255,255,255,0.5);
border-left: 2px solid var(--arrflix-red);
padding-left: 12px;
line-height: 1.4;
}
.design-label strong {
display: block;
color: white;
font-size: 18px;
letter-spacing: 0.1em;
}
.design-label .desc {
font-family: 'Geist', sans-serif;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: none;
color: var(--ink-dim);
margin-top: 4px;
max-width: 300px;
}
.footer {
padding: 28px 56px 36px;
font-size: 12px;
color: var(--ink-faint);
letter-spacing: 0.04em;
line-height: 1.7;
border-top: 1px solid rgba(255,255,255,0.06);
}
.footer strong { color: var(--ink-dim); font-weight: 500; }
.footer kbd {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 3px;
padding: 2px 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--ink-dim);
}
</style>
</head>
<body>
<header class="header">
<h1>ARRFLIX · NEXT-EPISODE POPUP</h1>
<div class="picker">
<button data-d="a" class="active">A · STRIP</button>
<button data-d="b">B · TERMINAL</button>
<button data-d="c">C · MINIMAL</button>
<button data-d="d">D · POSTER</button>
</div>
<div class="meta">PICK ONE · 2026-05-10</div>
</header>
<div class="stage">
<div class="stars"></div>
<div class="credits">
<div class="title-block" style="color:#4488dd; font-weight:600;">
Production Services Provided by CGCG, Inc.
</div>
<div class="row">
<div class="role">Lighting Director<br>Lighting Lead<br>Lighting Artists</div>
<div class="name">Chung-Kai Hsueh<br>Yin-Jung Huang<br>Jung-Tzu Chang · Po-Jui Chiu<br>Char Ho · Luna Jiang<br>Chuan-Sheng Lan · Po-Yu Li</div>
</div>
<div class="row">
<div class="role">Special Effects Director<br>Special Effects Artists</div>
<div class="name">Chia-Hung Chu<br>Jia-You Cai · Lin-Chi Chen<br>Cai-Jhu Li · Zhi-Hao Liu</div>
</div>
<div class="row">
<div class="role">Production Technology</div>
<div class="name">Indigo Tang · Joe Chang<br>I Chiang · Chih-Chiang Tsai</div>
</div>
</div>
<div class="design-label" id="dlabel">
<strong>A · CINEMATIC STRIP</strong>
<div class="desc">Full-bleed bottom strip. Big countdown ring. White CTA = Netflix muscle memory. Grand.</div>
</div>
<div class="popup-host">
<!-- DESIGN A -->
<div class="design-a" data-design="a">
<div class="ring">
<svg width="88" height="88" viewBox="0 0 88 88">
<circle class="track" cx="44" cy="44" r="42"/>
<circle class="progress" cx="44" cy="44" r="42"/>
</svg>
<div class="num">17</div>
</div>
<div class="info">
<div class="label">Up Next</div>
<div class="title">Star Wars: Maul · Shadow Lord</div>
<div class="episode-title">S1·E3 — Chapter 3: The Crucible</div>
<div class="meta">22 min · Ends at 2:49 AM</div>
</div>
<div class="actions">
<button class="btn">▶ Start Now</button>
<button class="btn btn-secondary">Hide</button>
</div>
</div>
<!-- DESIGN B -->
<div class="design-b hidden" data-design="b">
<div class="top-row">
<span class="tag">▎ Up Next</span>
<span class="countdown"><strong>17</strong>s</span>
</div>
<hr>
<div style="font-family:'Geist',sans-serif; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-faint); margin-bottom: 4px;">Star Wars: Maul · Shadow Lord · S1E3</div>
<div class="ep">Chapter 3: The Crucible</div>
<div class="ep-meta">22m / ends 02:49</div>
<div class="progress-bar"></div>
<div class="actions">
<button class="btn btn-primary">▶ Start now</button>
<button class="btn btn-secondary">Hide</button>
</div>
</div>
<!-- DESIGN C -->
<div class="design-c hidden" data-design="c">
<div class="progress-line"></div>
<div class="row">
<div class="label">UP NEXT</div>
<div class="text">
<strong>Star Wars: Maul · Shadow Lord</strong>S1·E3 — Chapter 3: The Crucible
<span class="countdown">00:17</span>
</div>
<div class="actions">
<button class="btn btn-primary">Start now ▶</button>
<button class="btn">Hide</button>
</div>
</div>
</div>
<!-- DESIGN D -->
<div class="design-d hidden" data-design="d">
<div class="poster"><div class="ep-num">E3</div></div>
<div class="body">
<div class="top">
<div class="label">Up Next</div>
<div class="timer">in <strong>17</strong>s</div>
</div>
<div class="show">Star Wars: Maul · Shadow Lord</div>
<div class="ep-title">Chapter 3: The Crucible</div>
<div class="meta">Season 1 <span class="dot">·</span> 22 min <span class="dot">·</span> Ends 02:49</div>
<div class="actions">
<button class="btn btn-primary">▶ Start now</button>
<button class="btn btn-secondary">Hide</button>
</div>
</div>
</div>
</div>
</div>
<div class="footer">
Pick one (<kbd>1</kbd><kbd>4</kbd> or click). When approved, design ships as a JS shim into <strong>web-overrides/index.html</strong> bracketed by <strong>NEXT-EP-POPUP-BEGIN/END</strong> markers, with one-shot revert via <strong>bin/revert-next-ep-popup.sh</strong> (matching the sub-label-shim pattern). The shim hides Jellyfin's stock card and renders the chosen design in its place when Jellyfin signals an upcoming episode.
</div>
<script>
const labels = {
a: ['A · CINEMATIC STRIP', 'Full-bleed bottom strip. Big countdown ring. White CTA = Netflix muscle memory. Grand.'],
b: ['B · TERMINAL CARD', 'Bottom-right card with monospace, dashed dividers, red accent line. Edgy, ARRFLIX-distinct.'],
c: ['C · MINIMAL BAR', 'Thin progress line + small text strip across bottom. Disappears into UX. Power-user.'],
d: ['D · POSTER CARD', 'Bottom-right card with episode-number tile + show + ep title + dual buttons. Polished, pragmatic.'],
};
const buttons = document.querySelectorAll('.picker button');
const designs = document.querySelectorAll('[data-design]');
const lbl = document.getElementById('dlabel');
function show(d) {
buttons.forEach(b => b.classList.toggle('active', b.dataset.d === d));
designs.forEach(el => el.classList.toggle('hidden', el.dataset.design !== d));
lbl.querySelector('strong').textContent = labels[d][0];
lbl.querySelector('.desc').textContent = labels[d][1];
// restart fillBar animations on switch (recreate elements)
designs.forEach(el => {
if (el.dataset.design === d) {
el.style.animation = 'none';
void el.offsetHeight;
el.style.animation = '';
}
});
}
buttons.forEach(b => b.addEventListener('click', () => show(b.dataset.d)));
document.addEventListener('keydown', (e) => {
const map = { '1':'a', '2':'b', '3':'c', '4':'d' };
if (map[e.key]) show(map[e.key]);
});
// animated countdown
let t = 17;
setInterval(() => {
t--;
if (t < 0) t = 17;
document.querySelector('.design-a .num').textContent = t;
document.querySelector('.design-a .ring .progress').style.strokeDashoffset = 67 + (264 - 67) * (1 - t/17);
document.querySelector('.design-b .top-row strong').textContent = t;
document.querySelector('.design-c .countdown').textContent = '00:' + String(t).padStart(2,'0');
document.querySelector('.design-d .timer strong').textContent = t;
}, 1000);
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,26 @@
/* ARRFLIX skin variant selector dropdown highlight: "Red underline"
*
* Alt design for the audio/subtitle dropdown selected-row highlight (and any
* other actionSheet/listItem picker). Currently NOT applied variant 04
* "Hairline ring" is the active design (see bin/inject-middle-theme.py).
*
* Saved for future skin/swap option per owner request 2026-05-10.
*
* Drop into the CSS string in bin/inject-middle-theme.py to swap. Replace the
* current variant-04 block at the end of the CSS section.
*
* Look: faint red wash + 2px red bottom border + soft red glow. Mirrors our
* search-input focus treatment for visual consistency.
*/
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 {
background: rgba(229, 9, 20, 0.04) !important;
color: #fff !important;
border-bottom: 2px solid #E50914 !important;
box-shadow: 0 1px 0 rgba(229, 9, 20, 0.35);
outline: none !important;
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View file

@ -0,0 +1,848 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ARRFLIX Login — Picker R3</title>
<style>
:root {
--black: #000000;
--white: #ffffff;
--red: #E50914;
--red-dim: #B0070F;
--font-display: "Bebas Neue", "Anton", Impact, "Haettenschweiler", "Liberation Sans Narrow", "Arial Narrow Bold", "Helvetica Neue Condensed Black", "DejaVu Sans Condensed", sans-serif;
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Helvetica, "Liberation Sans", Arial, sans-serif;
--font-mono: "SF Mono", "Monaco", "Cascadia Code", "JetBrains Mono", "Roboto Mono", "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: #050505;
color: #fff;
font-family: var(--font-body);
overflow-x: auto;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* === Picker page chrome === */
.picker-header {
padding: 56px 72px 40px;
border-bottom: 1px solid #1a1a1a;
min-width: 2240px;
background:
radial-gradient(ellipse at top right, rgba(229,9,20,0.05) 0%, transparent 50%),
linear-gradient(180deg, #000 0%, #050505 100%);
position: relative;
}
.picker-header::after {
content: ""; position: absolute;
left: 72px; bottom: -1px;
width: 120px; height: 3px; background: var(--red);
}
.picker-title {
font-family: var(--font-display);
font-size: 80px;
letter-spacing: 0.08em;
line-height: 0.9;
color: var(--red);
margin-bottom: 10px;
}
.picker-sub {
font-family: var(--font-mono);
font-size: 12px;
color: #888;
letter-spacing: 0.28em;
text-transform: uppercase;
}
.picker-sub .sep { color: var(--red); margin: 0 14px; }
.picker-meta {
margin-top: 24px;
display: flex;
gap: 56px;
font-family: var(--font-mono);
font-size: 10px;
color: rgba(255,255,255,0.4);
letter-spacing: 0.2em;
text-transform: uppercase;
}
.picker-meta span strong { color: #ccc; margin-left: 8px; }
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 64px 56px;
padding: 72px;
width: 2240px;
margin: 0 auto;
}
.variant { display: flex; flex-direction: column; gap: 20px; }
.variant-label { display: flex; align-items: baseline; gap: 22px; padding: 0 4px; }
.variant-num {
font-family: var(--font-display);
font-size: 60px;
color: var(--red);
line-height: 1;
letter-spacing: 0.04em;
}
.variant-name {
font-family: var(--font-display);
font-size: 34px;
color: #fff;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.variant-tag {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10px;
color: #666;
letter-spacing: 0.3em;
text-transform: uppercase;
padding: 6px 12px;
border: 1px solid #2a2a2a;
}
.viewport {
width: 960px;
height: 540px;
overflow: hidden;
position: relative;
border: 1px solid #1f1f1f;
box-shadow: 0 30px 80px rgba(0,0,0,0.85), 0 0 0 1px rgba(229,9,20,0.04);
background: #000;
}
.login-page {
width: 1920px;
height: 1080px;
transform: scale(0.5);
transform-origin: top left;
position: absolute;
top: 0; left: 0;
}
.variant-desc {
font-size: 13.5px;
color: #888;
line-height: 1.65;
padding: 0 4px;
max-width: 520px;
}
.variant-desc strong { color: #ddd; font-weight: 600; }
.variant-desc .red { color: var(--red); font-weight: 700; }
.arrflix-img { display: block; image-rendering: -webkit-optimize-contrast; user-select: none; }
/* === Shared top-bar (centered logo) — all variants === */
.topbar {
position: relative; z-index: 3;
height: 88px;
display: flex;
align-items: center;
justify-content: center;
}
/* === Shared form-row (centered) defaults === */
.row-c { display: flex; align-items: center; justify-content: center; }
/* ========================================================
VARIANT 1 — THE THEATER (dark rectangular card)
======================================================== */
.v1 {
background: url("poster-bg.jpg") center / cover no-repeat, #000;
position: relative;
overflow: hidden;
}
.v1::before {
content: ""; position: absolute; inset: 0; z-index: 1;
background:
radial-gradient(ellipse 70% 60% at center,
rgba(0,0,0,0) 0%,
rgba(0,0,0,0.55) 55%,
rgba(0,0,0,0.92) 100%),
linear-gradient(180deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 25%, rgba(0,0,0,0) 75%, rgba(0,0,0,0.7) 100%);
pointer-events: none;
}
.v1 .topbar { background: linear-gradient(180deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 100%); }
.v1 .topbar .arrflix-img { width: 156px; height: auto; }
.v1 .stage {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: center;
height: calc(100% - 88px);
}
.v1 .card {
background: rgba(0, 0, 0, 0.78);
border: 1px solid rgba(255,255,255,0.06);
width: 540px;
padding: 56px 56px 44px;
backdrop-filter: blur(8px);
}
.v1 .card h1 {
font-family: var(--font-display);
font-size: 48px;
color: #fff;
letter-spacing: 0.03em;
margin-bottom: 36px;
line-height: 1;
text-align: center;
}
.v1 .field { margin-bottom: 24px; }
.v1 .field label {
display: block;
font-family: var(--font-mono);
font-size: 11px;
color: rgba(255,255,255,0.55);
text-transform: uppercase;
letter-spacing: 0.22em;
margin-bottom: 10px;
font-weight: 600;
}
.v1 .field input {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid rgba(255,255,255,0.18);
padding: 12px 0 14px;
font-size: 18px;
color: #fff;
outline: none;
}
.v1 .field input:focus { border-bottom-color: var(--red); }
.v1 .row { margin: 30px 0; }
.v1 .check {
width: 20px; height: 20px;
background: var(--red);
position: relative;
margin-right: 12px;
border-radius: 2px;
}
.v1 .check::after {
content: ""; position: absolute;
top: 5px; left: 4px; width: 11px; height: 5px;
border-left: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(-45deg);
}
.v1 .remember-label { font-size: 14px; color: #ccc; }
.v1 .btn {
width: 100%;
background: var(--red);
color: #fff;
font-size: 17px;
font-weight: 700;
padding: 18px;
border: none;
cursor: pointer;
border-radius: 4px;
letter-spacing: 0.04em;
}
.v1 .disclaimer {
margin-top: 28px;
font-size: 12px;
color: rgba(255,255,255,0.5);
text-align: center;
letter-spacing: 0.04em;
}
/* ========================================================
VARIANT 2 — THE MARQUEE (neon-red outlined card, glowing)
======================================================== */
.v2 {
background: url("poster-bg.jpg") center / cover no-repeat, #000;
position: relative;
overflow: hidden;
}
.v2::before {
content: ""; position: absolute; inset: 0; z-index: 1;
background:
radial-gradient(ellipse 60% 50% at center, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.92) 100%),
linear-gradient(180deg, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0) 25%, rgba(0,0,0,0) 75%, rgba(0,0,0,0.8) 100%);
pointer-events: none;
}
.v2 .topbar { background: linear-gradient(180deg, rgba(0,0,0,0.65) 0%, rgba(0,0,0,0) 100%); }
.v2 .topbar .arrflix-img { width: 156px; height: auto; }
.v2 .stage {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: center;
height: calc(100% - 88px);
}
.v2 .marquee {
width: 520px;
padding: 52px 48px 44px;
background: rgba(0,0,0,0.88);
border: 2px solid var(--red);
position: relative;
box-shadow:
0 0 0 1px rgba(229,9,20,0.3),
0 0 32px rgba(229,9,20,0.45),
0 0 80px rgba(229,9,20,0.25),
inset 0 0 24px rgba(229,9,20,0.06);
}
/* Corner L-bracket accents */
.v2 .marquee .corner {
position: absolute;
width: 18px; height: 18px;
border: 2px solid var(--red);
background: #000;
}
.v2 .marquee .corner.tl { top: -10px; left: -10px; border-right: none; border-bottom: none; }
.v2 .marquee .corner.tr { top: -10px; right: -10px; border-left: none; border-bottom: none; }
.v2 .marquee .corner.bl { bottom: -10px; left: -10px; border-right: none; border-top: none; }
.v2 .marquee .corner.br { bottom: -10px; right: -10px; border-left: none; border-top: none; }
/* Top-edge "marquee bulbs" — series of red dots above the card */
.v2 .marquee .bulbs {
position: absolute;
top: -28px;
left: 50%; transform: translateX(-50%);
display: flex; gap: 12px;
}
.v2 .marquee .bulbs span {
width: 6px; height: 6px;
background: var(--red);
border-radius: 50%;
box-shadow: 0 0 10px rgba(229,9,20,0.9);
}
.v2 .marquee .bulbs span:nth-child(odd) { opacity: 0.45; }
.v2 .marquee h1 {
font-family: var(--font-display);
font-size: 46px;
color: #fff;
letter-spacing: 0.06em;
margin-bottom: 6px;
line-height: 1;
text-align: center;
text-shadow: 0 0 24px rgba(229,9,20,0.4);
}
.v2 .marquee .sub {
text-align: center;
color: var(--red);
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.45em;
margin-bottom: 36px;
}
.v2 .field { margin-bottom: 22px; }
.v2 .field label {
display: block;
font-family: var(--font-mono);
font-size: 11px;
color: rgba(255,255,255,0.55);
text-transform: uppercase;
letter-spacing: 0.25em;
margin-bottom: 10px;
font-weight: 600;
}
.v2 .field input {
width: 100%;
background: rgba(0,0,0,0.5);
border: 1px solid rgba(229,9,20,0.3);
padding: 14px 16px;
font-size: 17px;
color: #fff;
outline: none;
}
.v2 .field input:focus {
border-color: var(--red);
box-shadow: 0 0 16px rgba(229,9,20,0.4);
}
.v2 .row { margin: 26px 0 28px; }
.v2 .check {
width: 18px; height: 18px;
background: var(--red);
position: relative;
margin-right: 12px;
box-shadow: 0 0 10px rgba(229,9,20,0.7);
}
.v2 .check::after {
content: ""; position: absolute;
top: 4px; left: 3px; width: 10px; height: 5px;
border-left: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(-45deg);
}
.v2 .remember-label { font-size: 14px; color: #ddd; }
.v2 .btn {
width: 100%;
background: var(--red);
color: #fff;
font-family: var(--font-mono);
font-size: 13px;
font-weight: 700;
padding: 18px;
border: none;
cursor: pointer;
letter-spacing: 0.4em;
text-transform: uppercase;
box-shadow: 0 0 20px rgba(229,9,20,0.5), inset 0 1px 0 rgba(255,255,255,0.15);
}
.v2 .disclaimer {
margin-top: 22px;
font-family: var(--font-mono);
font-size: 11px;
color: rgba(255,255,255,0.5);
text-align: center;
letter-spacing: 0.25em;
text-transform: uppercase;
}
.v2 .disclaimer .red { color: var(--red); }
/* ========================================================
VARIANT 3 — THE CINEMA (letterbox bars, wide thin card)
======================================================== */
.v3 {
background: url("poster-bg.jpg") center / cover no-repeat, #000;
position: relative;
overflow: hidden;
}
.v3::before {
content: ""; position: absolute; inset: 0; z-index: 1;
background:
linear-gradient(180deg,
rgba(0,0,0,1) 0%,
rgba(0,0,0,1) 13%,
rgba(0,0,0,0.35) 13%,
rgba(0,0,0,0.35) 87%,
rgba(0,0,0,1) 87%,
rgba(0,0,0,1) 100%);
pointer-events: none;
}
.v3::after {
content: ""; position: absolute; inset: 0; z-index: 1;
background:
radial-gradient(ellipse 70% 50% at center, rgba(0,0,0,0) 0%, rgba(0,0,0,0.6) 80%, rgba(0,0,0,0.9) 100%),
linear-gradient(90deg, rgba(229,9,20,0.04) 0%, transparent 30%, transparent 70%, rgba(229,9,20,0.04) 100%);
pointer-events: none;
}
.v3 .topbar {
background: #000;
border-bottom: 1px solid rgba(229,9,20,0.4);
}
.v3 .topbar .lr-bar {
display: flex; align-items: center; gap: 32px;
}
.v3 .topbar .line-left,
.v3 .topbar .line-right {
width: 120px; height: 1px; background: var(--red);
}
.v3 .topbar .arrflix-img { width: 156px; height: auto; }
.v3 .stage {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: center;
height: calc(100% - 88px);
}
.v3 .wide-card {
width: 720px;
padding: 48px 64px 40px;
background: rgba(0,0,0,0.82);
border-top: 1px solid rgba(229,9,20,0.6);
border-bottom: 1px solid rgba(229,9,20,0.6);
backdrop-filter: blur(6px);
}
.v3 .wide-card h1 {
font-family: var(--font-display);
font-size: 48px;
color: #fff;
letter-spacing: 0.06em;
margin-bottom: 6px;
line-height: 1;
text-align: center;
}
.v3 .wide-card .divider {
width: 60px; height: 2px;
background: var(--red);
margin: 12px auto 32px;
}
.v3 .two-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
margin-bottom: 24px;
}
.v3 .field label {
display: block;
font-family: var(--font-mono);
font-size: 11px;
color: rgba(255,255,255,0.55);
text-transform: uppercase;
letter-spacing: 0.25em;
margin-bottom: 10px;
font-weight: 600;
text-align: center;
}
.v3 .field input {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid rgba(255,255,255,0.2);
padding: 10px 0 12px;
font-size: 18px;
color: #fff;
outline: none;
text-align: center;
}
.v3 .field input:focus { border-bottom-color: var(--red); }
.v3 .row { margin: 24px 0 28px; }
.v3 .check {
width: 18px; height: 18px;
background: var(--red);
position: relative;
margin-right: 12px;
}
.v3 .check::after {
content: ""; position: absolute;
top: 4px; left: 3px; width: 10px; height: 5px;
border-left: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(-45deg);
}
.v3 .remember-label {
font-family: var(--font-mono);
font-size: 12px;
color: rgba(255,255,255,0.75);
letter-spacing: 0.18em;
text-transform: uppercase;
}
.v3 .btn {
width: 320px;
margin: 0 auto;
display: block;
background: var(--red);
color: #fff;
font-family: var(--font-mono);
font-size: 13px;
font-weight: 700;
padding: 18px;
border: none;
cursor: pointer;
letter-spacing: 0.4em;
text-transform: uppercase;
}
.v3 .disclaimer {
margin-top: 24px;
font-family: var(--font-mono);
font-size: 11px;
color: rgba(255,255,255,0.5);
text-align: center;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.v3 .disclaimer .red { color: var(--red); }
/* ========================================================
VARIANT 4 — THE NOIR (heavily dimmed, tight mono card)
======================================================== */
.v4 {
background: url("poster-bg.jpg") center / cover no-repeat, #000;
position: relative;
overflow: hidden;
}
.v4::before {
content: ""; position: absolute; inset: 0; z-index: 1;
background:
radial-gradient(ellipse 50% 50% at center, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.92) 100%);
backdrop-filter: grayscale(1) blur(2px);
-webkit-backdrop-filter: grayscale(1) blur(2px);
pointer-events: none;
}
.v4 .topbar {
border-bottom: 1px solid #1a1a1a;
background: rgba(0,0,0,0.55);
}
.v4 .topbar .arrflix-img { width: 142px; height: auto; }
.v4 .stage {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: center;
height: calc(100% - 88px);
}
.v4 .tight-card {
width: 440px;
padding: 56px 48px 44px;
background: rgba(8,8,8,0.92);
border: 1px solid #1f1f1f;
border-top: 2px solid var(--red);
position: relative;
}
.v4 .tight-card::before {
content: "ARR · MMXXVI";
position: absolute;
top: -10px; left: 50%; transform: translateX(-50%);
background: #000;
padding: 0 12px;
font-family: var(--font-mono);
font-size: 9px;
color: var(--red);
letter-spacing: 0.4em;
}
.v4 .tight-card h1 {
font-family: var(--font-display);
font-size: 38px;
color: #fff;
letter-spacing: 0.08em;
margin-bottom: 4px;
line-height: 1;
text-align: center;
}
.v4 .tight-card .sub {
text-align: center;
font-family: var(--font-mono);
font-size: 10px;
color: rgba(255,255,255,0.4);
letter-spacing: 0.4em;
text-transform: uppercase;
margin-bottom: 36px;
}
.v4 .field { margin-bottom: 22px; }
.v4 .field label {
display: flex;
justify-content: space-between;
align-items: baseline;
font-family: var(--font-mono);
font-size: 10px;
color: rgba(255,255,255,0.5);
text-transform: uppercase;
letter-spacing: 0.3em;
margin-bottom: 8px;
font-weight: 600;
}
.v4 .field label .num { color: var(--red); font-size: 9px; }
.v4 .field input {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid rgba(255,255,255,0.2);
padding: 6px 0 12px;
font-family: var(--font-mono);
font-size: 16px;
letter-spacing: 0.05em;
color: #fff;
outline: none;
}
.v4 .field input:focus { border-bottom-color: var(--red); }
.v4 .row { margin: 20px 0 28px; }
.v4 .check {
width: 14px; height: 14px;
background: var(--red);
position: relative;
margin-right: 12px;
}
.v4 .check::after {
content: ""; position: absolute;
top: 2px; left: 2px; width: 9px; height: 4px;
border-left: 1.5px solid #fff;
border-bottom: 1.5px solid #fff;
transform: rotate(-45deg);
}
.v4 .remember-label {
font-family: var(--font-mono);
font-size: 10px;
color: rgba(255,255,255,0.7);
letter-spacing: 0.28em;
text-transform: uppercase;
}
.v4 .btn {
width: 100%;
background: transparent;
color: var(--red);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
padding: 16px;
border: 1px solid var(--red);
cursor: pointer;
letter-spacing: 0.4em;
text-transform: uppercase;
transition: background 0.2s, color 0.2s;
}
.v4 .btn:hover { background: var(--red); color: #000; }
.v4 .disclaimer {
margin-top: 24px;
font-family: var(--font-mono);
font-size: 10px;
color: rgba(255,255,255,0.4);
text-align: center;
letter-spacing: 0.3em;
text-transform: uppercase;
}
.v4 .disclaimer .red { color: var(--red); }
.picker-footer {
min-width: 2240px;
padding: 32px 72px 64px;
border-top: 1px solid #1a1a1a;
font-family: var(--font-mono);
font-size: 11px;
color: rgba(255,255,255,0.4);
letter-spacing: 0.22em;
text-transform: uppercase;
display: flex;
justify-content: space-between;
align-items: center;
}
.picker-footer .red { color: var(--red); }
.picker-footer .legend { display: flex; gap: 40px; }
.picker-footer .legend span strong { color: #ccc; }
.login-page input { caret-color: transparent; pointer-events: none; }
.login-page button { pointer-events: none; }
</style>
</head>
<body>
<header class="picker-header">
<h1 class="picker-title">LOGIN PICKER R3</h1>
<div class="picker-sub">
ARRFLIX <span class="sep">/</span> 4 VARIANTS <span class="sep">/</span> ALL SHARE: TOP-BAR CENTERED LOGO + CENTERED SIGN IN
</div>
<div class="picker-meta">
<span>VIEWPORT <strong>1920 × 1080</strong></span>
<span>SCALE <strong>50%</strong></span>
<span>SERVED <strong>127.0.0.1:8888 ONLY</strong></span>
<span>ASSETS <strong>REAL LOGO + REAL BG</strong></span>
</div>
</header>
<main class="grid">
<!-- ================== VARIANT 1 ================== -->
<section class="variant">
<div class="variant-label">
<div class="variant-num">01</div>
<div class="variant-name">The Theater</div>
<div class="variant-tag">Classic Card</div>
</div>
<div class="viewport">
<div class="login-page v1">
<div class="topbar"><img class="arrflix-img" src="arrflix-logo.png" alt="ARRFLIX"></div>
<div class="stage">
<div class="card">
<h1>Sign In</h1>
<div class="field"><label>User</label><input type="text"></div>
<div class="field"><label>Password</label><input type="password"></div>
<div class="row row-c"><div class="check"></div><span class="remember-label">Remember me</span></div>
<button class="btn">Sign In</button>
<div class="disclaimer">Private invite only</div>
</div>
</div>
</div>
</div>
<p class="variant-desc">
<strong>Classic dark card.</strong> Top bar with centered ARRFLIX logo on
gradient fade. Centered "Sign In" heading. Rectangular dark glass card,
underline inputs, square red Sign In. Most familiar / Netflix-like.
<span class="red">Safe pick.</span>
</p>
</section>
<!-- ================== VARIANT 2 ================== -->
<section class="variant">
<div class="variant-label">
<div class="variant-num">02</div>
<div class="variant-name">The Marquee</div>
<div class="variant-tag">Neon Outline</div>
</div>
<div class="viewport">
<div class="login-page v2">
<div class="topbar"><img class="arrflix-img" src="arrflix-logo.png" alt="ARRFLIX"></div>
<div class="stage">
<div class="marquee">
<span class="corner tl"></span><span class="corner tr"></span>
<span class="corner bl"></span><span class="corner br"></span>
<div class="bulbs"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
<h1>Sign In</h1>
<div class="sub">Now Showing</div>
<div class="field"><label>User</label><input type="text"></div>
<div class="field"><label>Password</label><input type="password"></div>
<div class="row row-c"><div class="check"></div><span class="remember-label">Remember me</span></div>
<button class="btn">Sign In</button>
<div class="disclaimer">Private invite only</div>
</div>
</div>
</div>
</div>
<p class="variant-desc">
<strong>Neon-red outlined card with marquee bulbs.</strong> 2px red border
with multi-layer glow (32px + 80px), L-bracket corner accents at 4 corners,
row of red dots like theater marquee bulbs above. "NOW SHOWING" subtitle in
red mono caps. Inputs have red borders. Most theatrical / signage feel.
<span class="red">Stands out from the dark-card crowd.</span>
</p>
</section>
<!-- ================== VARIANT 3 ================== -->
<section class="variant">
<div class="variant-label">
<div class="variant-num">03</div>
<div class="variant-name">The Cinema</div>
<div class="variant-tag">Letterbox Wide</div>
</div>
<div class="viewport">
<div class="login-page v3">
<div class="topbar"><img class="arrflix-img" src="arrflix-logo.png" alt="ARRFLIX"></div>
<div class="stage">
<div class="wide-card">
<h1>Sign In</h1>
<div class="divider"></div>
<div class="two-cols">
<div class="field"><label>User</label><input type="text"></div>
<div class="field"><label>Password</label><input type="password"></div>
</div>
<div class="row row-c"><div class="check"></div><span class="remember-label">Remember me</span></div>
<button class="btn">Sign In</button>
<div class="disclaimer">Private invite only</div>
</div>
</div>
</div>
</div>
<p class="variant-desc">
<strong>2.35:1 cinematic letterbox.</strong> Top + bottom black bars frame
the visible poster strip. Wide thin form card sits in the lit band — User
and Password side-by-side, centered short Sign In button. Red hairline
top + bottom borders on card give it a marquee feel.
<span class="red">Most unique layout.</span>
</p>
</section>
<!-- ================== VARIANT 4 ================== -->
<section class="variant">
<div class="variant-label">
<div class="variant-num">04</div>
<div class="variant-name">The Noir</div>
<div class="variant-tag">Tight Mono Card</div>
</div>
<div class="viewport">
<div class="login-page v4">
<div class="topbar"><img class="arrflix-img" src="arrflix-logo.png" alt="ARRFLIX"></div>
<div class="stage">
<div class="tight-card">
<h1>Sign In</h1>
<div class="sub">Private · Invite · Only</div>
<div class="field"><label>User <span class="num">01</span></label><input type="text"></div>
<div class="field"><label>Password <span class="num">02</span></label><input type="password"></div>
<div class="row row-c"><div class="check"></div><span class="remember-label">Remember</span></div>
<button class="btn">Sign In</button>
<div class="disclaimer">Private invite only</div>
</div>
</div>
</div>
</div>
<p class="variant-desc">
<strong>Dimmed noir, tight mono card.</strong> Backdrop is grayscale-blurred
and 92%-dark for a moody movie-still feel. Narrow 440px card with red top
border + a centered "ARR · MMXXVI" badge on the rim, mono numbered field
labels, outlined-then-filled Sign In button.
<span class="red">Most editorial / boutique.</span>
</p>
</section>
</main>
<footer class="picker-footer">
<div>SERVED VIA <span class="red">HTTP://LOCALHOST:8888/PICKER.HTML</span> · 127.0.0.1 ONLY</div>
<div class="legend">
<span>PICK ONE <strong>01 / 02 / 03 / 04</strong></span>
<span>NEXT <strong>PORT INTO TV.S8N.RU</strong></span>
</div>
</footer>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

View file

@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ARRFLIX Login — The Theater (fullsize target)</title>
<style>
:root {
--red: #E50914;
--font-display: "Bebas Neue", "Anton", Impact, "Haettenschweiler", "Liberation Sans Narrow", "Arial Narrow Bold", "Helvetica Neue Condensed Black", "DejaVu Sans Condensed", sans-serif;
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Helvetica, "Liberation Sans", Arial, sans-serif;
--font-mono: "SF Mono", "Monaco", "Cascadia Code", "JetBrains Mono", "Roboto Mono", "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: #000;
color: #fff;
font-family: var(--font-body);
width: 1920px;
height: 1080px;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
.login-page.v1 {
width: 1920px;
height: 1080px;
position: relative;
background: url("poster-bg.jpg") center / cover no-repeat, #000;
overflow: hidden;
}
.v1::before {
content: ""; position: absolute; inset: 0; z-index: 1;
background:
radial-gradient(ellipse 70% 60% at center,
rgba(0,0,0,0) 0%,
rgba(0,0,0,0.55) 55%,
rgba(0,0,0,0.92) 100%),
linear-gradient(180deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 25%, rgba(0,0,0,0) 75%, rgba(0,0,0,0.7) 100%);
pointer-events: none;
}
.v1 .topbar {
position: relative; z-index: 3;
height: 88px;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(180deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 100%);
}
.v1 .topbar img { width: 156px; height: auto; display: block; }
.v1 .stage {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: center;
height: calc(100% - 88px);
}
.v1 .card {
background: rgba(0, 0, 0, 0.78);
border: 1px solid rgba(255,255,255,0.06);
width: 540px;
padding: 56px 56px 44px;
backdrop-filter: blur(8px);
}
.v1 .card h1 {
font-family: var(--font-display);
font-size: 48px;
color: #fff;
letter-spacing: 0.03em;
margin-bottom: 36px;
line-height: 1;
text-align: center;
font-weight: 400;
}
.v1 .field { margin-bottom: 24px; }
.v1 .field label {
display: block;
font-family: var(--font-mono);
font-size: 11px;
color: rgba(255,255,255,0.55);
text-transform: uppercase;
letter-spacing: 0.22em;
margin-bottom: 10px;
font-weight: 600;
}
.v1 .field input {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid rgba(255,255,255,0.18);
padding: 12px 0 14px;
font-size: 18px;
color: #fff;
outline: none;
}
.v1 .row {
display: flex; align-items: center; justify-content: center;
margin: 30px 0;
}
.v1 .check {
width: 20px; height: 20px;
background: var(--red);
position: relative;
margin-right: 12px;
border-radius: 2px;
}
.v1 .check::after {
content: ""; position: absolute;
top: 5px; left: 4px; width: 11px; height: 5px;
border-left: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(-45deg);
}
.v1 .remember-label { font-size: 14px; color: #ccc; }
.v1 .btn {
width: 100%;
background: var(--red);
color: #fff;
font-size: 17px;
font-weight: 700;
padding: 18px;
border: none;
cursor: pointer;
border-radius: 4px;
letter-spacing: 0.04em;
}
.v1 .disclaimer {
margin-top: 28px;
font-size: 12px;
color: rgba(255,255,255,0.5);
text-align: center;
letter-spacing: 0.04em;
}
input { caret-color: transparent; pointer-events: none; }
button { pointer-events: none; }
</style>
</head>
<body>
<div class="login-page v1">
<div class="topbar"><img src="arrflix-logo.png" alt="ARRFLIX"></div>
<div class="stage">
<div class="card">
<h1>Sign In</h1>
<div class="field"><label>User</label><input type="text"></div>
<div class="field"><label>Password</label><input type="password"></div>
<div class="row"><div class="check"></div><span class="remember-label">Remember me</span></div>
<button class="btn">Sign In</button>
<div class="disclaimer">Private invite only</div>
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Port "The Theater" login design (variant 01 from picker R3) to
tv.s8n.ru jellyfin-stock.
Edits:
1. /opt/docker/jellyfin-stock/web-overrides/index.html (precss)
2. /home/docker/jellyfin-stock/config/config/branding.xml (CustomCss)
Theater spec:
- Backdrop: Cineplex poster-bg.jpg with vignette + linear shadow
- Top bar: 88px tall, transparent gradient fade, ARRFLIX logo
CENTERED (156px wide) via .skinHeader::before
- Hide .pageTitleWithLogo top-left (we paint centered instead)
- "Sign In" heading: keep stock h1.sectionTitle "Please sign in",
re-style large + centered (the visible text stays "Please sign in"
-- short of bundle JS rewrite that's the JF i18n string; matches
arrflix.s8n.ru behavior)
- Form card: .padded-left.padded-right.padded-bottom-page styled
as 540px wide dark glass card, padding, border, blur
- Inputs: underline-only, red focus
- Sign In: full-width red square button, 4px radius
- Remember Me: red square checkbox (already wired in earlier round)
- Disclaimer: small white-50% centered
"""
import re, sys
THEATER_PRECSS = """
/* === ARRFLIX 2026-05-12 -- THE THEATER login port ====================
Applied AFTER previous "ARRFLIX login-parity" + selectserver-extend
+ hide-visualform + polish-flash blocks. !important + late cascade
beats earlier rules. Sources:
picker variant 01 at /tmp/picker_assets/picker.html (.v1.*)
User pick 2026-05-12: "the theater sign in looks the best".
-------------------------------------------------------------------- */
/* Top bar: 88px tall transparent fade. ARRFLIX wordmark centered. */
.skinHeader,
.skinHeader.semiTransparent,
.skinHeader.focuscontainer-x,
.skinHeader.focuscontainer-x.skinHeader-withBackground,
.skinHeader.focuscontainer-x.skinHeader-withBackground.skinHeader-blurred {
height: 88px !important;
background: linear-gradient(180deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 100%) !important;
border: none !important;
contain: none !important;
overflow: visible !important;
}
/* Centered wordmark in top bar -- override the earlier
.skinHeader::before { bg: none } from selectserver-extend block. */
.skinHeader::before {
content: "" !important;
position: fixed !important;
top: 16px !important;
left: 50% !important;
transform: translateX(-50%) !important;
width: 156px !important;
height: 56px !important;
background-image: var(--arrflix-logo) !important;
background-repeat: no-repeat !important;
background-position: center !important;
background-size: contain !important;
z-index: 1001 !important;
pointer-events: none !important;
}
/* Kill the top-left .pageTitleWithLogo entirely -- centered
::before above replaces it. */
.pageTitleWithLogo,
.pageTitleWithDefaultLogo,
h3.pageTitle.pageTitleWithLogo,
h3.pageTitle.pageTitleWithDefaultLogo {
display: none !important;
}
/* #loginPage backdrop already handled by earlier #loginPage block
(poster-bg + vignette). Add a stronger vignette on top so the
card has more contrast. */
#loginPage::before {
content: "" !important;
position: absolute !important;
inset: 0 !important;
background:
radial-gradient(ellipse 70% 60% at center,
rgba(0,0,0,0) 0%,
rgba(0,0,0,0.55) 55%,
rgba(0,0,0,0.92) 100%),
linear-gradient(180deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 25%,
rgba(0,0,0,0) 75%, rgba(0,0,0,0.7) 100%) !important;
pointer-events: none !important;
z-index: 0 !important;
/* override the earlier blur+filter from cineplex.css #loginPage::before */
filter: none !important;
transform: none !important;
}
/* UN-hide the "Please sign in" heading (was hidden by hide-visualform
block). Re-style as Theater Sign In heading -- big, white, centered. */
#loginPage h1.sectionTitle,
#loginPage .formDialogHeader,
#loginPage .padded-left.padded-right.padded-bottom-page > h1.sectionTitle {
display: block !important;
font-family: "Bebas Neue", "Anton", Impact, "Haettenschweiler",
"Liberation Sans Narrow", "Arial Narrow Bold", sans-serif !important;
font-size: 48px !important;
color: #fff !important;
letter-spacing: 0.03em !important;
margin: 0 0 32px !important;
padding: 0 !important;
line-height: 1 !important;
text-align: center !important;
text-transform: none !important;
font-weight: 400 !important;
}
/* Form card: style the padded-left wrapper as a 540px-wide centered
dark-glass card. Override hide-visualform's flex layout to keep
center-vertical positioning. */
#loginPage .padded-left.padded-right.padded-bottom-page {
max-width: 540px !important;
width: 540px !important;
margin: 0 auto !important;
padding: 56px 56px 44px !important;
background: rgba(0, 0, 0, 0.78) !important;
border: 1px solid rgba(255,255,255,0.06) !important;
border-radius: 0 !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
min-height: 0 !important;
align-items: stretch !important;
box-shadow: 0 30px 80px rgba(0,0,0,0.6) !important;
}
/* Vertical center the card on the page (account for 88px top bar). */
#loginPage {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
padding-top: 88px !important;
}
/* Form-internal: manualLoginForm + visualLoginForm full width inside card. */
#loginPage .manualLoginForm,
#loginPage form.manualLoginForm {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
}
/* Inputs: underline-only, transparent bg, red focus. */
#loginPage .inputContainer { margin-bottom: 24px !important; }
#loginPage .inputContainer label,
#loginPage .inputLabelFocused,
#loginPage label.inputLabel {
font-family: "SF Mono", "Monaco", "Cascadia Code", "JetBrains Mono",
"Roboto Mono", "DejaVu Sans Mono", "Liberation Mono",
Consolas, monospace !important;
font-size: 11px !important;
color: rgba(255,255,255,0.55) !important;
text-transform: uppercase !important;
letter-spacing: 0.22em !important;
margin-bottom: 10px !important;
font-weight: 600 !important;
display: block !important;
}
#loginPage .emby-input {
width: 100% !important;
background: transparent !important;
border: none !important;
border-bottom: 1px solid rgba(255,255,255,0.18) !important;
border-radius: 0 !important;
padding: 12px 0 14px !important;
font-size: 18px !important;
color: #fff !important;
outline: none !important;
box-shadow: none !important;
}
#loginPage .emby-input:focus,
#loginPage .inputContainer.focused .emby-input {
border-bottom-color: #E50914 !important;
box-shadow: none !important;
}
/* Remember Me row centered. (Checkbox red already from earlier polish.) */
#loginPage .checkboxContainer,
#loginPage label.emby-checkbox-label {
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: 28px 0 30px !important;
padding: 0 !important;
}
/* Sign In button: full-width red, 4px radius, square label. */
#loginPage .raised.button-submit,
#loginPage button.raised.button-submit,
#loginPage .raised.block.emby-button[type="submit"] {
width: 100% !important;
background: #E50914 !important;
color: #fff !important;
font-size: 17px !important;
font-weight: 700 !important;
padding: 18px !important;
border: none !important;
border-radius: 4px !important;
letter-spacing: 0.04em !important;
text-transform: none !important;
margin: 0 0 0 !important;
box-shadow: none !important;
}
/* Disclaimer: centered, light grey, small. */
#loginPage .loginDisclaimer,
#loginPage .disclaimerContainer,
#loginPage .loginDisclaimerContainer {
margin-top: 28px !important;
font-size: 12px !important;
color: rgba(255,255,255,0.5) !important;
text-align: center !important;
letter-spacing: 0.04em !important;
width: 100% !important;
display: block !important;
}
"""
# Branding mirror: same selectors + rules, copied. Doesn't include the
# var(--arrflix-logo) reference issue since branding.xml already
# defines --arrflix-logo at L64.
THEATER_BRANDING = THEATER_PRECSS # identical CSS body
def patch_precss(path, marker, block):
with open(path, "r", encoding="utf-8") as f:
s = f.read()
if marker in s:
print(f" {path}: already patched")
return
m = re.search(r'(<style id="arrflix-precss">.*?)(</style>)', s, flags=re.DOTALL)
if not m:
sys.exit(f"ERROR: arrflix-precss block not found in {path}")
s = s[:m.end(1)] + block + s[m.end(1):]
with open(path, "w", encoding="utf-8") as f:
f.write(s)
print(f" {path}: patched (+{len(block)})")
def patch_branding(path, marker, block):
with open(path, "r", encoding="utf-8") as f:
s = f.read()
if marker in s:
print(f" {path}: already patched")
return
CLOSE = "</CustomCss>"
if CLOSE not in s:
sys.exit(f"ERROR: </CustomCss> not found in {path}")
s = s.replace(CLOSE, block + CLOSE, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(s)
print(f" {path}: patched (+{len(block)})")
MARKER = "THE THEATER login port"
patch_precss(
"/opt/docker/jellyfin-stock/web-overrides/index.html",
MARKER, THEATER_PRECSS)
patch_branding(
"/home/docker/jellyfin-stock/config/config/branding.xml",
MARKER, THEATER_BRANDING)