Skip to content

Commit 01e80f2

Browse files
authored
configure downloadclients service (#8)
* configure downloadclients service * fix apikeypath, always run download clients service
1 parent 2c83ad5 commit 01e80f2

9 files changed

Lines changed: 529 additions & 205 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
{
2+
lib,
3+
pkgs,
4+
}: serviceName: serviceConfig:
5+
with lib; let
6+
mkWaitForApiScript = import ./mkWaitForApiScript.nix {inherit lib pkgs;};
7+
capitalizedName = lib.toUpper (builtins.substring 0 1 serviceName) + builtins.substring 1 (-1) serviceName;
8+
in {
9+
description = "Configure ${serviceName} download clients via API";
10+
after = ["${serviceName}-config.service"];
11+
requires = ["${serviceName}-config.service"];
12+
wantedBy = ["multi-user.target"];
13+
14+
serviceConfig = {
15+
Type = "oneshot";
16+
RemainAfterExit = true;
17+
ExecStartPre = mkWaitForApiScript serviceName serviceConfig;
18+
};
19+
20+
script = ''
21+
set -eu
22+
23+
# Read API key secret
24+
API_KEY=$(cat ${serviceConfig.apiKeyPath})
25+
26+
BASE_URL="http://127.0.0.1:${builtins.toString serviceConfig.hostConfig.port}${serviceConfig.hostConfig.urlBase}/api/${serviceConfig.apiVersion}"
27+
28+
# Fetch all download client schemas
29+
echo "Fetching download client schemas..."
30+
SCHEMAS=$(${pkgs.curl}/bin/curl -sS -H "X-Api-Key: $API_KEY" "$BASE_URL/downloadclient/schema")
31+
32+
# Fetch existing download clients
33+
echo "Fetching existing download clients..."
34+
DOWNLOAD_CLIENTS=$(${pkgs.curl}/bin/curl -sS -H "X-Api-Key: $API_KEY" "$BASE_URL/downloadclient")
35+
36+
# Build list of configured download client names
37+
CONFIGURED_NAMES=$(cat <<'EOF'
38+
${builtins.toJSON (map (d: d.name) serviceConfig.downloadClients)}
39+
EOF
40+
)
41+
42+
# Delete download clients that are not in the configuration
43+
echo "Removing download clients not in configuration..."
44+
echo "$DOWNLOAD_CLIENTS" | ${pkgs.jq}/bin/jq -r '.[] | @json' | while IFS= read -r downloadClient; do
45+
CLIENT_NAME=$(echo "$downloadClient" | ${pkgs.jq}/bin/jq -r '.name')
46+
CLIENT_ID=$(echo "$downloadClient" | ${pkgs.jq}/bin/jq -r '.id')
47+
48+
if ! echo "$CONFIGURED_NAMES" | ${pkgs.jq}/bin/jq -e --arg name "$CLIENT_NAME" 'index($name)' >/dev/null 2>&1; then
49+
echo "Deleting download client not in config: $CLIENT_NAME (ID: $CLIENT_ID)"
50+
${pkgs.curl}/bin/curl -sSf -X DELETE \
51+
-H "X-Api-Key: $API_KEY" \
52+
"$BASE_URL/downloadclient/$CLIENT_ID" >/dev/null || echo "Warning: Failed to delete download client $CLIENT_NAME"
53+
fi
54+
done
55+
56+
${concatMapStringsSep "\n" (clientConfig: let
57+
clientName = clientConfig.name;
58+
inherit (clientConfig) implementationName;
59+
inherit (clientConfig) apiKeyPath;
60+
allOverrides = builtins.removeAttrs clientConfig ["implementationName" "apiKeyPath"];
61+
fieldOverrides = lib.filterAttrs (name: value: value != null && !lib.hasPrefix "_" name) allOverrides;
62+
fieldOverridesJson = builtins.toJSON fieldOverrides;
63+
in ''
64+
echo "Processing download client: ${clientName}"
65+
66+
apply_field_overrides() {
67+
local client_json="$1"
68+
local api_key="$2"
69+
local overrides="$3"
70+
71+
echo "$client_json" | ${pkgs.jq}/bin/jq \
72+
--arg apiKey "$api_key" \
73+
--argjson overrides "$overrides" '
74+
.fields[] |= (if .name == "apiKey" then .value = $apiKey else . end)
75+
| . + $overrides
76+
| .fields[] |= (
77+
. as $field |
78+
if $overrides[$field.name] != null then
79+
.value = $overrides[$field.name]
80+
else
81+
.
82+
end
83+
)
84+
'
85+
}
86+
87+
CLIENT_API_KEY=$(cat ${apiKeyPath})
88+
FIELD_OVERRIDES='${fieldOverridesJson}'
89+
90+
EXISTING_CLIENT=$(echo "$DOWNLOAD_CLIENTS" | ${pkgs.jq}/bin/jq -r '.[] | select(.name == "${clientName}") | @json' || echo "")
91+
92+
if [ -n "$EXISTING_CLIENT" ]; then
93+
echo "Download client ${clientName} already exists, updating..."
94+
CLIENT_ID=$(echo "$EXISTING_CLIENT" | ${pkgs.jq}/bin/jq -r '.id')
95+
96+
UPDATED_CLIENT=$(apply_field_overrides "$EXISTING_CLIENT" "$CLIENT_API_KEY" "$FIELD_OVERRIDES")
97+
98+
${pkgs.curl}/bin/curl -sSf -X PUT \
99+
-H "X-Api-Key: $API_KEY" \
100+
-H "Content-Type: application/json" \
101+
-d "$UPDATED_CLIENT" \
102+
"$BASE_URL/downloadclient/$CLIENT_ID" >/dev/null
103+
104+
echo "Download client ${clientName} updated"
105+
else
106+
echo "Download client ${clientName} does not exist, creating..."
107+
108+
SCHEMA=$(echo "$SCHEMAS" | ${pkgs.jq}/bin/jq -r '.[] | select(.implementationName == "${implementationName}") | @json' || echo "")
109+
110+
if [ -z "$SCHEMA" ]; then
111+
echo "Error: No schema found for download client implementationName ${implementationName}"
112+
exit 1
113+
fi
114+
115+
NEW_CLIENT=$(apply_field_overrides "$SCHEMA" "$CLIENT_API_KEY" "$FIELD_OVERRIDES")
116+
117+
${pkgs.curl}/bin/curl -sSf -X POST \
118+
-H "X-Api-Key: $API_KEY" \
119+
-H "Content-Type: application/json" \
120+
-d "$NEW_CLIENT" \
121+
"$BASE_URL/downloadclient" >/dev/null
122+
123+
echo "Download client ${clientName} created"
124+
fi
125+
'')
126+
serviceConfig.downloadClients}
127+
128+
echo "${capitalizedName} download clients configuration complete"
129+
'';
130+
}

modules/arr-common/mkArrServiceModule.nix

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ with lib; let
1313
arrConfigModule = import ./configModule.nix {inherit lib;};
1414
mkArrHostConfigService = import ./hostConfigService.nix {inherit lib pkgs;};
1515
mkArrRootFoldersService = import ./rootFoldersService.nix {inherit lib pkgs;};
16+
mkArrDownloadClientsService = import ./downloadClientsService.nix {inherit lib pkgs;};
1617
capitalizedName = toUpper (substring 0 1 serviceName) + substring 1 (-1) serviceName;
1718
usesMediaDirs = !(elem serviceName ["prowlarr"]);
1819
serviceSupportsUserGroup = !(elem serviceName ["prowlarr"]);
@@ -48,7 +49,37 @@ in {
4849
config = mkOption {
4950
type =
5051
arrConfigModule
51-
(extraConfigOptions
52+
(
53+
extraConfigOptions
54+
// {
55+
downloadClients = mkOption {
56+
type = types.listOf (types.submodule {
57+
freeformType = types.attrsOf types.anything;
58+
options = {
59+
name = mkOption {
60+
type = types.str;
61+
description = "User-defined name for the download client instance";
62+
};
63+
implementationName = mkOption {
64+
type = types.str;
65+
description = "Type of download client to configure (matches schema implementationName)";
66+
example = "SABnzbd";
67+
};
68+
apiKeyPath = mkOption {
69+
type = types.str;
70+
description = "Path to file containing the API key for the download client";
71+
};
72+
};
73+
});
74+
default = [];
75+
description = ''
76+
List of download clients to configure via the API /downloadclient endpoint.
77+
Any additional attributes beyond name, implementationName, and apiKeyPath
78+
will be applied as field values to the download client schema.
79+
The useSsl field defaults to true if not specified.
80+
'';
81+
};
82+
}
5283
// optionalAttrs usesMediaDirs {
5384
rootFolders = mkOption {
5485
type = types.listOf types.attrs;
@@ -61,7 +92,8 @@ in {
6192
For Lidarr, additional fields are required like defaultQualityProfileId, etc.
6293
'';
6394
};
64-
});
95+
}
96+
);
6597
default = {};
6698
description = "${capitalizedName} configuration options that will be set via the API.";
6799
};
@@ -106,6 +138,16 @@ in {
106138
else ""
107139
);
108140
};
141+
downloadClients = mkDefault (
142+
optionals (config.nixflix.sabnzbd.enable or false) [
143+
{
144+
name = "SABnzbd";
145+
implementationName = "SABnzbd";
146+
inherit (config.nixflix.sabnzbd) apiKeyPath;
147+
urlBase = config.nixflix.sabnzbd.settings.url_base;
148+
}
149+
]
150+
);
109151
};
110152

111153
nixflix.dirRegistrations =
@@ -290,6 +332,10 @@ in {
290332
# Only create root folders service if rootFolders is not empty
291333
// optionalAttrs (usesMediaDirs && cfg.config.apiKeyPath != null && cfg.config.rootFolders != []) {
292334
"${serviceName}-rootfolders" = mkArrRootFoldersService serviceName cfg.config;
335+
}
336+
# Only create download clients service if downloadClients is not empty
337+
// optionalAttrs (cfg.config.apiKeyPath != null) {
338+
"${serviceName}-downloadclients" = mkArrDownloadClientsService serviceName cfg.config;
293339
};
294340
};
295341
}

modules/sabnzbd/configureApiService.nix

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,11 @@ in {
146146
description = "Configure SABnzbd via API";
147147
after = ["sabnzbd.service"];
148148
bindsTo = ["sabnzbd.service"];
149+
wantedBy = ["sabnzbd.service"];
149150

150151
serviceConfig = {
151152
Type = "oneshot";
153+
RemainAfterExit = true;
152154
};
153155

154156
script = ''

modules/sabnzbd/default.nix

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,6 @@ in {
165165
after = ["nixflix-setup-dirs.service" "network-online.target"];
166166
requires = ["nixflix-setup-dirs.service"];
167167
wants = ["network-online.target"];
168-
# Ensure sabnzbd-config runs every time sabnzbd starts
169-
upholds = ["sabnzbd-config.service"];
170168

171169
serviceConfig = {
172170
ExecStartPre = pkgs.writeShellScript "sabnzbd-prestart" ''
@@ -178,7 +176,7 @@ in {
178176
${optionalString (cfg.apiKeyPath != null) ''
179177
export SABNZBD_API_KEY=$(${pkgs.coreutils}/bin/cat ${cfg.apiKeyPath})
180178
''}
181-
${optionalString (cfg.apiKeyPath != null) ''
179+
${optionalString (cfg.nzbKeyPath != null) ''
182180
export SABNZBD_NZB_KEY=$(${pkgs.coreutils}/bin/cat ${cfg.nzbKeyPath})
183181
''}
184182

tests/vm-tests/lidarr-basic.nix

Lines changed: 87 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,66 +2,104 @@
22
system ? builtins.currentSystem,
33
pkgs ? import <nixpkgs> {inherit system;},
44
nixosModules,
5-
}:
6-
pkgs.testers.runNixOSTest {
7-
name = "lidarr-basic-test";
8-
9-
nodes.machine = {
10-
config,
11-
pkgs,
12-
...
13-
}: {
14-
imports = [nixosModules];
5+
}: let
6+
pkgsUnfree = import pkgs.path {
7+
inherit system;
8+
config.allowUnfree = true;
9+
};
10+
in
11+
pkgsUnfree.testers.runNixOSTest {
12+
name = "lidarr-basic-test";
1513

16-
nixflix = {
17-
enable = true;
14+
nodes.machine = {
15+
config,
16+
pkgs,
17+
...
18+
}: {
19+
imports = [nixosModules];
1820

19-
lidarr = {
21+
nixflix = {
2022
enable = true;
21-
user = "testuser";
22-
mediaDirs = [
23-
{dir = "/media/music";}
24-
];
25-
config = {
26-
hostConfig = {
27-
port = 8686;
28-
username = "admin";
29-
passwordPath = "${pkgs.writeText "lidarr-password" "testpassword123"}";
23+
24+
lidarr = {
25+
enable = true;
26+
user = "testuser";
27+
mediaDirs = [
28+
{dir = "/media/music";}
29+
];
30+
config = {
31+
hostConfig = {
32+
port = 8686;
33+
username = "admin";
34+
passwordPath = "${pkgs.writeText "lidarr-password" "testpassword123"}";
35+
};
36+
apiKeyPath = "${pkgs.writeText "lidarr-apikey" "5678efgh5678efgh5678efgh5678efgh"}";
37+
};
38+
};
39+
40+
sabnzbd = {
41+
enable = true;
42+
apiKeyPath = "${pkgs.writeText "sabnzbd-apikey" "sabnzbd555555555555555555555555555"}";
43+
nzbKeyPath = "${pkgs.writeText "sabnzbd-nzbkey" "sabnzbdnzb666666666666666666666"}";
44+
settings = {
45+
port = 8080;
46+
host = "127.0.0.1";
47+
url_base = "/sabnzbd";
3048
};
31-
apiKeyPath = "${pkgs.writeText "lidarr-apikey" "5678efgh5678efgh5678efgh5678efgh"}";
3249
};
3350
};
3451
};
35-
};
3652

37-
testScript = ''
38-
start_all()
53+
testScript = ''
54+
start_all()
55+
56+
# Wait for services to start
57+
machine.wait_for_unit("lidarr.service", timeout=60)
58+
machine.wait_for_unit("sabnzbd.service", timeout=60)
59+
machine.wait_for_open_port(8686, timeout=60)
60+
machine.wait_for_open_port(8080, timeout=60)
61+
62+
# Wait for configuration services to complete
63+
machine.wait_for_unit("sabnzbd-config.service", timeout=60)
64+
machine.wait_for_unit("lidarr-config.service", timeout=180)
3965
40-
machine.wait_for_unit("lidarr.service", timeout=60)
41-
machine.wait_for_open_port(8686, timeout=60)
42-
machine.wait_for_unit("lidarr-config.service", timeout=60)
66+
# Wait for lidarr to come back up after restart
67+
machine.wait_for_unit("lidarr.service", timeout=60)
68+
machine.wait_for_open_port(8686, timeout=60)
4369
44-
# Wait for lidarr to come back up after restart
45-
machine.wait_for_unit("lidarr.service", timeout=60)
46-
machine.wait_for_open_port(8686, timeout=60)
70+
# Test API connectivity
71+
machine.succeed(
72+
"curl -f -H 'X-Api-Key: 5678efgh5678efgh5678efgh5678efgh' "
73+
"http://127.0.0.1:8686/api/v1/system/status"
74+
)
4775
48-
# Test API connectivity
49-
machine.succeed(
50-
"curl -f -H 'X-Api-Key: 5678efgh5678efgh5678efgh5678efgh' "
51-
"http://127.0.0.1:8686/api/v1/system/status"
52-
)
76+
# Wait for root folders and download clients services
77+
machine.wait_for_unit("lidarr-rootfolders.service", timeout=60)
78+
machine.wait_for_unit("lidarr-downloadclients.service", timeout=60)
5379
54-
# Wait for root folders service
55-
machine.wait_for_unit("lidarr-rootfolders.service", timeout=60)
80+
# Check root folder
81+
folders = machine.succeed(
82+
"curl -s -H 'X-Api-Key: 5678efgh5678efgh5678efgh5678efgh' "
83+
"http://127.0.0.1:8686/api/v1/rootfolder"
84+
)
85+
print(f"Root folders: {folders}")
86+
assert "/media/music" in folders, "Root folder not created"
5687
57-
# Check root folder
58-
folders = machine.succeed(
59-
"curl -s -H 'X-Api-Key: 5678efgh5678efgh5678efgh5678efgh' "
60-
"http://127.0.0.1:8686/api/v1/rootfolder"
61-
)
62-
print(f"Root folders: {folders}")
63-
assert "/media/music" in folders, "Root folder not created"
88+
# Check that SABnzbd download client was configured
89+
import json
90+
clients = machine.succeed(
91+
"curl -s -H 'X-Api-Key: 5678efgh5678efgh5678efgh5678efgh' "
92+
"http://127.0.0.1:8686/api/v1/downloadclient"
93+
)
94+
clients_list = json.loads(clients)
95+
print(f"Download clients: {clients}")
96+
assert len(clients_list) == 1, f"Expected 1 download client, found {len(clients_list)}"
97+
assert clients_list[0]['name'] == 'SABnzbd', \
98+
f"Expected SABnzbd download client, found {clients_list[0]['name']}"
99+
assert clients_list[0]['implementationName'] == 'SABnzbd', \
100+
"Expected SABnzbd implementation"
101+
print("SABnzbd download client configured successfully!")
64102
65-
machine.succeed("pgrep -u testuser dotnet")
66-
'';
67-
}
103+
machine.succeed("pgrep -u testuser dotnet")
104+
'';
105+
}

0 commit comments

Comments
 (0)