Compare commits

..

9 Commits

Author SHA1 Message Date
f914dc00ca qbittorrent: enable queueing and AutoTMM 2026-02-19 18:30:47 -05:00
32861ee048 firewall: trust wg-br interface 2026-02-19 18:27:48 -05:00
d415b7f42f arr-init: add module for API-based configuration 2026-02-19 18:27:40 -05:00
c0baa6e235 recyclarr: init 2026-02-19 18:27:19 -05:00
ddfa1e3a31 jellyseerr: init 2026-02-19 18:27:10 -05:00
4efe814e6a bazarr: init 2026-02-19 18:26:54 -05:00
8b3f0706d0 radarr: init 2026-02-19 18:26:42 -05:00
e251472c61 sonarr: init 2026-02-19 18:26:31 -05:00
7e045b5eb1 prowlarr: init 2026-02-19 18:26:21 -05:00
14 changed files with 781 additions and 186 deletions

View File

@@ -19,6 +19,8 @@
./modules/secureboot.nix ./modules/secureboot.nix
./modules/no-rgb.nix ./modules/no-rgb.nix
./modules/security.nix ./modules/security.nix
./modules/arr-init.nix
./modules/arr-init.nix
./services/postgresql.nix ./services/postgresql.nix
./services/jellyfin.nix ./services/jellyfin.nix
@@ -32,12 +34,42 @@
./services/jellyfin-qbittorrent-monitor.nix ./services/jellyfin-qbittorrent-monitor.nix
./services/bitmagnet.nix ./services/bitmagnet.nix
./services/prowlarr.nix ./services/arr/prowlarr.nix
./services/sonarr.nix ./services/arr/sonarr.nix
./services/radarr.nix ./services/arr/radarr.nix
./services/bazarr.nix ./services/arr/bazarr.nix
./services/jellyseerr.nix ./services/arr/jellyseerr.nix
./services/recyclarr.nix ./services/arr/recyclarr.nix
./services/arr/init.nix
./services/arr/prowlarr.nix
./services/arr/sonarr.nix
./services/arr/radarr.nix
./services/arr/bazarr.nix
./services/arr/jellyseerr.nix
./services/arr/recyclarr.nix
./services/arr/init.nix
./services/arr/sonarr.nix
./services/arr/radarr.nix
./services/arr/bazarr.nix
./services/arr/jellyseerr.nix
./services/arr/recyclarr.nix
./services/arr/init.nix
./services/arr/radarr.nix
./services/arr/bazarr.nix
./services/arr/jellyseerr.nix
./services/arr/recyclarr.nix
./services/arr/init.nix
./services/arr/bazarr.nix
./services/arr/jellyseerr.nix
./services/arr/recyclarr.nix
./services/arr/init.nix
./services/arr/jellyseerr.nix
./services/arr/recyclarr.nix
./services/arr/init.nix
./services/arr/recyclarr.nix
./services/arr/init.nix
./services/arr/init.nix
./services/soulseek.nix ./services/soulseek.nix

View File

@@ -199,6 +199,10 @@
grayjayBackupDir = "/${zpool_ssds}/bak/grayjay"; grayjayBackupDir = "/${zpool_ssds}/bak/grayjay";
}; };
prowlarr = {
dataDir = services_dir + "/prowlarr";
};
sonarr = { sonarr = {
dataDir = services_dir + "/sonarr"; dataDir = services_dir + "/sonarr";
}; };
@@ -207,10 +211,6 @@
dataDir = services_dir + "/radarr"; dataDir = services_dir + "/radarr";
}; };
prowlarr = {
dataDir = services_dir + "/prowlarr";
};
bazarr = { bazarr = {
dataDir = services_dir + "/bazarr"; dataDir = services_dir + "/bazarr";
}; };

218
modules/arr-init.nix Normal file
View File

@@ -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 '(?<=<ApiKey>)[^<]+' "$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;
};
}

46
services/arr/init.nix Normal file
View File

@@ -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";
};
}
];
};
};
}

202
services/arr/recyclarr.nix Normal file
View File

@@ -0,0 +1,202 @@
{
pkgs,
config,
service_configs,
lib,
...
}:
let
radarrConfig = "${service_configs.radarr.dataDir}/config.xml";
sonarrConfig = "${service_configs.sonarr.dataDir}/config.xml";
appDataDir = "${service_configs.recyclarr.dataDir}/data";
# Runs as root (via + prefix) to read API keys, writes secrets.yml for recyclarr
generateSecrets = pkgs.writeShellScript "recyclarr-generate-secrets" ''
RADARR_KEY=$(${pkgs.gnugrep}/bin/grep -oP '(?<=<ApiKey>)[^<]+' ${radarrConfig})
SONARR_KEY=$(${pkgs.gnugrep}/bin/grep -oP '(?<=<ApiKey>)[^<]+' ${sonarrConfig})
cat > ${appDataDir}/secrets.yml <<EOF
movies_api_key: $RADARR_KEY
series_api_key: $SONARR_KEY
EOF
chown recyclarr:recyclarr ${appDataDir}/secrets.yml
chmod 600 ${appDataDir}/secrets.yml
'';
in
{
imports = [
(lib.serviceMountWithZpool "recyclarr" service_configs.zpool_ssds [
service_configs.recyclarr.dataDir
])
];
systemd.tmpfiles.rules = [
"d ${service_configs.recyclarr.dataDir} 0755 recyclarr recyclarr -"
"d ${appDataDir} 0755 recyclarr recyclarr -"
];
services.recyclarr = {
enable = true;
command = "sync";
schedule = "daily";
user = "recyclarr";
group = "recyclarr";
configuration = {
radarr.movies = {
base_url = "http://localhost:${builtins.toString service_configs.ports.radarr}";
include = [
{ template = "radarr-quality-definition-movie"; }
{ template = "radarr-quality-profile-remux-web-2160p"; }
{ template = "radarr-custom-formats-remux-web-2160p"; }
];
quality_profiles = [
{
name = "Remux + WEB 2160p";
upgrade = {
allowed = true;
until_quality = "Remux-2160p";
};
qualities = [
{ name = "Remux-2160p"; }
{
name = "WEB 2160p";
qualities = [
"WEBDL-2160p"
"WEBRip-2160p"
];
}
{ name = "Remux-1080p"; }
{ name = "Bluray-1080p"; }
{
name = "WEB 1080p";
qualities = [
"WEBDL-1080p"
"WEBRip-1080p"
];
}
{ name = "HDTV-1080p"; }
];
}
];
custom_formats = [
# Upscaled
{
trash_ids = [ "bfd8eb01832d646a0a89c4deb46f8564" ];
assign_scores_to = [
{
name = "Remux + WEB 2160p";
score = -10000;
}
];
}
# x265 (HD) - override template -10000 penalty
{
trash_ids = [ "dc98083864ea246d05a42df0d05f81cc" ];
assign_scores_to = [
{
name = "Remux + WEB 2160p";
score = 0;
}
];
}
# x265 (no HDR/DV) - override template -10000 penalty
{
trash_ids = [ "839bea857ed2c0a8e084f3cbdbd65ecb" ];
assign_scores_to = [
{
name = "Remux + WEB 2160p";
score = 0;
}
];
}
];
};
sonarr.series = {
base_url = "http://localhost:${builtins.toString service_configs.ports.sonarr}";
include = [
{ template = "sonarr-quality-definition-series"; }
{ template = "sonarr-v4-quality-profile-web-2160p"; }
{ template = "sonarr-v4-custom-formats-web-2160p"; }
];
quality_profiles = [
{
name = "WEB-2160p";
upgrade = {
allowed = true;
until_quality = "WEB 2160p";
};
qualities = [
{
name = "WEB 2160p";
qualities = [
"WEBDL-2160p"
"WEBRip-2160p"
];
}
{ name = "Bluray-1080p Remux"; }
{ name = "Bluray-1080p"; }
{
name = "WEB 1080p";
qualities = [
"WEBDL-1080p"
"WEBRip-1080p"
];
}
{ name = "HDTV-1080p"; }
];
}
];
custom_formats = [
# Upscaled
{
trash_ids = [ "23297a736ca77c0fc8e70f8edd7ee56c" ];
assign_scores_to = [
{
name = "WEB-2160p";
score = -10000;
}
];
}
# x265 (HD) - override template -10000 penalty
{
trash_ids = [ "47435ece6b99a0b477caf360e79ba0bb" ];
assign_scores_to = [
{
name = "WEB-2160p";
score = 0;
}
];
}
# x265 (no HDR/DV) - override template -10000 penalty
{
trash_ids = [ "9b64dff695c2115facf1b6ea59c9bd07" ];
assign_scores_to = [
{
name = "WEB-2160p";
score = 0;
}
];
}
];
};
};
};
# Add secrets generation before recyclarr runs
systemd.services.recyclarr = {
after = [
"network-online.target"
"radarr.service"
"sonarr.service"
];
wants = [ "network-online.target" ];
serviceConfig.ExecStartPre = "+${generateSecrets}";
};
}

View File

@@ -48,7 +48,7 @@
serverConfig.BitTorrent = { serverConfig.BitTorrent = {
Session = { Session = {
MaxConnectionsPerTorrent = 10; MaxConnectionsPerTorrent = 100;
MaxUploadsPerTorrent = 10; MaxUploadsPerTorrent = 10;
MaxConnections = -1; MaxConnections = -1;
MaxUploads = -1; MaxUploads = -1;
@@ -56,9 +56,10 @@
MaxActiveCheckingTorrents = 5; MaxActiveCheckingTorrents = 5;
# queueing # queueing
QueueingSystemEnabled = false; QueueingSystemEnabled = true;
MaxActiveDownloads = 2; # num of torrents that can download at the same time MaxActiveDownloads = 8; # num of torrents that can download at the same time
MaxActiveUploads = 20; MaxActiveUploads = -1;
MaxActiveTorrents = -1;
IgnoreSlowTorrentsForQueueing = true; IgnoreSlowTorrentsForQueueing = true;
GlobalUPSpeedLimit = 0; GlobalUPSpeedLimit = 0;

View File

@@ -1,172 +0,0 @@
{
pkgs,
config,
service_configs,
lib,
...
}:
let
configTemplate = (pkgs.formats.yaml {}).generate "recyclarr.yml" {
radarr.movies = {
base_url = "http://localhost:${builtins.toString service_configs.ports.radarr}";
api_key = "RADARR_API_KEY_PLACEHOLDER";
include = [
{ template = "radarr-quality-definition-movie"; }
{ template = "radarr-quality-profile-remux-web-2160p"; }
{ template = "radarr-custom-formats-remux-web-2160p"; }
];
quality_profiles = [{
name = "Remux + WEB 2160p";
upgrade = {
allowed = true;
until_quality = "Remux-2160p";
};
qualities = [
{ name = "Remux-2160p"; }
{
name = "WEB 2160p";
qualities = [ "WEBDL-2160p" "WEBRip-2160p" ];
}
{ name = "Remux-1080p"; }
{ name = "Bluray-1080p"; }
{
name = "WEB 1080p";
qualities = [ "WEBDL-1080p" "WEBRip-1080p" ];
}
{ name = "HDTV-1080p"; }
];
}];
custom_formats = [
# Upscaled
{
trash_ids = [ "bfd8eb01832d646a0a89c4deb46f8564" ];
assign_scores_to = [{ name = "Remux + WEB 2160p"; score = -10000; }];
}
# x265 (HD) - override template -10000 penalty
{
trash_ids = [ "dc98083864ea246d05a42df0d05f81cc" ];
assign_scores_to = [{ name = "Remux + WEB 2160p"; score = 0; }];
}
# x265 (no HDR/DV) - override template -10000 penalty
{
trash_ids = [ "839bea857ed2c0a8e084f3cbdbd65ecb" ];
assign_scores_to = [{ name = "Remux + WEB 2160p"; score = 0; }];
}
];
};
sonarr.series = {
base_url = "http://localhost:${builtins.toString service_configs.ports.sonarr}";
api_key = "SONARR_API_KEY_PLACEHOLDER";
include = [
{ template = "sonarr-quality-definition-series"; }
{ template = "sonarr-v4-quality-profile-web-2160p"; }
{ template = "sonarr-v4-custom-formats-web-2160p"; }
];
quality_profiles = [{
name = "WEB-2160p";
upgrade = {
allowed = true;
until_quality = "WEB 2160p";
};
qualities = [
{
name = "WEB 2160p";
qualities = [ "WEBDL-2160p" "WEBRip-2160p" ];
}
{ name = "Bluray-1080p Remux"; }
{ name = "Bluray-1080p"; }
{
name = "WEB 1080p";
qualities = [ "WEBDL-1080p" "WEBRip-1080p" ];
}
{ name = "HDTV-1080p"; }
];
}];
custom_formats = [
# Upscaled
{
trash_ids = [ "23297a736ca77c0fc8e70f8edd7ee56c" ];
assign_scores_to = [{ name = "WEB-2160p"; score = -10000; }];
}
# x265 (HD) - override template -10000 penalty
{
trash_ids = [ "47435ece6b99a0b477caf360e79ba0bb" ];
assign_scores_to = [{ name = "WEB-2160p"; score = 0; }];
}
# x265 (no HDR/DV) - override template -10000 penalty
{
trash_ids = [ "9b64dff695c2115facf1b6ea59c9bd07" ];
assign_scores_to = [{ name = "WEB-2160p"; score = 0; }];
}
];
};
};
dataDir = service_configs.recyclarr.dataDir;
radarrConfig = "${service_configs.radarr.dataDir}/config.xml";
sonarrConfig = "${service_configs.sonarr.dataDir}/config.xml";
user = "recyclarr";
group = "recyclarr";
# Runs as root (via + prefix) to read API keys from config.xml, then chowns output
generateConfig = pkgs.writeShellScript "recyclarr-generate-config" ''
RADARR_KEY=$(${pkgs.gnugrep}/bin/grep -oP '(?<=<ApiKey>)[^<]+' ${radarrConfig})
SONARR_KEY=$(${pkgs.gnugrep}/bin/grep -oP '(?<=<ApiKey>)[^<]+' ${sonarrConfig})
${pkgs.gnused}/bin/sed \
-e "s/RADARR_API_KEY_PLACEHOLDER/$RADARR_KEY/" \
-e "s/SONARR_API_KEY_PLACEHOLDER/$SONARR_KEY/" \
${configTemplate} > ${dataDir}/recyclarr.yml
chown ${user}:${group} ${dataDir}/recyclarr.yml
'';
in
{
imports = [
(lib.serviceMountWithZpool "recyclarr" service_configs.zpool_ssds [
dataDir
])
];
systemd.tmpfiles.rules = [
"d ${dataDir} 0755 ${user} ${group} -"
"d ${dataDir}/data 0755 ${user} ${group} -"
];
systemd.services.recyclarr = {
description = "Recyclarr TRaSH Guides Sync";
after = [ "network-online.target" "radarr.service" "sonarr.service" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
User = user;
Group = group;
ExecStartPre = "+${generateConfig}";
ExecStart = "${lib.getExe pkgs.recyclarr} sync --config ${dataDir}/recyclarr.yml --app-data ${dataDir}/data";
};
};
users.users.${user} = {
isSystemUser = true;
group = group;
home = dataDir;
};
users.groups.${group} = {};
systemd.timers.recyclarr = {
description = "Run Recyclarr daily";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "daily";
Persistent = true;
RandomizedDelaySec = "1h";
};
};
}

265
tests/arr-init.nix Normal file
View File

@@ -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 '(?<=<ApiKey>)[^<]+' /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 '(?<=<ApiKey>)[^<]+' /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 '(?<=<ApiKey>)[^<]+' /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 '(?<=<ApiKey>)[^<]+' /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 '(?<=<ApiKey>)[^<]+' /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 '(?<=<ApiKey>)[^<]+' /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 '(?<=<ApiKey>)[^<]+' /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 '(?<=<ApiKey>)[^<]+' /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 '(?<=<ApiKey>)[^<]+' /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 '(?<=<ApiKey>)[^<]+' /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}"
'';
}

View File

@@ -21,4 +21,7 @@ in
fail2banVaultwardenTest = handleTest ./fail2ban-vaultwarden.nix; fail2banVaultwardenTest = handleTest ./fail2ban-vaultwarden.nix;
fail2banImmichTest = handleTest ./fail2ban-immich.nix; fail2banImmichTest = handleTest ./fail2ban-immich.nix;
fail2banJellyfinTest = handleTest ./fail2ban-jellyfin.nix; fail2banJellyfinTest = handleTest ./fail2ban-jellyfin.nix;
# arr tests
arrInitTest = handleTest ./arr-init.nix;
} }