Skip to content

Commit 752ab4c

Browse files
committed
implement prowlarr applications configuration
1 parent 9946d3e commit 752ab4c

3 files changed

Lines changed: 231 additions & 0 deletions

File tree

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

modules/prowlarr/default.nix

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,36 @@
66
}:
77
with lib; let
88
mkProwlarrIndexersService = import ./indexersService.nix {inherit lib pkgs;};
9+
mkProwlarrApplicationsService = import ./applicationsService.nix {inherit lib pkgs;};
910
arrCommon = import ../arr-common {
1011
inherit config lib pkgs;
1112
};
1213

14+
# List of arr services that can be auto-configured as applications
15+
arrServices = ["lidarr" "radarr" "sonarr"];
16+
17+
# Helper to create default application config for an enabled service
18+
mkDefaultApplication = serviceName: let
19+
serviceConfig = config.nixflix.${serviceName}.config;
20+
capitalizedName = lib.toUpper (builtins.substring 0 1 serviceName) + builtins.substring 1 (-1) serviceName;
21+
useNginx = config.nixflix.nginx.enable or false;
22+
baseUrl = if useNginx
23+
then "http://127.0.0.1${serviceConfig.hostConfig.urlBase}"
24+
else "http://127.0.0.1:${toString serviceConfig.hostConfig.port}${serviceConfig.hostConfig.urlBase}";
25+
prowlarrUrl = if useNginx
26+
then "http://127.0.0.1${config.nixflix.prowlarr.config.hostConfig.urlBase}"
27+
else "http://127.0.0.1:${toString config.nixflix.prowlarr.config.hostConfig.port}${config.nixflix.prowlarr.config.hostConfig.urlBase}";
28+
in mkIf (config.nixflix.${serviceName}.enable or false) {
29+
name = capitalizedName;
30+
implementationName = capitalizedName;
31+
apiKeyPath = mkDefault serviceConfig.apiKeyPath;
32+
baseUrl = mkDefault baseUrl;
33+
prowlarrUrl = mkDefault prowlarrUrl;
34+
};
35+
36+
# Generate default applications list from enabled services
37+
defaultApplications = filter (app: app != {}) (map mkDefaultApplication arrServices);
38+
1339
extraConfigOptions = {
1440
indexers = mkOption {
1541
type = types.listOf (types.submodule {
@@ -36,6 +62,32 @@ with lib; let
3662
will be applied as field values to the indexer schema.
3763
'';
3864
};
65+
66+
applications = mkOption {
67+
type = types.listOf (types.submodule {
68+
freeformType = types.attrsOf types.anything;
69+
options = {
70+
name = mkOption {
71+
type = types.str;
72+
description = "User-defined name for the application instance";
73+
};
74+
implementationName = mkOption {
75+
type = types.enum ["LazyLibrarian" "Lidarr" "Mylar" "Readarr" "Radarr" "Sonarr" "Whisper"];
76+
description = "Type of application to configure (matches schema implementationName)";
77+
};
78+
apiKeyPath = mkOption {
79+
type = types.str;
80+
description = "Path to file containing the API key for the application";
81+
};
82+
};
83+
});
84+
default = [];
85+
description = ''
86+
List of applications to configure in Prowlarr.
87+
Any additional attributes beyond name, implementationName, and apiKeyPath
88+
will be applied as field values to the application schema.
89+
'';
90+
};
3991
};
4092
in {
4193
imports = [(arrCommon.mkArrServiceModule "prowlarr" extraConfigOptions)];
@@ -48,11 +100,16 @@ in {
48100
port = lib.mkDefault 9696;
49101
branch = lib.mkDefault "master";
50102
};
103+
applications = lib.mkDefault defaultApplications;
51104
};
52105
};
53106

54107
systemd.services."prowlarr-indexers" = mkIf (config.nixflix.enable && config.nixflix.prowlarr.enable && config.nixflix.prowlarr.config.apiKeyPath != null) (
55108
mkProwlarrIndexersService "prowlarr" config.nixflix.prowlarr.config
56109
);
110+
111+
systemd.services."prowlarr-applications" = mkIf (config.nixflix.enable && config.nixflix.prowlarr.enable && config.nixflix.prowlarr.config.apiKeyPath != null) (
112+
mkProwlarrApplicationsService "prowlarr" config.nixflix.prowlarr.config
113+
);
57114
};
58115
}

tests/vm-tests/full-stack.nix

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,55 @@ pkgs.testers.runNixOSTest {
126126
machine.wait_for_unit("radarr-rootfolders.service", timeout=60)
127127
machine.wait_for_unit("lidarr-rootfolders.service", timeout=60)
128128
machine.wait_for_unit("prowlarr-indexers.service", timeout=60)
129+
machine.wait_for_unit("prowlarr-applications.service", timeout=60)
129130
130131
# Verify all processes running under correct user
131132
machine.succeed("pgrep Prowlarr")
132133
machine.succeed("pgrep -u mediauser Sonarr")
133134
machine.succeed("pgrep -u mediauser Radarr")
134135
machine.succeed("pgrep -u mediauser dotnet")
135136
137+
# Test Prowlarr applications were configured
138+
print("Testing Prowlarr applications configuration...")
139+
applications = machine.succeed(
140+
"curl -s -H 'X-Api-Key: prowlarr11111111111111111111111111' "
141+
"http://127.0.0.1:9696/api/v1/applications"
142+
)
143+
144+
# Verify we have 3 applications (Sonarr, Radarr, Lidarr)
145+
import json
146+
apps = json.loads(applications)
147+
assert len(apps) == 3, f"Expected 3 applications, found {len(apps)}"
148+
149+
# Verify each application type is present
150+
app_names = {app['name'] for app in apps}
151+
expected_apps = {'Sonarr', 'Radarr', 'Lidarr'}
152+
assert app_names == expected_apps, f"Expected {expected_apps}, found {app_names}"
153+
154+
# Verify implementation names match
155+
for app in apps:
156+
assert app['implementationName'] == app['name'], \
157+
f"Implementation name mismatch for {app['name']}"
158+
159+
# Verify baseUrl is set correctly for each application
160+
for app in apps:
161+
if app['name'] == 'Sonarr':
162+
expected_url = "http://127.0.0.1:8989"
163+
actual_url = next(f['value'] for f in app['fields'] if f['name'] == 'baseUrl')
164+
assert actual_url == expected_url, \
165+
f"Sonarr baseUrl mismatch: expected {expected_url}, got {actual_url}"
166+
elif app['name'] == 'Radarr':
167+
expected_url = "http://127.0.0.1:7878"
168+
actual_url = next(f['value'] for f in app['fields'] if f['name'] == 'baseUrl')
169+
assert actual_url == expected_url, \
170+
f"Radarr baseUrl mismatch: expected {expected_url}, got {actual_url}"
171+
elif app['name'] == 'Lidarr':
172+
expected_url = "http://127.0.0.1:8686"
173+
actual_url = next(f['value'] for f in app['fields'] if f['name'] == 'baseUrl')
174+
assert actual_url == expected_url, \
175+
f"Lidarr baseUrl mismatch: expected {expected_url}, got {actual_url}"
176+
177+
print("Prowlarr applications configuration verified successfully!")
136178
print("All services are running successfully!")
137179
'';
138180
}

0 commit comments

Comments
 (0)