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

19 KiB
Raw Blame History

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

<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:

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:

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

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:

# /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:

# /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

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:

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:

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

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:

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):

<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

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.

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:
    /* 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:
    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:

.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)