{ 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"; }; }; }; }; 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 sync category IDs for the application."; 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)."; }; }; }; 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."; }; sonarr = lib.mkOption { type = bazarrProviderModule; default = { enable = false; }; description = "Sonarr provider configuration."; }; radarr = lib.mkOption { type = bazarrProviderModule; default = { enable = false; }; description = "Radarr provider configuration."; }; }; }; curl = "${pkgs.curl}/bin/curl"; jq = "${pkgs.jq}/bin/jq"; grep = "${pkgs.gnugrep}/bin/grep"; awk = "${pkgs.gawk}/bin/awk"; 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 ''; mkSyncedAppSection = app: '' # Synced app: ${app.name} echo "Checking synced app '${app.name}'..." TARGET_API_KEY=$(${grep} -oP '(?<=)[^<]+' ${lib.escapeShellArg app.apiKeyFrom}) EXISTING_APPS=$(${curl} -sf "$BASE_URL/applications" -H "X-Api-Key: $API_KEY") if echo "$EXISTING_APPS" | ${jq} -e --arg name ${lib.escapeShellArg app.name} '.[] | select(.name == $name)' > /dev/null 2>&1; then echo "Synced app '${app.name}' already exists, skipping" else echo "Adding synced app '${app.name}'..." PAYLOAD=$(${jq} -n \ --arg name ${lib.escapeShellArg app.name} \ --arg implementation ${lib.escapeShellArg app.implementation} \ --arg configContract ${lib.escapeShellArg app.configContract} \ --arg syncLevel ${lib.escapeShellArg app.syncLevel} \ --arg prowlarrUrl ${lib.escapeShellArg app.prowlarrUrl} \ --arg baseUrl ${lib.escapeShellArg app.baseUrl} \ --arg apiKey "$TARGET_API_KEY" \ --argjson syncCategories ${builtins.toJSON app.syncCategories} \ '{ name: $name, implementation: $implementation, configContract: $configContract, syncLevel: $syncLevel, fields: [ {name: "prowlarrUrl", value: $prowlarrUrl}, {name: "baseUrl", value: $baseUrl}, {name: "apiKey", value: $apiKey}, {name: "syncCategories", value: $syncCategories} ], tags: [] }') ${curl} -sf -X POST "$BASE_URL/applications?forceSave=true" \ -H "X-Api-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d "$PAYLOAD" echo "Synced app '${app.name}' 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} ${lib.concatMapStringsSep "\n" mkSyncedAppSection inst.syncedApps} echo "${name} init complete" ''; # Get list of service names that syncedApps depend on getSyncedAppDeps = inst: map (app: "${app.serviceName}.service") inst.syncedApps; enabledInstances = lib.filterAttrs (_: inst: inst.enable) cfg; mkBazarrProviderSection = type: provider: '' # ${type} provider echo "Checking ${type} provider..." PROVIDER_API_KEY=$(${grep} -oP '(?<=)[^<]+' ${lib.escapeShellArg "${provider.dataDir}/config.xml"}) EXISTING=$(${curl} -sf "$BASE_URL/api/${lib.toLower type}" -H "X-API-KEY: $API_KEY") if echo "$EXISTING" | ${jq} -e '. | length > 0' > /dev/null 2>&1; then echo "${type} provider already configured, skipping" else echo "Adding ${type} provider..." PAYLOAD=$(${jq} -n \ --arg ip "localhost" \ --argjson port ${builtins.toString provider.port} \ --arg apikey "$PROVIDER_API_KEY" \ --argjson ssl false \ --arg base_url "/" \ '{ip: $ip, port: $port, apikey: $apikey, ssl: $ssl, base_url: $base_url}') ${curl} -sf -X POST "$BASE_URL/api/${lib.toLower type}" \ -H "X-API-KEY: $API_KEY" \ -H "Content-Type: application/json" \ -d "$PAYLOAD" echo "${type} provider added" fi ''; mkBazarrInitScript = pkgs.writeShellScript "bazarr-init" '' set -euo pipefail CONFIG_YAML="${bazarrCfg.dataDir}/config/config.yaml" if [ ! -f "$CONFIG_YAML" ]; then echo "Config file $CONFIG_YAML not found, skipping bazarr init" exit 0 fi API_KEY=$(${awk} '/^auth:/{f=1} f && /apikey:/{gsub(/.*apikey: /, ""); print; exit}' "$CONFIG_YAML") BASE_URL="http://localhost:${builtins.toString bazarrCfg.port}" # Wait for API to become available echo "Waiting for Bazarr API..." for i in $(seq 1 90); do if ${curl} -sf "$BASE_URL/api/system/status" -H "X-API-KEY: $API_KEY" > /dev/null 2>&1; then echo "Bazarr API is ready" break fi if [ "$i" -eq 90 ]; then echo "Bazarr API not available after 90 seconds" >&2 exit 1 fi sleep 1 done ${lib.optionalString bazarrCfg.sonarr.enable (mkBazarrProviderSection "Sonarr" bazarrCfg.sonarr)} ${lib.optionalString bazarrCfg.radarr.enable (mkBazarrProviderSection "Radarr" bazarrCfg.radarr)} echo "Bazarr init complete" ''; 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) ++ (lib.optional (inst.networkNamespacePath != null) "wg.service"); requires = [ "${inst.serviceName}.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; 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" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; ExecStart = "${mkBazarrInitScript}"; }; }; }) ]; }