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 = [
|
imports = [
|
||||||
./jellyfin.nix
|
./jellyfin.nix
|
||||||
./jellyfin-qbittorrent-monitor.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
|
# jellyfin annotation service test
|
||||||
jellyfinAnnotationsTest = handleTest ./jellyfin-annotations.nix;
|
jellyfinAnnotationsTest = handleTest ./jellyfin-annotations.nix;
|
||||||
|
jellyfinSetDefaultsTest = handleTest ./jellyfin-set-defaults.nix;
|
||||||
|
|
||||||
# zfs scrub annotations test
|
# zfs scrub annotations test
|
||||||
zfsScrubAnnotationsTest = handleTest ./zfs-scrub-annotations.nix;
|
zfsScrubAnnotationsTest = handleTest ./zfs-scrub-annotations.nix;
|
||||||
|
|||||||
Reference in New Issue
Block a user