From ea735d380b7f253d12da2217fa4361e36dc23ab4 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Sun, 15 Mar 2026 20:09:46 -0400 Subject: [PATCH] arr: search for missing and cutoff-unmet media --- configuration.nix | 1 + services/arr/arr-search.nix | 115 ++++++++++++++++++++++++++++++++++++ services/arr/recyclarr.nix | 2 + 3 files changed, 118 insertions(+) create mode 100644 services/arr/arr-search.nix diff --git a/configuration.nix b/configuration.nix index 18f1e25..2c19e8b 100644 --- a/configuration.nix +++ b/configuration.nix @@ -39,6 +39,7 @@ ./services/arr/bazarr.nix ./services/arr/jellyseerr.nix ./services/arr/recyclarr.nix + ./services/arr/arr-search.nix ./services/arr/init.nix ./services/soulseek.nix diff --git a/services/arr/arr-search.nix b/services/arr/arr-search.nix new file mode 100644 index 0000000..e2ee7ca --- /dev/null +++ b/services/arr/arr-search.nix @@ -0,0 +1,115 @@ +{ + pkgs, + service_configs, + ... +}: +let + radarrConfig = "${service_configs.radarr.dataDir}/config.xml"; + sonarrConfig = "${service_configs.sonarr.dataDir}/config.xml"; + + radarrUrl = "http://localhost:${builtins.toString service_configs.ports.radarr}"; + sonarrUrl = "http://localhost:${builtins.toString service_configs.ports.sonarr}"; + + curl = "${pkgs.curl}/bin/curl"; + jq = "${pkgs.jq}/bin/jq"; + grep = "${pkgs.gnugrep}/bin/grep"; + + # Max items to search per cycle per category (missing + cutoff) per app + maxPerCycle = 5; + + searchScript = pkgs.writeShellScript "arr-search" '' + set -euo pipefail + + RADARR_KEY=$(${grep} -oP '(?<=)[^<]+' ${radarrConfig}) + SONARR_KEY=$(${grep} -oP '(?<=)[^<]+' ${sonarrConfig}) + + search_radarr() { + local endpoint="$1" + local label="$2" + + local ids + ids=$(${curl} -sf --max-time 30 \ + -H "X-Api-Key: $RADARR_KEY" \ + "${radarrUrl}/api/v3/wanted/$endpoint?page=1&pageSize=${builtins.toString maxPerCycle}&monitored=true&sortKey=title&sortDirection=ascending" \ + | ${jq} -r '.records[].id // empty') + + if [ -z "$ids" ]; then + echo "radarr: no $label items" + return + fi + + local id_array + id_array=$(echo "$ids" | ${jq} -Rs '[split("\n") | .[] | select(. != "") | tonumber]') + echo "radarr: searching $label: $id_array" + + ${curl} -sf --max-time 60 \ + -H "X-Api-Key: $RADARR_KEY" \ + -H "Content-Type: application/json" \ + -X POST "${radarrUrl}/api/v3/command" \ + -d "{\"name\": \"MoviesSearch\", \"movieIds\": $id_array}" > /dev/null + } + + search_sonarr() { + local endpoint="$1" + local label="$2" + + local series_ids + series_ids=$(${curl} -sf --max-time 30 \ + -H "X-Api-Key: $SONARR_KEY" \ + "${sonarrUrl}/api/v3/wanted/$endpoint?page=1&pageSize=${builtins.toString maxPerCycle}&monitored=true&sortKey=title&sortDirection=ascending&includeSeries=true" \ + | ${jq} -r '[.records[].seriesId] | unique | .[] // empty') + + if [ -z "$series_ids" ]; then + echo "sonarr: no $label items" + return + fi + + # search per series (sonarr searches by series, not episode) + for sid in $series_ids; do + echo "sonarr: searching $label series $sid" + ${curl} -sf --max-time 60 \ + -H "X-Api-Key: $SONARR_KEY" \ + -H "Content-Type: application/json" \ + -X POST "${sonarrUrl}/api/v3/command" \ + -d "{\"name\": \"SeriesSearch\", \"seriesId\": $sid}" > /dev/null + done + } + + echo "=== arr-search $(date -Iseconds) ===" + + search_radarr "missing" "missing" + search_radarr "cutoff" "cutoff-unmet" + + search_sonarr "missing" "missing" + search_sonarr "cutoff" "cutoff-unmet" + + echo "=== done ===" + ''; +in +{ + systemd.services.arr-search = { + description = "Search for missing and cutoff-unmet media in Radarr/Sonarr"; + after = [ + "network-online.target" + "radarr.service" + "sonarr.service" + ]; + wants = [ "network-online.target" ]; + + serviceConfig = { + Type = "oneshot"; + ExecStart = "+${searchScript}"; # + prefix: runs as root to read API keys from config.xml + TimeoutSec = 300; + }; + }; + + systemd.timers.arr-search = { + description = "Periodically search for missing and cutoff-unmet media"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "*-*-* 03:00:00"; # daily at 3 AM + Persistent = true; # run on boot if missed + RandomizedDelaySec = "30m"; + }; + }; +} diff --git a/services/arr/recyclarr.nix b/services/arr/recyclarr.nix index da7414f..1dd9876 100644 --- a/services/arr/recyclarr.nix +++ b/services/arr/recyclarr.nix @@ -58,6 +58,7 @@ in upgrade = { allowed = true; until_quality = "Remux-2160p"; + until_score = 0; }; qualities = [ { name = "Remux-2160p"; } @@ -132,6 +133,7 @@ in upgrade = { allowed = true; until_quality = "WEB 2160p"; + until_score = 0; }; qualities = [ {