diff --git a/Arrs_Stack/lidarr_cleanup.sh b/Arrs_Stack/lidarr_cleanup.sh index 09ef18f..8a30957 100755 --- a/Arrs_Stack/lidarr_cleanup.sh +++ b/Arrs_Stack/lidarr_cleanup.sh @@ -329,12 +329,20 @@ TRACKED_FILE="$TMP_DIR/tracked_paths.txt" # Fetches every artist's track-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. +# +# Write-through — also caches the raw per-track data via arr_item_cache_write() (2026-07-17) +# so scripts running later in the same maintenance window (lidarr_missing_art.sh) can read it +# instead of repeating this same per-artist walk. This walk is happening regardless for our +# own cleanup decisions; the cache write is free by comparison. See common.sh for the pattern. _fetch_tracked_files() { > "$TRACKED_FILE" + local all_tracks_tmp + all_tracks_tmp=$(mktemp) while IFS= read -r artist_id; do [[ -z "$artist_id" ]] && continue ARTIST_TRACKS=$(arr_api "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "trackFile?artistId=${artist_id}" "Lidarr" 2>/dev/null) if [[ -n "$ARTIST_TRACKS" ]]; then + echo "$ARTIST_TRACKS" >> "$all_tracks_tmp" while IFS= read -r api_path; do [[ -z "$api_path" ]] && continue translate_path "$api_path" >> "$TRACKED_FILE" @@ -342,6 +350,9 @@ _fetch_tracked_files() { fi done <<< "$ARTIST_IDS" + arr_item_cache_write "lidarr" "$(jq -s 'add // []' "$all_tracks_tmp" 2>/dev/null)" + rm -f "$all_tracks_tmp" + sort -u "$TRACKED_FILE" -o "$TRACKED_FILE" # Build in-memory lookup map — O(1) per lookup vs O(n) grep per file diff --git a/Arrs_Stack/lidarr_missing_art.sh b/Arrs_Stack/lidarr_missing_art.sh index b5eb5c1..7718d3b 100755 --- a/Arrs_Stack/lidarr_missing_art.sh +++ b/Arrs_Stack/lidarr_missing_art.sh @@ -261,15 +261,29 @@ _artist_list=$(arr_get_tracked_data "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1 _map_artist_count=$(echo "$_artist_list" | jq '. | length') info "Fetching track files for $_map_artist_count artists..." -while IFS= read -r _artist_id; do - [[ -z "$_artist_id" ]] && continue +# Cache-first for the per-track data too — lidarr_cleanup.sh (runs earlier in the same nightly +# window) already does this exact per-artist walk for its own cleanup decisions and writes the +# result through via arr_item_cache_write(). Read that instead of repeating the walk; fall +# back to the live per-artist walk below only if it's missing or from outside this window. +# See arr_item_cache_write()/arr_get_cached_items() in common.sh. (2026-07-17) +_cached_tracks=$(arr_get_cached_items "lidarr") +if [[ -n "$_cached_tracks" ]]; then + info "Using cached track data from lidarr_cleanup.sh — skipping live per-artist walk" while IFS=$'\t' read -r _album_id _track_path; do [[ -z "$_album_id" || -z "$_track_path" || "$_track_path" == "null" ]] && continue ALBUM_DIR_MAP["$_album_id"]=$(dirname "$_track_path") - done < <(curl_json "$LIDARR_URL/api/v1/trackFile?artistId=${_artist_id}&apikey=$LIDARR_API_KEY" | \ - jq -r '.[] | [(.albumId | tostring), .path] | @tsv' 2>/dev/null) -done < <(echo "$_artist_list" | jq -r '.[].id') -unset _artist_list _map_artist_count _artist_id _album_id _track_path + done < <(echo "$_cached_tracks" | jq -r '.[] | [(.albumId | tostring), .path] | @tsv' 2>/dev/null) +else + while IFS= read -r _artist_id; do + [[ -z "$_artist_id" ]] && continue + while IFS=$'\t' read -r _album_id _track_path; do + [[ -z "$_album_id" || -z "$_track_path" || "$_track_path" == "null" ]] && continue + ALBUM_DIR_MAP["$_album_id"]=$(dirname "$_track_path") + done < <(curl_json "$LIDARR_URL/api/v1/trackFile?artistId=${_artist_id}&apikey=$LIDARR_API_KEY" | \ + jq -r '.[] | [(.albumId | tostring), .path] | @tsv' 2>/dev/null) + done < <(echo "$_artist_list" | jq -r '.[].id') +fi +unset _artist_list _map_artist_count _artist_id _album_id _track_path _cached_tracks info "Mapped ${#ALBUM_DIR_MAP[@]} albums with local tracks" diff --git a/Arrs_Stack/sonarr_cleanup.sh b/Arrs_Stack/sonarr_cleanup.sh index 0e67f91..9dca58e 100755 --- a/Arrs_Stack/sonarr_cleanup.sh +++ b/Arrs_Stack/sonarr_cleanup.sh @@ -310,9 +310,17 @@ TRACKED_FILE="$TMP_DIR/tracked_paths.txt" # 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. +# +# Write-through — also caches the raw per-episode data via arr_item_cache_write() (2026-07-17) +# for any future script that needs Sonarr's per-episode file data — none exist yet (unlike +# Lidarr, where lidarr_missing_art.sh already reads this), but the walk is happening regardless +# for our own cleanup decisions, so the cache write is free by comparison. Future consumers: +# call arr_get_cached_items("sonarr") first, fall back to your own live walk on a miss. _fetch_tracked_files() { > "$TRACKED_FILE" local _series_index=0 + local all_episodes_tmp + all_episodes_tmp=$(mktemp) while IFS= read -r series_id; do [[ -z "$series_id" ]] && continue (( _series_index++ )) @@ -320,6 +328,7 @@ _fetch_tracked_files() { 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 + echo "$SERIES_FILES" >> "$all_episodes_tmp" while IFS= read -r api_path; do [[ -z "$api_path" ]] && continue translate_path "$api_path" >> "$TRACKED_FILE" @@ -327,6 +336,9 @@ _fetch_tracked_files() { fi done <<< "$SERIES_IDS" + arr_item_cache_write "sonarr" "$(jq -s 'add // []' "$all_episodes_tmp" 2>/dev/null)" + rm -f "$all_episodes_tmp" + sort -u "$TRACKED_FILE" -o "$TRACKED_FILE" # Build in-memory lookup map — O(1) per lookup vs O(n) grep per file diff --git a/common.sh b/common.sh index 4c2e740..d208255 100755 --- a/common.sh +++ b/common.sh @@ -2387,6 +2387,71 @@ arr_cache_age_seconds() { echo $(( $(date +%s) - ${ts:-0} )) } +# ============================================================================================== +# ── PER-ITEM TRACKED-FILE CACHE (Lidarr trackFile, Sonarr episodefile) ─────────────────────── +# ============================================================================================== +# Different in kind from the tracked-LIBRARY cache above. That one caches the top-level +# artist/series/movie list (one call). This caches the much more expensive per-item walk — +# one live API call per artist/series — that lidarr_cleanup.sh/sonarr_cleanup.sh already have +# to do for their own cleanup decisions regardless. Write-through only, from a walk that's +# already happening: no independent live-fetch-and-refresh path here, since the field set +# each consumer needs differs (lidarr_cleanup.sh only needs .path; lidarr_missing_art.sh also +# needs .albumId) and there's no single generic "fetch everything" call worth centralizing — +# each script keeps its own live per-item fallback, this is purely a fast path in front of it. +# +# Freshness is short (default 4h, not the library cache's 1 day) because this is meant for a +# script running shortly after in the same maintenance window (e.g. lidarr_missing_art.sh +# right after lidarr_cleanup.sh), not held across a whole day. tmpfs only, no persistent +# backup — unlike the library cache, nothing unique lives only here (every consumer already +# has its own live fallback), and it's short-lived by design, so surviving a reboot doesn't +# matter the way it did for the always-wanted library cache. (2026-07-17) +# +# Future scripts needing this data: call arr_get_cached_items() first, fall back to your own +# live per-item fetch on a miss, and write through via arr_item_cache_write() if you're the +# one doing that fetch anyway — same pattern as lidarr_cleanup.sh/sonarr_cleanup.sh below. +# ============================================================================================== + +arr_item_cache_file() { echo "${ARR_CACHE_DIR}/${1}_items_cache.json"; } + +# Args: arr_type, items_json (array of raw per-item track/episode-file objects) +arr_item_cache_write() { + local arr_type="$1" items_json="$2" + + local url_var="${arr_type^^}_URL" key_var="${arr_type^^}_API_KEY" + local url="${!url_var:-}" api_key="${!key_var:-}" api_version="${ARR_API_VERSION[$arr_type]:-}" + if [[ -n "$url" && -n "$api_key" && -n "$api_version" ]]; then + local active_cmd + active_cmd=$(arr_active_rescan_command "$arr_type" "$url" "$api_key" "$api_version") + [[ -n "$active_cmd" ]] && return 1 + fi + + local cache_file items_tmp + cache_file=$(arr_item_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 ts "$(date +%s)" \ + '{ts:$ts, items:$items[0]}' > "${cache_file}.tmp" 2>/dev/null \ + && mv "${cache_file}.tmp" "$cache_file" + rm -f "$items_tmp" +} + +# Echoes the cached per-item array if present and fresher than max_age_seconds, empty/1 on a +# miss or stale cache — caller falls back to its own live per-item fetch exactly as it already +# does today. +# Args: arr_type, max_age_seconds (default 14400 = 4h) +arr_get_cached_items() { + local arr_type="$1" max_age="${2:-14400}" + local cache_file + cache_file=$(arr_item_cache_file "$arr_type") + [[ -f "$cache_file" ]] || return 1 + local ts age + ts=$(jq -r '.ts // 0' "$cache_file" 2>/dev/null) + age=$(( $(date +%s) - ${ts:-0} )) + [[ "$age" -ge "$max_age" ]] && return 1 + jq -c '.items // empty' "$cache_file" 2>/dev/null +} + # 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