# 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`)
