The Jellyfin 10.10.3 web client reads DisplayPreferences with the client
name 'Jellyfin Web'. The legacy 'emby' value is read by older SDKs only —
so writing only to 'emby' (the original script) updates the DB but has no
visible effect on the web UI. The empty 'Jellyfin Web' doc falls back to
factory defaults that include Next Up.
Now patches all four per-client docs ('Jellyfin Web', 'emby', 'emby-mobile',
'emby-web') and ensures both Continue Watching (resume) and Latest Media
are present so blank users don't fall back to defaults.
Applied 2026-05-11 across prod (13 users x 4 clients) and dev (1x4).
100 lines
3.3 KiB
Python
Executable file
100 lines
3.3 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""
|
|
Patch Jellyfin home-screen section layout for every user on a given instance.
|
|
|
|
Default policy (this script):
|
|
- Continue Watching (`resume`) ENABLED for every user
|
|
- Resume Audio (`resumeaudio`) preserved if present
|
|
- Next Up (`nextup`) DISABLED (replaced with `none`)
|
|
- Latest Media (`latestmedia`) preserved if present
|
|
- If a user's layout was empty (factory default), seed slot 0 with `resume`
|
|
|
|
Idempotent. Safe to re-run.
|
|
|
|
Usage:
|
|
JF_URL=https://arrflix.s8n.ru \
|
|
JF_TOKEN=<admin token> \
|
|
python3 bin/set-home-layout.py
|
|
|
|
Token retrieval (admin):
|
|
docker cp jellyfin:/config/data/jellyfin.db /tmp/jf.db
|
|
sqlite3 /tmp/jf.db \\
|
|
'SELECT d.AccessToken FROM Devices d JOIN Users u ON d.UserId=u.Id
|
|
WHERE u.Username="s8n" ORDER BY d.DateLastActivity DESC LIMIT 1'
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
JF_URL = os.environ.get("JF_URL")
|
|
JF_TOKEN = os.environ.get("JF_TOKEN")
|
|
|
|
if not JF_URL or not JF_TOKEN:
|
|
sys.exit("set JF_URL and JF_TOKEN env vars")
|
|
|
|
# Jellyfin 10.10.3 web client uses 'Jellyfin Web' as the DisplayPreferences
|
|
# client name. The older 'emby' name is read by legacy SDKs only — writing
|
|
# only to 'emby' has no effect on the web UI. Patch every per-client doc to
|
|
# keep all consumers in sync.
|
|
CLIENTS = ["Jellyfin Web", "emby", "emby-mobile", "emby-web"]
|
|
|
|
|
|
def http(method, url, body=None):
|
|
req = urllib.request.Request(url, method=method)
|
|
req.add_header("X-Emby-Token", JF_TOKEN)
|
|
data = None
|
|
if body is not None:
|
|
req.add_header("Content-Type", "application/json")
|
|
data = json.dumps(body).encode()
|
|
with urllib.request.urlopen(req, data=data) as r:
|
|
text = r.read().decode()
|
|
return r.status, (json.loads(text) if text else None)
|
|
|
|
|
|
def patch_user_client(user_id, client):
|
|
q = urllib.parse.urlencode({"userId": user_id, "client": client})
|
|
url = f"{JF_URL}/DisplayPreferences/usersettings?{q}"
|
|
_, prefs = http("GET", url)
|
|
cp = prefs.get("CustomPrefs") or {}
|
|
|
|
sections = [cp.get(f"homesection{i}", "none") for i in range(10)]
|
|
before = list(sections)
|
|
|
|
sections = ["none" if s == "nextup" else s for s in sections]
|
|
if "resume" not in sections:
|
|
if "none" in sections:
|
|
sections[sections.index("none")] = "resume"
|
|
else:
|
|
sections = ["resume"] + sections[:9]
|
|
if "latestmedia" not in sections:
|
|
for i, s in enumerate(sections):
|
|
if s == "none":
|
|
sections[i] = "latestmedia"
|
|
break
|
|
|
|
for i, s in enumerate(sections):
|
|
cp[f"homesection{i}"] = s
|
|
prefs["CustomPrefs"] = cp
|
|
|
|
changed = before != sections
|
|
if changed:
|
|
http("POST", url, body=prefs)
|
|
return changed, before, sections
|
|
|
|
|
|
def main():
|
|
users = http("GET", f"{JF_URL}/Users")[1]
|
|
print(f"{JF_URL} — {len(users)} users")
|
|
for u in users:
|
|
for client in CLIENTS:
|
|
changed, before, after = patch_user_client(u["Id"], client)
|
|
if changed:
|
|
print(f" [CHANGED] {u['Name']:<14} client={client:<14} {before} -> {after}")
|
|
else:
|
|
print(f" [ ok ] {u['Name']:<14} client={client:<14} {after}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|