From 49f06fc26c0116922e051f72ddc7e9c72e5fd933 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 27 Feb 2026 15:39:19 -0500 Subject: [PATCH] arr-init: extract to standalone flake repo --- configuration.nix | 1 - flake.lock | 21 ++ flake.nix | 9 +- modules/arr-init.nix | 497 ------------------------------------------- tests/arr-init.nix | 419 ------------------------------------ tests/tests.nix | 3 +- 6 files changed, 30 insertions(+), 920 deletions(-) delete mode 100644 modules/arr-init.nix delete mode 100644 tests/arr-init.nix diff --git a/configuration.nix b/configuration.nix index 6212c0c..61ecae2 100644 --- a/configuration.nix +++ b/configuration.nix @@ -19,7 +19,6 @@ ./modules/secureboot.nix ./modules/no-rgb.nix ./modules/security.nix - ./modules/arr-init.nix ./modules/ntfy-alerts.nix ./services/postgresql.nix diff --git a/flake.lock b/flake.lock index 4d89aeb..0141f0c 100644 --- a/flake.lock +++ b/flake.lock @@ -25,6 +25,26 @@ "type": "github" } }, + "arr-init": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772224010, + "narHash": "sha256-nCchZRcIU2IRRjXUi/7jAfKZFtWD4NZqshAKqNJP+Mg=", + "ref": "refs/heads/main", + "rev": "e9d5d8e0c92ef408f3340933d9109738675d64b8", + "revCount": 1, + "type": "git", + "url": "ssh://gitea@git.gardling.com/titaniumtown/arr-init" + }, + "original": { + "type": "git", + "url": "ssh://gitea@git.gardling.com/titaniumtown/arr-init" + } + }, "crane": { "locked": { "lastModified": 1771796463, @@ -352,6 +372,7 @@ "root": { "inputs": { "agenix": "agenix", + "arr-init": "arr-init", "deploy-rs": "deploy-rs", "disko": "disko", "home-manager": "home-manager", diff --git a/flake.nix b/flake.nix index 2285cc2..1a0fbd7 100644 --- a/flake.nix +++ b/flake.nix @@ -68,6 +68,11 @@ ytbn-graphing-software = { url = "git+https://git.gardling.com/titaniumtown/YTBN-Graphing-Software"; }; + + arr-init = { + url = "git+ssh://gitea@git.gardling.com/titaniumtown/arr-init"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; outputs = @@ -83,7 +88,7 @@ srvos, deploy-rs, impermanence, - agenix, + arr-init, ... }@inputs: let @@ -295,6 +300,8 @@ lanzaboote.nixosModules.lanzaboote + arr-init.nixosModules.default + home-manager.nixosModules.home-manager ( { diff --git a/modules/arr-init.nix b/modules/arr-init.nix deleted file mode 100644 index 67ac081..0000000 --- a/modules/arr-init.nix +++ /dev/null @@ -1,497 +0,0 @@ -{ - 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: - 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://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/tests/arr-init.nix b/tests/arr-init.nix deleted file mode 100644 index be94be1..0000000 --- a/tests/arr-init.nix +++ /dev/null @@ -1,419 +0,0 @@ -{ - 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.bazarr = { - enable = true; - listenPort = 6767; - }; - - 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"; - } - ]; - }; - - services.bazarrInit = { - enable = true; - dataDir = "/var/lib/bazarr"; - port = 6767; - sonarr = { - enable = true; - dataDir = "/var/lib/sonarr/.config/NzbDrone"; - port = 8989; - serviceName = "sonarr"; - }; - radarr = { - enable = true; - dataDir = "/var/lib/radarr/.config/Radarr"; - port = 7878; - 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") - machine.wait_for_unit("bazarr.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}" - - # Wait for Bazarr to generate config.yaml - machine.wait_until_succeeds( - "test -f /var/lib/bazarr/config/config.yaml", - timeout=120, - ) - - # Wait for Bazarr API to be ready - machine.wait_until_succeeds( - "API_KEY=$(awk '/^auth:/{f=1} f && /apikey:/{gsub(/.*apikey: /, \"\"); print; exit}' /var/lib/bazarr/config/config.yaml) && " - "curl -sf http://localhost:6767/api/system/status -H \"X-API-KEY: $API_KEY\"", - timeout=120, - ) - - # Restart bazarr-init now that config.yaml exists - machine.succeed("systemctl restart bazarr-init.service") - machine.wait_for_unit("bazarr-init.service") - - # Verify Sonarr provider configured in Bazarr - machine.succeed( - "API_KEY=$(awk '/^auth:/{f=1} f && /apikey:/{gsub(/.*apikey: /, \"\"); print; exit}' /var/lib/bazarr/config/config.yaml) && " - "curl -sf http://localhost:6767/api/system/settings -H \"X-API-KEY: $API_KEY\" | " - "jq -e '.general.use_sonarr == true and (.sonarr.apikey // \"\") != \"\"'" - ) - - # Verify Radarr provider configured in Bazarr - machine.succeed( - "API_KEY=$(awk '/^auth:/{f=1} f && /apikey:/{gsub(/.*apikey: /, \"\"); print; exit}' /var/lib/bazarr/config/config.yaml) && " - "curl -sf http://localhost:6767/api/system/settings -H \"X-API-KEY: $API_KEY\" | " - "jq -e '.general.use_radarr == true and (.radarr.apikey // \"\") != \"\"'" - ) - - # Idempotency: restart bazarr-init and verify no duplicate config - machine.succeed("systemctl restart bazarr-init.service") - machine.wait_for_unit("bazarr-init.service") - - machine.succeed( - "API_KEY=$(awk '/^auth:/{f=1} f && /apikey:/{gsub(/.*apikey: /, \"\"); print; exit}' /var/lib/bazarr/config/config.yaml) && " - "curl -sf http://localhost:6767/api/system/settings -H \"X-API-KEY: $API_KEY\" | " - "jq -e '.general.use_sonarr == true and (.sonarr.apikey // \"\") != \"\"'" - ) - ''; -} diff --git a/tests/tests.nix b/tests/tests.nix index 295485d..40a8fcf 100644 --- a/tests/tests.nix +++ b/tests/tests.nix @@ -22,8 +22,7 @@ in fail2banImmichTest = handleTest ./fail2ban-immich.nix; fail2banJellyfinTest = handleTest ./fail2ban-jellyfin.nix; - # arr tests - arrInitTest = handleTest ./arr-init.nix; + # ntfy alerts test ntfyAlertsTest = handleTest ./ntfy-alerts.nix; }