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

174 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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