ARRFLIX/docs/17-dev-mirror-and-settings-fix.md

474 lines
19 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.

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