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 e49a82b..ca46157 100644 --- a/flake.nix +++ b/flake.nix @@ -221,6 +221,11 @@ recyclarr = { dataDir = services_dir + "/recyclarr"; }; + + media = { + moviesDir = torrents_path + "/media/movies"; + tvDir = torrents_path + "/media/tv"; + }; }; }; diff --git a/modules/arr-init.nix b/modules/arr-init.nix new file mode 100644 index 0000000..774e276 --- /dev/null +++ b/modules/arr-init.nix @@ -0,0 +1,218 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.arrInit; + + 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"; + }; + }; + }; + }; + + 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."; + }; + + 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" + ]; + }; + }; + }; + + 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 + ''; + + 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} + + echo "${name} init complete" + ''; + + enabledInstances = lib.filterAttrs (_: inst: inst.enable) cfg; +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 and root folders. + ''; + }; + + config = lib.mkIf (enabledInstances != { }) { + systemd.services = lib.mapAttrs' ( + name: inst: + lib.nameValuePair "${inst.serviceName}-init" { + description = "Initialize ${name} API connections"; + after = [ "${inst.serviceName}.service" ]; + requires = [ "${inst.serviceName}.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${mkInitScript name inst}"; + }; + } + ) enabledInstances; + }; +} diff --git a/services/arr/init.nix b/services/arr/init.nix new file mode 100644 index 0000000..05b7849 --- /dev/null +++ b/services/arr/init.nix @@ -0,0 +1,46 @@ +{ config, service_configs, ... }: +{ + services.arrInit = { + 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"; + }; + } + ]; + }; + }; +} diff --git a/tests/arr-init.nix b/tests/arr-init.nix new file mode 100644 index 0000000..50dc0d6 --- /dev/null +++ b/tests/arr-init.nix @@ -0,0 +1,265 @@ +{ + 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 = 2048; + + 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.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" ]; + }; + }; + + 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") + + # 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, + ) + + # 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\")'" + ) + + # Idempotency test: restart init services and verify no duplicate entries + machine.succeed("systemctl restart sonarr-init.service") + machine.succeed("systemctl restart radarr-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}" + ''; +} diff --git a/tests/tests.nix b/tests/tests.nix index f7a2e93..8c3504b 100644 --- a/tests/tests.nix +++ b/tests/tests.nix @@ -22,3 +22,6 @@ in fail2banImmichTest = handleTest ./fail2ban-immich.nix; fail2banJellyfinTest = handleTest ./fail2ban-jellyfin.nix; } + + # arr tests + arrInitTest = handleTest ./arr-init.nix;