diff --git a/services/jellyfin/default.nix b/services/jellyfin/default.nix index a396984..d9b2f91 100644 --- a/services/jellyfin/default.nix +++ b/services/jellyfin/default.nix @@ -2,5 +2,6 @@ imports = [ ./jellyfin.nix ./jellyfin-qbittorrent-monitor.nix + ./jellyfin-set-defaults.nix ]; } diff --git a/services/jellyfin/jellyfin-set-defaults.nix b/services/jellyfin/jellyfin-set-defaults.nix new file mode 100644 index 0000000..5666b45 --- /dev/null +++ b/services/jellyfin/jellyfin-set-defaults.nix @@ -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"; + }; + }; +} diff --git a/services/jellyfin/jellyfin-set-defaults.py b/services/jellyfin/jellyfin-set-defaults.py new file mode 100644 index 0000000..81eb0c1 --- /dev/null +++ b/services/jellyfin/jellyfin-set-defaults.py @@ -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() diff --git a/tests/jellyfin-set-defaults.nix b/tests/jellyfin-set-defaults.nix new file mode 100644 index 0000000..08e9296 --- /dev/null +++ b/tests/jellyfin-set-defaults.nix @@ -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}" + ''; +} diff --git a/tests/tests.nix b/tests/tests.nix index 8493569..198d613 100644 --- a/tests/tests.nix +++ b/tests/tests.nix @@ -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;