{ 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. ''; }; 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."; }; sonarr = lib.mkOption { type = bazarrProviderModule; default = { enable = false; }; description = "Sonarr provider configuration."; }; radarr = lib.mkOption { type = bazarrProviderModule; default = { enable = false; }; description = "Radarr provider configuration."; }; }; }; # Map Servarr implementation names to their Newznab parent category names. # Used to auto-detect syncCategories from the Prowlarr API when not explicitly set. implementationCategoryMap = { Sonarr = "TV"; Radarr = "Movies"; Lidarr = "Audio"; Readarr = "Books"; Whisparr = "XXX"; }; # Emit shell code that sets SYNC_CATEGORIES to a JSON array of category IDs. # When the user provides explicit IDs, use those. Otherwise, query the Prowlarr # /indexer/categories endpoint and collect the parent + all subcategory IDs for # the implementation's Newznab category. mkResolveSyncCategories = app: let hasExplicit = app.syncCategories != [ ]; categoryName = implementationCategoryMap.${app.implementation} or null; in if hasExplicit then "SYNC_CATEGORIES=${lib.escapeShellArg (builtins.toJSON app.syncCategories)}" else if categoryName != null then '' echo "Auto-detecting sync categories for ${app.implementation}..." ALL_CATEGORIES=$(${curl} -sf "$BASE_URL/indexer/categories" -H "X-Api-Key: $API_KEY") SYNC_CATEGORIES=$(echo "$ALL_CATEGORIES" | ${jq} --arg name ${lib.escapeShellArg categoryName} \ '[.[] | select(.name == $name) | .id, .subCategories[].id]') if [ "$SYNC_CATEGORIES" = "[]" ] || [ -z "$SYNC_CATEGORIES" ]; then echo "Warning: could not auto-detect categories for '${categoryName}', using empty list" >&2 SYNC_CATEGORIES='[]' else echo "Resolved sync categories: $SYNC_CATEGORIES" fi '' else "SYNC_CATEGORIES='[]' "; 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}'..." ${mkResolveSyncCategories app} 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 "$SYNC_CATEGORIES" \ '{ 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 ''; mkNamingSection = inst: lib.optionalString (inst.naming != { }) '' # Naming configuration echo "Checking naming configuration..." CURRENT_NAMING=$(${curl} -sf "$BASE_URL/config/naming" -H "X-Api-Key: $API_KEY") DESIRED_NAMING=${lib.escapeShellArg (builtins.toJSON inst.naming)} NEEDS_UPDATE=$(${jq} -n --argjson current "$CURRENT_NAMING" --argjson desired "$DESIRED_NAMING" \ '[$desired | to_entries[] | select(.value != $current[.key])] | length > 0') if [ "$NEEDS_UPDATE" = "true" ]; then echo "Updating naming configuration..." MERGED_NAMING=$(echo "$CURRENT_NAMING" | ${jq} --argjson desired "$DESIRED_NAMING" '. * $desired') ${curl} -sf -X PUT "$BASE_URL/config/naming" \ -H "X-Api-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d "$MERGED_NAMING" echo "Naming configuration updated" else echo "Naming configuration already correct, skipping" fi ''; mkHealthCheckSection = name: inst: lib.optionalString inst.healthChecks '' # Health checks echo "Running ${name} health checks..." HC_MAX_RETRIES=${builtins.toString inst.healthCheckRetries} HC_INTERVAL=${builtins.toString inst.healthCheckInterval} ${lib.optionalString (inst.downloadClients != [ ]) '' # Test download client connectivity (with retries) echo "Testing download client connectivity..." DC_ATTEMPT=0 while true; do DC_HEALTHY=true DC_TEST=$(${curl} -s --connect-timeout 10 --max-time 30 -X POST "$BASE_URL/downloadclient/testall" \ -H "X-Api-Key: $API_KEY" \ -H "Content-Type: application/json") || { DC_HEALTHY=false DC_LAST_ERROR="could not reach ${name} API for download client test" } if [ "$DC_HEALTHY" = true ]; then DC_FAILURES=$(echo "$DC_TEST" | ${jq} '[.[] | select(.isValid == false)]') DC_FAIL_COUNT=$(echo "$DC_FAILURES" | ${jq} 'length') if [ "$DC_FAIL_COUNT" -gt 0 ]; then DC_HEALTHY=false DC_LAST_ERROR=$(echo "$DC_FAILURES" | ${jq} -r '.[] | " - ID \(.id): \(.validationFailures | map(.errorMessage) | join(", "))"') fi fi if [ "$DC_HEALTHY" = true ]; then echo "All download clients healthy" break fi DC_ATTEMPT=$((DC_ATTEMPT + 1)) if [ "$DC_ATTEMPT" -gt "$HC_MAX_RETRIES" ]; then echo "Health check FAILED after $DC_ATTEMPT attempts: download client(s) unreachable:" >&2 echo "$DC_LAST_ERROR" >&2 exit 1 fi echo "Download client health check failed (attempt $DC_ATTEMPT/$HC_MAX_RETRIES), retrying in ''${HC_INTERVAL}s..." sleep "$HC_INTERVAL" done ''} ${lib.optionalString (inst.syncedApps != [ ]) '' # Test synced application connectivity (with retries) echo "Testing synced application connectivity..." APP_ATTEMPT=0 while true; do APP_HEALTHY=true APP_TEST=$(${curl} -s --connect-timeout 10 --max-time 30 -X POST "$BASE_URL/applications/testall" \ -H "X-Api-Key: $API_KEY" \ -H "Content-Type: application/json") || { APP_HEALTHY=false APP_LAST_ERROR="could not reach ${name} API for synced app test" } if [ "$APP_HEALTHY" = true ]; then APP_FAILURES=$(echo "$APP_TEST" | ${jq} '[.[] | select(.isValid == false)]') APP_FAIL_COUNT=$(echo "$APP_FAILURES" | ${jq} 'length') if [ "$APP_FAIL_COUNT" -gt 0 ]; then APP_HEALTHY=false APP_LAST_ERROR=$(echo "$APP_FAILURES" | ${jq} -r '.[] | " - ID \(.id): \(.validationFailures | map(.errorMessage) | join(", "))"') fi fi if [ "$APP_HEALTHY" = true ]; then echo "All synced applications healthy" break fi APP_ATTEMPT=$((APP_ATTEMPT + 1)) if [ "$APP_ATTEMPT" -gt "$HC_MAX_RETRIES" ]; then echo "Health check FAILED after $APP_ATTEMPT attempts: synced application(s) unreachable:" >&2 echo "$APP_LAST_ERROR" >&2 exit 1 fi echo "Synced app health check failed (attempt $APP_ATTEMPT/$HC_MAX_RETRIES), retrying in ''${HC_INTERVAL}s..." sleep "$HC_INTERVAL" done ''} echo "${name} health checks passed" ''; 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://127.0.0.1:${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 --connect-timeout 5 "$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} ${mkNamingSection inst} ${mkHealthCheckSection name inst} echo "${name} init complete" ''; # 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; mkBazarrProviderSection = type: provider: let ltype = lib.toLower type; in '' # ${type} provider echo "Checking ${type} provider..." PROVIDER_API_KEY=$(${grep} -oP '(?<=)[^<]+' ${lib.escapeShellArg "${provider.dataDir}/config.xml"}) EXISTING=$(${curl} -sf "$BASE_URL/api/system/settings" -H "X-API-KEY: $API_KEY") USE_FLAG=$(echo "$EXISTING" | ${jq} -r '.general.use_${ltype}') EXISTING_KEY=$(echo "$EXISTING" | ${jq} -r '.${ltype}.apikey // ""') if [ "$USE_FLAG" = "true" ] && [ -n "$EXISTING_KEY" ]; then echo "${type} provider already configured, skipping" else echo "Adding ${type} provider..." ${curl} -sf -X POST "$BASE_URL/api/system/settings" \ -H "X-API-KEY: $API_KEY" \ -d "settings-general-use_${ltype}=true" \ -d "settings-${ltype}-ip=localhost" \ -d "settings-${ltype}-port=${builtins.toString provider.port}" \ -d "settings-${ltype}-apikey=$PROVIDER_API_KEY" \ -d "settings-${ltype}-ssl=false" \ -d "settings-${ltype}-base_url=/" 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://127.0.0.1:${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 --connect-timeout 5 "$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) ++ (getDownloadClientDeps inst) ++ (lib.optional (inst.networkNamespacePath != null) "wg.service"); requires = [ "${inst.serviceName}.service" ] ++ (getDownloadClientDeps inst); wantedBy = [ "multi-user.target" ]; 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" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; Restart = "on-failure"; RestartSec = 30; ExecStart = "${mkBazarrInitScript}"; }; }; }) ]; }