From ed22d777414693f356669eb284bee1b604e39f25 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 27 Feb 2026 15:26:50 -0500 Subject: [PATCH] init --- .gitignore | 1 + flake.lock | 27 ++ flake.nix | 35 +++ module.nix | 497 +++++++++++++++++++++++++++++++++++++ tests/bazarr.nix | 189 ++++++++++++++ tests/default.nix | 13 + tests/edge-cases.nix | 187 ++++++++++++++ tests/error-handling.nix | 105 ++++++++ tests/integration.nix | 435 ++++++++++++++++++++++++++++++++ tests/multiple-clients.nix | 252 +++++++++++++++++++ tests/partial-config.nix | 251 +++++++++++++++++++ 11 files changed, 1992 insertions(+) create mode 100644 .gitignore create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 module.nix create mode 100644 tests/bazarr.nix create mode 100644 tests/default.nix create mode 100644 tests/edge-cases.nix create mode 100644 tests/error-handling.nix create mode 100644 tests/integration.nix create mode 100644 tests/multiple-clients.nix create mode 100644 tests/partial-config.nix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2be92b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +result diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f52c5ed --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1771848320, + "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2fc6539b481e1d2569f25f8799236694180c0993", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..90729fd --- /dev/null +++ b/flake.nix @@ -0,0 +1,35 @@ +{ + description = "Declarative API initialization for Servarr applications (Sonarr, Radarr, Prowlarr, Bazarr)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + in + { + nixosModules.default = import ./module.nix; + nixosModules.arr-init = import ./module.nix; + + checks = forAllSystems ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + import ./tests { + inherit pkgs; + lib = nixpkgs.lib; + inherit self; + } + ); + + formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.nixfmt-tree); + }; +} diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..67ac081 --- /dev/null +++ b/module.nix @@ -0,0 +1,497 @@ +{ + 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/bazarr.nix b/tests/bazarr.nix new file mode 100644 index 0000000..e795e07 --- /dev/null +++ b/tests/bazarr.nix @@ -0,0 +1,189 @@ +{ + pkgs, + lib, + self, +}: + +pkgs.testers.runNixOSTest { + name = "arr-init-bazarr"; + + nodes = { + # Test 1: Bazarr with Sonarr only + sonarr_only = + { pkgs, lib, ... }: + { + imports = [ self.nixosModules.default ]; + + system.stateVersion = "24.11"; + + virtualisation.memorySize = 4096; + + environment.systemPackages = with pkgs; [ + curl + jq + gnugrep + gawk + ]; + + services.sonarr = { + enable = true; + dataDir = "/var/lib/sonarr/.config/NzbDrone"; + settings.server.port = lib.mkDefault 8989; + }; + + services.bazarr = { + enable = true; + listenPort = 6767; + }; + + 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 = false; + }; + }; + }; + + # Test 2: Bazarr with Radarr only + radarr_only = + { pkgs, lib, ... }: + { + imports = [ self.nixosModules.default ]; + + system.stateVersion = "24.11"; + + virtualisation.memorySize = 4096; + + environment.systemPackages = with pkgs; [ + curl + jq + gnugrep + gawk + ]; + + services.radarr = { + enable = true; + dataDir = "/var/lib/radarr/.config/Radarr"; + settings.server.port = lib.mkDefault 7878; + }; + + services.bazarr = { + enable = true; + listenPort = 6767; + }; + + services.bazarrInit = { + enable = true; + dataDir = "/var/lib/bazarr"; + port = 6767; + sonarr = { + enable = false; + }; + radarr = { + enable = true; + dataDir = "/var/lib/radarr/.config/Radarr"; + port = 7878; + serviceName = "radarr"; + }; + }; + }; + }; + + testScript = '' + # Test 1: Sonarr only + start_all() + sonarr_only.wait_for_unit("sonarr.service") + sonarr_only.wait_for_unit("bazarr.service") + + # Wait for Sonarr API + sonarr_only.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 Bazarr config.yaml + sonarr_only.wait_until_succeeds( + "test -f /var/lib/bazarr/config/config.yaml", + timeout=120, + ) + + # Wait for Bazarr API + sonarr_only.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, + ) + + # Trigger bazarr-init + sonarr_only.succeed("systemctl restart bazarr-init.service") + sonarr_only.wait_for_unit("bazarr-init.service") + + # Verify Sonarr provider is configured + sonarr_only.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 is NOT configured + result = sonarr_only.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 '.general.use_radarr'" + ).strip() + assert result == "false", f"Expected use_radarr to be false, got {result}" + + # Test 2: Radarr only + start_all() + radarr_only.wait_for_unit("radarr.service") + radarr_only.wait_for_unit("bazarr.service") + + # Wait for Radarr API + radarr_only.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 Bazarr config.yaml + radarr_only.wait_until_succeeds( + "test -f /var/lib/bazarr/config/config.yaml", + timeout=120, + ) + + # Wait for Bazarr API + radarr_only.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, + ) + + # Trigger bazarr-init + radarr_only.succeed("systemctl restart bazarr-init.service") + radarr_only.wait_for_unit("bazarr-init.service") + + # Verify Radarr provider is configured + radarr_only.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 // \"\") != \"\"'" + ) + + # Verify Sonarr provider is NOT configured + result = radarr_only.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 '.general.use_sonarr'" + ).strip() + assert result == "false", f"Expected use_sonarr to be false, got {result}" + ''; +} diff --git a/tests/default.nix b/tests/default.nix new file mode 100644 index 0000000..9d18c58 --- /dev/null +++ b/tests/default.nix @@ -0,0 +1,13 @@ +{ + pkgs, + lib, + self, +}: +{ + integration = import ./integration.nix { inherit pkgs lib self; }; + partial-config = import ./partial-config.nix { inherit pkgs lib self; }; + multiple-clients = import ./multiple-clients.nix { inherit pkgs lib self; }; + bazarr = import ./bazarr.nix { inherit pkgs lib self; }; + error-handling = import ./error-handling.nix { inherit pkgs lib self; }; + edge-cases = import ./edge-cases.nix { inherit pkgs lib self; }; +} diff --git a/tests/edge-cases.nix b/tests/edge-cases.nix new file mode 100644 index 0000000..fbc6d3d --- /dev/null +++ b/tests/edge-cases.nix @@ -0,0 +1,187 @@ +{ + pkgs, + lib, + self, +}: + +pkgs.testers.runNixOSTest { + name = "arr-init-edge-cases"; + + nodes.machine = + { pkgs, lib, ... }: + { + imports = [ self.nixosModules.default ]; + + system.stateVersion = "24.11"; + + 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 = {} + + + 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} + 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" ]; + serviceConfig = { + ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}"; + Type = "simple"; + }; + }; + + # Create directories including one with spaces + systemd.tmpfiles.rules = [ + "d /media/tv 0755 sonarr sonarr -" + "d /media/anime 0755 sonarr sonarr -" + "d '/media/my shows' 0755 sonarr sonarr -" + ]; + + services.sonarr = { + enable = true; + dataDir = "/var/lib/sonarr/.config/NzbDrone"; + settings.server.port = lib.mkDefault 8989; + }; + + # Test 1: Empty lists (both downloadClients and rootFolders are empty) + # Test 2: Multiple root folders + # Test 3: Path with spaces + services.arrInit.sonarr = { + enable = true; + serviceName = "sonarr"; + dataDir = "/var/lib/sonarr/.config/NzbDrone"; + port = 8989; + # Empty downloadClients (explicitly set) + downloadClients = [ ]; + # Multiple root folders including one with spaces + rootFolders = [ + "/media/tv" + "/media/anime" + "/media/my shows" + ]; + }; + }; + + testScript = '' + start_all() + + # Wait for services + 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") + + # Wait for Sonarr API + 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, + ) + + # Trigger init + machine.succeed("systemctl restart sonarr-init.service") + machine.wait_for_unit("sonarr-init.service") + + # Test 1: Empty downloadClients list should work + 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 == "0", f"Expected 0 download clients, got {result}" + + # Test 2 & 3: Multiple root folders including one with spaces + 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 == "3", f"Expected 3 root folders, got {result}" + + # Verify each root folder exists + 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\")'" + ) + 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/anime\")'" + ) + # Path with spaces + 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/my shows\")'" + ) + + # Idempotency: restart and verify counts remain the same + machine.succeed("systemctl restart sonarr-init.service") + machine.wait_for_unit("sonarr-init.service") + + 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 == "3", f"Expected 3 root folders after idempotency test, got {result}" + ''; +} diff --git a/tests/error-handling.nix b/tests/error-handling.nix new file mode 100644 index 0000000..84bdadd --- /dev/null +++ b/tests/error-handling.nix @@ -0,0 +1,105 @@ +{ + pkgs, + lib, + self, +}: + +pkgs.testers.runNixOSTest { + name = "arr-init-error-handling"; + + nodes.machine = + { pkgs, lib, ... }: + { + imports = [ self.nixosModules.default ]; + + system.stateVersion = "24.11"; + + virtualisation.memorySize = 2048; + + environment.systemPackages = with pkgs; [ + curl + jq + gnugrep + ]; + + # Configure arrInit for a service that doesn't exist + # The dataDir points to a non-existent config.xml + services.arrInit.sonarr = { + enable = true; + serviceName = "sonarr"; + dataDir = "/var/lib/nonexistent"; + port = 8989; + downloadClients = [ + { + name = "qBittorrent"; + implementation = "QBittorrent"; + configContract = "QBittorrentSettings"; + protocol = "torrent"; + fields = { + host = "127.0.0.1"; + port = 6011; + useSsl = false; + }; + } + ]; + rootFolders = [ "/media/tv" ]; + }; + + # Also test bazarrInit with missing config + services.bazarrInit = { + enable = true; + dataDir = "/var/lib/nonexistent-bazarr"; + port = 6767; + sonarr = { + enable = true; + dataDir = "/var/lib/nonexistent"; + port = 8989; + serviceName = "sonarr"; + }; + }; + + # Override service dependencies since we're testing without real services + systemd.services.sonarr-init = { + requires = lib.mkForce [ ]; + after = lib.mkForce [ ]; + }; + systemd.services.bazarr-init = { + requires = lib.mkForce [ ]; + after = lib.mkForce [ ]; + }; + }; + + testScript = '' + start_all() + + # Trigger sonarr-init service - it should exit gracefully + # because config.xml doesn't exist + machine.succeed("systemctl start sonarr-init.service || true") + machine.wait_for_unit("sonarr-init.service") + + # Verify the service exited with ExitCode=0 (success) + # The module exits 0 when config.xml is missing + exit_code = machine.succeed( + "systemctl show sonarr-init.service --property=ExecMainStatus | cut -d= -f2" + ).strip() + assert exit_code == "0", f"Expected ExitCode 0 for missing config.xml, got {exit_code}" + + # Check journal for the skipping message + journal = machine.succeed("journalctl -u sonarr-init.service --no-pager") + assert "skipping" in journal.lower(), f"Expected 'skipping' message in journal, got: {journal}" + + # Trigger bazarr-init service - it should also exit gracefully + machine.succeed("systemctl start bazarr-init.service || true") + machine.wait_for_unit("bazarr-init.service") + + # Verify bazarr-init also exited with ExitCode=0 + exit_code = machine.succeed( + "systemctl show bazarr-init.service --property=ExecMainStatus | cut -d= -f2" + ).strip() + assert exit_code == "0", f"Expected ExitCode 0 for missing config.yaml, got {exit_code}" + + # Check journal for the skipping message + journal = machine.succeed("journalctl -u bazarr-init.service --no-pager") + assert "skipping" in journal.lower(), f"Expected 'skipping' message in journal, got: {journal}" + ''; +} diff --git a/tests/integration.nix b/tests/integration.nix new file mode 100644 index 0000000..e468cff --- /dev/null +++ b/tests/integration.nix @@ -0,0 +1,435 @@ +{ + pkgs, + lib, + self, +}: + +pkgs.testers.runNixOSTest { + name = "arr-init-integration"; + + nodes.machine = + { pkgs, lib, ... }: + { + imports = [ self.nixosModules.default ]; + + system.stateVersion = "24.11"; + + 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 // \"\") != \"\"'" + ) + + # Third run: verify counts are still correct (full idempotency) + machine.succeed("systemctl restart sonarr-init.service") + machine.succeed("systemctl restart radarr-init.service") + machine.succeed("systemctl restart prowlarr-init.service") + machine.succeed("systemctl restart bazarr-init.service") + + # Verify counts are still correct after third run + 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 after third run, got {result}" + + 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 after third run, got {result}" + ''; +} diff --git a/tests/multiple-clients.nix b/tests/multiple-clients.nix new file mode 100644 index 0000000..6087ba7 --- /dev/null +++ b/tests/multiple-clients.nix @@ -0,0 +1,252 @@ +{ + pkgs, + lib, + self, +}: + +pkgs.testers.runNixOSTest { + name = "arr-init-multiple-clients"; + + nodes.machine = + { pkgs, lib, ... }: + { + imports = [ self.nixosModules.default ]; + + system.stateVersion = "24.11"; + + virtualisation.memorySize = 4096; + + environment.systemPackages = with pkgs; [ + curl + jq + gnugrep + ]; + + # Mock qBittorrent on port 6011 + 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"}, + } + + + 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} + 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" ]; + serviceConfig = { + ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}"; + Type = "simple"; + }; + }; + + # Mock SABnzbd on port 6012 + systemd.services.mock-sabnzbd = + let + mockSabScript = pkgs.writeScript "mock-sabnzbd.py" '' + import json + from http.server import HTTPServer, BaseHTTPRequestHandler + from urllib.parse import parse_qs, urlparse + + + class SabMock(BaseHTTPRequestHandler): + def _respond(self, code=200, body=b'{"config": {}}', content_type="application/json"): + self.send_response(code) + self.send_header("Content-Type", content_type) + self.end_headers() + self.wfile.write(body if isinstance(body, bytes) else body.encode()) + + def do_GET(self): + path = self.path.split("?")[0] + if "mode=config" in self.path or "mode=version" in self.path: + self._respond(body=b'{"config": {"misc": {"api_key": "test"}}, "version": "4.0.0"}') + elif "mode=get_config" in self.path: + self._respond(body=b'{"config": {"misc": {"complete_dir": "/downloads/usenet", "pre_check": false}, "categories": [{"name": "tv", "order": 0, "pp": "", "script": "Default", "dir": "tv"}], "sorters": []}}') + else: + self._respond() + + def do_POST(self): + self._respond() + + def log_message(self, format, *args): + pass + + + HTTPServer(("0.0.0.0", 6012), SabMock).serve_forever() + ''; + in + { + description = "Mock SABnzbd API"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = "${pkgs.python3}/bin/python3 ${mockSabScript}"; + Type = "simple"; + }; + }; + + systemd.tmpfiles.rules = [ + "d /media/tv 0755 sonarr sonarr -" + ]; + + services.sonarr = { + enable = true; + dataDir = "/var/lib/sonarr/.config/NzbDrone"; + settings.server.port = lib.mkDefault 8989; + }; + + # Sonarr with TWO download clients: qBittorrent + SABnzbd + 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"; + }; + } + { + name = "SABnzbd"; + implementation = "Sabnzbd"; + configContract = "SabnzbdSettings"; + protocol = "usenet"; + fields = { + host = "127.0.0.1"; + port = 6012; + useSsl = false; + apiKey = "test-api-key"; + tvCategory = "tv"; + }; + } + ]; + rootFolders = [ "/media/tv" ]; + }; + }; + + testScript = '' + start_all() + + # Wait for mock services + machine.wait_for_unit("mock-qbittorrent.service") + machine.wait_for_unit("mock-sabnzbd.service") + machine.wait_until_succeeds("curl -sf http://localhost:6011/api/v2/app/version", timeout=30) + + # Wait for Sonarr + machine.wait_for_unit("sonarr.service") + 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, + ) + + # Trigger init + machine.succeed("systemctl restart sonarr-init.service") + machine.wait_for_unit("sonarr-init.service") + + # Verify both download clients exist + 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 == "2", f"Expected 2 Sonarr download clients, got {result}" + + # Verify qBittorrent client + 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 SABnzbd client + 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 == \"SABnzbd\")'" + ) + + # Idempotency test: restart init and verify still exactly 2 clients + machine.succeed("systemctl restart sonarr-init.service") + machine.wait_for_unit("sonarr-init.service") + + 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 == "2", f"Expected 2 Sonarr download clients after idempotency test, got {result}" + + # Verify qBittorrent has correct configuration + 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\") | .protocol == \"torrent\"'" + ) + + # Verify SABnzbd has correct configuration + 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 == \"SABnzbd\") | .protocol == \"usenet\"'" + ) + ''; +} diff --git a/tests/partial-config.nix b/tests/partial-config.nix new file mode 100644 index 0000000..f0a4680 --- /dev/null +++ b/tests/partial-config.nix @@ -0,0 +1,251 @@ +{ + pkgs, + lib, + self, +}: + +pkgs.testers.runNixOSTest { + name = "arr-init-partial-config"; + + nodes.machine = + { pkgs, lib, ... }: + { + imports = [ self.nixosModules.default ]; + + system.stateVersion = "24.11"; + + 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 = {} + + + 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} + 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" ]; + serviceConfig = { + ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}"; + Type = "simple"; + }; + }; + + systemd.tmpfiles.rules = [ + "d /media/tv 0755 sonarr sonarr -" + ]; + + 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; + }; + + # Test 1: Only rootFolders (no downloadClients) + services.arrInit.sonarr = { + enable = true; + serviceName = "sonarr"; + dataDir = "/var/lib/sonarr/.config/NzbDrone"; + port = 8989; + rootFolders = [ "/media/tv" ]; + # downloadClients = []; (default empty) + # syncedApps = []; (default empty) + }; + + # Test 2: Only downloadClients (no rootFolders) + 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 = []; (default empty) + # syncedApps = []; (default empty) + }; + + # Test 3: Only syncedApps (Prowlarr) + 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 ]; + serviceName = "sonarr"; + } + ]; + # downloadClients = []; (default empty) + # rootFolders = []; (default empty) + }; + }; + + testScript = '' + start_all() + + # Wait for mock and services + 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 APIs to be ready + 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, + ) + 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, + ) + 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, + ) + + # Trigger init services + machine.succeed("systemctl restart sonarr-init.service") + machine.succeed("systemctl restart radarr-init.service") + machine.succeed("systemctl restart prowlarr-init.service") + + machine.wait_for_unit("sonarr-init.service") + machine.wait_for_unit("radarr-init.service") + machine.wait_for_unit("prowlarr-init.service") + + # Test 1: Sonarr - only rootFolders should exist, no download clients + 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}" + + 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 == "0", f"Expected 0 Sonarr download clients, got {result}" + + # Test 2: Radarr - only downloadClients should exist, no root folders + 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}" + + 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 == "0", f"Expected 0 Radarr root folders, got {result}" + + # Test 3: Prowlarr - only syncedApps should exist + 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 == "1", f"Expected 1 Prowlarr synced app, got {result}" + + # Verify Sonarr is the synced app + 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\")'" + ) + ''; +}