arr-interconnect: declarative root folders for radarr/sonarr

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-07-28 11:42:04 +01:00
parent 384c6918ff
commit 81d1b80a53

View file

@ -1,5 +1,15 @@
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
let let
# Declarative root folders. The script reconciles against these: adds any
# that are missing, removes any that aren't listed. Removing a root folder
# only unregisters the path in Radarr/Sonarr — it never touches media files.
# Download-client staging dirs (/mnt/storage/usenet/downloads, the torrent
# downloads dir) must never appear here; the *arrs would import from library.
rootFolders = {
radarr = [ "/mnt/storage/torrents/movies" ];
sonarr = [ "/mnt/storage/torrents/shows" ];
};
interconnectScript = pkgs.writeShellScript "arr-interconnect" '' interconnectScript = pkgs.writeShellScript "arr-interconnect" ''
set -euo pipefail set -euo pipefail
PATH="${lib.makeBinPath [ pkgs.curl pkgs.jq pkgs.gnused pkgs.gnugrep pkgs.coreutils pkgs.systemd pkgs.sqlite pkgs.docker ]}:$PATH" PATH="${lib.makeBinPath [ pkgs.curl pkgs.jq pkgs.gnused pkgs.gnugrep pkgs.coreutils pkgs.systemd pkgs.sqlite pkgs.docker ]}:$PATH"
@ -73,6 +83,59 @@ let
wait_for "Radarr" "$BASE:7878/api/v3/system/status" "$RADARR_KEY" || true wait_for "Radarr" "$BASE:7878/api/v3/system/status" "$RADARR_KEY" || true
wait_for "Prowlarr" "$BASE:9696/api/v1/system/status" "$PROWLARR_KEY" || true wait_for "Prowlarr" "$BASE:9696/api/v1/system/status" "$PROWLARR_KEY" || true
##########################################################################
# Root folders — reconcile against the declarative list above.
#
# Runs before the Seerr blocks on purpose: they read rootfolder[0] to pick
# activeDirectory, which is only unambiguous once this has pruned the
# strays. Deleting a root folder unregisters the path only; existing
# movies/series keep their absolute paths and no files are touched.
##########################################################################
reconcile_root_folders() {
local name="$1" port="$2" key="$3" desired="$4" current
[ -n "$key" ] || return 0
current=$(curl -sf -H "X-Api-Key: $key" "$BASE:$port/api/v3/rootfolder" || true)
if [ -z "$current" ]; then
echo "$name not reachable, skipping root folders"
return 0
fi
# Refuse to prune against an empty list — that would unregister every
# root folder the moment the Nix attrset is mistyped.
if [ "$(echo "$desired" | jq 'length')" = "0" ]; then
echo "$name has no declared root folders, skipping"
return 0
fi
echo "$desired" | jq -r '.[]' | while read -r path; do
if ! echo "$current" | jq -e --arg p "$path" 'any(.[]; .path == $p)' > /dev/null; then
echo "Adding root folder to $name: $path"
curl -sf -X POST \
-H "Content-Type: application/json" \
-H "X-Api-Key: $key" \
"$BASE:$port/api/v3/rootfolder" \
-d "$(jq -n --arg p "$path" '{path: $p}')" > /dev/null \
&& echo " done" || echo " failed"
fi
done
# Tab-separated: root folder paths routinely contain spaces and brackets.
echo "$current" \
| jq -r --argjson d "$desired" \
'.[] | select(.path as $p | ($d | index($p)) == null) | "\(.id)\t\(.path)"' \
| while IFS=$'\t' read -r id path; do
echo "Removing stray root folder from $name: $path"
curl -sf -X DELETE -H "X-Api-Key: $key" \
"$BASE:$port/api/v3/rootfolder/$id" > /dev/null \
&& echo " done" || echo " failed"
done
}
reconcile_root_folders "Radarr" 7878 "$RADARR_KEY" '${builtins.toJSON rootFolders.radarr}'
reconcile_root_folders "Sonarr" 8989 "$SONARR_KEY" '${builtins.toJSON rootFolders.sonarr}'
########################################################################## ##########################################################################
# Prowlarr → Sonarr (push indexers for TV) # Prowlarr → Sonarr (push indexers for TV)
########################################################################## ##########################################################################
@ -519,9 +582,10 @@ RUBY
fi fi
# --- Radarr. activeProfileId/activeDirectory are required and have no # --- Radarr. activeProfileId/activeDirectory are required and have no
# --- sane default, so pull them from Radarr itself. # --- sane default, so pull them from Radarr itself. rootfolder[0] is
# ponytail: takes Radarr's first quality profile and root folder; pick # --- unambiguous because reconcile_root_folders ran first.
# them explicitly here if you ever run more than one of either. # ponytail: still takes the first quality profile; name it explicitly
# here if you ever run more than one.
if [ -n "$RADARR_KEY" ] && [ "$(seerr_get /settings/radarr | jq 'length')" = "0" ]; then if [ -n "$RADARR_KEY" ] && [ "$(seerr_get /settings/radarr | jq 'length')" = "0" ]; then
# || true on every assignment: set -e aborts the whole job on a bare # || true on every assignment: set -e aborts the whole job on a bare
# failing substitution, and Radarr being briefly down must not take # failing substitution, and Radarr being briefly down must not take