diff --git a/configuration.nix b/configuration.nix index 48980b2..a181410 100644 --- a/configuration.nix +++ b/configuration.nix @@ -19,6 +19,7 @@ ./modules/secureboot.nix ./modules/no-rgb.nix ./modules/security.nix + ./modules/arr-init.nix ./services/postgresql.nix ./services/jellyfin.nix @@ -38,6 +39,7 @@ ./services/arr/bazarr.nix ./services/arr/jellyseerr.nix ./services/arr/recyclarr.nix + ./services/arr/init.nix ./services/soulseek.nix diff --git a/flake.nix b/flake.nix index 3f96036..edd15c6 100644 --- a/flake.nix +++ b/flake.nix @@ -222,6 +222,11 @@ recyclarr = { dataDir = services_dir + "/recyclarr"; }; + + media = { + moviesDir = torrents_path + "/media/movies"; + tvDir = torrents_path + "/media/tv"; + }; }; pkgs = import nixpkgs { diff --git a/modules/arr-init.nix b/modules/arr-init.nix new file mode 100644 index 0000000..26ed0cb --- /dev/null +++ b/modules/arr-init.nix @@ -0,0 +1,492 @@ +{ + 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"; + + 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_INI="${bazarrCfg.dataDir}/config/config.ini" + + if [ ! -f "$CONFIG_INI" ]; then + echo "Config file $CONFIG_INI not found, skipping bazarr init" + exit 0 + fi + + API_KEY=$(${grep} -oP '(?<=apikey = )[^\n]+' "$CONFIG_INI") + 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}"; + }; + }; + }) + ]; +} diff --git a/services/arr/init.nix b/services/arr/init.nix new file mode 100644 index 0000000..f677512 --- /dev/null +++ b/services/arr/init.nix @@ -0,0 +1,115 @@ +{ config, service_configs, ... }: +{ + services.arrInit = { + prowlarr = { + enable = true; + serviceName = "prowlarr"; + port = service_configs.ports.prowlarr; + dataDir = service_configs.prowlarr.dataDir; + apiVersion = "v1"; + networkNamespacePath = "/run/netns/wg"; + syncedApps = [ + { + name = "Sonarr"; + implementation = "Sonarr"; + configContract = "SonarrSettings"; + prowlarrUrl = "http://localhost:${builtins.toString service_configs.ports.prowlarr}"; + baseUrl = "http://${config.vpnNamespaces.wg.bridgeAddress}:${builtins.toString service_configs.ports.sonarr}"; + apiKeyFrom = "${service_configs.sonarr.dataDir}/config.xml"; + syncCategories = [ + 5000 + 5010 + 5020 + 5030 + 5040 + 5045 + 5050 + 5090 + ]; + serviceName = "sonarr"; + } + { + name = "Radarr"; + implementation = "Radarr"; + configContract = "RadarrSettings"; + prowlarrUrl = "http://localhost:${builtins.toString service_configs.ports.prowlarr}"; + baseUrl = "http://${config.vpnNamespaces.wg.bridgeAddress}:${builtins.toString service_configs.ports.radarr}"; + apiKeyFrom = "${service_configs.radarr.dataDir}/config.xml"; + syncCategories = [ + 2000 + 2010 + 2020 + 2030 + 2040 + 2045 + 2050 + 2060 + 2070 + 2080 + ]; + serviceName = "radarr"; + } + ]; + }; + + sonarr = { + enable = true; + serviceName = "sonarr"; + port = service_configs.ports.sonarr; + dataDir = service_configs.sonarr.dataDir; + rootFolders = [ service_configs.media.tvDir ]; + downloadClients = [ + { + name = "qBittorrent"; + implementation = "QBittorrent"; + configContract = "QBittorrentSettings"; + fields = { + host = config.vpnNamespaces.wg.namespaceAddress; + port = service_configs.ports.torrent; + useSsl = false; + tvCategory = "tvshows"; + }; + } + ]; + }; + + radarr = { + enable = true; + serviceName = "radarr"; + port = service_configs.ports.radarr; + dataDir = service_configs.radarr.dataDir; + rootFolders = [ service_configs.media.moviesDir ]; + downloadClients = [ + { + name = "qBittorrent"; + implementation = "QBittorrent"; + configContract = "QBittorrentSettings"; + fields = { + host = config.vpnNamespaces.wg.namespaceAddress; + port = service_configs.ports.torrent; + useSsl = false; + movieCategory = "movies"; + }; + } + ]; + }; + }; + + services.bazarrInit = { + enable = true; + dataDir = service_configs.bazarr.dataDir; + port = service_configs.ports.bazarr; + sonarr = { + enable = true; + dataDir = service_configs.sonarr.dataDir; + port = service_configs.ports.sonarr; + serviceName = "sonarr"; + }; + radarr = { + enable = true; + dataDir = service_configs.radarr.dataDir; + port = service_configs.ports.radarr; + serviceName = "radarr"; + }; + }; +} diff --git a/services/arr/prowlarr.nix b/services/arr/prowlarr.nix index c5e9c69..a2ad767 100644 --- a/services/arr/prowlarr.nix +++ b/services/arr/prowlarr.nix @@ -19,6 +19,10 @@ settings.server.port = service_configs.ports.prowlarr; }; + systemd.services.prowlarr.serviceConfig = { + ExecStartPre = "+${pkgs.coreutils}/bin/chown -R prowlarr /var/lib/prowlarr"; + }; + services.caddy.virtualHosts."prowlarr.${service_configs.https.domain}".extraConfig = '' import ${config.age.secrets.caddy_auth.path} reverse_proxy ${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.prowlarr} diff --git a/tests/arr-init.nix b/tests/arr-init.nix new file mode 100644 index 0000000..ab49200 --- /dev/null +++ b/tests/arr-init.nix @@ -0,0 +1,354 @@ +{ + config, + lib, + pkgs, + ... +}: +let + testPkgs = pkgs.appendOverlays [ (import ../modules/overlays.nix) ]; +in +testPkgs.testers.runNixOSTest { + name = "arr-init"; + + nodes.machine = + { pkgs, ... }: + { + imports = [ + ../modules/arr-init.nix + ]; + + system.stateVersion = config.system.stateVersion; + + virtualisation.memorySize = 4096; + + environment.systemPackages = with pkgs; [ + curl + jq + gnugrep + ]; + + systemd.services.mock-qbittorrent = + let + mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" '' + import json + from http.server import HTTPServer, BaseHTTPRequestHandler + from urllib.parse import parse_qs, urlparse + + + CATEGORIES = { + "tv": {"name": "tv", "savePath": "/downloads"}, + "movies": {"name": "movies", "savePath": "/downloads"}, + } + + + class QBitMock(BaseHTTPRequestHandler): + def _respond(self, code=200, body=b"Ok.", content_type="text/plain"): + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Set-Cookie", "SID=mock_session_id; Path=/") + self.end_headers() + self.wfile.write(body if isinstance(body, bytes) else body.encode()) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/api/v2/app/webapiVersion": + self._respond(body=b"2.9.3") + elif path == "/api/v2/app/version": + self._respond(body=b"v5.0.0") + elif path == "/api/v2/torrents/info": + self._respond(body=b"[]", content_type="application/json") + elif path == "/api/v2/torrents/categories": + body = json.dumps(CATEGORIES).encode() + self._respond(body=body, content_type="application/json") + elif path == "/api/v2/app/preferences": + body = json.dumps({"save_path": "/tmp"}).encode() + self._respond(body=body, content_type="application/json") + else: + self._respond() + + def do_POST(self): + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length).decode() + path = urlparse(self.path).path + query = parse_qs(urlparse(self.path).query) + form = parse_qs(body) + params = {**query, **form} + if path == "/api/v2/torrents/createCategory": + name = params.get("category", [""])[0] + save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads" + if name: + CATEGORIES[name] = {"name": name, "savePath": save_path} + if path in ["/api/v2/torrents/editCategory", "/api/v2/torrents/removeCategory"]: + self._respond() + return + self._respond() + + def log_message(self, format, *args): + pass + + + HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever() + ''; + in + { + description = "Mock qBittorrent API"; + wantedBy = [ "multi-user.target" ]; + before = [ + "sonarr-init.service" + "radarr-init.service" + ]; + serviceConfig = { + ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}"; + Type = "simple"; + }; + }; + + systemd.tmpfiles.rules = [ + "d /media/tv 0755 sonarr sonarr -" + "d /media/movies 0755 radarr radarr -" + ]; + + services.sonarr = { + enable = true; + dataDir = "/var/lib/sonarr/.config/NzbDrone"; + settings.server.port = lib.mkDefault 8989; + }; + + services.radarr = { + enable = true; + dataDir = "/var/lib/radarr/.config/Radarr"; + settings.server.port = lib.mkDefault 7878; + }; + + services.prowlarr = { + enable = true; + }; + + services.arrInit.sonarr = { + enable = true; + serviceName = "sonarr"; + dataDir = "/var/lib/sonarr/.config/NzbDrone"; + port = 8989; + downloadClients = [ + { + name = "qBittorrent"; + implementation = "QBittorrent"; + configContract = "QBittorrentSettings"; + protocol = "torrent"; + fields = { + host = "127.0.0.1"; + port = 6011; + useSsl = false; + tvCategory = "tv"; + }; + } + ]; + rootFolders = [ "/media/tv" ]; + }; + + services.arrInit.radarr = { + enable = true; + serviceName = "radarr"; + dataDir = "/var/lib/radarr/.config/Radarr"; + port = 7878; + downloadClients = [ + { + name = "qBittorrent"; + implementation = "QBittorrent"; + configContract = "QBittorrentSettings"; + protocol = "torrent"; + fields = { + host = "127.0.0.1"; + port = 6011; + useSsl = false; + movieCategory = "movies"; + }; + } + ]; + rootFolders = [ "/media/movies" ]; + }; + + services.arrInit.prowlarr = { + enable = true; + serviceName = "prowlarr"; + dataDir = "/var/lib/prowlarr"; + port = 9696; + apiVersion = "v1"; + syncedApps = [ + { + name = "Sonarr"; + implementation = "Sonarr"; + configContract = "SonarrSettings"; + prowlarrUrl = "http://localhost:9696"; + baseUrl = "http://localhost:8989"; + apiKeyFrom = "/var/lib/sonarr/.config/NzbDrone/config.xml"; + syncCategories = [ + 5000 + 5010 + 5020 + 5030 + 5040 + 5045 + 5050 + 5090 + ]; + serviceName = "sonarr"; + } + { + name = "Radarr"; + implementation = "Radarr"; + configContract = "RadarrSettings"; + prowlarrUrl = "http://localhost:9696"; + baseUrl = "http://localhost:7878"; + apiKeyFrom = "/var/lib/radarr/.config/Radarr/config.xml"; + syncCategories = [ + 2000 + 2010 + 2020 + 2030 + 2040 + 2045 + 2050 + 2060 + 2070 + 2080 + ]; + serviceName = "radarr"; + } + ]; + }; + }; + + testScript = '' + start_all() + + # Wait for services to start + machine.wait_for_unit("mock-qbittorrent.service") + machine.wait_until_succeeds("curl -sf http://localhost:6011/api/v2/app/version", timeout=30) + machine.wait_for_unit("sonarr.service") + machine.wait_for_unit("radarr.service") + machine.wait_for_unit("prowlarr.service") + + # Wait for Sonarr API to be ready (config.xml is auto-generated on first start) + machine.wait_until_succeeds( + "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && " + "curl -sf http://localhost:8989/api/v3/system/status -H \"X-Api-Key: $API_KEY\"", + timeout=120, + ) + + # Wait for Radarr API to be ready + machine.wait_until_succeeds( + "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/radarr/.config/Radarr/config.xml) && " + "curl -sf http://localhost:7878/api/v3/system/status -H \"X-Api-Key: $API_KEY\"", + timeout=120, + ) + + # Wait for Prowlarr API to be ready + machine.wait_until_succeeds( + "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/prowlarr/config.xml) && " + "curl -sf http://localhost:9696/api/v1/system/status -H \"X-Api-Key: $API_KEY\"", + timeout=180, + ) + + # Ensure init services run after config.xml exists + machine.succeed("systemctl restart sonarr-init.service") + machine.succeed("systemctl restart radarr-init.service") + machine.wait_for_unit("sonarr-init.service") + machine.wait_for_unit("radarr-init.service") + + # Wait for init services to complete + machine.wait_for_unit("sonarr-init.service") + machine.wait_for_unit("radarr-init.service") + + # Verify Sonarr download clients + machine.succeed( + "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && " + "curl -sf http://localhost:8989/api/v3/downloadclient -H \"X-Api-Key: $API_KEY\" | " + "jq -e '.[] | select(.name == \"qBittorrent\")'" + ) + + # Verify Sonarr root folders + machine.succeed( + "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && " + "curl -sf http://localhost:8989/api/v3/rootfolder -H \"X-Api-Key: $API_KEY\" | " + "jq -e '.[] | select(.path == \"/media/tv\")'" + ) + + # Verify Radarr download clients + machine.succeed( + "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/radarr/.config/Radarr/config.xml) && " + "curl -sf http://localhost:7878/api/v3/downloadclient -H \"X-Api-Key: $API_KEY\" | " + "jq -e '.[] | select(.name == \"qBittorrent\")'" + ) + + # Verify Radarr root folders + machine.succeed( + "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/radarr/.config/Radarr/config.xml) && " + "curl -sf http://localhost:7878/api/v3/rootfolder -H \"X-Api-Key: $API_KEY\" | " + "jq -e '.[] | select(.path == \"/media/movies\")'" + ) + + # Restart prowlarr-init now that all config.xml files exist + machine.succeed("systemctl restart prowlarr-init.service") + machine.wait_for_unit("prowlarr-init.service") + + # Verify Sonarr registered as synced app in Prowlarr + machine.succeed( + "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/prowlarr/config.xml) && " + "curl -sf http://localhost:9696/api/v1/applications -H \"X-Api-Key: $API_KEY\" | " + "jq -e '.[] | select(.name == \"Sonarr\")'" + ) + + # Verify Radarr registered as synced app in Prowlarr + machine.succeed( + "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/prowlarr/config.xml) && " + "curl -sf http://localhost:9696/api/v1/applications -H \"X-Api-Key: $API_KEY\" | " + "jq -e '.[] | select(.name == \"Radarr\")'" + ) + + # Idempotency test: restart init services and verify no duplicate entries + machine.succeed("systemctl restart sonarr-init.service") + machine.succeed("systemctl restart radarr-init.service") + machine.succeed("systemctl restart prowlarr-init.service") + + # Verify Sonarr still has exactly 1 download client + result = machine.succeed( + "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && " + "curl -sf http://localhost:8989/api/v3/downloadclient -H \"X-Api-Key: $API_KEY\" | " + "jq '. | length'" + ).strip() + assert result == "1", f"Expected 1 Sonarr download client, got {result}" + + # Verify Sonarr still has exactly 1 root folder + result = machine.succeed( + "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && " + "curl -sf http://localhost:8989/api/v3/rootfolder -H \"X-Api-Key: $API_KEY\" | " + "jq '. | length'" + ).strip() + assert result == "1", f"Expected 1 Sonarr root folder, got {result}" + + # Verify Radarr still has exactly 1 download client + result = machine.succeed( + "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/radarr/.config/Radarr/config.xml) && " + "curl -sf http://localhost:7878/api/v3/downloadclient -H \"X-Api-Key: $API_KEY\" | " + "jq '. | length'" + ).strip() + assert result == "1", f"Expected 1 Radarr download client, got {result}" + + # Verify Radarr still has exactly 1 root folder + result = machine.succeed( + "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/radarr/.config/Radarr/config.xml) && " + "curl -sf http://localhost:7878/api/v3/rootfolder -H \"X-Api-Key: $API_KEY\" | " + "jq '. | length'" + ).strip() + assert result == "1", f"Expected 1 Radarr root folder, got {result}" + + # Verify Prowlarr still has exactly 2 synced apps + result = machine.succeed( + "API_KEY=$(grep -oP '(?<=)[^<]+' /var/lib/prowlarr/config.xml) && " + "curl -sf http://localhost:9696/api/v1/applications -H \"X-Api-Key: $API_KEY\" | " + "jq '. | length'" + ).strip() + assert result == "2", f"Expected 2 Prowlarr synced apps, got {result}" + ''; +} diff --git a/tests/tests.nix b/tests/tests.nix index f7a2e93..5b1e57b 100644 --- a/tests/tests.nix +++ b/tests/tests.nix @@ -21,4 +21,7 @@ in fail2banVaultwardenTest = handleTest ./fail2ban-vaultwarden.nix; fail2banImmichTest = handleTest ./fail2ban-immich.nix; fail2banJellyfinTest = handleTest ./fail2ban-jellyfin.nix; + + # arr tests + arrInitTest = handleTest ./arr-init.nix; }