From e7dda1e08e600f716ee17cf419f09804338de5fa Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Mon, 13 Apr 2026 03:38:00 -0400 Subject: [PATCH] add jellyseerrInit: declarative quality profile defaults --- module.nix | 297 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) diff --git a/module.nix b/module.nix index 05d4c4c..dcc8b88 100644 --- a/module.nix +++ b/module.nix @@ -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 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}"; + }; + }; + }) ]; }