From c7ecf99c3fa38108f0a71de90b82d8801af14a07 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Thu, 16 Jul 2026 15:34:56 -0400 Subject: [PATCH] Generalize tracked-data cache from Lidarr-only to all three arrs Shared cache/rescan-duration logic in common.sh now takes an arr_type param instead of being Lidarr-specific, so Sonarr and Radarr cleanup scripts get the same cache-first fetch + rescan-aware retry Lidarr had. Avoids redundant full-library API calls across scripts run back to back, and stops false failures when a fetch lands mid-rescan. --- Arrs_Stack/arr_cache_prefill.sh | 104 ++++++++ Arrs_Stack/lidarr_cache_prefill.sh | 81 ------ Arrs_Stack/lidarr_cleanup.sh | 20 +- Arrs_Stack/lidarr_duplicate_artist_cleanup.sh | 2 +- Arrs_Stack/lidarr_missing_art.sh | 4 +- Arrs_Stack/lidarr_release_fixer.sh | 4 +- Arrs_Stack/radarr_cleanup.sh | 101 +++++-- Arrs_Stack/sonarr_cleanup.sh | 100 +++++-- Deployment/master.conf.template | 4 +- common.sh | 246 +++++++++++------- 10 files changed, 430 insertions(+), 236 deletions(-) create mode 100755 Arrs_Stack/arr_cache_prefill.sh delete mode 100755 Arrs_Stack/lidarr_cache_prefill.sh diff --git a/Arrs_Stack/arr_cache_prefill.sh b/Arrs_Stack/arr_cache_prefill.sh new file mode 100755 index 0000000..5f2eafb --- /dev/null +++ b/Arrs_Stack/arr_cache_prefill.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# ============================================================================================== +# ================================= Arr Cache Prefill =========================================== +# ============================================================================================== +# +# PURPOSE +# ───────────────────────────────────────────────────────────────────────────── +# Populates the shared tracked-data cache (see arr_get_tracked_data() in common.sh) for +# Lidarr, Sonarr, and Radarr once at array start, before any other script needs it. Without +# this, each arr's cache stays cold from boot until whichever script happens to touch that +# arr first writes through — which could be hours, depending on the daily schedule. One-shot: +# runs and exits. Originally Lidarr-only (lidarr_cache_prefill.sh, 2026-07-16), generalized +# the same day to cover all three arrs once the cache mechanism itself was generalized. +# +# ============================================================================================== +# OPERATIONAL MODEL +# ============================================================================================== +# +# Each arr's container may still be starting when this fires (array just started) — retries +# reaching that arr's API for up to ARR_PREFILL_WAIT_MINUTES before giving up on it and moving +# to the next. Not fatal if one never comes up in time; that arr's cache just stays cold until +# the next script writes through naturally, exactly as it would without this script existing. +# An arr not configured on this host (e.g. Lidarr is HOST1-only) is skipped cleanly. +# +# ============================================================================================== +# CONFIGURATION +# ============================================================================================== +# +# host*.conf +# HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY +# HOST*_SONARR_URL / HOST*_SONARR_API_KEY +# HOST*_RADARR_URL / HOST*_RADARR_API_KEY +# All aliased by detect_hosts() +# +# master.conf +# ARR_PREFILL_WAIT_MINUTES — how long to retry reaching each arr before giving up on it (default 10) +# +# ============================================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source "$SCRIPT_DIR/../load_config.sh" + +parse_args "$@" + +if [[ "$EUID" -ne 0 ]]; then + error "Must be run as root" + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + warn "jq not found — skipping arr cache prefill" + exit 0 +fi + +acquire_lock + +detect_hosts + +WAIT_MINUTES="${ARR_PREFILL_WAIT_MINUTES:-10}" + +# Args: arr_type, url, api_key, api_version +_prefill_one() { + local arr_type="$1" url="$2" api_key="$3" api_version="$4" + + if [[ -z "$url" ]] || [[ -z "$api_key" ]]; then + info "${arr_type^} not configured on $MY_ID ($LOCAL_SERVER_NAME) — nothing to prefill" + return 0 + fi + + local waited=0 + until check_api "$url" "${arr_type^}" 5 >/dev/null 2>&1; do + if [[ "$waited" -ge $(( WAIT_MINUTES * 60 )) ]]; then + warn "${arr_type^} not reachable after ${WAIT_MINUTES}m — leaving cache cold, next script will write through" + return 0 + fi + sleep 15 + (( waited += 15 )) + done + + local endpoint="${ARR_LIBRARY_ENDPOINT[$arr_type]:-}" + if [[ -z "$endpoint" ]]; then + warn "No library endpoint known for ${arr_type} — skipping" + return 0 + fi + + local items + items=$(arr_api "$url" "$api_key" "$api_version" "$endpoint" "${arr_type^}") || { + warn "Could not fetch ${arr_type} library for cache prefill — leaving cache cold" + return 0 + } + + if arr_cache_write "$arr_type" "$items"; then + log "$ICON_DONE ${arr_type^} cache prefilled ($(echo "$items" | jq 'length') items)" + else + warn "Failed to write ${arr_type} cache prefill" + fi +} + +_prefill_one "lidarr" "${LIDARR_URL:-}" "${LIDARR_API_KEY:-}" "v1" +_prefill_one "sonarr" "${SONARR_URL:-}" "${SONARR_API_KEY:-}" "v3" +_prefill_one "radarr" "${RADARR_URL:-}" "${RADARR_API_KEY:-}" "v3" + +exit 0 diff --git a/Arrs_Stack/lidarr_cache_prefill.sh b/Arrs_Stack/lidarr_cache_prefill.sh deleted file mode 100755 index 8b84976..0000000 --- a/Arrs_Stack/lidarr_cache_prefill.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/bash -# ============================================================================================== -# ================================= Lidarr Cache Prefill ======================================== -# ============================================================================================== -# -# PURPOSE -# ───────────────────────────────────────────────────────────────────────────── -# Populates the shared Lidarr tracked-data cache (see lidarr_get_tracked_data() in common.sh) -# once at array start, before any other script needs it. Without this, the cache stays cold -# from boot until whichever Lidarr-touching script happens to run first and write through — -# which could be hours, depending on the daily schedule. One-shot: runs and exits. -# -# ============================================================================================== -# OPERATIONAL MODEL -# ============================================================================================== -# -# Lidarr's container may still be starting when this fires (array just started) — retries -# reaching the API for up to LIDARR_PREFILL_WAIT_MINUTES before giving up. Not fatal if it -# never comes up in time; the cache just stays cold until the next script writes through -# naturally, exactly as it would without this script existing at all. -# -# ============================================================================================== -# CONFIGURATION -# ============================================================================================== -# -# host*.conf -# HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY — aliased by detect_hosts() -# -# master.conf -# LIDARR_PREFILL_WAIT_MINUTES — how long to retry reaching Lidarr before giving up (default 10) -# -# ============================================================================================== - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -source "$SCRIPT_DIR/../load_config.sh" - -parse_args "$@" - -if [[ "$EUID" -ne 0 ]]; then - error "Must be run as root" - exit 1 -fi - -if ! command -v jq >/dev/null 2>&1; then - warn "jq not found — skipping Lidarr cache prefill" - exit 0 -fi - -acquire_lock - -detect_hosts - -if [[ -z "${LIDARR_URL:-}" ]] || [[ -z "${LIDARR_API_KEY:-}" ]]; then - info "Lidarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — nothing to prefill" - exit 0 -fi - -WAIT_MINUTES="${LIDARR_PREFILL_WAIT_MINUTES:-10}" -WAITED=0 -until check_api "$LIDARR_URL" "Lidarr" 5 >/dev/null 2>&1; do - if [[ "$WAITED" -ge $(( WAIT_MINUTES * 60 )) ]]; then - warn "Lidarr not reachable after ${WAIT_MINUTES}m — leaving cache cold, next script will write through" - exit 0 - fi - sleep 15 - (( WAITED += 15 )) -done - -ARTISTS=$(arr_api "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "artist" "Lidarr") || { - warn "Could not fetch artists for cache prefill — leaving cache cold" - exit 0 -} - -if lidarr_cache_write "$ARTISTS"; then - log "$ICON_DONE Lidarr cache prefilled ($(echo "$ARTISTS" | jq 'length') artists)" -else - warn "Failed to write Lidarr cache prefill" -fi - -exit 0 diff --git a/Arrs_Stack/lidarr_cleanup.sh b/Arrs_Stack/lidarr_cleanup.sh index ed29c0f..e0da5a7 100755 --- a/Arrs_Stack/lidarr_cleanup.sh +++ b/Arrs_Stack/lidarr_cleanup.sh @@ -15,7 +15,7 @@ # 22% of normal during an active RescanFolders), which used to trigger this script's own # hard abort every time it overlapped with a real rescan. Now it checks for an active # rescan-type command first — if one's running, it waits (calibrated to that command's -# own historical duration via lidarr_get_rescan_duration(), up to 3 strikes) and re-fetches +# own historical duration via arr_get_rescan_duration(), up to 3 strikes) and re-fetches # rather than either stacking a duplicate scan or crying wolf on a normal, if slow, state. # Only escalates to the scary abort-and-notify when the count is genuinely low AND nothing # is actively rescanning. @@ -260,14 +260,14 @@ unset _cp # repeated runs each firing their own DownloadedAlbumsScan piled up in Lidarr's command # queue behind each other rather than replacing/coalescing, contributing to a multi-hour # backlog. If something's already scanning, just wait for that one instead. -_already_active=$(lidarr_active_rescan_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1") +_already_active=$(arr_active_rescan_command "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1") if [[ -n "$_already_active" ]]; then info "$_already_active already in progress — waiting for it instead of starting a new scan" - _wait=$(( $(lidarr_get_rescan_duration "$_already_active" "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}") )) + _wait=$(( $(arr_get_rescan_duration "lidarr" "$_already_active" "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}") )) _polled=0 while [[ "$_polled" -lt "$_wait" ]]; do - [[ -z "$(lidarr_active_rescan_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1")" ]] && break + [[ -z "$(arr_active_rescan_command "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1")" ]] && break sleep 15 (( _polled += 15 )) [[ $(( _polled % 60 )) -eq 0 ]] && log " Still waiting on $_already_active... (${_polled}s elapsed)" @@ -275,11 +275,11 @@ if [[ -n "$_already_active" ]]; then elif [[ -n "$LIDARR_CONTAINER_ROOT" ]]; then info "Triggering DownloadedAlbumsScan on: $LIDARR_CONTAINER_ROOT" SCAN_PAYLOAD="{\"name\": \"DownloadedAlbumsScan\", \"path\": \"$LIDARR_CONTAINER_ROOT\"}" - trigger_and_await_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "$SCAN_PAYLOAD" "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}" + trigger_and_await_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "$SCAN_PAYLOAD" "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}" "lidarr" else info "No path map match — triggering DownloadedAlbumsScan (all root folders)" SCAN_PAYLOAD='{"name": "DownloadedAlbumsScan"}' - trigger_and_await_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "$SCAN_PAYLOAD" "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}" + trigger_and_await_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "$SCAN_PAYLOAD" "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}" "lidarr" fi # ============================================================================================== @@ -306,6 +306,8 @@ ARTIST_RESPONSE=$(arr_api "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "artist" "Lidarr" "Lidarr Cleanup" "warning" exit 1 } +# Write-through — free cache refresh from a fetch this script already needed. +arr_cache_write "lidarr" "$ARTIST_RESPONSE" ARTIST_IDS=$(echo "$ARTIST_RESPONSE" | jq -r '.[].id' 2>/dev/null) ARTIST_COUNT=$(echo "$ARTIST_IDS" | grep -c "[0-9]" 2>/dev/null || echo 0) @@ -379,10 +381,10 @@ if [[ "$_last_known" -gt 0 ]]; then _pct=$(awk "BEGIN {printf \"%d\", ($TRACKED_COUNT / $_last_known) * 100}") [[ "$_pct" -ge "${LIDARR_MIN_TRACKED_PCT:-50}" ]] && break - _active_cmd=$(lidarr_active_rescan_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1") + _active_cmd=$(arr_active_rescan_command "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1") [[ -z "$_active_cmd" ]] && break # low count, nothing rescanning — genuine, don't retry - _wait=$(( $(lidarr_get_rescan_duration "$_active_cmd" 300) / 2 )) + _wait=$(( $(arr_get_rescan_duration "lidarr" "$_active_cmd" 300) / 2 )) [[ "$_wait" -lt 30 ]] && _wait=30 warn "Tracked count ${_pct}% of last run, but $_active_cmd active — waiting ${_wait}s (strike ${_strike}/3)" sleep "$_wait" @@ -391,7 +393,7 @@ if [[ "$_last_known" -gt 0 ]]; then done if [[ "$_strike" -gt 3 ]]; then - _active_cmd=$(lidarr_active_rescan_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1") + _active_cmd=$(arr_active_rescan_command "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1") if [[ -n "$_active_cmd" ]]; then warn "Lidarr still busy ($_active_cmd) after 3 strikes — deferring to next scheduled run" exit 0 diff --git a/Arrs_Stack/lidarr_duplicate_artist_cleanup.sh b/Arrs_Stack/lidarr_duplicate_artist_cleanup.sh index 6a7de5e..abff3a4 100755 --- a/Arrs_Stack/lidarr_duplicate_artist_cleanup.sh +++ b/Arrs_Stack/lidarr_duplicate_artist_cleanup.sh @@ -134,7 +134,7 @@ trap 'rm -rf "$TMP_DIR"' EXIT # ━━━ Fetch Artists (cache-aware — waits out an active rescan rather than trusting a # mid-scan number, falls back to cache if Lidarr's still busy after the strike limit) ━━━ # ============================================================================================== -ARTISTS=$(lidarr_get_tracked_data "$LIDARR_URL" "$LIDARR_API_KEY" "v1") +ARTISTS=$(arr_get_tracked_data "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1") if [[ -z "$ARTISTS" ]]; then warn "Lidarr busy and no usable cache — deferring to next scheduled run" exit 0 diff --git a/Arrs_Stack/lidarr_missing_art.sh b/Arrs_Stack/lidarr_missing_art.sh index 2d45ff2..f2d33fc 100755 --- a/Arrs_Stack/lidarr_missing_art.sh +++ b/Arrs_Stack/lidarr_missing_art.sh @@ -256,8 +256,8 @@ echo "━━━ $ICON_SYNC Building Album Directory Map ━━━" declare -A ALBUM_DIR_MAP _artist_list=$(curl_json "$LIDARR_URL/api/v1/artist?apikey=$LIDARR_API_KEY") # Write-through — keeps the shared tracked-data cache fresh as a side effect of a fetch -# this script already needed for its own purposes. See lidarr_get_tracked_data() in common.sh. -lidarr_cache_write "$_artist_list" +# this script already needed for its own purposes. See arr_get_tracked_data() in common.sh. +arr_cache_write "lidarr" "$_artist_list" _map_artist_count=$(echo "$_artist_list" | jq '. | length') info "Fetching track files for $_map_artist_count artists..." diff --git a/Arrs_Stack/lidarr_release_fixer.sh b/Arrs_Stack/lidarr_release_fixer.sh index a8d362d..59addc2 100755 --- a/Arrs_Stack/lidarr_release_fixer.sh +++ b/Arrs_Stack/lidarr_release_fixer.sh @@ -307,8 +307,8 @@ ALL_ARTISTS=$(lidarr_api "artist") || { exit 1 } # Write-through — keeps the shared tracked-data cache fresh as a side effect of a fetch -# this script already needed for its own purposes. See lidarr_get_tracked_data() in common.sh. -lidarr_cache_write "$ALL_ARTISTS" +# this script already needed for its own purposes. See arr_get_tracked_data() in common.sh. +arr_cache_write "lidarr" "$ALL_ARTISTS" while IFS= read -r artist; do aid=$(echo "$artist" | jq -r '.id') diff --git a/Arrs_Stack/radarr_cleanup.sh b/Arrs_Stack/radarr_cleanup.sh index 13ca7ec..1f39002 100755 --- a/Arrs_Stack/radarr_cleanup.sh +++ b/Arrs_Stack/radarr_cleanup.sh @@ -10,6 +10,14 @@ # untracked that is old enough to be past the import window. Triggers an Emby # library clean after each deletion run so ghost entries disappear immediately. # +# The tracked-count floor check (Safety Layer 6) is rescan-aware: if Radarr's own +# RescanMovie/DownloadedMoviesScan is active (independently of this script's own +# lighter ProcessMonitoredDownloads pre-flight), a genuinely low mid-scan count gets +# waited out (calibrated to that command's historical duration via +# arr_get_rescan_duration(), up to 3 strikes) and re-fetched rather than triggering a +# false-alarm abort. Mirrors the same fix built for lidarr_cleanup.sh 2026-07-16 after +# a whole-library rescan there made trackFileCount read 22% of normal mid-scan. +# # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== @@ -251,7 +259,7 @@ info "Scan targets (${#SCAN_ROOTS[@]}): ${SCAN_ROOTS[*]}" info "Triggering ProcessMonitoredDownloads pre-flight" SCAN_PAYLOAD='{"name": "ProcessMonitoredDownloads"}' -trigger_and_await_command "$RADARR_URL" "$RADARR_API_KEY" "v3" "$SCAN_PAYLOAD" "${RADARR_IMPORT_SCAN_TIMEOUT:-600}" +trigger_and_await_command "$RADARR_URL" "$RADARR_API_KEY" "v3" "$SCAN_PAYLOAD" "${RADARR_IMPORT_SCAN_TIMEOUT:-600}" "radarr" # ============================================================================================== # ━━━ Fetch Radarr Tracked Files ━━━ @@ -277,6 +285,8 @@ MOVIES_RESPONSE=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "movie" "Radarr") "Radarr Cleanup" "warning" exit 1 } +# Write-through — free cache refresh from a fetch this script already needed. +arr_cache_write "radarr" "$MOVIES_RESPONSE" MOVIE_IDS=$(echo "$MOVIES_RESPONSE" | jq -r '.[].id' 2>/dev/null) MOVIE_COUNT=$(echo "$MOVIE_IDS" | grep -c "." 2>/dev/null || echo 0) @@ -294,32 +304,41 @@ info "Found $MOVIE_COUNT movies — fetching movie files..." TRACKED_FILE="$TMP_DIR/tracked_paths.txt" > "$TRACKED_FILE" -MOVIE_INDEX=0 -while IFS= read -r movie_id; do - [[ -z "$movie_id" ]] && continue - (( MOVIE_INDEX++ )) - [[ $(( MOVIE_INDEX % 100 )) -eq 0 ]] && \ - log "Fetching files: $MOVIE_INDEX/$MOVIE_COUNT movies..." - MOVIE_FILES=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "moviefile?movieId=${movie_id}" "Radarr" 2>/dev/null) - if [[ -n "$MOVIE_FILES" ]]; then - while IFS= read -r api_path; do - [[ -z "$api_path" ]] && continue - translate_path "$api_path" >> "$TRACKED_FILE" - done < <(echo "$MOVIE_FILES" | jq -r '.[].path // .path' 2>/dev/null) - fi -done <<< "$MOVIE_IDS" +# Fetches every movie's file path fresh into TRACKED_FILE/TRACKED_MAP/TRACKED_COUNT. Pulled +# into a function so the rescan-aware retry below can re-fetch after waiting without +# duplicating this whole loop inline. +_fetch_tracked_files() { + > "$TRACKED_FILE" + local _movie_index=0 + while IFS= read -r movie_id; do + [[ -z "$movie_id" ]] && continue + (( _movie_index++ )) + [[ $(( _movie_index % 100 )) -eq 0 ]] && \ + log "Fetching files: $_movie_index/$MOVIE_COUNT movies..." + MOVIE_FILES=$(arr_api "$RADARR_URL" "$RADARR_API_KEY" "v3" "moviefile?movieId=${movie_id}" "Radarr" 2>/dev/null) + if [[ -n "$MOVIE_FILES" ]]; then + while IFS= read -r api_path; do + [[ -z "$api_path" ]] && continue + translate_path "$api_path" >> "$TRACKED_FILE" + done < <(echo "$MOVIE_FILES" | jq -r '.[].path // .path' 2>/dev/null) + fi + done <<< "$MOVIE_IDS" -sort -u "$TRACKED_FILE" -o "$TRACKED_FILE" + sort -u "$TRACKED_FILE" -o "$TRACKED_FILE" -# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file -# Eliminates the main performance bottleneck for large libraries -declare -A TRACKED_MAP -while IFS= read -r _tracked_path; do - [[ -n "$_tracked_path" ]] && TRACKED_MAP["$_tracked_path"]=1 -done < "$TRACKED_FILE" -unset _tracked_path + # Build in-memory lookup map — O(1) per lookup vs O(n) grep per file + # Eliminates the main performance bottleneck for large libraries + unset TRACKED_MAP + declare -gA TRACKED_MAP + while IFS= read -r _tracked_path; do + [[ -n "$_tracked_path" ]] && TRACKED_MAP["$_tracked_path"]=1 + done < "$TRACKED_FILE" + unset _tracked_path + TRACKED_COUNT=$(wc -l < "$TRACKED_FILE") +} + +_fetch_tracked_files info "Built in-memory lookup map: ${#TRACKED_MAP[@]} tracked paths" -TRACKED_COUNT=$(wc -l < "$TRACKED_FILE") # Safety Layer 5 — tracked count > 0 if [[ "$TRACKED_COUNT" -eq 0 ]]; then @@ -331,7 +350,39 @@ fi info "$MOVIE_COUNT movies | $TRACKED_COUNT tracked movie files" -# Safety Layer 6 — percentage drop vs last known count +# Safety Layer 6 — percentage drop vs last known count, with rescan-aware retry. +# ProcessMonitoredDownloads (this script's own pre-flight) is a different, lighter operation +# than a full library rescan — but Radarr's own RescanMovie/DownloadedMoviesScan can be +# triggered independently and would cause the exact same mid-scan count dip confirmed on +# Lidarr 2026-07-16. Wait it out (calibrated to that command's own historical duration) +# before treating a drop as genuine. +_last_known=$(cat "$RADARR_TRACKED_COUNT_FILE" 2>/dev/null || echo 0) +if [[ "$_last_known" -gt 0 ]]; then + _strike=1 + while [[ "$_strike" -le 3 ]]; do + _pct=$(awk "BEGIN {printf \"%d\", ($TRACKED_COUNT / $_last_known) * 100}") + [[ "$_pct" -ge "${RADARR_MIN_TRACKED_PCT:-50}" ]] && break + + _active_cmd=$(arr_active_rescan_command "radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3") + [[ -z "$_active_cmd" ]] && break # low count, nothing rescanning — genuine, don't retry + + _wait=$(( $(arr_get_rescan_duration "radarr" "$_active_cmd" 300) / 2 )) + [[ "$_wait" -lt 30 ]] && _wait=30 + warn "Tracked count ${_pct}% of last run, but $_active_cmd active — waiting ${_wait}s (strike ${_strike}/3)" + sleep "$_wait" + _fetch_tracked_files + (( _strike++ )) + done + + if [[ "$_strike" -gt 3 ]]; then + _active_cmd=$(arr_active_rescan_command "radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3") + if [[ -n "$_active_cmd" ]]; then + warn "Radarr still busy ($_active_cmd) after 3 strikes — deferring to next scheduled run" + exit 0 + fi + fi +fi + check_tracked_count_floor "$TRACKED_COUNT" "$RADARR_TRACKED_COUNT_FILE" "$RADARR_MIN_TRACKED_PCT" "Radarr Cleanup" # ============================================================================================== diff --git a/Arrs_Stack/sonarr_cleanup.sh b/Arrs_Stack/sonarr_cleanup.sh index 79623b0..5f59bcb 100755 --- a/Arrs_Stack/sonarr_cleanup.sh +++ b/Arrs_Stack/sonarr_cleanup.sh @@ -10,6 +10,15 @@ # untracked that is old enough to be past the import window. Triggers an Emby # library clean after each deletion run so ghost entries disappear immediately. # +# The tracked-count floor check (Safety Layer 6) is rescan-aware: if Sonarr's own +# RescanSeries/DownloadedEpisodesScan is active (independently of this script's own +# lighter ProcessMonitoredDownloads pre-flight — e.g. during a large missing-episode +# search campaign), a genuinely low mid-scan count gets waited out (calibrated to that +# command's historical duration via arr_get_rescan_duration(), up to 3 strikes) and +# re-fetched rather than triggering a false-alarm abort. Mirrors the same fix built for +# lidarr_cleanup.sh 2026-07-16 after a whole-library rescan there made trackFileCount +# read 22% of normal mid-scan. +# # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== @@ -251,7 +260,7 @@ info "Scan targets (${#SCAN_ROOTS[@]}): ${SCAN_ROOTS[*]}" info "Triggering ProcessMonitoredDownloads pre-flight" SCAN_PAYLOAD='{"name": "ProcessMonitoredDownloads"}' -trigger_and_await_command "$SONARR_URL" "$SONARR_API_KEY" "v3" "$SCAN_PAYLOAD" "${SONARR_IMPORT_SCAN_TIMEOUT:-600}" +trigger_and_await_command "$SONARR_URL" "$SONARR_API_KEY" "v3" "$SCAN_PAYLOAD" "${SONARR_IMPORT_SCAN_TIMEOUT:-600}" "sonarr" # ============================================================================================== # ━━━ Fetch Sonarr Tracked Files ━━━ @@ -277,6 +286,8 @@ SERIES_RESPONSE=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "series" "Sonarr" "Sonarr Cleanup" "warning" exit 1 } +# Write-through — free cache refresh from a fetch this script already needed. +arr_cache_write "sonarr" "$SERIES_RESPONSE" SERIES_IDS=$(echo "$SERIES_RESPONSE" | jq -r '.[].id' 2>/dev/null) SERIES_COUNT=$(echo "$SERIES_IDS" | grep -c "." 2>/dev/null || echo 0) @@ -294,31 +305,40 @@ info "Found $SERIES_COUNT series — fetching episode files..." TRACKED_FILE="$TMP_DIR/tracked_paths.txt" > "$TRACKED_FILE" -SERIES_INDEX=0 -while IFS= read -r series_id; do - [[ -z "$series_id" ]] && continue - (( SERIES_INDEX++ )) - [[ $(( SERIES_INDEX % 50 )) -eq 0 ]] && \ - log "Fetching files: $SERIES_INDEX/$SERIES_COUNT series..." - SERIES_FILES=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "episodefile?seriesId=${series_id}" "Sonarr" 2>/dev/null) - if [[ -n "$SERIES_FILES" ]]; then - while IFS= read -r api_path; do - [[ -z "$api_path" ]] && continue - translate_path "$api_path" >> "$TRACKED_FILE" - done < <(echo "$SERIES_FILES" | jq -r '.[].path' 2>/dev/null) - fi -done <<< "$SERIES_IDS" +# Fetches every series' episode-file paths fresh into TRACKED_FILE/TRACKED_MAP/TRACKED_COUNT. +# Pulled into a function so the rescan-aware retry below can re-fetch after waiting without +# duplicating this whole loop inline. +_fetch_tracked_files() { + > "$TRACKED_FILE" + local _series_index=0 + while IFS= read -r series_id; do + [[ -z "$series_id" ]] && continue + (( _series_index++ )) + [[ $(( _series_index % 50 )) -eq 0 ]] && \ + log "Fetching files: $_series_index/$SERIES_COUNT series..." + SERIES_FILES=$(arr_api "$SONARR_URL" "$SONARR_API_KEY" "v3" "episodefile?seriesId=${series_id}" "Sonarr" 2>/dev/null) + if [[ -n "$SERIES_FILES" ]]; then + while IFS= read -r api_path; do + [[ -z "$api_path" ]] && continue + translate_path "$api_path" >> "$TRACKED_FILE" + done < <(echo "$SERIES_FILES" | jq -r '.[].path' 2>/dev/null) + fi + done <<< "$SERIES_IDS" -sort -u "$TRACKED_FILE" -o "$TRACKED_FILE" + sort -u "$TRACKED_FILE" -o "$TRACKED_FILE" -# Build in-memory lookup map — O(1) per lookup vs O(n) grep per file -declare -A TRACKED_MAP -while IFS= read -r _tracked_path; do - [[ -n "$_tracked_path" ]] && TRACKED_MAP["$_tracked_path"]=1 -done < "$TRACKED_FILE" -unset _tracked_path + # Build in-memory lookup map — O(1) per lookup vs O(n) grep per file + unset TRACKED_MAP + declare -gA TRACKED_MAP + while IFS= read -r _tracked_path; do + [[ -n "$_tracked_path" ]] && TRACKED_MAP["$_tracked_path"]=1 + done < "$TRACKED_FILE" + unset _tracked_path + TRACKED_COUNT=$(wc -l < "$TRACKED_FILE") +} + +_fetch_tracked_files info "Built in-memory lookup map: ${#TRACKED_MAP[@]} tracked paths" -TRACKED_COUNT=$(wc -l < "$TRACKED_FILE") # Safety Layer 5 — tracked count > 0 if [[ "$TRACKED_COUNT" -eq 0 ]]; then @@ -330,7 +350,39 @@ fi info "$SERIES_COUNT series | $TRACKED_COUNT tracked episode files" -# Safety Layer 6 — percentage drop vs last known count +# Safety Layer 6 — percentage drop vs last known count, with rescan-aware retry. +# ProcessMonitoredDownloads (this script's own pre-flight) is a different, lighter operation +# than a full library rescan — but Sonarr's own RescanSeries/DownloadedEpisodesScan can be +# triggered independently (e.g. during a large missing-episode search campaign) and would +# cause the exact same mid-scan count dip confirmed on Lidarr 2026-07-16. Wait it out +# (calibrated to that command's own historical duration) before treating a drop as genuine. +_last_known=$(cat "$SONARR_TRACKED_COUNT_FILE" 2>/dev/null || echo 0) +if [[ "$_last_known" -gt 0 ]]; then + _strike=1 + while [[ "$_strike" -le 3 ]]; do + _pct=$(awk "BEGIN {printf \"%d\", ($TRACKED_COUNT / $_last_known) * 100}") + [[ "$_pct" -ge "${SONARR_MIN_TRACKED_PCT:-50}" ]] && break + + _active_cmd=$(arr_active_rescan_command "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3") + [[ -z "$_active_cmd" ]] && break # low count, nothing rescanning — genuine, don't retry + + _wait=$(( $(arr_get_rescan_duration "sonarr" "$_active_cmd" 300) / 2 )) + [[ "$_wait" -lt 30 ]] && _wait=30 + warn "Tracked count ${_pct}% of last run, but $_active_cmd active — waiting ${_wait}s (strike ${_strike}/3)" + sleep "$_wait" + _fetch_tracked_files + (( _strike++ )) + done + + if [[ "$_strike" -gt 3 ]]; then + _active_cmd=$(arr_active_rescan_command "sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3") + if [[ -n "$_active_cmd" ]]; then + warn "Sonarr still busy ($_active_cmd) after 3 strikes — deferring to next scheduled run" + exit 0 + fi + fi +fi + check_tracked_count_floor "$TRACKED_COUNT" "$SONARR_TRACKED_COUNT_FILE" "$SONARR_MIN_TRACKED_PCT" "Sonarr Cleanup" # ============================================================================================== diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index 067abf8..6f761e9 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -300,7 +300,7 @@ "unRAID_Essentials/php_fpm_max_children.sh" # WebGUI performance tuning "unRAID_Essentials/inotify_tuning.sh" # bump inotify limits — containers miss events if exhausted "Docker_Essentials/docker_network_connect.sh" # ensure networks exist + connect containers - "Arrs_Stack/lidarr_cache_prefill.sh" # warm Lidarr tracked-data cache before anything reads it cold + "Arrs_Stack/arr_cache_prefill.sh" # warm Lidarr/Sonarr/Radarr tracked-data caches before anything reads them cold "Arrs_Stack/start_webhook_listener.sh" # arr upgrade webhook listener — continuous "Fallback/fallback.sh" # mutual failover — continuous ) @@ -1101,7 +1101,7 @@ LIDARR_CACHE_FILE="$DATA_DIR/lidarr_tracked_cache.json" LIDARR_RESCAN_DURATION_DB="$DATA_DIR/lidarr_rescan_duration.db" LIDARR_CACHE_MAX_AGE_DAYS=1 # force a live refresh (or rescan-aware wait) past this age - LIDARR_PREFILL_WAIT_MINUTES=10 # array-start prefill: how long to retry reaching Lidarr + ARR_PREFILL_WAIT_MINUTES=10 # array-start prefill: how long to retry reaching each arr LIDARR_EXTENSIONS=("flac" "mp3" "m4a" "wav" "aac" "ogg" "opus" "wma") LIDARR_PROTECTED_PATTERNS=( # Metadata diff --git a/common.sh b/common.sh index 59824f6..edf15b8 100755 --- a/common.sh +++ b/common.sh @@ -2176,16 +2176,19 @@ arr_api() { # an unreachable/timed-out scan as fatal, matching original per-script behavior. # # On a genuine "completed" observation, records the actual elapsed duration keyed by the -# command's own name via lidarr_record_rescan_duration() — this is the one place in the -# codebase that reliably watches a command from trigger to completion, so it's the natural -# spot to build up real historical duration data for the wait-calibration logic in -# lidarr_get_tracked_data(). A timeout doesn't record anything — we only know a lower bound, -# not the true duration, and recording that would corrupt future wait calculations downward. +# command's own name via arr_record_rescan_duration() (needs arr_type to know which arr's +# duration DB to write to) — this is the one place in the codebase that reliably watches a +# command from trigger to completion, so it's the natural spot to build up real historical +# duration data for the wait-calibration logic in arr_get_tracked_data(). A timeout doesn't +# record anything — we only know a lower bound, not the true duration, and recording that +# would corrupt future wait calculations downward. arr_type is optional — omit it (or pass +# empty) to skip duration recording entirely, e.g. for one-off commands that aren't one of +# the three tracked arrs. # # Usage: trigger_and_await_command "$LIDARR_URL" "$LIDARR_API_KEY" "v1" \ -# '{"name": "DownloadedAlbumsScan"}' "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}" +# '{"name": "DownloadedAlbumsScan"}' "${LIDARR_IMPORT_SCAN_TIMEOUT:-600}" "lidarr" trigger_and_await_command() { - local base_url="$1" api_key="$2" api_version="$3" payload="$4" poll_timeout="${5:-600}" + local base_url="$1" api_key="$2" api_version="$3" payload="$4" poll_timeout="${5:-600}" arr_type="${6:-}" local cmd_name cmd_name=$(echo "$payload" | jq -r '.name // empty' 2>/dev/null) @@ -2215,7 +2218,8 @@ trigger_and_await_command() { case "$scan_status" in completed) info "Import scan complete ✅" - [[ -n "$cmd_name" ]] && lidarr_record_rescan_duration "$cmd_name" "$(( $(date +%s) - start_epoch ))" + [[ -n "$cmd_name" && -n "$arr_type" ]] && \ + arr_record_rescan_duration "$arr_type" "$cmd_name" "$(( $(date +%s) - start_epoch ))" return 0 ;; failed) @@ -2232,146 +2236,208 @@ trigger_and_await_command() { } # ============================================================================================== -# ── LIDARR TRACKED-DATA CACHE ───────────────────────────────────────────────────────────────── +# ── ARR TRACKED-DATA CACHE (Lidarr, Sonarr, Radarr) ────────────────────────────────────────── # ============================================================================================== -# Shared cache for Lidarr's tracked-artist/file-count data. Multiple scripts (lidarr_cleanup.sh, -# lidarr_duplicate_artist_cleanup.sh, lidarr_missing_art.sh, lidarr_release_fixer.sh) all need a -# reasonably-current snapshot of "what does Lidarr think it has tracked." Hitting the live API -# fresh every time is wasteful, and during an active rescan the live number is actively -# misleading — tracked counts dip and recover as files are detached/re-verified one by one -# (confirmed 2026-07-16: a whole-library RescanFolders made trackFileCount read 22% of normal -# mid-scan, which is exactly the false-alarm lidarr_cleanup.sh's count-drop guard is meant to -# catch, but a genuine rescan isn't the "something's actually wrong" case that guard exists for). +# Shared cache for each arr's tracked-library data (Lidarr artists, Sonarr series, Radarr +# movies). Multiple scripts across all three arrs need a reasonably-current snapshot of "what +# does this arr think it has tracked." Hitting the live API fresh every time is wasteful, and +# during an active rescan the live number is actively misleading — tracked counts dip and +# recover as files are detached/re-verified one by one (confirmed 2026-07-16 on Lidarr: a +# whole-library RescanFolders made trackFileCount read 22% of normal mid-scan, which is +# exactly the false-alarm lidarr_cleanup.sh's count-drop guard is meant to catch, but a +# genuine rescan isn't the "something's actually wrong" case that guard exists for — the same +# risk applies to Sonarr's RescanSeries and Radarr's RescanMovie). # -# Write-through: any script that already does a live artist-list fetch for its own purposes -# writes the result here as a side effect via lidarr_cache_write() — no dedicated polling timer -# needed. Arrs_Stack/lidarr_cache_prefill.sh closes the cold-boot gap by populating the cache -# once at array start, before anything else needs it. +# Built Lidarr-only first (2026-07-16), generalized the same day to cover all three arrs — +# identical mechanism, keyed by arr_type ("lidarr"/"sonarr"/"radarr") so each arr's cache and +# duration history stay separate. # -# Consumers should call lidarr_get_tracked_data() — never read the cache file directly. It -# handles the fresh/stale-no-rescan/stale-rescan-active branching so no script reimplements it. +# Write-through: any script that already does a live library-list fetch for its own purposes +# writes the result here as a side effect via arr_cache_write() — no dedicated polling timer +# needed. Arrs_Stack/arr_cache_prefill.sh closes the cold-boot gap by populating all three +# caches once at array start, before anything else needs them. +# +# Consumers should call arr_get_tracked_data() — never read a cache file directly. It handles +# the fresh/stale-no-rescan/stale-rescan-active branching so no script reimplements it. # ============================================================================================== -LIDARR_CACHE_FILE="${LIDARR_CACHE_FILE:-$DATA_DIR/lidarr_tracked_cache.json}" -LIDARR_RESCAN_DURATION_DB="${LIDARR_RESCAN_DURATION_DB:-$DATA_DIR/lidarr_rescan_duration.db}" +# Rescan-type command names per arr — operations long/heavy enough that overlapping with one +# should trigger a wait rather than a live fetch. Verified live against each arr's API +# 2026-07-16 (not assumed from memory — RescanSeries/RescanMovie confirmed to scan the whole +# library when given no id, matching Lidarr's RescanFolders). +declare -gA ARR_RESCAN_COMMANDS=( + [lidarr]="RescanFolders DownloadedAlbumsScan RefreshArtist" + [sonarr]="RescanSeries DownloadedEpisodesScan RefreshSeries" + [radarr]="RescanMovie DownloadedMoviesScan RefreshMovie" +) -# Writes the current artist list + total tracked count to the shared cache. -# Args: artists_json (the full /api/v1/artist response body) -lidarr_cache_write() { - local artists_json="$1" - local total_files - total_files=$(echo "$artists_json" | jq '[.[].statistics.trackFileCount] | add' 2>/dev/null) - [[ -z "$total_files" || "$total_files" == "null" ]] && return 1 +# jq expression computing each arr's "total tracked" number from its library-list response — +# schemas differ: Lidarr/Sonarr have a per-item file count to sum, Radarr is a binary hasFile +# per movie (one file at most), so the aggregate has to be a count of true values instead. +declare -gA ARR_TRACKED_COUNT_EXPR=( + [lidarr]='[.[].statistics.trackFileCount] | add' + [sonarr]='[.[].statistics.episodeFileCount] | add' + [radarr]='[.[] | select(.hasFile==true)] | length' +) - mkdir -p "$(dirname "$LIDARR_CACHE_FILE")" 2>/dev/null - jq -c -n --argjson artists "$artists_json" --argjson total "$total_files" --argjson ts "$(date +%s)" \ - '{ts:$ts, totalTrackedFiles:$total, artists:$artists}' > "${LIDARR_CACHE_FILE}.tmp" 2>/dev/null \ - && mv "${LIDARR_CACHE_FILE}.tmp" "$LIDARR_CACHE_FILE" +# Library-list endpoint name per arr, for arr_api(). +declare -gA ARR_LIBRARY_ENDPOINT=( + [lidarr]="artist" + [sonarr]="series" + [radarr]="movie" +) + +arr_cache_file() { echo "${DATA_DIR}/${1}_tracked_cache.json"; } +arr_rescan_duration_db() { echo "${DATA_DIR}/${1}_rescan_duration.db"; } + +# Writes the current library-list JSON + computed total tracked count to arr_type's cache. +# Radarr's movie list alone runs ~16MB — passing that through jq's --argjson as a literal +# command-line argument blows past the OS's ARG_MAX ("Argument list too long"), the exact +# same class of bug the queue-pagination fix (2026-07-16, arrs_failed_stalled_recovery.sh) +# hit before. Fixed the same way: write the payload to a temp file and use --slurpfile, +# which reads from disk instead of argv. +# Args: arr_type, items_json (the full library-list response body) +arr_cache_write() { + local arr_type="$1" items_json="$2" + local expr="${ARR_TRACKED_COUNT_EXPR[$arr_type]:-}" + [[ -z "$expr" ]] && return 1 + local total + total=$(echo "$items_json" | jq "$expr" 2>/dev/null) + [[ -z "$total" || "$total" == "null" ]] && return 1 + + local cache_file items_tmp + cache_file=$(arr_cache_file "$arr_type") + items_tmp=$(mktemp) + echo "$items_json" > "$items_tmp" + mkdir -p "$(dirname "$cache_file")" 2>/dev/null + jq -c -n --slurpfile items "$items_tmp" --argjson total "$total" --argjson ts "$(date +%s)" \ + '{ts:$ts, totalTracked:$total, items:$items[0]}' > "${cache_file}.tmp" 2>/dev/null \ + && mv "${cache_file}.tmp" "$cache_file" + rm -f "$items_tmp" } -# Echoes the cached artists JSON array if a cache file exists, regardless of age — staleness -# is lidarr_get_tracked_data()'s decision, not this raw reader's. -lidarr_cache_read_raw() { - [[ -f "$LIDARR_CACHE_FILE" ]] || return 1 - jq -c '.artists // empty' "$LIDARR_CACHE_FILE" 2>/dev/null +# Echoes the cached library-list JSON array for arr_type if a cache file exists, regardless +# of age — staleness is arr_get_tracked_data()'s decision, not this raw reader's. +arr_cache_read_raw() { + local arr_type="$1" + local cache_file + cache_file=$(arr_cache_file "$arr_type") + [[ -f "$cache_file" ]] || return 1 + jq -c '.items // empty' "$cache_file" 2>/dev/null } -# Echoes the cache's age in seconds, or a very large number if no cache exists (so age +# Echoes arr_type's cache age in seconds, or a very large number if no cache exists (so age # comparisons naturally treat "no cache" the same as "very stale cache"). -lidarr_cache_age_seconds() { - [[ -f "$LIDARR_CACHE_FILE" ]] || { echo 999999999; return; } +arr_cache_age_seconds() { + local arr_type="$1" + local cache_file + cache_file=$(arr_cache_file "$arr_type") + [[ -f "$cache_file" ]] || { echo 999999999; return; } local ts - ts=$(jq -r '.ts // 0' "$LIDARR_CACHE_FILE" 2>/dev/null) + ts=$(jq -r '.ts // 0' "$cache_file" 2>/dev/null) echo $(( $(date +%s) - ${ts:-0} )) } -# Records how long a rescan-type command actually took, keyed by command name, so future -# waits can be calibrated per command type instead of guessed or blended across very -# different operations — a whole-library RescanFolders takes vastly longer than a targeted -# DownloadedAlbumsScan, and averaging them would miscalibrate the wait for both. -# Args: command_name, duration_seconds -lidarr_record_rescan_duration() { - local cmd_name="$1" duration="$2" +# Records how long a rescan-type command actually took for arr_type, keyed by command name, +# so future waits can be calibrated per command type instead of guessed or blended across +# very different operations — a whole-library RescanFolders/RescanSeries/RescanMovie takes +# vastly longer than a targeted DownloadedXScan, and averaging them would miscalibrate the +# wait for both. +# Args: arr_type, command_name, duration_seconds +arr_record_rescan_duration() { + local arr_type="$1" cmd_name="$2" duration="$3" [[ -z "$cmd_name" || -z "$duration" ]] && return 1 - mkdir -p "$(dirname "$LIDARR_RESCAN_DURATION_DB")" 2>/dev/null + local db + db=$(arr_rescan_duration_db "$arr_type") + mkdir -p "$(dirname "$db")" 2>/dev/null local tmp tmp=$(mktemp) - [[ -f "$LIDARR_RESCAN_DURATION_DB" ]] && grep -v "^${cmd_name}|" "$LIDARR_RESCAN_DURATION_DB" > "$tmp" 2>/dev/null + [[ -f "$db" ]] && grep -v "^${cmd_name}|" "$db" > "$tmp" 2>/dev/null echo "${cmd_name}|${duration}" >> "$tmp" - mv "$tmp" "$LIDARR_RESCAN_DURATION_DB" + mv "$tmp" "$db" } -# Echoes the last recorded duration (seconds) for a given command name, or a fallback +# Echoes the last recorded duration (seconds) for arr_type + command name, or a fallback # default if none has ever been recorded. -# Args: command_name, fallback_default_seconds -lidarr_get_rescan_duration() { - local cmd_name="$1" fallback="${2:-300}" - [[ -f "$LIDARR_RESCAN_DURATION_DB" ]] || { echo "$fallback"; return; } +# Args: arr_type, command_name, fallback_default_seconds +arr_get_rescan_duration() { + local arr_type="$1" cmd_name="$2" fallback="${3:-300}" + local db + db=$(arr_rescan_duration_db "$arr_type") + [[ -f "$db" ]] || { echo "$fallback"; return; } local val - val=$(grep "^${cmd_name}|" "$LIDARR_RESCAN_DURATION_DB" 2>/dev/null | tail -1 | cut -d'|' -f2) + val=$(grep "^${cmd_name}|" "$db" 2>/dev/null | tail -1 | cut -d'|' -f2) echo "${val:-$fallback}" } -# Checks Lidarr's command queue for any currently-active (started or queued) rescan-type -# command. Echoes the command name of the first one found (so the caller can look up its -# specific historical duration), empty if none active. -# Args: url, api_key, api_version -lidarr_active_rescan_command() { - local url="$1" api_key="$2" api_version="$3" +# Checks arr_type's command queue for any currently-active (started or queued) rescan-type +# command (per ARR_RESCAN_COMMANDS). Echoes the command name of the first one found (so the +# caller can look up its specific historical duration), empty if none active. +# Args: arr_type, url, api_key, api_version +arr_active_rescan_command() { + local arr_type="$1" url="$2" api_key="$3" api_version="$4" + local names="${ARR_RESCAN_COMMANDS[$arr_type]:-}" + [[ -z "$names" ]] && return 1 + local jq_names + jq_names=$(printf '%s\n' $names | jq -R . | jq -sc .) curl -sf --max-time 15 -H "X-Api-Key: $api_key" \ "${url}/api/${api_version}/command" 2>/dev/null | \ - jq -r '[.[] | select( + jq -r --argjson names "$jq_names" '[.[] | select( (.status=="started" or .status=="queued") and - (.name=="RescanFolders" or .name=="DownloadedAlbumsScan" or .name=="RefreshArtist") + (.name as $n | $names | index($n) != null) )] | .[0].name // empty' 2>/dev/null } -# Main entry point — the only function consuming scripts should call for tracked artist data. -# Handles fresh/stale-no-rescan/stale-rescan-active branching and always writes through on any -# live fetch it performs. Echoes the artists JSON array on success, returns 1 if no usable data -# (no cache and live fetch impossible) could be obtained. -# Args: url, api_key, api_version, max_age_days (default 1), max_wait_strikes (default 3) -lidarr_get_tracked_data() { - local url="$1" api_key="$2" api_version="$3" - local max_age_days="${4:-${LIDARR_CACHE_MAX_AGE_DAYS:-1}}" max_strikes="${5:-3}" +# Main entry point — the only function consuming scripts should call for tracked library +# data. Handles fresh/stale-no-rescan/stale-rescan-active branching and always writes through +# on any live fetch it performs. Echoes the library-list JSON array on success, returns 1 if +# no usable data (no cache and live fetch impossible) could be obtained. +# Args: arr_type, url, api_key, api_version, max_age_days (default 1), max_wait_strikes (default 3) +arr_get_tracked_data() { + local arr_type="$1" url="$2" api_key="$3" api_version="$4" + local max_age_days="${5:-${LIDARR_CACHE_MAX_AGE_DAYS:-1}}" max_strikes="${6:-3}" local max_age_seconds=$(( max_age_days * 86400 )) local age - age=$(lidarr_cache_age_seconds) + age=$(arr_cache_age_seconds "$arr_type") if [[ "$age" -lt "$max_age_seconds" ]]; then - lidarr_cache_read_raw && return 0 + arr_cache_read_raw "$arr_type" && return 0 fi # Cache missing or stale — check for an active rescan before deciding how to proceed. local active_cmd - active_cmd=$(lidarr_active_rescan_command "$url" "$api_key" "$api_version") + active_cmd=$(arr_active_rescan_command "$arr_type" "$url" "$api_key" "$api_version") if [[ -n "$active_cmd" ]]; then - local wait_duration strike - wait_duration=$(( $(lidarr_get_rescan_duration "$active_cmd" 300) / 2 )) + local wait_duration strike arr_label + wait_duration=$(( $(arr_get_rescan_duration "$arr_type" "$active_cmd" 300) / 2 )) [[ "$wait_duration" -lt 30 ]] && wait_duration=30 + arr_label="$(tr '[:lower:]' '[:upper:]' <<< "${arr_type:0:1}")${arr_type:1}" for (( strike=1; strike<=max_strikes; strike++ )); do # Redirected to stderr — this function's stdout is a data channel (callers # capture it via command substitution), never mix log/warn output into it. - warn " Lidarr busy ($active_cmd) — waiting ${wait_duration}s (strike ${strike}/${max_strikes})" >&2 + warn " $arr_label busy ($active_cmd) — waiting ${wait_duration}s (strike ${strike}/${max_strikes})" >&2 sleep "$wait_duration" - active_cmd=$(lidarr_active_rescan_command "$url" "$api_key" "$api_version") + active_cmd=$(arr_active_rescan_command "$arr_type" "$url" "$api_key" "$api_version") [[ -z "$active_cmd" ]] && break done if [[ -n "$active_cmd" ]]; then - warn " Lidarr still busy ($active_cmd) after ${max_strikes} strikes — using cache if present, else skipping" >&2 - lidarr_cache_read_raw && return 0 + warn " $arr_label still busy ($active_cmd) after ${max_strikes} strikes — using cache if present, else skipping" >&2 + arr_cache_read_raw "$arr_type" && return 0 return 1 fi fi # No active rescan (or it just finished mid-wait) — safe to fetch live and refresh cache. - local fresh - fresh=$(arr_api "$url" "$api_key" "$api_version" "artist" "Lidarr") || { - lidarr_cache_read_raw && return 0 + local endpoint fresh arr_label + endpoint="${ARR_LIBRARY_ENDPOINT[$arr_type]:-}" + [[ -z "$endpoint" ]] && return 1 + arr_label="$(tr '[:lower:]' '[:upper:]' <<< "${arr_type:0:1}")${arr_type:1}" + fresh=$(arr_api "$url" "$api_key" "$api_version" "$endpoint" "$arr_label") || { + arr_cache_read_raw "$arr_type" && return 0 return 1 } - lidarr_cache_write "$fresh" + arr_cache_write "$arr_type" "$fresh" echo "$fresh" }