{ config, lib, pkgs, ... }: let cfg = config.services.arrInit; scriptDir = ../scripts; pythonEnv = pkgs.python3.withPackages ( ps: with ps; [ pyyaml requests ] ); 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"; }; bindAddress = lib.mkOption { type = lib.types.str; default = "127.0.0.1"; description = "IP address the Servarr application API is listening on."; }; 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)."; }; networkNamespaceService = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = '' Systemd service that manages the network namespace. When set, the init service orders after this service. ''; example = "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. ''; }; 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. ''; }; 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}"; }; }; configXml = lib.mkOption { type = lib.types.attrsOf ( lib.types.oneOf [ lib.types.str lib.types.int lib.types.bool ] ); default = { }; description = '' XML elements to ensure in the service's config.xml before startup. Each key-value pair corresponds to a direct child element of the root. Existing elements are updated if their value differs; new elements are added. Undeclared elements are preserved. This runs as a preStart hook on the main service, guaranteeing config.xml is correct before the application reads it. ''; example = { Port = 9696; BindAddress = "*"; AnalyticsEnabled = false; }; }; }; }; mkInitConfig = name: inst: builtins.toJSON { inherit name; inherit (inst) dataDir bindAddress 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; }; mkConfigFile = name: inst: pkgs.writeText "${name}-init-config.json" (mkInitConfig name inst); getSyncedAppDeps = inst: map (app: "${app.serviceName}.service") inst.syncedApps; getDownloadClientDeps = inst: lib.concatMap ( dc: lib.optional (dc.serviceName != null) "${dc.serviceName}.service" ) inst.downloadClients; enabledInstances = lib.filterAttrs (_: inst: inst.enable) cfg; configXmlInstances = lib.filterAttrs (_: inst: inst.configXml != { }) enabledInstances; mkConfigXmlFile = name: inst: pkgs.writeText "${name}-config-xml.json" ( builtins.toJSON { inherit (inst) dataDir; elements = inst.configXml; } ); # Shared hardening options for all init services. hardeningConfig = { PrivateTmp = true; NoNewPrivileges = true; ProtectKernelTunables = true; ProtectKernelModules = true; ProtectControlGroups = true; RestrictSUIDSGID = true; ProtectHome = true; SystemCallArchitectures = "native"; }; 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. ''; }; config = lib.mkIf (enabledInstances != { }) { assertions = let configXmlTargets = map (inst: inst.serviceName) (builtins.attrValues configXmlInstances); in [ { # Two arrInit entries targeting the same systemd service with configXml # would silently collide on the preStart definition; only one would win. # Force the user to deduplicate instead of producing surprising behaviour. assertion = (lib.length configXmlTargets) == (lib.length (lib.unique configXmlTargets)); message = '' services.arrInit: multiple entries target the same serviceName with configXml. Each systemd service may have configXml defined by at most one arrInit entry. Targets: ${lib.concatStringsSep ", " configXmlTargets} ''; } ]; systemd.services = # Init services: oneshot units that configure the app via HTTP API (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.networkNamespaceService != null) "${inst.networkNamespaceService}.service"); requires = [ "${inst.serviceName}.service" ] ++ (getDownloadClientDeps inst); wantedBy = [ "multi-user.target" ]; environment.PYTHONPATH = "${scriptDir}"; unitConfig = { StartLimitIntervalSec = 5 * (inst.apiTimeout + 30); StartLimitBurst = 5; }; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; Restart = "on-failure"; RestartSec = 30; ExecStart = "${pythonEnv}/bin/python3 ${scriptDir}/servarr_init.py ${mkConfigFile name inst}"; } // hardeningConfig // lib.optionalAttrs (inst.networkNamespacePath != null) { NetworkNamespacePath = inst.networkNamespacePath; }; } ) enabledInstances) # config.xml preStart: ensure declared elements exist before the service reads them // (lib.mapAttrs' ( name: inst: lib.nameValuePair inst.serviceName { preStart = lib.mkBefore ( "${pythonEnv}/bin/python3 ${scriptDir}/ensure_config_xml.py ${mkConfigXmlFile name inst}" ); } ) configXmlInstances); }; }