ARRFLIX/docs/29-middle-theme-v6-2026-05-09.md
s8n 52a7df6695 middle-theme v6 + branding.xml video escape
Add ARRFLIX wordmark center, Movies/Series nav left, search right,
favicon=A-mark, auth gate so login stays stock, hide on video page.

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

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

See docs/29 for design + deploy procedure + recovery quirk.
2026-05-09 04:01:49 +01:00

11 KiB
Raw Blame History

29 — Middle-Theme v6 + Prod Stream Restore (2026-05-09)

Outcome: ARRFLIX wordmark logo dead-center, Movies/Series nav left, search right; auth-gated so login page is untouched; header hidden during video playback. Same patch shipped to prod simultaneously with the branding.xml <video> XML escape that restored the INC7 transparent-video CSS — closing the live black-screen issue users saw on prod.

Status: DEPLOYED 2026-05-09 — dev (dev.arrflix.s8n.ru) and prod (arrflix.s8n.ru) both serve web-overrides/index.html md5 c6c85076951633c434864a0133d602e5. Prod /Branding/Css.css went 0 B → 36 256 B post-fix.

Sibling docs: 28 (prod-vs-dev playback divergence — INC7 streaming fix), 26 (incident chain INC1INC5), 12 (dev mirror), 17 (dev mirror + settings fix).


What v6 ships

  1. ARRFLIX wordmark, dead-center in .skinHeader .headerTop. .arrflix-headerLogo is an <a href="#/home.html"> with position:absolute; left:50%; transform:translate(-50%,-50%). Background-image inlined as base64 (the same wordmark already used by .adminDrawerLogo img and .pageTitleWithLogo in branding.xml's CustomCss). Width 120, height 38, aspect 235:85.
  2. Movies + Series uppercase nav links injected into .headerLeft. <a is="emby-linkbutton" class="emby-button arrflix-nav" href="#/movies.html">Movies</a> (and #/tv.html for Series). The link href is bare — no topParentId query — so Jellyfin's MoviesPage resolves the library via user policy.
  3. Search button on the right — Jellyfin's stock .headerSearchButton left untouched. .headerLeft, .headerRight { flex:1 1 0 } + .headerRight { justify-content: flex-end } push it to the corner.
  4. Stock clutter hidden under body.arrflix-themed: .headerHomeButton, .pageTitleWithLogo, .headerCastButton, .headerSyncButton, .headerTabs.sectionTabs, and the bare h3.pageTitle:not(.pageTitleWithLogo) (the duplicate "Movies" title that appeared on library pages).
  5. Favicon swap to the ARRFLIX "A" mark — injected as <link rel="icon" type="image/png" href="data:image/png;base64,…"> plus apple-touch-icon, both wrapped in <!--ARRFLIX-FAVICON-BEGIN/END--> markers for idempotent re-runs.
  6. Auth gate. body.arrflix-themed is added by JS only when ApiClient.isLoggedIn() AND localStorage.jellyfin_credentials has an AccessToken AND the current location.hash is not on /login|/wizard|/forgotpassword|/selectserver. CSS rules are scoped to body.arrflix-themed so the login page renders stock-with-Cineplex (ARRFLIX top-left red, Manual Login form) — not the rearranged middle-theme.
  7. Video page suppression. When location.hash includes /video OR #videoOsdPage:not(.hide) is in the DOM OR a visible .htmlVideoPlayer exists, JS adds body.arrflix-video-active. CSS rule body.arrflix-video-active:not(:has(#loginPage:not(.hide))) .skinHeader, body.arrflix-video-active .arrflix-headerLogo, body.arrflix-video-active .arrflix-nav { display:none !important } — specificity (0,4,2) beats Cineplex's body:not(:has(#loginPage:not(.hide))) .skinHeader { display:flex !important } (0,3,2), so our hide wins.

JS uses MutationObserver on body + hashchange listener + setInterval(1500) watchdog. Idempotent: re-entry checks via [data-arrflix-nav="movies"] selector.


Build

The patch is a single Python script: bin/inject-middle-theme.py. It:

  1. Reads the target HTML (default /opt/docker/jellyfin/web-overrides/index.html — overridable via env var ARRFLIX_OVERLAY_PATH).
  2. Strips any prior <style>ARRFLIX-MIDDLE-THEME-BEGIN…END</style>, <script>…BEGIN…END</script>, and <!--ARRFLIX-FAVICON-BEGIN→END--> blocks (idempotent — safe to re-run).
  3. Reads two artifacts:
    • web-overrides/assets/arrflix-A.png (encoded inline as base64 for favicon)
    • The wordmark base64 embedded in branding.xml (extracted at build time)
  4. Inlines a <style> block, a <script> block, and two <link> tags into <head> immediately before </head>.
  5. Writes a backup at <target>.bak.pre-middle-v6.<timestamp> before overwriting.

Re-run safely — old marker blocks are stripped first; result is byte-deterministic (same inputs → same md5).


Stream-restore side-fix

Prod's branding.xml had a <video> literal in a CSS comment (BLACK-PASS section explaining INC7's transparent-video rule). The XML parser choked on the unescaped < → Jellyfin silently dropped the entire <CustomCss> block → the INC7 transparent-video rule never reached the browser → #videoOsdPage rendered an opaque black .libraryPage background OVER the decoded <video> frames → users saw black screens during playback.

/Branding/Css.css returned 0 bytes until this was fixed (and 36 256 bytes after).

Fix: escape the two unescaped <video> tokens to &lt;video&gt;. Before:

on top of <video> as opaque black -> visually black despite <video>

After:

on top of &lt;video&gt; as opaque black -> visually black despite &lt;video&gt;

XML now passes xmllint --noout cleanly. Same fix applied to dev simultaneously — both branding.xml files now have md5 <see config> and parse identically.

This single character-level escape is what restored streaming on prod. The doc-28 chain (Traefik SW pin, INC7 transparent CSS) was technically correct upstream — the diagnosis was right, but the delivery was broken because the XML never loaded. INC7's CSS rule had been "in" branding.xml since 2026-05-09 02:46Z, but Branding/Css.css was empty so the rule never reached any browser.

Lesson: add xmllint --noout branding.xml to deploy CI. The user-visible failure mode of a malformed BrandingOptions XML is silent (zero-byte response, no banner, no admin notification), and both prod and dev had been running unthemed-via-CustomCss for multiple deploy cycles before anyone noticed.


Files touched

Path Change
web-overrides/index.html Apply bin/inject-middle-theme.py — adds 75 KB (wordmark + favicon base64 + style + script + link). Idempotent markers ARRFLIX-MIDDLE-THEME-BEGIN/END and ARRFLIX-FAVICON-BEGIN/END. md5 c6c85076951633c434864a0133d602e5.
web-overrides/assets/arrflix-A.png New — 1695×928 PNG of the ARRFLIX "A" mark on white. Source for the favicon (white→transparent + resize to 138×180 → base64 inline).
bin/inject-middle-theme.py New — the patch builder.
docs/29-middle-theme-v6-2026-05-09.md This doc.
Server-side /home/docker/jellyfin/config/config/branding.xml (prod) Two <video>&lt;video&gt; escapes. Not in repo (config is per-deployment; document the change here).
Server-side /home/docker/jellyfin-dev/config/config/branding.xml (dev) Same escape.

Deploy procedure

Dev

# Re-run patch builder against dev's overlay (idempotent)
python3 bin/inject-middle-theme.py
scp web-overrides/index.html user@192.168.0.100:/opt/docker/jellyfin-dev/web-overrides/index-dev.html
# Single-file bind mount — no container restart needed

Prod

Prod's overlay file is owned root:root, so ssh user@… can't write directly. Use a docker-as-root shim:

docker run --rm --userns=host \
  -v /opt/docker/jellyfin/web-overrides:/d:rw \
  -v /tmp:/tmp:rw \
  alpine sh -c '
    apk add --no-cache python3 >/dev/null 2>&1 &&
    python3 /tmp/inject-middle-theme.py /d/index.html
  '
docker run --rm --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw \
  alpine chown root:root /d/index.html

If branding.xml was rewritten with new content, also escape any new <video> (or any other unescaped <) and xmllint --noout before restart. Then:

docker restart jellyfin
# 30s downtime; users will need to refresh

Verify

docker exec jellyfin curl -s http://127.0.0.1:8096/Branding/Css.css | wc -c    # expect ~36 KB
docker exec jellyfin curl -s http://127.0.0.1:8096/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN   # expect 2

Headless visual: run bin/headless-test-v2.py against prod with a known user — darkPct on the OSD frame should drop from ~100 % (pre-fix) to <10 % (post-fix), per the doc-28 INC7-final lesson.


Account state on dev

Dev jellyfin instance currently hosts a single account for theme testing:

User Password Admin Hidden
test 123 yes no

The 7 mirror accounts (marco-mirror, house-mirror, guest-mirror, aloy-mirror, pet-mirror, 5-mirror, s8n-dev) were deleted earlier in the session per owner's "replace all" decision. Library content (Movies + TV Shows) was inherited from prod via a one-time /config rsync (excluded data/jellyfin.db) so dev sees the same titles and metadata as prod.

Recovery quirk: test's password gets nuked occasionally after docker cp jellyfin.db operations because userns_mode: host flips ownership back to host uid 101000 (the userns-remap of container 1000). Recovery cycle:

docker stop jellyfin-dev
docker cp jellyfin-dev:/config/data/jellyfin.db /tmp/r.db
docker cp jellyfin-dev:/config/data/jellyfin.db-wal /tmp/r.db-wal 2>/dev/null
sqlite3 /tmp/r.db 'PRAGMA wal_checkpoint(TRUNCATE); UPDATE Users SET Password=NULL, InvalidLoginAttemptCount=0 WHERE Username="test";'
docker cp /tmp/r.db jellyfin-dev:/config/data/jellyfin.db
docker exec --user 0 jellyfin-dev sh -c 'rm -f /config/data/jellyfin.db-wal /config/data/jellyfin.db-shm; chown 1000:1000 /config/data/jellyfin.db'
docker restart jellyfin-dev && sleep 9
# Authenticate with blank password, then POST /Users/{id}/Password { "CurrentPw":"", "NewPw":"123" }

User ID for test: a0ea2751d4e2467cb634485614a959e8.


Open follow-ups

Item Where
compose-dev/docker-compose.yml in repo lacks the overlay bind-mount that the live host has compose-dev/docker-compose.yml
Dev's system.xml has QuickConnectAvailable=true, prod has false — Quick Connect button visible on dev login only system.xml line ~7
Locale-en-only chunk JS files (*-json.*.chunk.js) bind-mounted on prod (94 of them) but absent on dev → dev users get stock locale strings host /opt/docker/jellyfin/web-overrides/locale-en-only/
Movies/Shows pages on dev show a stuck spinner because Jellyfin's tryRestoreView bounces a cached ?topParentId=movies URL → /Items/movies 400. Not a v6 regression — present in stock build too. Jellyfin viewContainer.tryRestoreView
Add xmllint --noout branding.xml to repo CI new
Headless darkPct assertion to surface CSS-overlay-over-video regressions automatically bin/headless-test-v2.py

Snapshot

Asset md5
web-overrides/index.html (post-v6) c6c85076951633c434864a0133d602e5
branding.xml (prod, post-escape) (see live config)
branding.xml (dev, post-escape) (see live config)
arrflix-A.png (asset source) (see repo)

Both deploy targets running c6c85076951633c434864a0133d602e5 as of 2026-05-09 ~03:00 UTC.