{ config, lib, pkgs, ... }: let cfg = config.services.arrInit; 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"; }; }; }; }; 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."; }; 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" ]; }; }; }; curl = "${pkgs.curl}/bin/curl"; jq = "${pkgs.jq}/bin/jq"; grep = "${pkgs.gnugrep}/bin/grep"; mkDownloadClientPayload = dc: builtins.toJSON { enable = true; protocol = dc.protocol; priority = 1; name = dc.name; implementation = dc.implementation; configContract = dc.configContract; fields = lib.mapAttrsToList (n: v: { name = n; value = v; }) dc.fields; tags = [ ]; }; mkDownloadClientSection = dc: '' # Download client: ${dc.name} echo "Checking download client '${dc.name}'..." EXISTING_DC=$(${curl} -sf "$BASE_URL/downloadclient" -H "X-Api-Key: $API_KEY") if echo "$EXISTING_DC" | ${jq} -e --arg name ${lib.escapeShellArg dc.name} '.[] | select(.name == $name)' > /dev/null 2>&1; then echo "Download client '${dc.name}' already exists, skipping" else echo "Adding download client '${dc.name}'..." ${curl} -sf -X POST "$BASE_URL/downloadclient?forceSave=true" \ -H "X-Api-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d ${lib.escapeShellArg (mkDownloadClientPayload dc)} echo "Download client '${dc.name}' added" fi ''; mkRootFolderSection = path: '' # Root folder: ${path} echo "Checking root folder '${path}'..." EXISTING_RF=$(${curl} -sf "$BASE_URL/rootfolder" -H "X-Api-Key: $API_KEY") if echo "$EXISTING_RF" | ${jq} -e --arg path ${lib.escapeShellArg path} '.[] | select(.path == $path)' > /dev/null 2>&1; then echo "Root folder '${path}' already exists, skipping" else echo "Adding root folder '${path}'..." ${curl} -sf -X POST "$BASE_URL/rootfolder" \ -H "X-Api-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d ${lib.escapeShellArg (builtins.toJSON { inherit path; })} echo "Root folder '${path}' added" fi ''; mkInitScript = name: inst: pkgs.writeShellScript "${name}-init" '' set -euo pipefail CONFIG_XML="${inst.dataDir}/config.xml" if [ ! -f "$CONFIG_XML" ]; then echo "Config file $CONFIG_XML not found, skipping ${name} init" exit 0 fi API_KEY=$(${grep} -oP '(?<=)[^<]+' "$CONFIG_XML") BASE_URL="http://localhost:${builtins.toString inst.port}/api/${inst.apiVersion}" # Wait for API to become available echo "Waiting for ${name} API..." for i in $(seq 1 90); do if ${curl} -sf "$BASE_URL/system/status" -H "X-Api-Key: $API_KEY" > /dev/null 2>&1; then echo "${name} API is ready" break fi if [ "$i" -eq 90 ]; then echo "${name} API not available after 90 seconds" >&2 exit 1 fi sleep 1 done ${lib.concatMapStringsSep "\n" mkDownloadClientSection inst.downloadClients} ${lib.concatMapStringsSep "\n" mkRootFolderSection inst.rootFolders} echo "${name} init complete" ''; enabledInstances = lib.filterAttrs (_: inst: inst.enable) cfg; 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 and root folders. ''; }; config = lib.mkIf (enabledInstances != { }) { systemd.services = lib.mapAttrs' ( name: inst: lib.nameValuePair "${inst.serviceName}-init" { description = "Initialize ${name} API connections"; after = [ "${inst.serviceName}.service" ]; requires = [ "${inst.serviceName}.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; ExecStart = "${mkInitScript name inst}"; }; } ) enabledInstances; }; }