1004 lines
32 KiB
Nix
1004 lines
32 KiB
Nix
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
let
|
|
cfg = config.services.arrInit;
|
|
bazarrCfg = config.services.bazarrInit;
|
|
|
|
downloadClientModule = lib.types.submodule {
|
|
options = {
|
|
name = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Display name of the download client (e.g. \"qBittorrent\").";
|
|
example = "qBittorrent";
|
|
};
|
|
|
|
implementation = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Implementation identifier for the Servarr API.";
|
|
example = "QBittorrent";
|
|
};
|
|
|
|
configContract = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Config contract identifier for the Servarr API.";
|
|
example = "QBittorrentSettings";
|
|
};
|
|
|
|
protocol = lib.mkOption {
|
|
type = lib.types.enum [
|
|
"torrent"
|
|
"usenet"
|
|
];
|
|
default = "torrent";
|
|
description = "Download protocol type.";
|
|
};
|
|
|
|
fields = lib.mkOption {
|
|
type = lib.types.attrsOf lib.types.anything;
|
|
default = { };
|
|
description = ''
|
|
Flat key/value pairs for the download client configuration.
|
|
These are converted to the API's [{name, value}] array format.
|
|
'';
|
|
example = {
|
|
host = "192.168.15.1";
|
|
port = 6011;
|
|
useSsl = false;
|
|
tvCategory = "tvshows";
|
|
};
|
|
};
|
|
|
|
serviceName = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
description = ''
|
|
Name of the systemd service for this download client.
|
|
When set, the init service will depend on (After + Requires) this service,
|
|
ensuring the download client is running before health checks execute.
|
|
'';
|
|
example = "qbittorrent";
|
|
};
|
|
};
|
|
};
|
|
|
|
syncedAppModule = lib.types.submodule {
|
|
options = {
|
|
name = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Display name of the application to sync (e.g. \"Sonarr\").";
|
|
example = "Sonarr";
|
|
};
|
|
|
|
implementation = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Implementation identifier for the Prowlarr application API.";
|
|
example = "Sonarr";
|
|
};
|
|
|
|
configContract = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Config contract identifier for the Prowlarr application API.";
|
|
example = "SonarrSettings";
|
|
};
|
|
|
|
syncLevel = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "fullSync";
|
|
description = "Sync level for the application.";
|
|
};
|
|
|
|
prowlarrUrl = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "URL of the Prowlarr instance.";
|
|
example = "http://localhost:9696";
|
|
};
|
|
|
|
baseUrl = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "URL of the target application.";
|
|
example = "http://localhost:8989";
|
|
};
|
|
|
|
apiKeyFrom = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Path to the config.xml file to read the API key from at runtime.";
|
|
example = "/services/sonarr/config.xml";
|
|
};
|
|
|
|
syncCategories = lib.mkOption {
|
|
type = lib.types.listOf lib.types.int;
|
|
default = [ ];
|
|
description = ''
|
|
List of Newznab category IDs to sync for this application.
|
|
When empty (default), categories are auto-detected at runtime
|
|
by querying Prowlarr's indexer/categories API endpoint and
|
|
collecting all IDs under the parent category matching the
|
|
implementation type (e.g. Sonarr -> TV, Radarr -> Movies).
|
|
'';
|
|
example = [
|
|
5000
|
|
5010
|
|
5020
|
|
];
|
|
};
|
|
|
|
serviceName = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Name of the systemd service to depend on for reading the API key.";
|
|
example = "sonarr";
|
|
};
|
|
};
|
|
};
|
|
|
|
instanceModule = lib.types.submodule {
|
|
options = {
|
|
enable = lib.mkEnableOption "Servarr application API initialization";
|
|
|
|
serviceName = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Name of the systemd service this init depends on.";
|
|
example = "sonarr";
|
|
};
|
|
|
|
dataDir = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Path to the application data directory containing config.xml.";
|
|
example = "/var/lib/sonarr";
|
|
};
|
|
|
|
port = lib.mkOption {
|
|
type = lib.types.port;
|
|
description = "API port of the Servarr application.";
|
|
example = 8989;
|
|
};
|
|
|
|
apiVersion = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "v3";
|
|
description = "API version string used in the base URL.";
|
|
};
|
|
|
|
networkNamespacePath = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
description = "If set, run this init service inside the given network namespace path (e.g. /run/netns/wg).";
|
|
};
|
|
|
|
downloadClients = lib.mkOption {
|
|
type = lib.types.listOf downloadClientModule;
|
|
default = [ ];
|
|
description = "List of download clients to configure via the API.";
|
|
};
|
|
|
|
rootFolders = lib.mkOption {
|
|
type = lib.types.listOf lib.types.str;
|
|
default = [ ];
|
|
description = "List of root folder paths to configure via the API.";
|
|
example = [
|
|
"/media/tv"
|
|
"/media/movies"
|
|
];
|
|
};
|
|
|
|
syncedApps = lib.mkOption {
|
|
type = lib.types.listOf syncedAppModule;
|
|
default = [ ];
|
|
description = "Applications to register for indexer sync (Prowlarr only).";
|
|
};
|
|
|
|
healthChecks = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
description = ''
|
|
When enabled, the init service will verify connectivity after provisioning:
|
|
- Tests all download clients are reachable via the application's testall API
|
|
- For Prowlarr instances: tests all synced applications are reachable
|
|
The init service will fail if any health check fails after all retries.
|
|
'';
|
|
};
|
|
|
|
healthCheckRetries = lib.mkOption {
|
|
type = lib.types.ints.unsigned;
|
|
default = 5;
|
|
description = ''
|
|
Number of times to retry health checks before failing.
|
|
Each retry waits healthCheckInterval seconds. This prevents transient
|
|
failures (e.g. download clients still starting) from triggering alerts.
|
|
'';
|
|
};
|
|
|
|
healthCheckInterval = lib.mkOption {
|
|
type = lib.types.ints.positive;
|
|
default = 10;
|
|
description = ''
|
|
Seconds to wait between health check retries.
|
|
'';
|
|
};
|
|
|
|
apiTimeout = lib.mkOption {
|
|
type = lib.types.ints.positive;
|
|
default = 90;
|
|
description = ''
|
|
Seconds to wait for the application API to become available before
|
|
considering the init attempt failed. When the API is not reachable
|
|
within this window, the service exits non-zero and systemd's
|
|
Restart=on-failure will schedule another attempt after RestartSec.
|
|
|
|
The systemd start limit is computed from this value to allow 5 full
|
|
retry cycles before the unit enters permanent failure (which would
|
|
trigger any configured OnFailure= target).
|
|
'';
|
|
};
|
|
|
|
naming = lib.mkOption {
|
|
type = lib.types.attrsOf lib.types.anything;
|
|
default = { };
|
|
description = ''
|
|
Naming configuration to set via the API's config/naming endpoint.
|
|
Keys/values map directly to the API fields (e.g. renameEpisodes,
|
|
standardEpisodeFormat for Sonarr; renameMovies, standardMovieFormat
|
|
for Radarr). Only specified fields are updated; unspecified fields
|
|
retain their current values.
|
|
'';
|
|
example = {
|
|
renameEpisodes = true;
|
|
standardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}";
|
|
seasonFolderFormat = "Season {season}";
|
|
seriesFolderFormat = "{Series Title}";
|
|
};
|
|
};
|
|
};
|
|
};
|
|
|
|
bazarrProviderModule = lib.types.submodule {
|
|
options = {
|
|
enable = lib.mkEnableOption "provider connection";
|
|
|
|
dataDir = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Path to the provider's data directory containing config.xml.";
|
|
example = "/services/sonarr";
|
|
};
|
|
|
|
port = lib.mkOption {
|
|
type = lib.types.port;
|
|
description = "API port of the provider.";
|
|
example = 8989;
|
|
};
|
|
|
|
serviceName = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Name of the systemd service to depend on.";
|
|
example = "sonarr";
|
|
};
|
|
};
|
|
};
|
|
|
|
bazarrInitModule = lib.types.submodule {
|
|
options = {
|
|
enable = lib.mkEnableOption "Bazarr API initialization";
|
|
|
|
dataDir = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Path to Bazarr's data directory containing config/config.ini.";
|
|
example = "/services/bazarr";
|
|
};
|
|
|
|
port = lib.mkOption {
|
|
type = lib.types.port;
|
|
default = 6767;
|
|
description = "API port of Bazarr.";
|
|
};
|
|
|
|
apiTimeout = lib.mkOption {
|
|
type = lib.types.ints.positive;
|
|
default = 90;
|
|
description = ''
|
|
Seconds to wait for the Bazarr API to become available before
|
|
considering the init attempt failed. When the API is not reachable
|
|
within this window, the service exits non-zero and systemd's
|
|
Restart=on-failure will schedule another attempt after RestartSec.
|
|
|
|
The systemd start limit is computed from this value to allow 5 full
|
|
retry cycles before the unit enters permanent failure (which would
|
|
trigger any configured OnFailure= target).
|
|
'';
|
|
};
|
|
|
|
sonarr = lib.mkOption {
|
|
type = bazarrProviderModule;
|
|
default = {
|
|
enable = false;
|
|
};
|
|
description = "Sonarr provider configuration.";
|
|
};
|
|
|
|
radarr = lib.mkOption {
|
|
type = bazarrProviderModule;
|
|
default = {
|
|
enable = false;
|
|
};
|
|
description = "Radarr provider configuration.";
|
|
};
|
|
};
|
|
};
|
|
|
|
# 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 (
|
|
ps: with ps; [
|
|
pyarr
|
|
pyyaml
|
|
requests
|
|
]
|
|
);
|
|
|
|
# Build a JSON configuration blob for a Servarr instance, baked into the
|
|
# Python init script at Nix evaluation time.
|
|
mkInitConfig =
|
|
name: inst:
|
|
builtins.toJSON {
|
|
inherit name;
|
|
inherit (inst)
|
|
dataDir
|
|
port
|
|
apiVersion
|
|
apiTimeout
|
|
healthChecks
|
|
healthCheckRetries
|
|
healthCheckInterval
|
|
rootFolders
|
|
naming
|
|
;
|
|
downloadClients = map (dc: {
|
|
inherit (dc)
|
|
name
|
|
implementation
|
|
configContract
|
|
protocol
|
|
fields
|
|
;
|
|
}) inst.downloadClients;
|
|
syncedApps = map (app: {
|
|
inherit (app)
|
|
name
|
|
implementation
|
|
configContract
|
|
syncLevel
|
|
prowlarrUrl
|
|
baseUrl
|
|
apiKeyFrom
|
|
syncCategories
|
|
;
|
|
}) inst.syncedApps;
|
|
};
|
|
|
|
mkInitScript =
|
|
name: inst:
|
|
pkgs.writeScript "${name}-init" ''
|
|
#!${pythonEnv}/bin/python3
|
|
"""Declarative API initialization for ${name}.
|
|
|
|
Uses pyarr (SonarrAPI) for standard Servarr CRUD operations and the
|
|
requests library for Prowlarr-specific endpoints, health checks, and
|
|
download-client creation (which needs the forceSave query parameter
|
|
that pyarr does not expose).
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import requests as http
|
|
from pyarr import SonarrAPI
|
|
|
|
CONFIG = json.loads(${builtins.toJSON (mkInitConfig name inst)})
|
|
|
|
IMPLEMENTATION_CATEGORY_MAP = {
|
|
"Sonarr": "TV",
|
|
"Radarr": "Movies",
|
|
"Lidarr": "Audio",
|
|
"Readarr": "Books",
|
|
"Whisparr": "XXX",
|
|
}
|
|
|
|
|
|
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 ensure_download_clients(client, base_url, api_key, download_clients):
|
|
"""Idempotently provision download clients."""
|
|
existing = client.get_download_client()
|
|
existing_names = {dc["name"] for dc in existing}
|
|
|
|
for dc in download_clients:
|
|
dc_name = dc["name"]
|
|
print(f"Checking download client '{dc_name}'...")
|
|
if dc_name in existing_names:
|
|
print(f"Download client '{dc_name}' already exists, skipping")
|
|
continue
|
|
|
|
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": [
|
|
{"name": k, "value": v} for k, v in dc["fields"].items()
|
|
],
|
|
"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")
|
|
|
|
|
|
def ensure_root_folders(client, root_folders):
|
|
"""Idempotently provision root folders via pyarr."""
|
|
existing = client.get_root_folder()
|
|
existing_paths = {rf["path"] for rf in existing}
|
|
|
|
for path in root_folders:
|
|
print(f"Checking root folder '{path}'...")
|
|
if path in existing_paths:
|
|
print(f"Root folder '{path}' already exists, skipping")
|
|
continue
|
|
|
|
print(f"Adding root folder '{path}'...")
|
|
client.add_root_folder(path)
|
|
print(f"Root folder '{path}' added")
|
|
|
|
|
|
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).
|
|
|
|
Uses requests directly because pyarr 5.x has no Prowlarr support.
|
|
"""
|
|
resp = http.get(
|
|
f"{base_url}/applications",
|
|
headers={"X-Api-Key": api_key},
|
|
timeout=30,
|
|
)
|
|
resp.raise_for_status()
|
|
existing_names = {app["name"] for app in resp.json()}
|
|
|
|
for app in synced_apps:
|
|
app_name = app["name"]
|
|
print(f"Checking synced app '{app_name}'...")
|
|
if app_name in existing_names:
|
|
print(f"Synced app '{app_name}' already exists, skipping")
|
|
continue
|
|
|
|
print(f"Adding synced app '{app_name}'...")
|
|
target_api_key = read_api_key(app["apiKeyFrom"])
|
|
sync_categories = resolve_sync_categories(
|
|
base_url,
|
|
api_key,
|
|
app["implementation"],
|
|
app.get("syncCategories", []),
|
|
)
|
|
|
|
payload = {
|
|
"name": app_name,
|
|
"implementation": app["implementation"],
|
|
"configContract": app["configContract"],
|
|
"syncLevel": app["syncLevel"],
|
|
"fields": [
|
|
{"name": "prowlarrUrl", "value": app["prowlarrUrl"]},
|
|
{"name": "baseUrl", "value": app["baseUrl"]},
|
|
{"name": "apiKey", "value": target_api_key},
|
|
{"name": "syncCategories", "value": sync_categories},
|
|
],
|
|
"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")
|
|
|
|
|
|
def update_naming(client, naming_config):
|
|
"""Merge desired naming fields into the current config via pyarr."""
|
|
if not naming_config:
|
|
return
|
|
|
|
print("Checking naming configuration...")
|
|
current = client.get_config_naming()
|
|
|
|
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}
|
|
client.upd_config_naming(merged)
|
|
print("Naming configuration updated")
|
|
|
|
|
|
def health_check_loop(url, api_key, entity_name, svc_name, max_retries, interval):
|
|
"""POST to a testall endpoint with retry logic."""
|
|
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 Exception:
|
|
healthy = False
|
|
last_error = (
|
|
f"could not reach {svc_name} API for {entity_name} test"
|
|
)
|
|
|
|
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)
|
|
|
|
|
|
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")
|
|
|
|
|
|
def main():
|
|
name = CONFIG["name"]
|
|
data_dir = CONFIG["dataDir"]
|
|
port = CONFIG["port"]
|
|
api_version = CONFIG["apiVersion"]
|
|
api_timeout = CONFIG["apiTimeout"]
|
|
|
|
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(config_xml)
|
|
base_url = f"http://127.0.0.1:{port}/api/{api_version}"
|
|
|
|
wait_for_api(base_url, api_key, api_timeout, name)
|
|
|
|
# pyarr client for standard Servarr operations (download clients,
|
|
# root folders, naming). SonarrAPI is used generically here because
|
|
# the relevant endpoints are identical across all Servarr applications
|
|
# and it accepts a custom ver_uri for API version selection.
|
|
client = SonarrAPI(
|
|
f"http://127.0.0.1:{port}", api_key, ver_uri=f"/{api_version}"
|
|
)
|
|
|
|
if CONFIG.get("downloadClients"):
|
|
ensure_download_clients(
|
|
client, base_url, api_key, CONFIG["downloadClients"]
|
|
)
|
|
|
|
if CONFIG.get("rootFolders"):
|
|
ensure_root_folders(client, CONFIG["rootFolders"])
|
|
|
|
if CONFIG.get("syncedApps"):
|
|
ensure_synced_apps(base_url, api_key, CONFIG["syncedApps"])
|
|
|
|
if CONFIG.get("naming"):
|
|
update_naming(client, CONFIG["naming"])
|
|
|
|
run_health_checks(base_url, api_key, name, CONFIG)
|
|
|
|
print(f"{name} init complete")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
'';
|
|
|
|
# Get list of service names that syncedApps depend on
|
|
getSyncedAppDeps = inst: map (app: "${app.serviceName}.service") inst.syncedApps;
|
|
|
|
# Get list of service names that download clients depend on
|
|
getDownloadClientDeps =
|
|
inst:
|
|
lib.concatMap (
|
|
dc: lib.optional (dc.serviceName != null) "${dc.serviceName}.service"
|
|
) inst.downloadClients;
|
|
|
|
enabledInstances = lib.filterAttrs (_: inst: inst.enable) cfg;
|
|
|
|
mkBazarrInitConfig = builtins.toJSON {
|
|
dataDir = bazarrCfg.dataDir;
|
|
port = bazarrCfg.port;
|
|
apiTimeout = bazarrCfg.apiTimeout;
|
|
providers =
|
|
{ }
|
|
// lib.optionalAttrs bazarrCfg.sonarr.enable {
|
|
sonarr = {
|
|
enable = true;
|
|
dataDir = bazarrCfg.sonarr.dataDir;
|
|
port = bazarrCfg.sonarr.port;
|
|
};
|
|
}
|
|
// lib.optionalAttrs bazarrCfg.radarr.enable {
|
|
radarr = {
|
|
enable = true;
|
|
dataDir = bazarrCfg.radarr.dataDir;
|
|
port = bazarrCfg.radarr.port;
|
|
};
|
|
};
|
|
};
|
|
|
|
mkBazarrInitScript = pkgs.writeScript "bazarr-init" ''
|
|
#!${pythonEnv}/bin/python3
|
|
"""Declarative API initialization for Bazarr provider connections.
|
|
|
|
Uses the requests library directly since Bazarr has its own API that
|
|
is not compatible with the Servarr/pyarr ecosystem.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import requests as http
|
|
import yaml
|
|
|
|
CONFIG = json.loads(${builtins.toJSON mkBazarrInitConfig})
|
|
|
|
|
|
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 read_api_key_xml(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):
|
|
"""Poll Bazarr's system/status endpoint until available or timeout."""
|
|
print(f"Waiting for Bazarr API (timeout: {timeout}s)...")
|
|
for i in range(1, timeout + 1):
|
|
try:
|
|
resp = http.get(
|
|
f"{base_url}/api/system/status",
|
|
headers={"X-API-KEY": api_key},
|
|
timeout=5,
|
|
)
|
|
if resp.ok:
|
|
print("Bazarr API is ready")
|
|
return
|
|
except (http.ConnectionError, http.Timeout):
|
|
pass
|
|
if i == timeout:
|
|
print(
|
|
f"Bazarr API not available after {timeout} seconds",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
time.sleep(1)
|
|
|
|
|
|
def configure_provider(base_url, api_key, provider_type, provider_config):
|
|
"""Idempotently configure a Sonarr/Radarr provider in Bazarr."""
|
|
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", "")
|
|
|
|
if use_flag and existing_key:
|
|
print(f"{provider_type} provider already configured, skipping")
|
|
return
|
|
|
|
print(f"Adding {provider_type} provider...")
|
|
provider_api_key = read_api_key_xml(
|
|
f"{provider_config['dataDir']}/config.xml"
|
|
)
|
|
|
|
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": "localhost",
|
|
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 added")
|
|
|
|
|
|
def main():
|
|
data_dir = CONFIG["dataDir"]
|
|
port = CONFIG["port"]
|
|
api_timeout = CONFIG["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://127.0.0.1:{port}"
|
|
|
|
wait_for_api(base_url, api_key, api_timeout)
|
|
|
|
providers = CONFIG.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()
|
|
'';
|
|
|
|
bazarrDeps = [
|
|
"bazarr.service"
|
|
]
|
|
++ (lib.optional bazarrCfg.sonarr.enable "${bazarrCfg.sonarr.serviceName}.service")
|
|
++ (lib.optional bazarrCfg.radarr.enable "${bazarrCfg.radarr.serviceName}.service");
|
|
in
|
|
{
|
|
options.services.arrInit = lib.mkOption {
|
|
type = lib.types.attrsOf instanceModule;
|
|
default = { };
|
|
description = ''
|
|
Attribute set of Servarr application instances to initialize via their APIs.
|
|
Each instance generates a systemd oneshot service that idempotently configures
|
|
download clients, root folders, and synced applications.
|
|
'';
|
|
};
|
|
|
|
options.services.bazarrInit = lib.mkOption {
|
|
type = bazarrInitModule;
|
|
default = {
|
|
enable = false;
|
|
};
|
|
description = ''
|
|
Bazarr API initialization for connecting Sonarr and Radarr providers.
|
|
Bazarr uses a different API than Servarr applications, so it has its own module.
|
|
'';
|
|
};
|
|
|
|
config = lib.mkMerge [
|
|
(lib.mkIf (enabledInstances != { }) {
|
|
systemd.services = lib.mapAttrs' (
|
|
name: inst:
|
|
lib.nameValuePair "${inst.serviceName}-init" {
|
|
description = "Initialize ${name} API connections";
|
|
after = [
|
|
"${inst.serviceName}.service"
|
|
]
|
|
++ (getSyncedAppDeps inst)
|
|
++ (getDownloadClientDeps inst)
|
|
++ (lib.optional (inst.networkNamespacePath != null) "wg.service");
|
|
requires = [ "${inst.serviceName}.service" ] ++ (getDownloadClientDeps inst);
|
|
wantedBy = [ "multi-user.target" ];
|
|
unitConfig = {
|
|
# Allow 5 full retry cycles (apiTimeout + RestartSec each) before
|
|
# entering permanent failure, which is what triggers OnFailure=.
|
|
StartLimitIntervalSec = 5 * (inst.apiTimeout + 30);
|
|
StartLimitBurst = 5;
|
|
};
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
Restart = "on-failure";
|
|
RestartSec = 30;
|
|
ExecStart = "${mkInitScript name inst}";
|
|
}
|
|
// lib.optionalAttrs (inst.networkNamespacePath != null) {
|
|
NetworkNamespacePath = inst.networkNamespacePath;
|
|
};
|
|
}
|
|
) enabledInstances;
|
|
})
|
|
|
|
(lib.mkIf bazarrCfg.enable {
|
|
systemd.services.bazarr-init = {
|
|
description = "Initialize Bazarr API connections";
|
|
after = bazarrDeps;
|
|
requires = bazarrDeps;
|
|
wantedBy = [ "multi-user.target" ];
|
|
unitConfig = {
|
|
StartLimitIntervalSec = 5 * (bazarrCfg.apiTimeout + 30);
|
|
StartLimitBurst = 5;
|
|
};
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
Restart = "on-failure";
|
|
RestartSec = 30;
|
|
ExecStart = "${mkBazarrInitScript}";
|
|
};
|
|
};
|
|
})
|
|
];
|
|
}
|