From 4772ddf934ab7d41a27c0bb1a0bf63387628e44d Mon Sep 17 00:00:00 2001 From: s8n Date: Fri, 8 May 2026 13:34:04 +0100 Subject: [PATCH] doc 17: dev mirror + Settings drawer leak fix (dev only, no prod swap) --- docs/17-dev-mirror-and-settings-fix.md | 474 +++++++++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 docs/17-dev-mirror-and-settings-fix.md diff --git a/docs/17-dev-mirror-and-settings-fix.md b/docs/17-dev-mirror-and-settings-fix.md new file mode 100644 index 0000000..fccd611 --- /dev/null +++ b/docs/17-dev-mirror-and-settings-fix.md @@ -0,0 +1,474 @@ +# 17 - Dev Mirror + Settings Drawer Leak Diagnosis & Fix (Dev Only) + +> Owner asked for two things in one session: +> +> 1. Make `https://dev.arrflix.s8n.ru` a complete behavioural mirror of prod +> `https://arrflix.s8n.ru` so the dev box is a faithful test bench. +> 2. With dev mirroring prod, definitively diagnose and fix the long-standing +> "Settings entry still appears in the drawer for non-admin users" issue — +> **on dev only**. Owner reviews dev visually before any prod swap. +> +> Date: 2026-05-08. Live verification in `/tmp/arrflix-headless/` (screenshots, +> drawer DOM dumps, selector tests). Prod was **not** modified. The shared +> `web-overrides/index.html` bind-mounted into the prod container was **not** +> edited. Dev now bind-mounts a separate `index-dev.html` of its own. + +--- + +## TL;DR + +| Surface | Mirrored to dev? | Method | +|---|---|---| +| Branding (`LoginDisclaimer`, `CustomCss`, `SplashscreenEnabled`) | YES — byte-equal | `GET /System/Configuration/branding` on prod, `POST` on dev | +| `web-overrides/index.html` shim+splash+favicon | YES (initially the shared file; now dev-only `index-dev.html`) | docker-compose bind-mount | +| Libraries (`Movies`, `TV Shows`) | YES — same paths, same `LibraryOptions` | `POST /Library/VirtualFolders` per lib | +| Non-admin users (5, aloy, guest, house, marco, pet) | YES — recreated as `-mirror` with placeholder `dev-test-` passwords | `bin/add-jellyfin-user.sh` | +| `DisplayPreferences` (`client=emby`) per user | YES — copied verbatim from prod | `GET → POST /DisplayPreferences/usersettings` | +| Library scan (item counts within tolerance) | YES — dev 173 ep / prod 168 ep (Mando importing) | `POST /Library/Refresh` | + +**Settings drawer leak — root cause:** The drawer Settings entry is rendered as +```html + + + Settings + +``` +The `href` is literally `#`. The actual route is wired by a JS click handler +keyed off `data-itemid="settings"`. Every existing CSS rule we had — +`a[href*="mypreferencesmenu"]`, `[to*="mypreferencesmenu"]`, +`[href$="mypreferencesmenu.html"]`, `[to="/mypreferencesmenu.html"]` — matched +**zero** elements in the live DOM (verified via headless probe). + +**Fix (dev only, in `index-dev.html`):** +- CSS: `a.btnSettings, .navMenuOption.btnSettings, [data-itemid="settings"] { display: none !important; }` +- JS shim `nukeSettings()` extended to also match `a.btnSettings` and `[data-itemid="settings"]`, with the legacy `mypreferencesmenu` selectors kept as fallback. + +--- + +## Phase 1 — Mirror procedure + +### 1.1 Complete dev's first-run wizard + +Dev was a fresh container (`StartupWizardCompleted=false`). Three calls: + +```bash +DEV=https://dev.arrflix.s8n.ru +curl -ks -X POST "$DEV/Startup/Configuration" \ + -H 'Content-Type: application/json' \ + -d '{"UICulture":"en-US","MetadataCountryCode":"US","PreferredMetadataLanguage":"en"}' + +# Gotcha: POSTing a NEW name to /Startup/User raises +# System.InvalidOperationException: Sequence contains no elements +# because the wizard already auto-created a placeholder admin "MyJellyfinUser" +# on first request. So set the password on the existing name first: +curl -ks -X POST "$DEV/Startup/User" \ + -H 'Content-Type: application/json' \ + -d '{"Name":"MyJellyfinUser","Password":"2001dude"}' + +curl -ks -X POST "$DEV/Startup/RemoteAccess" \ + -H 'Content-Type: application/json' \ + -d '{"EnableRemoteAccess":true,"EnableAutomaticPortMapping":false}' + +curl -ks -X POST "$DEV/Startup/Complete" +``` + +Then authenticate, save the token, and rename the admin: + +```bash +DEV_TOKEN=$(curl -ks -X POST "$DEV/Users/AuthenticateByName" \ + -H 'Content-Type: application/json' \ + -H 'Authorization: MediaBrowser Client="setup", Device="setup", DeviceId="setup", Version="1.0"' \ + -d '{"Username":"MyJellyfinUser","Pw":"2001dude"}' \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["AccessToken"])') + +# Rename: GET full user object, mutate Name, POST back to /Users/{id} +DEV_USER_ID=... +curl -ks "$DEV/Users/$DEV_USER_ID" -H "Authorization: MediaBrowser Token=\"$DEV_TOKEN\"" \ + | python3 -c 'import json,sys; u=json.load(sys.stdin); u["Name"]="s8n-dev"; print(json.dumps(u))' \ + | curl -ks -X POST "$DEV/Users/$DEV_USER_ID" \ + -H "Authorization: MediaBrowser Token=\"$DEV_TOKEN\"" \ + -H 'Content-Type: application/json' --data-binary @- +``` + +### 1.2 Mirror branding + +```bash +PROD=https://arrflix.s8n.ru +PROD_TOKEN=... +curl -ks "$PROD/System/Configuration/branding" \ + -H "Authorization: MediaBrowser Token=\"$PROD_TOKEN\"" > /tmp/prod-branding.json + +curl -ks -X POST "$DEV/System/Configuration/branding" \ + -H "Authorization: MediaBrowser Token=\"$DEV_TOKEN\"" \ + -H 'Content-Type: application/json' \ + --data-binary @/tmp/prod-branding.json +``` + +Verified `LoginDisclaimer`, `CustomCss` (25985 chars), `SplashscreenEnabled=true` +all byte-equal between dev and prod after POST. + +### 1.3 Mirror web-overrides bind-mount + +Initial mirror used the **shared** prod file: + +```yaml +# /opt/docker/jellyfin-dev/docker-compose.yml — initial mirror state +- /opt/docker/jellyfin/web-overrides/index.html:/jellyfin/jellyfin-web/index.html:ro +``` + +`docker compose up -d --force-recreate jellyfin-dev`. Confirmed dev served +`ARRFLIX`, ``, +embedded data-URL apple-touch-icon (ARRFLIX), and the `/* ARRFLIX-SHIM-BEGIN */` +script block. + +**Then for Phase 2 fix-isolation**, the mount was switched to a dev-only file +copy so dev fixes don't bleed into prod: + +```yaml +# /opt/docker/jellyfin-dev/docker-compose.yml — final dev state +- /opt/docker/jellyfin-dev/web-overrides/index-dev.html:/jellyfin/jellyfin-web/index.html:ro +``` + +`/opt/docker/jellyfin-dev/web-overrides/index-dev.html` was created by `cp` +from the prod shared file, then patched with the V2 fix described in Phase 2. + +### 1.4 Mirror libraries + +```bash +curl -ks "$PROD/Library/VirtualFolders" -H "Authorization: MediaBrowser Token=\"$PROD_TOKEN\"" \ + > /tmp/prod-libs.json + +# For each lib: POST /Library/VirtualFolders?name=...&collectionType=...&paths=...&refreshLibrary=false +# with body {"LibraryOptions": } +# (script in conversation log; reproducible via python3 driver.) +``` + +Result: dev has `Movies → /media/movies` and `TV Shows → /media/tv` with the +same `LibraryOptions` (`PreferredMetadataLanguage=en`, `MetadataCountryCode=US`, +`EnableInternetProviders=false`, `SubtitleDownloadLanguages=[eng]`, +`TheMovieDb` as sole metadata fetcher, etc.). + +### 1.5 Mirror users + +For each non-admin prod user (5, aloy, guest, house, marco, pet) the +existing `bin/add-jellyfin-user.sh` wrapper was reused with placeholder +passwords: + +```bash +export JELLYFIN_URL=https://dev.arrflix.s8n.ru +export JELLYFIN_TOKEN=$DEV_TOKEN +for u in 5 guest house marco pet aloy; do + bash bin/add-jellyfin-user.sh "$u-mirror" "dev-test-$u" +done +``` + +The `-mirror` suffix avoids any confusion with prod accounts. Owner can rotate +or rename later. + +### 1.6 Mirror DisplayPreferences + +`bin/add-jellyfin-user.sh` already applies the canonical home layout, BUT to +get full parity for any owner-customised layouts (marco's home in particular) +the prod prefs were copied verbatim: + +```bash +for u in 5 aloy guest house marco pet; do + curl -ks "$PROD/DisplayPreferences/usersettings?userId=&client=emby" \ + -H "Authorization: MediaBrowser Token=\"$PROD_TOKEN\"" \ + | curl -ks -X POST \ + "$DEV/DisplayPreferences/usersettings?userId=&client=emby" \ + -H "Authorization: MediaBrowser Token=\"$DEV_TOKEN\"" \ + -H 'Content-Type: application/json' --data-binary @- +done +``` + +All 6 returned HTTP 204. + +### 1.7 Library scan + parity check + +```bash +curl -ks -X POST "$DEV/Library/Refresh" -H "Authorization: MediaBrowser Token=\"$DEV_TOKEN\"" +``` + +Within 5 seconds: + +| | MovieCount | SeriesCount | EpisodeCount | +|---|---|---|---| +| Prod | 2 | 6 | 168 | +| Dev | 2 | 6 | 173 | + +Dev caught up to prod within tolerance. Episode delta of +5 likely reflects +slightly different scrape ordering / Mando still importing on prod-side; well +within the ±20 tolerance. + +--- + +## Phase 2 — Diagnosis (headless Chrome) + +### 2.1 Setup + +`chromium`/`chromedriver` not installed via dnf — instead used the existing +playwright cache at `~/.cache/ms-playwright/chromium-1217`: + +```bash +python3 -m venv /tmp/arrflix-venv +/tmp/arrflix-venv/bin/pip install -q playwright +# probe.py + verify_fix2.py + verify_native.py — see /tmp/arrflix-headless/ +``` + +Login page selectors discovered: +- username: `#txtManualName` (NOT `input[name="username"]`) +- password: `#txtManualPassword` + +Drawer button: `.mainDrawerButton`. + +### 2.2 Drawer DOM (the smoking gun) + +`/tmp/arrflix-headless/drawer-dom.html` (full): + +```html + +``` + +Key observations: +- **Settings `` `href="#"`** — pure dummy hash, no `mypreferencesmenu` substring anywhere. +- **Stable identifiers:** `class="... btnSettings ..."` and `data-itemid="settings"`. +- **Section header `

User

`** is rendered as a plain element. After hiding + Settings, only Sign Out remains under it; the "User" header itself stays + (not orphaned, since Sign Out keeps the section meaningful). Owner can + decide whether to also drop the header in a later iteration. + +### 2.3 Why every prior CSS rule failed + +Headless evaluation of each candidate selector against the live drawer: + +| Selector | Match count | +|---|---| +| `a[href*="mypreferencesmenu"]` | **0** | +| `li:has(> a[href*="mypreferencesmenu"])` | **0** | +| `.MuiListItem-root:has(a[href*="mypreferencesmenu"])` | **0** | +| `[to="/mypreferencesmenu.html"]` | **0** | +| `a[href*="mypreferences"]` | **0** | +| `a[href$="mypreferencesmenu.html"]` | **0** | +| `a[href="#/mypreferencesmenu.html"]` | **0** | +| `.navMenuOption[href*="mypreferencesmenu"]` | **0** | +| `div:has(> a[href*="mypreferencesmenu"])` | **0** | + +All 9 prior selectors target **zero** DOM nodes. The shim's +`nukeSettings()` MutationObserver was firing 1×/sec but matching nothing. +This explains why CSS-only and JS-only attempts both kept failing. + +### 2.4 The selector that works + +```css +a.btnSettings, +.navMenuOption.btnSettings, +[data-itemid="settings"] { + display: none !important; +} +``` + +Headless before/after: + +| | display | height | +|---|---|---| +| Before injection | `flex` | 47.0px | +| After CSS injected | `none` | 0px | +| Sign Out (control) | `flex` | 47.0px (unchanged) | + +Screenshots: +- `/tmp/arrflix-headless/v02-drawer-before-fix.png` — drawer shows Home / Media / User → Settings + Sign Out +- `/tmp/arrflix-headless/v03-drawer-after-fix.png` — drawer shows Home / Media / User → Sign Out only + +### 2.5 Why `#href` and a JS-routed click + +Jellyfin's web bundle uses an `embyRouter` (the legacy Emby app shell) that +dispatches navigation via JS click handlers. For drawer items wired to +internal routes, the bundle either: +1. Sets `href="#/path.html"` (works for plain hash routing — all our Movies/TV + links use this form). +2. Sets `href="#"` and registers a `click` handler keyed by some attribute. + Settings + Sign Out + the user-icon in the header all use form 2. + +The canonical attribute keys used in form 2 are: +- `data-itemid="settings"` → opens `Preferences/Display` (or + `Dashboard/General` for admins). +- `data-itemid="logout"` → calls the sign-out handler. + +This pattern dates back to the Emby fork and is unlikely to change in 10.x. + +--- + +## Phase 3 — Verification protocol + +### 3.1 Native verification (V2 fix in `index-dev.html`, no client injection) + +`/tmp/arrflix-headless/verify_native.py` — sign in, open drawer, measure. + +``` +Native dev (V2 fix in place): { + "settings": { "display": "none", "visibility": "visible", "height": 0, "inline": "none" }, + "signOut": { "display": "flex", "visibility": "visible", "height": 47.015625, "inline": "" }, + "settingsCount": 1 +} +PASS: Settings hidden by index-dev.html out-of-the-box +Final drawer post-nav: [{'display': 'none', 'height': 0}] +``` + +`settingsCount: 1` confirms the `
` is **still in the DOM** (we don't +delete the node — that risks Jellyfin's drawer-renderer rebuilding it on +the next render). The element is present but `display:none` from both the +CSS rule and the JS shim's inline-style override. Sign Out is preserved. + +After clicking Home from the drawer and reopening the drawer, the Settings +entry is still hidden (`display: 'none', height: 0`) — confirms the +MutationObserver re-applies on every drawer rebuild. + +Screenshots: +- `/tmp/arrflix-headless/native-01-home.png` — post-login home view +- `/tmp/arrflix-headless/native-02-drawer.png` — drawer after V2 fix + (Settings absent) +- `/tmp/arrflix-headless/native-03-home-via-drawer.png` — home reached + via drawer click (still works) +- `/tmp/arrflix-headless/native-04-drawer-post-nav.png` — drawer + reopened after navigation (Settings still hidden) + +### 3.2 Manual verification checklist (for owner) + +- [ ] Sign in to https://dev.arrflix.s8n.ru as `marco-mirror` / `dev-test-marco`. +- [ ] Click the hamburger top-left. +- [ ] Drawer should show: Home / Media (Movies, TV Shows) / User (Sign Out only). +- [ ] No "Settings" gear icon under the User section. +- [ ] Click Movies, TV Shows, Home — all navigate normally. +- [ ] Reopen drawer after each navigation — Settings should remain absent. +- [ ] Optional regression check: sign in as `s8n-dev` (admin) to confirm + admin still sees Settings — currently this fix hides it for **everyone** + (admins included). If owner wants admin to retain access, see open + question Q1 below. + +--- + +## Recommended swap-to-prod procedure + +When owner approves: **merge the `index-dev.html` JS shim block + CSS rule +into `web-overrides/index.html`, then `docker compose restart jellyfin`.** + +Concrete diff (to apply to `/opt/docker/jellyfin/web-overrides/index.html`): + +1. Inside the inline `` near line 16), add: + ```css + /* ARRFLIX V2 (2026-05-08) — hide drawer Settings for non-admins. + Drawer Settings link is .btnSettings / [data-itemid="settings"] href="#". + Old href*="mypreferencesmenu" rules never matched. */ + a.btnSettings, + .navMenuOption.btnSettings, + [data-itemid="settings"] { + display: none !important; + } + ``` +2. Inside the `nukeSettings()` function in `ARRFLIX-SHIM-BEGIN`, replace the + selector list: + ```js + var nodes = document.querySelectorAll( + 'a.btnSettings, [data-itemid="settings"], a[href*="mypreferencesmenu"], [to*="mypreferencesmenu"]' + ); + ``` + +The exact patched `index-dev.html` is at +`/opt/docker/jellyfin-dev/web-overrides/index-dev.html` on nullstone — diff +it against `/opt/docker/jellyfin/web-overrides/index.html` to see the two +isolated changes. The `inject-shim.py` script in `bin/` should also be +updated to match (so re-running it doesn't revert the fix). + +**No prod changes performed in this session.** Awaiting owner sign-off. + +--- + +## Open questions for owner + +**Q1 — Admins too?** Current rule hides Settings for **everyone**, including +admin users. If admin should still reach Settings, options: + (a) keep current rule, admins navigate to `/web/index.html#/dashboard.html` + manually via URL bar (works fine; Settings under-the-hood routes there); + (b) refine rule with a body-class check (`body.lacking-pref-access` — + requires bundle hint that doesn't exist today); + (c) accept the rule and document the workaround. + + Recommendation: **(a) — let admins type the URL.** They can also edit the + drawer DOM via dev tools if needed; no real friction. Non-admins are the + threat surface. + +**Q2 — User header?** The `

User

` section header remains visible +above the lone "Sign Out" entry. Visually fine but slightly orphan-feeling. +Worth hiding too? If yes: + ```css + .userMenuOptions .sidebarHeader { display: none !important; } + ``` + But this also fires for admins. + +**Q3 — Mirror vs prod password parity?** Dev mirror users have placeholder +passwords (`dev-test-`). For better visual fidelity owner may want to +match prod passwords. Not strictly needed for testing the drawer fix. + +**Q4 — Dev admin name.** Created as `MyJellyfinUser` then renamed to +`s8n-dev`. Password is the same `2001dude` as prod admin — owner may want +to rotate. + +--- + +## Files referenced + +- Live patched dev index: `/opt/docker/jellyfin-dev/web-overrides/index-dev.html` (on nullstone) +- Live dev compose: `/opt/docker/jellyfin-dev/docker-compose.yml` (on nullstone, backups in same folder) +- Headless artifacts: `/tmp/arrflix-headless/` (on onyx) + - `drawer-dom.html` — full drawer DOM dump + - `selector-tests.json` — match counts for every prior selector + - `settings-finds.json` — every Settings-text and href-matching node + - `verify_native.py` — final verification script + - `native-{01..04}-*.png` — final fix screenshots + - `v02-drawer-before-fix.png` / `v03-drawer-after-fix.png` — before/after CSS injection +- Prod-state captures: + - `/tmp/prod-branding.json` + - `/tmp/prod-libs.json` + - `/tmp/prod-counts.json` +- Dev creds env: `/tmp/dev-creds.env` (on onyx — `DEV_TOKEN`, `DEV_USER_ID`)