diff --git a/scripts/bazarr_init.py b/scripts/bazarr_init.py new file mode 100644 index 0000000..b8d984c --- /dev/null +++ b/scripts/bazarr_init.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Declarative API initialization for Bazarr provider connections. + +Idempotently configures Sonarr and Radarr providers in Bazarr via its +settings API. Detects stale API keys and updates them. +""" + +import os + +import requests as http + +from common import ( + load_config, + read_api_key_xml, + read_api_key_yaml, + wait_for_api, +) + + +def configure_provider(base_url, api_key, provider_type, provider_config): + """Idempotently configure a Sonarr/Radarr provider in Bazarr. + + Compares the stored API key against the current one and updates if stale. + """ + ltype = provider_type.lower() + print(f"Checking {provider_type} provider...") + + resp = http.get( + f"{base_url}/api/system/settings", + headers={"X-API-KEY": api_key}, + timeout=30, + ) + resp.raise_for_status() + settings = resp.json() + + use_flag = settings.get("general", {}).get(f"use_{ltype}", False) + existing_key = settings.get(ltype, {}).get("apikey", "") + + provider_api_key = read_api_key_xml( + f"{provider_config['dataDir']}/config.xml" + ) + bind_address = provider_config.get("bindAddress", "127.0.0.1") + + if use_flag and existing_key == provider_api_key: + print(f"{provider_type} provider already correct, skipping") + return + + action = "Updating" if use_flag else "Adding" + print(f"{action} {provider_type} provider...") + + resp = http.post( + f"{base_url}/api/system/settings", + headers={"X-API-KEY": api_key}, + data={ + f"settings-general-use_{ltype}": "true", + f"settings-{ltype}-ip": bind_address, + f"settings-{ltype}-port": str(provider_config["port"]), + f"settings-{ltype}-apikey": provider_api_key, + f"settings-{ltype}-ssl": "false", + f"settings-{ltype}-base_url": "/", + }, + timeout=30, + ) + resp.raise_for_status() + print(f"{provider_type} provider configured") + + +def main(): + cfg = load_config() + data_dir = cfg["dataDir"] + bind_address = cfg.get("bindAddress", "127.0.0.1") + port = cfg["port"] + api_timeout = cfg["apiTimeout"] + + config_yaml = f"{data_dir}/config/config.yaml" + if not os.path.isfile(config_yaml): + print(f"Config file {config_yaml} not found, skipping bazarr init") + return + + api_key = read_api_key_yaml(config_yaml) + base_url = f"http://{bind_address}:{port}" + + wait_for_api( + base_url, + api_key, + api_timeout, + "Bazarr", + header_name="X-API-KEY", + status_path="/api/system/status", + ) + + providers = cfg.get("providers", {}) + if providers.get("sonarr", {}).get("enable"): + configure_provider(base_url, api_key, "Sonarr", providers["sonarr"]) + if providers.get("radarr", {}).get("enable"): + configure_provider(base_url, api_key, "Radarr", providers["radarr"]) + + print("Bazarr init complete") + + +if __name__ == "__main__": + main() diff --git a/scripts/common.py b/scripts/common.py new file mode 100644 index 0000000..58643ca --- /dev/null +++ b/scripts/common.py @@ -0,0 +1,140 @@ +"""Shared utilities for arr-init scripts.""" + +import json +import sys +import time +import xml.etree.ElementTree as ET + +import requests as http +import yaml + + +def load_config(): + """Load JSON configuration from the path given as the first CLI argument.""" + if len(sys.argv) < 2: + print("Usage: script ", file=sys.stderr) + sys.exit(1) + with open(sys.argv[1]) as f: + return json.load(f) + + +def read_api_key_xml(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 read_api_key_yaml(config_yaml_path): + """Extract the apikey from Bazarr's config.yaml (auth section).""" + with open(config_yaml_path) as fh: + data = yaml.safe_load(fh) + try: + return data["auth"]["apikey"] + except (KeyError, TypeError) as exc: + raise ValueError( + f"Could not find auth.apikey in {config_yaml_path}" + ) from exc + + +def wait_for_api( + base_url, + api_key, + timeout, + name, + *, + header_name="X-Api-Key", + status_path="/system/status", +): + """Poll a status endpoint until the API responds or timeout. + + Args: + base_url: Base URL including any API version prefix. + api_key: API key for authentication. + timeout: Maximum seconds to wait. + name: Human-readable service name for log messages. + header_name: HTTP header name for the API key. + status_path: Path appended to base_url for the health probe. + """ + print(f"Waiting for {name} API (timeout: {timeout}s)...") + for i in range(1, timeout + 1): + try: + resp = http.get( + f"{base_url}{status_path}", + headers={header_name: 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 health_check_loop(url, api_key, entity_name, svc_name, max_retries, interval): + """POST to a testall endpoint with retry logic. + + Exits the process on permanent failure so the systemd unit reflects the error. + """ + attempt = 0 + while True: + healthy = True + last_error = "" + try: + resp = http.post( + url, + headers={ + "X-Api-Key": api_key, + "Content-Type": "application/json", + }, + timeout=30, + ) + result = resp.json() + failures = [ + item for item in result if not item.get("isValid", True) + ] + if failures: + healthy = False + last_error = "\n".join( + f" - ID {f['id']}: " + + ", ".join( + v["errorMessage"] + for v in f.get("validationFailures", []) + ) + for f in failures + ) + except (http.RequestException, ValueError, KeyError) as exc: + healthy = False + last_error = ( + f"could not reach {svc_name} API for {entity_name} test: {exc}" + ) + + if healthy: + print(f"All {entity_name}s healthy") + return + + attempt += 1 + if attempt > max_retries: + print( + f"Health check FAILED after {attempt} attempts: " + f"{entity_name}(s) unreachable:", + file=sys.stderr, + ) + print(last_error, file=sys.stderr) + sys.exit(1) + + print( + f"{entity_name.capitalize()} health check failed " + f"(attempt {attempt}/{max_retries}), " + f"retrying in {interval}s..." + ) + time.sleep(interval) diff --git a/scripts/jellyseerr_init.py b/scripts/jellyseerr_init.py new file mode 100644 index 0000000..6a33234 --- /dev/null +++ b/scripts/jellyseerr_init.py @@ -0,0 +1,139 @@ +#!/usr/bin/env 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 subprocess +import sys + +import requests as http + +from common import load_config, read_api_key_xml, wait_for_api + + +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() + profiles = resp.json() + + for profile in profiles: + 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 profiles] + print( + f"Profile '{profile_name}' not found in {app_name}. " + f"Available: {available}", + file=sys.stderr, + ) + sys.exit(1) + + +def main(): + cfg = load_config() + settings_path = os.path.join(cfg["configDir"], "settings.json") + if not os.path.isfile(settings_path): + print(f"{settings_path} not found, skipping (Jellyseerr not yet initialized)") + return + + timeout = cfg["apiTimeout"] + + # Resolve Radarr profile + radarr_cfg = cfg["radarr"] + radarr_key = read_api_key_xml(f"{radarr_cfg['dataDir']}/config.xml") + radarr_bind = radarr_cfg.get("bindAddress", "127.0.0.1") + radarr_base = f"http://{radarr_bind}:{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 = cfg["sonarr"] + sonarr_key = read_api_key_xml(f"{sonarr_cfg['dataDir']}/config.xml") + sonarr_bind = sonarr_cfg.get("bindAddress", "127.0.0.1") + sonarr_base = f"http://{sonarr_bind}:{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', '?')}': " + f"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', '?')}': " + f"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...") + + result = subprocess.run( + ["systemctl", "restart", "jellyseerr.service"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print( + f"Failed to restart Jellyseerr: {result.stderr.strip()}", + file=sys.stderr, + ) + sys.exit(1) + + print("Jellyseerr init complete") + + +if __name__ == "__main__": + main() diff --git a/scripts/servarr_init.py b/scripts/servarr_init.py new file mode 100644 index 0000000..82ae771 --- /dev/null +++ b/scripts/servarr_init.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +"""Declarative API initialization for Servarr applications. + +Idempotently provisions download clients, root folders, synced applications +(Prowlarr), and naming configuration via the Servarr HTTP API. Existing +entities are updated when their configuration drifts from the declared state. +""" + +import sys + +import requests as http + +from common import ( + health_check_loop, + load_config, + read_api_key_xml, + wait_for_api, +) + +# Maps Prowlarr application implementation names to Newznab parent category +# names used for automatic sync-category detection. +IMPLEMENTATION_CATEGORY_MAP = { + "Sonarr": "TV", + "Radarr": "Movies", + "Lidarr": "Audio", + "Readarr": "Books", + "Whisparr": "XXX", +} + + +def _fields_to_dict(fields): + """Convert the API's [{name, value}] array into a flat dict. + + Some API responses omit the 'value' key for null/unset fields. + """ + return {f["name"]: f.get("value") for f in fields} + + +def _dict_to_fields(d): + """Convert a flat dict into the API's [{name, value}] array.""" + return [{"name": k, "value": v} for k, v in d.items()] + + +def _needs_field_update(desired, current_fields): + """Return True if any desired field value differs from the current state.""" + current = _fields_to_dict(current_fields) + return any(desired.get(k) != current.get(k) for k in desired) + + +# -- Download clients -------------------------------------------------------- + + +def ensure_download_clients(base_url, api_key, download_clients): + """Idempotently provision download clients, updating on drift.""" + resp = http.get( + f"{base_url}/downloadclient", + headers={"X-Api-Key": api_key}, + timeout=30, + ) + resp.raise_for_status() + existing_by_name = {dc["name"]: dc for dc in resp.json()} + + for dc in download_clients: + dc_name = dc["name"] + desired_fields = dc["fields"] + + if dc_name in existing_by_name: + current = existing_by_name[dc_name] + drift = ( + current.get("implementation") != dc["implementation"] + or current.get("configContract") != dc["configContract"] + or current.get("protocol") != dc["protocol"] + or _needs_field_update(desired_fields, current.get("fields", [])) + ) + if not drift: + print(f"Download client '{dc_name}' already correct, skipping") + continue + + print(f"Updating download client '{dc_name}'...") + payload = {**current} + payload.update( + implementation=dc["implementation"], + configContract=dc["configContract"], + protocol=dc["protocol"], + fields=_dict_to_fields(desired_fields), + ) + resp = http.put( + f"{base_url}/downloadclient/{current['id']}", + headers={"X-Api-Key": api_key, "Content-Type": "application/json"}, + params={"forceSave": "true"}, + json=payload, + timeout=30, + ) + resp.raise_for_status() + print(f"Download client '{dc_name}' updated") + else: + print(f"Adding download client '{dc_name}'...") + payload = { + "enable": True, + "protocol": dc["protocol"], + "priority": 1, + "name": dc_name, + "implementation": dc["implementation"], + "configContract": dc["configContract"], + "fields": _dict_to_fields(desired_fields), + "tags": [], + } + resp = http.post( + f"{base_url}/downloadclient", + headers={"X-Api-Key": api_key, "Content-Type": "application/json"}, + params={"forceSave": "true"}, + json=payload, + timeout=30, + ) + resp.raise_for_status() + print(f"Download client '{dc_name}' added") + + +# -- Root folders ------------------------------------------------------------ + + +def ensure_root_folders(base_url, api_key, root_folders): + """Idempotently provision root folders (create-only).""" + resp = http.get( + f"{base_url}/rootfolder", + headers={"X-Api-Key": api_key}, + timeout=30, + ) + resp.raise_for_status() + existing_paths = {rf["path"] for rf in resp.json()} + + for path in root_folders: + if path in existing_paths: + print(f"Root folder '{path}' already exists, skipping") + continue + + print(f"Adding root folder '{path}'...") + resp = http.post( + f"{base_url}/rootfolder", + headers={"X-Api-Key": api_key, "Content-Type": "application/json"}, + json={"path": path}, + timeout=30, + ) + resp.raise_for_status() + print(f"Root folder '{path}' added") + + +# -- Synced applications (Prowlarr) ------------------------------------------ + + +def resolve_sync_categories(base_url, api_key, implementation, explicit): + """Resolve Newznab sync categories, auto-detecting from Prowlarr if needed.""" + if explicit: + return explicit + + category_name = IMPLEMENTATION_CATEGORY_MAP.get(implementation) + if not category_name: + return [] + + print(f"Auto-detecting sync categories for {implementation}...") + resp = http.get( + f"{base_url}/indexer/categories", + headers={"X-Api-Key": api_key}, + timeout=30, + ) + resp.raise_for_status() + + sync_cats = [] + for cat in resp.json(): + if cat["name"] == category_name: + sync_cats.append(cat["id"]) + for sub in cat.get("subCategories", []): + sync_cats.append(sub["id"]) + + if not sync_cats: + print( + f"Warning: could not auto-detect categories for " + f"'{category_name}', using empty list", + file=sys.stderr, + ) + return [] + + print(f"Resolved sync categories: {sync_cats}") + return sync_cats + + +def ensure_synced_apps(base_url, api_key, synced_apps): + """Idempotently provision synced applications (Prowlarr), updating on drift.""" + resp = http.get( + f"{base_url}/applications", + headers={"X-Api-Key": api_key}, + timeout=30, + ) + resp.raise_for_status() + existing_by_name = {app["name"]: app for app in resp.json()} + + for app in synced_apps: + app_name = app["name"] + target_api_key = read_api_key_xml(app["apiKeyFrom"]) + sync_categories = resolve_sync_categories( + base_url, api_key, app["implementation"], app.get("syncCategories", []), + ) + + desired_fields = { + "prowlarrUrl": app["prowlarrUrl"], + "baseUrl": app["baseUrl"], + "apiKey": target_api_key, + "syncCategories": sync_categories, + } + + if app_name in existing_by_name: + current = existing_by_name[app_name] + drift = ( + current.get("implementation") != app["implementation"] + or current.get("configContract") != app["configContract"] + or current.get("syncLevel") != app["syncLevel"] + or _needs_field_update(desired_fields, current.get("fields", [])) + ) + if not drift: + print(f"Synced app '{app_name}' already correct, skipping") + continue + + print(f"Updating synced app '{app_name}'...") + payload = {**current} + payload.update( + implementation=app["implementation"], + configContract=app["configContract"], + syncLevel=app["syncLevel"], + fields=_dict_to_fields(desired_fields), + ) + resp = http.put( + f"{base_url}/applications/{current['id']}", + headers={"X-Api-Key": api_key, "Content-Type": "application/json"}, + params={"forceSave": "true"}, + json=payload, + timeout=30, + ) + resp.raise_for_status() + print(f"Synced app '{app_name}' updated") + else: + print(f"Adding synced app '{app_name}'...") + payload = { + "name": app_name, + "implementation": app["implementation"], + "configContract": app["configContract"], + "syncLevel": app["syncLevel"], + "fields": _dict_to_fields(desired_fields), + "tags": [], + } + resp = http.post( + f"{base_url}/applications", + headers={"X-Api-Key": api_key, "Content-Type": "application/json"}, + params={"forceSave": "true"}, + json=payload, + timeout=30, + ) + resp.raise_for_status() + print(f"Synced app '{app_name}' added") + + +# -- Naming ------------------------------------------------------------------ + + +def update_naming(base_url, api_key, naming_config): + """Merge desired naming fields into the current config.""" + if not naming_config: + return + + print("Checking naming configuration...") + resp = http.get( + f"{base_url}/config/naming", + headers={"X-Api-Key": api_key}, + timeout=30, + ) + resp.raise_for_status() + current = resp.json() + + needs_update = any( + naming_config.get(k) != current.get(k) for k in naming_config + ) + if not needs_update: + print("Naming configuration already correct, skipping") + return + + print("Updating naming configuration...") + merged = {**current, **naming_config} + resp = http.put( + f"{base_url}/config/naming", + headers={"X-Api-Key": api_key, "Content-Type": "application/json"}, + json=merged, + timeout=30, + ) + resp.raise_for_status() + print("Naming configuration updated") + + +# -- Health checks ----------------------------------------------------------- + + +def run_health_checks(base_url, api_key, name, cfg): + """Run connectivity health checks if enabled.""" + if not cfg["healthChecks"]: + return + + print(f"Running {name} health checks...") + max_retries = cfg["healthCheckRetries"] + interval = cfg["healthCheckInterval"] + + if cfg.get("downloadClients"): + print("Testing download client connectivity...") + health_check_loop( + f"{base_url}/downloadclient/testall", + api_key, + "download client", + name, + max_retries, + interval, + ) + + if cfg.get("syncedApps"): + print("Testing synced application connectivity...") + health_check_loop( + f"{base_url}/applications/testall", + api_key, + "synced application", + name, + max_retries, + interval, + ) + + print(f"{name} health checks passed") + + +# -- Main -------------------------------------------------------------------- + + +def main(): + cfg = load_config() + name = cfg["name"] + data_dir = cfg["dataDir"] + bind_address = cfg.get("bindAddress", "127.0.0.1") + port = cfg["port"] + api_version = cfg["apiVersion"] + api_timeout = cfg["apiTimeout"] + + import os + + config_xml = f"{data_dir}/config.xml" + if not os.path.isfile(config_xml): + print(f"Config file {config_xml} not found, skipping {name} init") + return + + api_key = read_api_key_xml(config_xml) + base_url = f"http://{bind_address}:{port}/api/{api_version}" + + wait_for_api(base_url, api_key, api_timeout, name) + + if cfg.get("downloadClients"): + ensure_download_clients(base_url, api_key, cfg["downloadClients"]) + + if cfg.get("rootFolders"): + ensure_root_folders(base_url, api_key, cfg["rootFolders"]) + + if cfg.get("syncedApps"): + ensure_synced_apps(base_url, api_key, cfg["syncedApps"]) + + if cfg.get("naming"): + update_naming(base_url, api_key, cfg["naming"]) + + run_health_checks(base_url, api_key, name, cfg) + + print(f"{name} init complete") + + +if __name__ == "__main__": + main()