add jellyseerrInit: declarative quality profile defaults

This commit is contained in:
2026-04-13 03:38:00 -04:00
parent f8475f6cb4
commit e7dda1e08e

View File

@@ -7,6 +7,7 @@
let
cfg = config.services.arrInit;
bazarrCfg = config.services.bazarrInit;
jellyseerrCfg = config.services.jellyseerrInit;
downloadClientModule = lib.types.submodule {
options = {
@@ -327,6 +328,96 @@ let
};
};
jellyseerrProviderModule = lib.types.submodule {
options = {
profileName = lib.mkOption {
type = lib.types.str;
description = "Quality profile name to set as the default. Resolved to an ID at runtime by querying the Servarr API.";
example = "Remux + WEB 2160p";
};
dataDir = lib.mkOption {
type = lib.types.str;
description = "Path to the Servarr application data directory containing config.xml.";
example = "/services/radarr";
};
port = lib.mkOption {
type = lib.types.port;
description = "API port of the Servarr application.";
example = 7878;
};
serviceName = lib.mkOption {
type = lib.types.str;
description = "Name of the systemd service to depend on.";
example = "radarr";
};
};
};
jellyseerrInitModule = lib.types.submodule {
options = {
enable = lib.mkEnableOption "Jellyseerr quality profile initialization";
configDir = lib.mkOption {
type = lib.types.str;
description = "Path to Jellyseerr's data directory containing settings.json.";
example = "/services/jellyseerr";
};
apiTimeout = lib.mkOption {
type = lib.types.ints.positive;
default = 90;
description = ''
Seconds to wait for Radarr/Sonarr APIs to become available.
'';
};
radarr = lib.mkOption {
type = jellyseerrProviderModule;
description = "Radarr quality profile configuration for Jellyseerr.";
};
sonarr = lib.mkOption {
type = lib.types.submodule {
options = {
profileName = lib.mkOption {
type = lib.types.str;
description = "Quality profile name for TV series.";
example = "WEB-2160p";
};
animeProfileName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Quality profile name for anime. Defaults to profileName when null.";
};
dataDir = lib.mkOption {
type = lib.types.str;
description = "Path to the Sonarr data directory containing config.xml.";
example = "/services/sonarr";
};
port = lib.mkOption {
type = lib.types.port;
description = "API port of Sonarr.";
example = 8989;
};
serviceName = lib.mkOption {
type = lib.types.str;
description = "Name of the systemd service to depend on.";
example = "sonarr";
};
};
};
description = "Sonarr quality profile configuration for Jellyseerr.";
};
};
};
# Python environment with pyarr for Servarr API operations and requests for
# Prowlarr, Bazarr, and health-check endpoints that pyarr doesn't cover.
pythonEnv = pkgs.python3.withPackages (
@@ -923,6 +1014,180 @@ let
]
++ (lib.optional bazarrCfg.sonarr.enable "${bazarrCfg.sonarr.serviceName}.service")
++ (lib.optional bazarrCfg.radarr.enable "${bazarrCfg.radarr.serviceName}.service");
mkJellyseerrInitConfig = builtins.toJSON {
configDir = jellyseerrCfg.configDir;
apiTimeout = jellyseerrCfg.apiTimeout;
radarr = {
profileName = jellyseerrCfg.radarr.profileName;
dataDir = jellyseerrCfg.radarr.dataDir;
port = jellyseerrCfg.radarr.port;
};
sonarr = {
profileName = jellyseerrCfg.sonarr.profileName;
animeProfileName =
if jellyseerrCfg.sonarr.animeProfileName != null then
jellyseerrCfg.sonarr.animeProfileName
else
jellyseerrCfg.sonarr.profileName;
dataDir = jellyseerrCfg.sonarr.dataDir;
port = jellyseerrCfg.sonarr.port;
};
};
mkJellyseerrInitScript = pkgs.writeScript "jellyseerr-init" ''
#!${pythonEnv}/bin/python3
"""Declarative quality profile initialization for Jellyseerr.
Resolves profile names to IDs by querying Radarr/Sonarr APIs,
then patches Jellyseerr's settings.json so new requests default
to the correct quality profiles.
"""
import json
import os
import sys
import time
import xml.etree.ElementTree as ET
import requests as http
CONFIG = json.loads(${builtins.toJSON mkJellyseerrInitConfig})
def read_api_key(config_xml_path):
"""Extract <ApiKey> from a Servarr config.xml file."""
tree = ET.parse(config_xml_path)
node = tree.find("ApiKey")
if node is None or not node.text:
raise ValueError(f"Could not find ApiKey in {config_xml_path}")
return node.text
def wait_for_api(base_url, api_key, timeout, name):
"""Poll the system/status endpoint until the API responds or timeout."""
print(f"Waiting for {name} API (timeout: {timeout}s)...")
for i in range(1, timeout + 1):
try:
resp = http.get(
f"{base_url}/system/status",
headers={"X-Api-Key": api_key},
timeout=5,
)
if resp.ok:
print(f"{name} API is ready")
return
except (http.ConnectionError, http.Timeout):
pass
if i == timeout:
print(
f"{name} API not available after {timeout} seconds",
file=sys.stderr,
)
sys.exit(1)
time.sleep(1)
def resolve_profile_id(base_url, api_key, profile_name, app_name):
"""Query a Servarr app for quality profiles and resolve a name to an ID."""
resp = http.get(
f"{base_url}/qualityprofile",
headers={"X-Api-Key": api_key},
timeout=30,
)
resp.raise_for_status()
for profile in resp.json():
if profile["name"] == profile_name:
print(f"Resolved {app_name} profile '{profile_name}' -> ID {profile['id']}")
return profile["id"]
available = [p["name"] for p in resp.json()]
print(
f"Profile '{profile_name}' not found in {app_name}. "
f"Available: {available}",
file=sys.stderr,
)
sys.exit(1)
def main():
settings_path = os.path.join(CONFIG["configDir"], "settings.json")
if not os.path.isfile(settings_path):
print(f"{settings_path} not found, skipping (Jellyseerr not yet initialized)")
return
timeout = CONFIG["apiTimeout"]
# Resolve Radarr profile
radarr_cfg = CONFIG["radarr"]
radarr_key = read_api_key(f"{radarr_cfg['dataDir']}/config.xml")
radarr_base = f"http://127.0.0.1:{radarr_cfg['port']}/api/v3"
wait_for_api(radarr_base, radarr_key, timeout, "Radarr")
radarr_profile_id = resolve_profile_id(
radarr_base, radarr_key, radarr_cfg["profileName"], "Radarr"
)
# Resolve Sonarr profiles
sonarr_cfg = CONFIG["sonarr"]
sonarr_key = read_api_key(f"{sonarr_cfg['dataDir']}/config.xml")
sonarr_base = f"http://127.0.0.1:{sonarr_cfg['port']}/api/v3"
wait_for_api(sonarr_base, sonarr_key, timeout, "Sonarr")
sonarr_profile_id = resolve_profile_id(
sonarr_base, sonarr_key, sonarr_cfg["profileName"], "Sonarr"
)
sonarr_anime_profile_id = resolve_profile_id(
sonarr_base, sonarr_key, sonarr_cfg["animeProfileName"], "Sonarr (anime)"
)
# Patch settings.json
with open(settings_path) as f:
settings = json.load(f)
changed = False
for entry in settings.get("radarr", []):
if (entry.get("activeProfileId") != radarr_profile_id
or entry.get("activeProfileName") != radarr_cfg["profileName"]):
entry["activeProfileId"] = radarr_profile_id
entry["activeProfileName"] = radarr_cfg["profileName"]
changed = True
print(f"Radarr '{entry.get('name', '?')}': set profile to {radarr_cfg['profileName']} (ID {radarr_profile_id})")
for entry in settings.get("sonarr", []):
updates = {}
if (entry.get("activeProfileId") != sonarr_profile_id
or entry.get("activeProfileName") != sonarr_cfg["profileName"]):
updates["activeProfileId"] = sonarr_profile_id
updates["activeProfileName"] = sonarr_cfg["profileName"]
if (entry.get("activeAnimeProfileId") != sonarr_anime_profile_id
or entry.get("activeAnimeProfileName") != sonarr_cfg["animeProfileName"]):
updates["activeAnimeProfileId"] = sonarr_anime_profile_id
updates["activeAnimeProfileName"] = sonarr_cfg["animeProfileName"]
if updates:
entry.update(updates)
changed = True
print(f"Sonarr '{entry.get('name', '?')}': set profile to {sonarr_cfg['profileName']} (ID {sonarr_profile_id})")
if not changed:
print("Jellyseerr profiles already correct, no changes needed")
return
with open(settings_path, "w") as f:
json.dump(settings, f, indent=2)
print("Updated settings.json, restarting Jellyseerr...")
os.system("systemctl restart jellyseerr.service")
print("Jellyseerr init complete")
if __name__ == "__main__":
main()
'';
jellyseerrDeps = [
"jellyseerr.service"
"${jellyseerrCfg.radarr.serviceName}.service"
"${jellyseerrCfg.sonarr.serviceName}.service"
];
in
{
options.services.arrInit = lib.mkOption {
@@ -946,6 +1211,18 @@ in
'';
};
options.services.jellyseerrInit = lib.mkOption {
type = jellyseerrInitModule;
default = {
enable = false;
};
description = ''
Jellyseerr quality profile initialization.
Patches Jellyseerr's settings.json so new requests default to the
correct Radarr/Sonarr quality profiles, resolved by name at runtime.
'';
};
config = lib.mkMerge [
(lib.mkIf (enabledInstances != { }) {
systemd.services = lib.mapAttrs' (
@@ -999,5 +1276,25 @@ in
};
};
})
(lib.mkIf jellyseerrCfg.enable {
systemd.services.jellyseerr-init = {
description = "Initialize Jellyseerr quality profile defaults";
after = jellyseerrDeps;
requires = jellyseerrDeps;
wantedBy = [ "multi-user.target" ];
unitConfig = {
StartLimitIntervalSec = 5 * (jellyseerrCfg.apiTimeout + 30);
StartLimitBurst = 5;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = 30;
ExecStart = "${mkJellyseerrInitScript}";
};
};
})
];
}