add jellyseerrInit: declarative quality profile defaults
This commit is contained in:
297
module.nix
297
module.nix
@@ -7,6 +7,7 @@
|
|||||||
let
|
let
|
||||||
cfg = config.services.arrInit;
|
cfg = config.services.arrInit;
|
||||||
bazarrCfg = config.services.bazarrInit;
|
bazarrCfg = config.services.bazarrInit;
|
||||||
|
jellyseerrCfg = config.services.jellyseerrInit;
|
||||||
|
|
||||||
downloadClientModule = lib.types.submodule {
|
downloadClientModule = lib.types.submodule {
|
||||||
options = {
|
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
|
# Python environment with pyarr for Servarr API operations and requests for
|
||||||
# Prowlarr, Bazarr, and health-check endpoints that pyarr doesn't cover.
|
# Prowlarr, Bazarr, and health-check endpoints that pyarr doesn't cover.
|
||||||
pythonEnv = pkgs.python3.withPackages (
|
pythonEnv = pkgs.python3.withPackages (
|
||||||
@@ -923,6 +1014,180 @@ let
|
|||||||
]
|
]
|
||||||
++ (lib.optional bazarrCfg.sonarr.enable "${bazarrCfg.sonarr.serviceName}.service")
|
++ (lib.optional bazarrCfg.sonarr.enable "${bazarrCfg.sonarr.serviceName}.service")
|
||||||
++ (lib.optional bazarrCfg.radarr.enable "${bazarrCfg.radarr.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
|
in
|
||||||
{
|
{
|
||||||
options.services.arrInit = lib.mkOption {
|
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 [
|
config = lib.mkMerge [
|
||||||
(lib.mkIf (enabledInstances != { }) {
|
(lib.mkIf (enabledInstances != { }) {
|
||||||
systemd.services = lib.mapAttrs' (
|
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}";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user