jellyfin: Prefer fMP4-HLS Media Container for all users
All checks were successful
Build and Deploy / deploy (push) Successful in 2m41s
All checks were successful
Build and Deploy / deploy (push) Successful in 2m41s
This commit is contained in:
@@ -2,5 +2,6 @@
|
||||
imports = [
|
||||
./jellyfin.nix
|
||||
./jellyfin-qbittorrent-monitor.nix
|
||||
./jellyfin-set-defaults.nix
|
||||
];
|
||||
}
|
||||
|
||||
47
services/jellyfin/jellyfin-set-defaults.nix
Normal file
47
services/jellyfin/jellyfin-set-defaults.nix
Normal 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";
|
||||
};
|
||||
};
|
||||
}
|
||||
97
services/jellyfin/jellyfin-set-defaults.py
Normal file
97
services/jellyfin/jellyfin-set-defaults.py
Normal 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()
|
||||
95
tests/jellyfin-set-defaults.nix
Normal file
95
tests/jellyfin-set-defaults.nix
Normal 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}"
|
||||
'';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user