jellyfin: Prefer fMP4-HLS Media Container for all users
All checks were successful
Build and Deploy / deploy (push) Successful in 2m41s

This commit is contained in:
2026-04-16 01:11:59 -04:00
parent 55fda4b5ee
commit 735603deb8
5 changed files with 241 additions and 0 deletions

View File

@@ -2,5 +2,6 @@
imports = [
./jellyfin.nix
./jellyfin-qbittorrent-monitor.nix
./jellyfin-set-defaults.nix
];
}

View File

@@ -0,0 +1,47 @@
{
pkgs,
config,
service_configs,
lib,
...
}:
lib.mkIf config.services.jellyfin.enable {
systemd.services."jellyfin-set-defaults" = {
description = "Enforce default Jellyfin user preferences (fMP4-HLS)";
after = [ "jellyfin.service" ];
requires = [ "jellyfin.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.python3}/bin/python ${./jellyfin-set-defaults.py}";
# Security hardening
DynamicUser = true;
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
MemoryDenyWriteExecute = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
LoadCredential = "jellyfin-api-key:${config.age.secrets.jellyfin-api-key.path}";
};
environment = {
JELLYFIN_URL = "http://127.0.0.1:${toString service_configs.ports.private.jellyfin.port}";
};
};
# Run at boot and daily to catch newly created users
systemd.timers."jellyfin-set-defaults" = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "2min";
OnUnitActiveSec = "1d";
};
};
}

View File

@@ -0,0 +1,97 @@
"""Enforce default Jellyfin user preferences via the API.
Iterates all users and sets preferFmp4HlsContainer in their
DisplayPreferences if not already enabled. Idempotent.
"""
import json
import os
import sys
import time
import urllib.request
from pathlib import Path
JELLYFIN_URL = os.environ.get("JELLYFIN_URL", "http://127.0.0.1:8096")
def get_api_key():
cred_dir = os.environ["CREDENTIALS_DIRECTORY"]
return Path(cred_dir, "jellyfin-api-key").read_text().strip()
def api(method, path, data=None):
url = f"{JELLYFIN_URL}{path}"
sep = "&" if "?" in url else "?"
url += f"{sep}api_key={get_api_key()}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, method=method)
if body:
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as resp:
if resp.status == 204:
return None
return json.loads(resp.read())
def wait_healthy(timeout=120):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
req = urllib.request.Request(f"{JELLYFIN_URL}/health")
with urllib.request.urlopen(req, timeout=5) as resp:
if resp.read().decode().strip() == "Healthy":
return
except Exception:
pass
time.sleep(2)
print("Jellyfin did not become healthy in time", file=sys.stderr)
sys.exit(1)
# Preferences to enforce: (key, desired_value)
DEFAULTS = [
("preferFmp4HlsContainer", "true"),
]
def main():
wait_healthy()
users = api("GET", "/Users")
if not users:
print("No users found, nothing to do")
return
for user in users:
user_id = user["Id"]
name = user["Name"]
prefs = api(
"GET",
f"/DisplayPreferences/usersettings?userId={user_id}&client=emby",
)
custom = prefs.get("CustomPrefs", {})
changed = False
for key, value in DEFAULTS:
if custom.get(key) != value:
custom[key] = value
changed = True
if not changed:
print(f"{name}: already up to date")
continue
prefs["CustomPrefs"] = custom
api(
"POST",
f"/DisplayPreferences/usersettings?userId={user_id}&client=emby",
prefs,
)
print(f"{name}: updated preferences")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,95 @@
{
lib,
pkgs,
...
}:
let
jfLib = import ./jellyfin-test-lib.nix { inherit pkgs lib; };
in
pkgs.testers.runNixOSTest {
name = "jellyfin-set-defaults";
nodes.machine =
{ ... }:
{
imports = [ jfLib.jellyfinTestConfig ];
};
testScript = ''
import json
import importlib.util
_spec = importlib.util.spec_from_file_location("jf_helpers", "${jfLib.helpers}")
assert _spec and _spec.loader
_jf = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_jf)
setup_jellyfin = _jf.setup_jellyfin
jellyfin_api = _jf.jellyfin_api
auth_header = 'MediaBrowser Client="NixOS Test", DeviceId="test-1337", Device="TestDevice", Version="1.0"'
script = "${../services/jellyfin/jellyfin-set-defaults.py}"
start_all()
token, user_id, movie_id, media_source_id = setup_jellyfin(
machine, retry, auth_header,
"${jfLib.payloads.auth}", "${jfLib.payloads.empty}",
)
with subtest("Preference is not set initially"):
raw = jellyfin_api(
machine, "GET",
f"/DisplayPreferences/usersettings?userId={user_id}&client=emby",
auth_header, token=token,
)
prefs = json.loads(raw)
custom = prefs.get("CustomPrefs", {})
assert custom.get("preferFmp4HlsContainer") != "true", \
f"Expected preference to be unset, got: {custom}"
with subtest("Script sets preference for all users"):
CREDS_DIR = "/run/jf-defaults-creds"
machine.succeed(f"mkdir -p {CREDS_DIR}")
machine.succeed(f"echo 'dummy-api-key' > {CREDS_DIR}/jellyfin-api-key")
# The script uses api_key query param which requires a real Jellyfin API key.
# In the test, we use the auth token via a patched approach: call the script
# by providing the token as the API key. Jellyfin accepts api_key= with either
# an API key or an access token.
machine.succeed(f"echo '{token}' > {CREDS_DIR}/jellyfin-api-key")
machine.succeed(
f"CREDENTIALS_DIRECTORY={CREDS_DIR} "
f"JELLYFIN_URL=http://127.0.0.1:8096 "
f"${pkgs.python3}/bin/python {script}"
)
with subtest("Preference is now enabled"):
raw = jellyfin_api(
machine, "GET",
f"/DisplayPreferences/usersettings?userId={user_id}&client=emby",
auth_header, token=token,
)
prefs = json.loads(raw)
custom = prefs.get("CustomPrefs", {})
assert custom.get("preferFmp4HlsContainer") == "true", \
f"Expected preferFmp4HlsContainer=true, got: {custom}"
with subtest("Script is idempotent"):
machine.succeed(
f"CREDENTIALS_DIRECTORY={CREDS_DIR} "
f"JELLYFIN_URL=http://127.0.0.1:8096 "
f"${pkgs.python3}/bin/python {script}"
)
raw = jellyfin_api(
machine, "GET",
f"/DisplayPreferences/usersettings?userId={user_id}&client=emby",
auth_header, token=token,
)
prefs = json.loads(raw)
custom = prefs.get("CustomPrefs", {})
assert custom.get("preferFmp4HlsContainer") == "true", \
f"Expected preference to remain true after re-run, got: {custom}"
'';
}

View File

@@ -24,6 +24,7 @@ in
# jellyfin annotation service test
jellyfinAnnotationsTest = handleTest ./jellyfin-annotations.nix;
jellyfinSetDefaultsTest = handleTest ./jellyfin-set-defaults.nix;
# zfs scrub annotations test
zfsScrubAnnotationsTest = handleTest ./zfs-scrub-annotations.nix;