feat: arr-native distributed media sync + config architecture fixes

Arr sync (new):
- Media/arr_sync.sh — full mesh bidirectional sync across all HOST* nodes
  - Lidarr (MusicBrainz), Sonarr (TVDB), Radarr (TMDB) all handled in one script
  - Remote API keys read live from config.xml via SSH — never stored in conf files
  - Shared blocklist (DATA_DIR/arr_sync_blocklist.tsv) merged from all nodes at runtime
  - Graceful skip if arr not configured locally or not reachable on a remote node
  - --blocklist-add / --blocklist-remove / --blocklist-list management flags
- daily_sync_maintenance.sh — arr sync runs as explicit phase before rsync
- partnership_onboard.sh — Step 3 bootstraps merged library on both sides at onboard
- master.conf — ARR_SYNC_* config block, DOCKER_APPDATA_BASE

Rsync / cleanup:
- DEFAULT_RSYNC_OPTS — removed --delete; arr_cleanup.sh owns orphan enforcement
- lidarr_cleanup.sh — removed HOST1-only guard; runs on any node with Lidarr configured

Config architecture:
- HOST1/HOST2 hostnames moved from master_host*.conf → master.conf (not credentials)
- Sparse checkout now works correctly: each server only needs its own host conf
- detect_hosts() still resolves MY_ID + REMOTE_ID via master.conf hostname values

Bug fix:
- common.sh line 493 — watchdog toggle eval had broken quoting; all SYS_WATCHDOG_CHECK_*
  globals were silently set to empty instead of their configured values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gmer4Lfe
2026-05-09 09:53:40 -04:00
co-authored by Claude Sonnet 4.6
parent 009820e981
commit b65f572367
21 changed files with 1548 additions and 272 deletions
+692
View File
@@ -0,0 +1,692 @@
#!/bin/bash
# ==============================================================================================
# ================================= ARR SYNC ===================================================
# ==============================================================================================
# Bidirectional arr library sync across all nodes in the ecosystem.
# Syncs Lidarr, Sonarr, and Radarr libraries so every node tracks the same content.
# Run before rsync — once arrs agree on library, rsync spreads the files.
#
# ── DESIGN ────────────────────────────────────────────────────────────────────────────────────
# Full mesh: every node syncs with every other node — no primary, no hierarchy.
# Union model: if any node tracks an item, all nodes get it (unless blocklisted).
# Convergence: any node can add content; after one full cycle all nodes agree.
# Upgrade-aware: server1 upgrades a file → arr tracks new path → rsync spreads it →
# arr_cleanup removes old file on all nodes because arr no longer tracks it.
#
# ── NODE DISCOVERY ────────────────────────────────────────────────────────────────────────────
# Reads HOST* vars from master.conf. Add HOST3= and it joins the sync automatically.
# No scripts change when adding a new node.
#
# ── REMOTE API KEY ACCESS ─────────────────────────────────────────────────────────────────────
# Remote API keys are not stored anywhere. Script SSHes to each remote node and reads
# the key directly from that arr's config.xml in its appdata directory.
# Only the API response (JSON) is returned — key never leaves the remote node.
# Self-maintaining: remote key regeneration is picked up automatically.
#
# ── BLOCKLIST ─────────────────────────────────────────────────────────────────────────────────
# ARR_SYNC_BLOCKLIST in DATA_DIR tombstones IDs that must never be re-added anywhere.
# Read from ALL nodes via SSH at start of each run — immediate effect before rsync.
# Propagates to all nodes via rsync on the next cycle.
# Manage via --blocklist-add / --blocklist-remove / --blocklist-list.
#
# ── GRACEFUL SKIP ─────────────────────────────────────────────────────────────────────────────
# Arr not configured locally → skip cleanly, no error.
# Arr not reachable on a remote → skip that node for that arr type, continue with others.
# Partial mesh works — nodes that share an arr type sync with each other.
#
# ── WHAT GETS SYNCED ──────────────────────────────────────────────────────────────────────────
# Library items (tracked artists/series/movies) keyed on stable IDs:
# Lidarr — MusicBrainz artist ID (foreignArtistId)
# Sonarr — TVDB series ID (tvdbId)
# Radarr — TMDB movie ID (tmdbId)
# When adding to a remote node, that node's own quality profile, metadata profile,
# and root folder path are used — settings are never copied from the source node.
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# arr_sync.sh — sync all arr types, all nodes
# arr_sync.sh --dry-run — preview only, no changes
# arr_sync.sh --log — verbose output
# arr_sync.sh --status — show config and exit
# arr_sync.sh --blocklist-add lidarr <id> "reason" — tombstone an ID on all nodes
# arr_sync.sh --blocklist-remove lidarr <id> — un-tombstone an ID
# arr_sync.sh --blocklist-list — show all blocklisted IDs
#
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
# ARR_SYNC_ENABLED — global on/off toggle (default true)
# ARR_SYNC_BLOCKLIST — path to TSV blocklist (default: DATA_DIR/arr_sync_blocklist.tsv)
# ARR_SYNC_CONNECT_TIMEOUT — SSH connect timeout in seconds (default 10)
# ARR_SYNC_API_TIMEOUT — curl API call timeout in seconds (default 60)
# DOCKER_APPDATA_BASE — base path for arr appdata dirs (default /mnt/user/appdata)
# ARR_SYNC_LIDARR_PORT — Lidarr port on all nodes (default 8686)
# ARR_SYNC_SONARR_PORT — Sonarr port on all nodes (default 8989)
# ARR_SYNC_RADARR_PORT — Radarr port on all nodes (default 7878)
#
# ── CONFIGURATION (master_host*.conf) ─────────────────────────────────────────────────────────
# HOST*_LIDARR_URL / HOST*_LIDARR_API_KEY — local Lidarr (aliased by detect_hosts)
# HOST*_SONARR_URL / HOST*_SONARR_API_KEY — local Sonarr
# HOST*_RADARR_URL / HOST*_RADARR_API_KEY — local Radarr
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Blocklist flag pre-processing ─────────────────────────────────────────────────────────────
# Handle before parse_args — these are action flags, not standard options
BLOCKLIST_ACTION=""
BLOCKLIST_ARR=""
BLOCKLIST_ID=""
BLOCKLIST_REASON=""
FILTERED_ARGS=()
_skip_next=false
for _arg in "$@"; do
if [[ "$_skip_next" == true ]]; then _skip_next=false; continue; fi
case "$_arg" in
--blocklist-add) BLOCKLIST_ACTION="add" ;;
--blocklist-remove) BLOCKLIST_ACTION="remove" ;;
--blocklist-list) BLOCKLIST_ACTION="list" ;;
*) FILTERED_ARGS+=("$_arg") ;;
esac
done
unset _arg _skip_next
parse_args "${FILTERED_ARGS[@]}"
# Consume blocklist positional args from remaining FILTERED_ARGS
# --blocklist-add lidarr <id> "reason"
# --blocklist-remove lidarr <id>
if [[ -n "$BLOCKLIST_ACTION" ]] && [[ "$BLOCKLIST_ACTION" != "list" ]]; then
for _a in "${FILTERED_ARGS[@]}"; do
[[ "$_a" == --* ]] && continue
if [[ -z "$BLOCKLIST_ARR" ]]; then BLOCKLIST_ARR="$_a"
elif [[ -z "$BLOCKLIST_ID" ]]; then BLOCKLIST_ID="$_a"
elif [[ -z "$BLOCKLIST_REASON" ]]; then BLOCKLIST_REASON="$_a"
fi
done
unset _a
fi
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Setup ━━━"
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
for _tool in curl jq; do
if ! command -v "$_tool" >/dev/null 2>&1; then
error "$_tool not found — required for arr API calls"
exit 1
fi
done
unset _tool
detect_hosts
# ── Runtime config with defaults ──────────────────────────────────────────────────────────────
ARR_SYNC_ENABLED="${ARR_SYNC_ENABLED:-true}"
ARR_SYNC_BLOCKLIST="${ARR_SYNC_BLOCKLIST:-${DATA_DIR}/arr_sync_blocklist.tsv}"
ARR_SYNC_CONNECT_TIMEOUT="${ARR_SYNC_CONNECT_TIMEOUT:-10}"
ARR_SYNC_API_TIMEOUT="${ARR_SYNC_API_TIMEOUT:-60}"
DOCKER_APPDATA_BASE="${DOCKER_APPDATA_BASE:-/mnt/user/appdata}"
ARR_SYNC_LIDARR_PORT="${ARR_SYNC_LIDARR_PORT:-8686}"
ARR_SYNC_SONARR_PORT="${ARR_SYNC_SONARR_PORT:-8989}"
ARR_SYNC_RADARR_PORT="${ARR_SYNC_RADARR_PORT:-7878}"
if [[ "$ARR_SYNC_ENABLED" != "true" ]]; then
log "ARR_SYNC_ENABLED=false — exiting"
exit 0
fi
# ── Arr type definitions ───────────────────────────────────────────────────────────────────────
# Each arr type maps to its port, API version, endpoint, stable ID field, and display name field
declare -A _PORT=([lidarr]="$ARR_SYNC_LIDARR_PORT" [sonarr]="$ARR_SYNC_SONARR_PORT" [radarr]="$ARR_SYNC_RADARR_PORT")
declare -A _VER=( [lidarr]="v1" [sonarr]="v3" [radarr]="v3")
declare -A _EP=( [lidarr]="artist" [sonarr]="series" [radarr]="movie")
declare -A _ID=( [lidarr]="foreignArtistId" [sonarr]="tvdbId" [radarr]="tmdbId")
declare -A _NAME=([lidarr]="artistName" [sonarr]="title" [radarr]="title")
# ID type: "string" for MusicBrainz UUID, "int" for TVDB/TMDB numeric IDs
declare -A _ID_TYPE=([lidarr]="string" [sonarr]="int" [radarr]="int")
ARR_TYPES=(lidarr sonarr radarr)
# ── Remote node discovery ──────────────────────────────────────────────────────────────────────
REMOTE_NODES=()
for _hv in HOST1 HOST2 HOST3 HOST4 HOST5 HOST6 HOST7 HOST8; do
[[ "$_hv" == "$MY_ID" ]] && continue
[[ -z "${!_hv:-}" ]] && continue
REMOTE_NODES+=("$_hv")
done
unset _hv
if [[ "${#REMOTE_NODES[@]}" -eq 0 ]]; then
warn "No remote nodes defined in master.conf — nothing to sync"
exit 0
fi
echo " Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Remote nodes: ${REMOTE_NODES[*]}"
echo " Blocklist: $ARR_SYNC_BLOCKLIST"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ── BLOCKLIST ─────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
declare -A BLOCKLIST_MAP # key: "arr_type:stable_id" → display name
_load_blocklist() {
BLOCKLIST_MAP=()
local count=0
_parse_blocklist_lines() {
while IFS=$'\t' read -r arr_type stable_id display_name rest; do
[[ -z "$arr_type" ]] || [[ "$arr_type" == \#* ]] && continue
BLOCKLIST_MAP["${arr_type}:${stable_id}"]="$display_name"
(( count++ ))
done
}
# Local blocklist
[[ -f "$ARR_SYNC_BLOCKLIST" ]] && _parse_blocklist_lines < "$ARR_SYNC_BLOCKLIST"
# Remote blocklists — read via SSH so tombstones are effective immediately
for _node_id in "${REMOTE_NODES[@]}"; do
local _node_ip
_node_ip=$(_resolve_node_ip "$_node_id") || continue
local _remote_lines
_remote_lines=$(ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \
root@"$_node_ip" "cat '$ARR_SYNC_BLOCKLIST' 2>/dev/null" 2>/dev/null) || continue
_parse_blocklist_lines <<< "$_remote_lines"
done
unset _node_id _node_ip _remote_lines
log "Loaded blocklist: $count entries from $(( ${#REMOTE_NODES[@]} + 1 )) nodes"
unset -f _parse_blocklist_lines
}
_is_blocklisted() {
[[ -n "${BLOCKLIST_MAP["${1}:${2}"]:-}" ]]
}
_blocklist_add() {
local arr_type="$1" stable_id="$2" display_name="$3" reason="${4:-manually excluded}"
mkdir -p "$(dirname "$ARR_SYNC_BLOCKLIST")"
if ! grep -qP "^${arr_type}\t${stable_id}\t" "$ARR_SYNC_BLOCKLIST" 2>/dev/null; then
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$arr_type" "$stable_id" "$display_name" \
"$MY_ID" "$(date -Iseconds)" "$reason" \
>> "$ARR_SYNC_BLOCKLIST"
BLOCKLIST_MAP["${arr_type}:${stable_id}"]="$display_name"
log "Blocklisted: [$arr_type] $display_name ($stable_id)"
else
log "Already blocklisted: [$arr_type] $stable_id"
fi
}
_blocklist_remove() {
local arr_type="$1" stable_id="$2"
if [[ -f "$ARR_SYNC_BLOCKLIST" ]]; then
local tmp
tmp=$(mktemp)
grep -vP "^${arr_type}\t${stable_id}\t" "$ARR_SYNC_BLOCKLIST" > "$tmp" && \
mv "$tmp" "$ARR_SYNC_BLOCKLIST" || rm -f "$tmp"
unset "BLOCKLIST_MAP[${arr_type}:${stable_id}]"
log "Removed from blocklist: [$arr_type] $stable_id"
fi
}
# ── Blocklist management mode ──────────────────────────────────────────────────────────────────
if [[ -n "$BLOCKLIST_ACTION" ]]; then
case "$BLOCKLIST_ACTION" in
list)
echo ""
echo "━━━━━ ARR SYNC BLOCKLIST ━━━━━"
if [[ ! -f "$ARR_SYNC_BLOCKLIST" ]] || [[ ! -s "$ARR_SYNC_BLOCKLIST" ]]; then
echo " (empty)"
else
echo ""
printf '%-8s %-40s %-30s %s\n' "Arr" "Stable ID" "Name" "Reason"
printf '%-8s %-40s %-30s %s\n' "---" "---------" "----" "------"
while IFS=$'\t' read -r arr_type stable_id display_name tombstoned_by ts reason; do
[[ -z "$arr_type" ]] || [[ "$arr_type" == \#* ]] && continue
printf '%-8s %-40s %-30s %s\n' "$arr_type" "$stable_id" "$display_name" "$reason"
done < "$ARR_SYNC_BLOCKLIST"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
;;
add)
if [[ -z "$BLOCKLIST_ARR" ]] || [[ -z "$BLOCKLIST_ID" ]]; then
error "Usage: arr_sync.sh --blocklist-add <arr_type> <stable_id> [reason]"
error " arr_type: lidarr | sonarr | radarr"
exit 1
fi
_blocklist_add "$BLOCKLIST_ARR" "$BLOCKLIST_ID" "${BLOCKLIST_ID}" "${BLOCKLIST_REASON:-manually excluded}"
echo " Blocklisted [$BLOCKLIST_ARR] $BLOCKLIST_ID — will propagate via rsync on next cycle"
exit 0
;;
remove)
if [[ -z "$BLOCKLIST_ARR" ]] || [[ -z "$BLOCKLIST_ID" ]]; then
error "Usage: arr_sync.sh --blocklist-remove <arr_type> <stable_id>"
exit 1
fi
_blocklist_remove "$BLOCKLIST_ARR" "$BLOCKLIST_ID"
echo " Removed [$BLOCKLIST_ARR] $BLOCKLIST_ID from blocklist"
exit 0
;;
esac
fi
# ==============================================================================================
# ── STATUS ────────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY ARR SYNC STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_HOST Remote nodes: ${REMOTE_NODES[*]}"
echo "$ICON_GEAR Appdata base: $DOCKER_APPDATA_BASE"
echo "$ICON_GEAR Blocklist: $ARR_SYNC_BLOCKLIST"
echo "$ICON_GEAR Connect timeout: ${ARR_SYNC_CONNECT_TIMEOUT}s"
echo "$ICON_GEAR API timeout: ${ARR_SYNC_API_TIMEOUT}s"
echo ""
echo " Arr Port URL"
for _arr in "${ARR_TYPES[@]}"; do
local _url="" _key=""
case "$_arr" in
lidarr) _url="${LIDARR_URL:-not configured}" ;;
sonarr) _url="${SONARR_URL:-not configured}" ;;
radarr) _url="${RADARR_URL:-not configured}" ;;
esac
printf ' %-8s %-6s %s\n' "$_arr" "${_PORT[$_arr]}" "$_url"
done
unset _arr _url
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── REMOTE HELPERS ────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
_resolve_node_ip() {
local node_id="$1"
local node_name="${!node_id}"
local ts_name="${node_name,,}"
local ip
ip=$(tailscale ip -4 "$ts_name" 2>/dev/null)
[[ -z "$ip" ]] && ip=$(tailscale status 2>/dev/null | \
awk -v n="$ts_name" '$2 ~ "^" n { print $1; exit }')
[[ -z "$ip" ]] && return 1
echo "$ip"
}
# Check if arr is reachable on remote node
_remote_arr_up() {
local node_ip="$1" port="$2" api_ver="$3" config_xml="$4"
ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \
root@"$node_ip" \
"KEY=\$(grep -oP '(?<=<ApiKey>)[^<]+' '${config_xml}' 2>/dev/null)
[[ -z \"\$KEY\" ]] && exit 1
curl -sf --max-time 5 -H \"X-Api-Key: \$KEY\" \
'http://localhost:${port}/api/${api_ver}/system/status' >/dev/null" 2>/dev/null
}
# Fetch full library from remote arr — returns raw JSON array
_remote_library() {
local node_ip="$1" port="$2" api_ver="$3" endpoint="$4" config_xml="$5"
ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \
root@"$node_ip" \
"KEY=\$(grep -oP '(?<=<ApiKey>)[^<]+' '${config_xml}' 2>/dev/null)
[[ -z \"\$KEY\" ]] && exit 1
curl -sf --max-time ${ARR_SYNC_API_TIMEOUT} \
-H \"X-Api-Key: \$KEY\" \
'http://localhost:${port}/api/${api_ver}/${endpoint}'" 2>/dev/null
}
# Fetch remote arr defaults: qualityProfileId, rootFolderPath, metadataProfileId (Lidarr)
_remote_defaults() {
local node_ip="$1" port="$2" api_ver="$3" arr_type="$4" config_xml="$5"
local meta_field=""
[[ "$arr_type" == "lidarr" ]] && \
meta_field=', metadataProfileId: ($mp | map(select(.name == "Standard")) | .[0].id // .[0].id // 1)'
ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \
root@"$node_ip" bash <<REMOTE 2>/dev/null
KEY=\$(grep -oP '(?<=<ApiKey>)[^<]+' '${config_xml}' 2>/dev/null)
[[ -z "\$KEY" ]] && exit 1
QP=\$(curl -sf -H "X-Api-Key: \$KEY" "http://localhost:${port}/api/${api_ver}/qualityprofile")
RF=\$(curl -sf -H "X-Api-Key: \$KEY" "http://localhost:${port}/api/${api_ver}/rootfolder")
MP=\$(curl -sf -H "X-Api-Key: \$KEY" "http://localhost:${port}/api/${api_ver}/metadataprofile" 2>/dev/null || echo '[]')
jq -n --argjson qp "\$QP" --argjson rf "\$RF" --argjson mp "\$MP" \
'{qualityProfileId: (\$qp | .[0].id // 1), rootFolderPath: (\$rf | .[0].path // "")${meta_field}}'
REMOTE
}
# Add item to remote arr — payload is base64-encoded to avoid SSH quoting issues
_remote_add() {
local node_ip="$1" port="$2" api_ver="$3" endpoint="$4" config_xml="$5"
local encoded="$6" # base64-encoded JSON body
ssh -i "$SSH_KEY" -o ConnectTimeout="$ARR_SYNC_CONNECT_TIMEOUT" \
root@"$node_ip" bash <<REMOTE 2>/dev/null
KEY=\$(grep -oP '(?<=<ApiKey>)[^<]+' '${config_xml}' 2>/dev/null)
[[ -z "\$KEY" ]] && exit 1
BODY=\$(printf '%s' '${encoded}' | base64 -d)
curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "X-Api-Key: \$KEY" \
-H 'Content-Type: application/json' \
-d "\$BODY" \
"http://localhost:${port}/api/${api_ver}/${endpoint}"
REMOTE
}
# ==============================================================================================
# ── LOCAL HELPERS ─────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
_local_library() {
local url="$1" api_key="$2" api_ver="$3" endpoint="$4"
curl -sf --max-time "$ARR_SYNC_API_TIMEOUT" \
-H "X-Api-Key: $api_key" \
"${url}/api/${api_ver}/${endpoint}" 2>/dev/null
}
_local_defaults() {
local url="$1" api_key="$2" api_ver="$3" arr_type="$4"
local qp rf mp meta_field=""
qp=$(curl -sf -H "X-Api-Key: $api_key" "${url}/api/${api_ver}/qualityprofile" | jq '.[0].id // 1')
rf=$(curl -sf -H "X-Api-Key: $api_key" "${url}/api/${api_ver}/rootfolder" | jq -r '.[0].path // ""')
if [[ "$arr_type" == "lidarr" ]]; then
mp=$(curl -sf -H "X-Api-Key: $api_key" "${url}/api/${api_ver}/metadataprofile" | \
jq 'map(select(.name == "Standard")) | .[0].id // .[0].id // 1')
jq -n --argjson qp "$qp" --arg rf "$rf" --argjson mp "$mp" \
'{qualityProfileId: $qp, rootFolderPath: $rf, metadataProfileId: $mp}'
else
jq -n --argjson qp "$qp" --arg rf "$rf" \
'{qualityProfileId: $qp, rootFolderPath: $rf}'
fi
}
_local_add() {
local url="$1" api_key="$2" api_ver="$3" endpoint="$4" payload="$5"
curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "X-Api-Key: $api_key" \
-H "Content-Type: application/json" \
-d "$payload" \
"${url}/api/${api_ver}/${endpoint}" 2>/dev/null
}
# ==============================================================================================
# ── PAYLOAD BUILDER ───────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Builds the minimal POST body to add an item to an arr.
# Uses the TARGET node's own defaults — never copies source node settings.
_build_payload() {
local arr_type="$1" stable_id="$2" display_name="$3" monitored="$4" defaults_json="$5"
case "$arr_type" in
lidarr)
jq -n \
--arg id "$stable_id" \
--arg nm "$display_name" \
--argjson mn "$monitored" \
--argjson df "$defaults_json" \
'{
foreignArtistId: $id,
artistName: $nm,
monitored: $mn,
qualityProfileId: $df.qualityProfileId,
metadataProfileId: ($df.metadataProfileId // 1),
rootFolderPath: $df.rootFolderPath,
addOptions: {monitor: "all", searchForMissingAlbums: false}
}'
;;
sonarr)
jq -n \
--argjson id "$stable_id" \
--arg nm "$display_name" \
--argjson mn "$monitored" \
--argjson df "$defaults_json" \
'{
tvdbId: $id,
title: $nm,
monitored: $mn,
qualityProfileId: $df.qualityProfileId,
rootFolderPath: $df.rootFolderPath,
seasons: [],
addOptions: {searchForMissingEpisodes: false, monitor: "all"}
}'
;;
radarr)
jq -n \
--argjson id "$stable_id" \
--arg nm "$display_name" \
--argjson mn "$monitored" \
--argjson df "$defaults_json" \
'{
tmdbId: $id,
title: $nm,
monitored: $mn,
qualityProfileId: $df.qualityProfileId,
rootFolderPath: $df.rootFolderPath,
addOptions: {searchForMovie: false}
}'
;;
esac
}
# ==============================================================================================
# ── CORE SYNC — one arr type across all remote nodes ──────────────────────────────────────────
# ==============================================================================================
_sync_arr() {
local arr_type="$1"
local port="${_PORT[$arr_type]}"
local api_ver="${_VER[$arr_type]}"
local endpoint="${_EP[$arr_type]}"
local id_field="${_ID[$arr_type]}"
local name_field="${_NAME[$arr_type]}"
local id_type="${_ID_TYPE[$arr_type]}"
local config_xml="${DOCKER_APPDATA_BASE}/$(echo "${arr_type^}")/config.xml"
# Resolve local credentials
local local_url local_key
case "$arr_type" in
lidarr) local_url="${LIDARR_URL:-}"; local_key="${LIDARR_API_KEY:-}" ;;
sonarr) local_url="${SONARR_URL:-}"; local_key="${SONARR_API_KEY:-}" ;;
radarr) local_url="${RADARR_URL:-}"; local_key="${RADARR_API_KEY:-}" ;;
esac
if [[ -z "$local_url" ]] || [[ -z "$local_key" ]]; then
log "${arr_type^}: not configured on $MY_ID — skipping"
return 0
fi
echo ""
echo "━━━ ${arr_type^} ━━━"
# ── Fetch local library ────────────────────────────────────────────────────────────────────
local local_json
local_json=$(_local_library "$local_url" "$local_key" "$api_ver" "$endpoint")
if [[ -z "$local_json" ]] || ! echo "$local_json" | jq -e '.' >/dev/null 2>&1; then
warn "${arr_type^}: could not fetch local library — skipping"
return 0
fi
# Build local ID map: stable_id → "display_name|monitored"
declare -A local_ids
local local_count=0
local _jq_id
[[ "$id_type" == "string" ]] && _jq_id=".${id_field}" || _jq_id="(.${id_field} | tostring)"
while IFS=$'\t' read -r stable_id display_name monitored; do
[[ -z "$stable_id" ]] && continue
local_ids["$stable_id"]="${display_name}|${monitored}"
(( local_count++ ))
done < <(echo "$local_json" | jq -r \
".[] | [${_jq_id}, .${name_field}, (.monitored | tostring)] | @tsv" 2>/dev/null)
unset _jq_id
log "${arr_type^}: $local_count items in local library"
local total_added_local=0 total_added_remote=0 total_skipped=0
# ── Sync with each remote node ─────────────────────────────────────────────────────────────
for node_id in "${REMOTE_NODES[@]}"; do
local node_name="${!node_id}"
log "${arr_type^}: syncing with $node_name..."
local node_ip
node_ip=$(_resolve_node_ip "$node_id") || {
warn "${arr_type^}: cannot resolve Tailscale IP for $node_name — skipping"
continue
}
if ! _remote_arr_up "$node_ip" "$port" "$api_ver" "$config_xml"; then
log "${arr_type^}: not reachable on $node_name — skipping"
continue
fi
local remote_json
remote_json=$(_remote_library "$node_ip" "$port" "$api_ver" "$endpoint" "$config_xml")
if [[ -z "$remote_json" ]] || ! echo "$remote_json" | jq -e '.' >/dev/null 2>&1; then
warn "${arr_type^}: could not fetch library from $node_name — skipping"
continue
fi
# Build remote ID map
declare -A remote_ids
local remote_count=0
local _jq_id
[[ "$id_type" == "string" ]] && _jq_id=".${id_field}" || _jq_id="(.${id_field} | tostring)"
while IFS=$'\t' read -r stable_id display_name monitored; do
[[ -z "$stable_id" ]] && continue
remote_ids["$stable_id"]="${display_name}|${monitored}"
(( remote_count++ ))
done < <(echo "$remote_json" | jq -r \
".[] | [${_jq_id}, .${name_field}, (.monitored | tostring)] | @tsv" 2>/dev/null)
unset _jq_id
log "${arr_type^}: $remote_count items on $node_name"
# ── Remote → Local: items on remote not in local ───────────────────────────────────────
local to_add_local=()
for stable_id in "${!remote_ids[@]}"; do
[[ -n "${local_ids[$stable_id]:-}" ]] && continue
if _is_blocklisted "$arr_type" "$stable_id"; then
log "BLOCKLISTED [$arr_type] $stable_id — skipping"
(( total_skipped++ ))
continue
fi
to_add_local+=("$stable_id")
done
if [[ "${#to_add_local[@]}" -gt 0 ]]; then
local local_defs
local_defs=$(_local_defaults "$local_url" "$local_key" "$api_ver" "$arr_type")
if [[ -z "$local_defs" ]]; then
warn "${arr_type^}: could not fetch local defaults — skipping adds from $node_name"
else
for stable_id in "${to_add_local[@]}"; do
IFS='|' read -r display_name monitored <<< "${remote_ids[$stable_id]}"
local payload
payload=$(_build_payload "$arr_type" "$stable_id" "$display_name" \
"$monitored" "$local_defs")
if [[ "$DRY_RUN" == true ]]; then
log "DRY RUN: would add to local ${arr_type^}: $display_name ($stable_id)"
(( total_added_local++ ))
else
local http_code
http_code=$(_local_add "$local_url" "$local_key" "$api_ver" \
"$endpoint" "$payload")
if [[ "$http_code" == "201" ]] || [[ "$http_code" == "200" ]]; then
log "Added to local ${arr_type^}: $display_name"
local_ids["$stable_id"]="${display_name}|${monitored}"
(( total_added_local++ ))
else
warn "Failed to add to local ${arr_type^}: $display_name (HTTP $http_code)"
fi
fi
done
fi
fi
# ── Local → Remote: items on local not on remote ───────────────────────────────────────
local to_add_remote=()
for stable_id in "${!local_ids[@]}"; do
[[ -n "${remote_ids[$stable_id]:-}" ]] && continue
if _is_blocklisted "$arr_type" "$stable_id"; then
(( total_skipped++ ))
continue
fi
to_add_remote+=("$stable_id")
done
if [[ "${#to_add_remote[@]}" -gt 0 ]]; then
local remote_defs
remote_defs=$(_remote_defaults "$node_ip" "$port" "$api_ver" "$arr_type" "$config_xml")
if [[ -z "$remote_defs" ]]; then
warn "${arr_type^}: could not fetch defaults from $node_name — skipping remote adds"
else
for stable_id in "${to_add_remote[@]}"; do
IFS='|' read -r display_name monitored <<< "${local_ids[$stable_id]}"
local payload
payload=$(_build_payload "$arr_type" "$stable_id" "$display_name" \
"$monitored" "$remote_defs")
if [[ "$DRY_RUN" == true ]]; then
log "DRY RUN: would add to $node_name ${arr_type^}: $display_name ($stable_id)"
(( total_added_remote++ ))
else
local encoded http_code
encoded=$(printf '%s' "$payload" | base64 -w0)
http_code=$(_remote_add "$node_ip" "$port" "$api_ver" "$endpoint" \
"$config_xml" "$encoded")
if [[ "$http_code" == "201" ]] || [[ "$http_code" == "200" ]]; then
log "Added to $node_name ${arr_type^}: $display_name"
(( total_added_remote++ ))
else
warn "Failed to add to $node_name ${arr_type^}: $display_name (HTTP $http_code)"
fi
fi
done
fi
fi
echo " $node_name: +${#to_add_local[@]} local | +${#to_add_remote[@]} remote | $total_skipped blocklisted"
unset remote_ids
declare -A remote_ids
done
echo " Total added to local: $total_added_local | to remotes: $total_added_remote | blocklisted: $total_skipped"
unset local_ids
declare -A local_ids
}
# ==============================================================================================
# ━━━ Main ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Loading Blocklist ━━━"
_load_blocklist
START=$(date +%s)
for arr_type in "${ARR_TYPES[@]}"; do
_sync_arr "$arr_type"
done
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY ARR SYNC SUMMARY ━━━━━"
echo "$ICON_HOST Node: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_SYNC Peers: ${REMOTE_NODES[*]}"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes were made"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
+9 -13
View File
@@ -43,9 +43,8 @@
# The user accepts full responsibility — this is 100% intentional by design.
#
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
# Lidarr runs on HOST1 only — music library is HOST1's source of truth.
# detect_hosts() sets MY_ID — if MY_ID != HOST1 script exits cleanly with no action.
# LIDARR_URL, LIDARR_API_KEY, LIDARR_MUSIC_ROOT aliased by detect_hosts() automatically.
# Runs on any node where Lidarr is configured — skips cleanly if LIDARR_URL/API_KEY not set.
# detect_hosts() aliases LIDARR_URL, LIDARR_API_KEY, LIDARR_MUSIC_ROOT from HOST*_LIDARR_*.
#
# ── SAFEGUARDS ────────────────────────────────────────────────────────────────────────────────
# acquire_lock "wait" — large scans take time, wait for previous run to finish
@@ -155,9 +154,9 @@ acquire_lock "wait"
# detect_hosts() sets MY_ID and aliases LIDARR_URL, LIDARR_API_KEY, LIDARR_MUSIC_ROOT
detect_hosts
# Lidarr is HOST1 only — exit cleanly on any other host
if [[ "$MY_ID" != "HOST1" ]]; then
log "Lidarr runs on HOST1 only — skipping on $MY_ID ($LOCAL_SERVER_NAME)"
# Skip if Lidarr is not configured on this host
if [[ -z "${LIDARR_URL:-}" ]] || [[ -z "${LIDARR_API_KEY:-}" ]]; then
log "Lidarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping"
exit 0
fi
@@ -182,8 +181,7 @@ if [[ ! -d "$LIDARR_MUSIC_ROOT" ]]; then
exit 1
fi
log "Lidarr URL: $LIDARR_URL"
log "Music root: $LIDARR_MUSIC_ROOT"
echo " $MY_ID ($LOCAL_SERVER_NAME) — $LIDARR_URL"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted"
[[ "$I_KNOW" == true ]] && warn "OVERRIDE — --i-know-what-im-doing active"
@@ -310,7 +308,7 @@ fi
# Safety Layer 3 — API version check
check_arr_version "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "$LIDARR_VERSION_MAJOR" "Lidarr" || exit 1
log "Querying Lidarr API: $LIDARR_URL"
echo " Querying Lidarr API..."
# Fetch all artists
ARTIST_RESPONSE=$(lidarr_api "artist") || {
@@ -371,7 +369,7 @@ if [[ "$TRACKED_COUNT" -eq 0 ]]; then
exit 1
fi
warn "Lidarr tracks $TRACKED_COUNT files across $ARTIST_COUNT artists"
echo " $ARTIST_COUNT artists | $TRACKED_COUNT tracked files"
# Safety Layer 6 — percentage drop vs last known count
if [[ -f "$LIDARR_TRACKED_COUNT_FILE" ]]; then
@@ -399,9 +397,7 @@ echo "$TRACKED_COUNT" > "$LIDARR_TRACKED_COUNT_FILE"
# ==============================================================================================
echo ""
echo "━━━ $ICON_CLEAN Scanning Music Root ━━━"
log "Root: $LIDARR_MUSIC_ROOT"
log "Orphan age: ${LIDARR_ORPHAN_AGE} days"
log "Protected: ${LIDARR_PROTECTED_PATTERNS[*]}"
echo " Root: $LIDARR_MUSIC_ROOT | Orphan age: ${LIDARR_ORPHAN_AGE} days"
echo ""
START=$(date +%s)
+66 -27
View File
@@ -24,6 +24,10 @@
# Lidarr runs on HOST1 only. detect_hosts() sets LIDARR_URL — if empty (HOST2) the
# script exits cleanly with no action rather than failing.
#
# ── OUTPUT ────────────────────────────────────────────────────────────────────────────────────
# Minimal by default — section headers + per-section summary always visible.
# --log shows per-item detail (each album, each artist, each file fetched).
#
# ── SAFEGUARDS ────────────────────────────────────────────────────────────────────────────────
# acquire_lock — prevents concurrent runs during large library scans
# curl + jq check — fail fast if tools missing
@@ -50,7 +54,7 @@
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# lidarr_missing_art.sh — fetch all missing artwork
# lidarr_missing_art.sh --dry-run — preview without downloading
# lidarr_missing_art.sh --log — verbose output
# lidarr_missing_art.sh --log — verbose per-item output
# lidarr_missing_art.sh --status — show config and exit
# ==============================================================================================
@@ -70,7 +74,6 @@ if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
success "Running as root"
if ! command -v curl >/dev/null 2>&1; then
error "curl not found — required for API calls"
@@ -80,7 +83,6 @@ if ! command -v jq >/dev/null 2>&1; then
error "jq not found — required for JSON parsing"
exit 1
fi
success "curl and jq found"
acquire_lock
@@ -91,7 +93,8 @@ if [[ -z "$LIDARR_URL" ]]; then
log "Lidarr not configured for $MY_ID — nothing to do"
exit 0
fi
success "Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo " $MY_ID ($LOCAL_SERVER_NAME) — tools OK"
# ==============================================================================================
# ━━━ Status ━━━
@@ -115,6 +118,10 @@ fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be written"
# ── Temp dir for subshell fetch/fail counters ─────────────────────────────────────────────────
LIDARR_TMP=$(mktemp -d)
trap 'rm -rf "$LIDARR_TMP"' EXIT
# ==============================================================================================
# ── FUNCTIONS ─────────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
@@ -143,7 +150,7 @@ download_if_valid() {
[[ -f "$dest" ]] && return 0
if [[ "$DRY_RUN" == true ]]; then
info "DRY RUN — would fetch: $(basename "$dest")"
log "DRY RUN — would fetch: $(basename "$dest")"
return 0
fi
@@ -155,14 +162,14 @@ download_if_valid() {
size=$(stat -c%s "$tmp" 2>/dev/null || echo 0)
if (( size > LIDARR_ART_MIN_SIZE )); then
mv "$tmp" "$dest"
log "Fetched: $dest"
log " Fetched: $(basename "$dest")"
return 0
fi
rm -f "$tmp"
sleep 1
done
warn "Failed to fetch valid image: $(basename "$dest")"
warn "Failed to fetch: $(basename "$dest")"
return 1
}
@@ -193,19 +200,15 @@ if ! curl_json "$LIDARR_URL/api/v1/system/status?apikey=$LIDARR_API_KEY" | jq -e
notify "lidarr_missing_art failed — Lidarr API unreachable on $(hostname)" "Lidarr Missing Art" "warning"
exit 1
fi
success "Lidarr API reachable"
echo " Reachable — $LIDARR_URL"
START=$(date +%s)
ALBUMS_CHECKED=0
ALBUMS_COMPLETE=0
ALBUM_FETCHES=0
ALBUM_FAILS=0
ARTISTS_CHECKED=0
ARTISTS_COMPLETE=0
ARTIST_FETCHES=0
ARTIST_FAILS=0
# ==============================================================================================
# ━━━ Albums ━━━
@@ -222,7 +225,7 @@ if [[ -z "$albums" || "$albums" == "null" ]]; then
fi
total_albums=$(echo "$albums" | jq '. | length')
info "$total_albums albums to process"
echo " Processing $total_albums albums..."
while IFS=$'\t' read -r local_path mbid artist_name album_name; do
(( ALBUMS_CHECKED++ ))
@@ -242,6 +245,8 @@ while IFS=$'\t' read -r local_path mbid artist_name album_name; do
wait_for_slot
(
_fetches=0 _fails=0
JSON=""
if [[ -n "$mbid" && "$mbid" != "null" ]]; then
JSON=$(curl_json "http://webservice.fanart.tv/v3/music/albums/$mbid?api_key=$FANART_API_KEY")
@@ -250,29 +255,43 @@ while IFS=$'\t' read -r local_path mbid artist_name album_name; do
if [[ ! -f "$local_path/cover.jpg" ]]; then
IMG=$(echo "$JSON" | jq -r '.[].albumcover[0].url // empty')
if ! download_if_valid "$IMG" "$local_path/cover.jpg"; then
if download_if_valid "$IMG" "$local_path/cover.jpg"; then
(( _fetches++ ))
else
query=$(printf "%s %s" "$artist_name" "$album_name" | sed 's/ /+/g')
itunes=$(curl_json "https://itunes.apple.com/search?term=$query&entity=album&limit=1" |
jq -r '.results[0].artworkUrl100 // empty' | sed 's/100x100/600x600/')
download_if_valid "$itunes" "$local_path/cover.jpg"
if download_if_valid "$itunes" "$local_path/cover.jpg"; then
(( _fetches++ ))
else
(( _fails++ ))
fi
fi
fi
if [[ ! -f "$local_path/cdart.png" ]]; then
IMG=$(echo "$JSON" | jq -r '.[].cdart[0].url // empty')
download_if_valid "$IMG" "$local_path/cdart.png"
if download_if_valid "$IMG" "$local_path/cdart.png"; then (( _fetches++ )); else (( _fails++ )); fi
fi
if [[ ! -f "$local_path/back.jpg" ]]; then
IMG=$(echo "$JSON" | jq -r '.[].albumback[0].url // empty')
download_if_valid "$IMG" "$local_path/back.jpg"
if download_if_valid "$IMG" "$local_path/back.jpg"; then (( _fetches++ )); else (( _fails++ )); fi
fi
(( _fetches > 0 )) && printf '1\n' >> "$LIDARR_TMP/album_fetches"
(( _fails > 0 )) && printf '1\n' >> "$LIDARR_TMP/album_fails"
) &
done < <(echo "$albums" | jq -r '.[] | [.path, .foreignAlbumId, .artist.artistName, .title] | @tsv')
wait
ALBUM_FETCHED=$(wc -l < "$LIDARR_TMP/album_fetches" 2>/dev/null || echo 0)
ALBUM_FAILED=$(wc -l < "$LIDARR_TMP/album_fails" 2>/dev/null || echo 0)
ALBUM_MISSING=$(( ALBUMS_CHECKED - ALBUMS_COMPLETE ))
echo " Checked: $ALBUMS_CHECKED | Complete: $ALBUMS_COMPLETE | Needed art: $ALBUM_MISSING | Fetched: $ALBUM_FETCHED | Failed: $ALBUM_FAILED"
# ==============================================================================================
# ━━━ Artists ━━━
# ==============================================================================================
@@ -288,7 +307,7 @@ if [[ -z "$artists" || "$artists" == "null" ]]; then
fi
total_artists=$(echo "$artists" | jq '. | length')
info "$total_artists artists to process"
echo " Processing $total_artists artists..."
while IFS=$'\t' read -r local_path mbid name; do
(( ARTISTS_CHECKED++ ))
@@ -310,43 +329,63 @@ while IFS=$'\t' read -r local_path mbid name; do
wait_for_slot
(
_fetches=0 _fails=0
JSON=$(curl_json "http://webservice.fanart.tv/v3/music/$mbid?api_key=$FANART_API_KEY")
sleep "$LIDARR_ART_SLEEP_BETWEEN"
if [[ ! -f "$local_path/folder.jpg" ]]; then
IMG=$(echo "$JSON" | jq -r '.artistthumb[0].url // empty')
if ! download_if_valid "$IMG" "$local_path/folder.jpg"; then
if download_if_valid "$IMG" "$local_path/folder.jpg"; then
(( _fetches++ ))
else
IMG=$(deezer_artist_image "$name")
if ! download_if_valid "$IMG" "$local_path/folder.jpg"; then
if download_if_valid "$IMG" "$local_path/folder.jpg"; then
(( _fetches++ ))
else
IMG=$(lastfm_artist_image "$name")
download_if_valid "$IMG" "$local_path/folder.jpg"
if download_if_valid "$IMG" "$local_path/folder.jpg"; then
(( _fetches++ ))
else
(( _fails++ ))
fi
fi
fi
fi
if [[ ! -f "$local_path/fanart.jpg" ]]; then
IMG=$(echo "$JSON" | jq -r '.artistbackground[0].url // empty')
if ! download_if_valid "$IMG" "$local_path/fanart.jpg"; then
if download_if_valid "$IMG" "$local_path/fanart.jpg"; then
(( _fetches++ ))
else
IMG=$(deezer_artist_image "$name")
download_if_valid "$IMG" "$local_path/fanart.jpg"
if download_if_valid "$IMG" "$local_path/fanart.jpg"; then (( _fetches++ )); else (( _fails++ )); fi
fi
fi
if [[ ! -f "$local_path/logo.png" ]]; then
IMG=$(echo "$JSON" | jq -r '.hdmusiclogo[0].url // empty')
download_if_valid "$IMG" "$local_path/logo.png"
if download_if_valid "$IMG" "$local_path/logo.png"; then (( _fetches++ )); else (( _fails++ )); fi
fi
if [[ ! -f "$local_path/banner.jpg" ]]; then
IMG=$(echo "$JSON" | jq -r '.musicbanner[0].url // empty')
download_if_valid "$IMG" "$local_path/banner.jpg"
if download_if_valid "$IMG" "$local_path/banner.jpg"; then (( _fetches++ )); else (( _fails++ )); fi
fi
(( _fetches > 0 )) && printf '1\n' >> "$LIDARR_TMP/artist_fetches"
(( _fails > 0 )) && printf '1\n' >> "$LIDARR_TMP/artist_fails"
) &
done < <(echo "$artists" | jq -r '.[] | [.path, .foreignArtistId, .artistName] | @tsv')
wait
ARTIST_FETCHED=$(wc -l < "$LIDARR_TMP/artist_fetches" 2>/dev/null || echo 0)
ARTIST_FAILED=$(wc -l < "$LIDARR_TMP/artist_fails" 2>/dev/null || echo 0)
ARTIST_MISSING=$(( ARTISTS_CHECKED - ARTISTS_COMPLETE ))
echo " Checked: $ARTISTS_CHECKED | Complete: $ARTISTS_COMPLETE | Needed art: $ARTIST_MISSING | Fetched: $ARTIST_FETCHED | Failed: $ARTIST_FAILED"
END=$(date +%s)
# ==============================================================================================
@@ -356,8 +395,8 @@ echo ""
echo "━━━━━ $ICON_SUMMARY LIDARR MISSING ART SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $((END - START)))"
echo "$ICON_EMBY Albums: $ALBUMS_CHECKED checked, $ALBUMS_COMPLETE already complete"
echo "$ICON_EMBY Artists: $ARTISTS_CHECKED checked, $ARTISTS_COMPLETE already complete"
echo "$ICON_EMBY Albums: $ALBUMS_CHECKED checked | $ALBUMS_COMPLETE complete | $ALBUM_FETCHED fetched | $ALBUM_FAILED failed"
echo "$ICON_EMBY Artists: $ARTISTS_CHECKED checked | $ARTISTS_COMPLETE complete | $ARTIST_FETCHED fetched | $ARTIST_FAILED failed"
if [[ "$DRY_RUN" == true ]]; then
echo "$ICON_WARN Status: DRY RUN — no files written"
+4 -7
View File
@@ -164,8 +164,7 @@ if [[ ! -d "$RADARR_MOVIES_ROOT" ]]; then
exit 1
fi
log "Radarr URL: $RADARR_URL"
log "Movies root: $RADARR_MOVIES_ROOT"
echo " $MY_ID ($LOCAL_SERVER_NAME) — $RADARR_URL"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted"
[[ "$I_KNOW" == true ]] && warn "OVERRIDE — --i-know-what-im-doing active"
@@ -298,7 +297,7 @@ fi
# Safety Layer 3 — API version check
check_arr_version "$RADARR_URL" "$RADARR_API_KEY" "v3" "$RADARR_VERSION_MAJOR" "Radarr" || exit 1
log "Querying Radarr API: $RADARR_URL"
echo " Querying Radarr API..."
# Fetch all movies
MOVIES_RESPONSE=$(radarr_api "movie") || {
@@ -363,16 +362,14 @@ if [[ "$TRACKED_COUNT" -eq 0 ]]; then
exit 1
fi
warn "Radarr tracks $TRACKED_COUNT movie files across $MOVIE_COUNT movies"
echo " $MOVIE_COUNT movies | $TRACKED_COUNT tracked movie files"
# ==============================================================================================
# ━━━ Scan Movies Root ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CLEAN Scanning Movies Root ━━━"
log "Root: $RADARR_MOVIES_ROOT"
log "Orphan age: ${RADARR_ORPHAN_AGE} days"
log "Protected: ${RADARR_PROTECTED_PATTERNS[*]}"
echo " Root: $RADARR_MOVIES_ROOT | Orphan age: ${RADARR_ORPHAN_AGE} days"
echo ""
START=$(date +%s)
+230
View File
@@ -0,0 +1,230 @@
#!/bin/bash
# ==============================================================================================
# ============================= Radarr — TMDb Removed ==========================================
# ==============================================================================================
# Removes movies from Radarr that have been dropped from TMDb.
# Radarr marks these with status="deleted" — they generate system health errors and
# can never be monitored or downloaded. 99% are future/announced movies that were
# delisted before release.
#
# ── WHAT IT DOES ──────────────────────────────────────────────────────────────────────────────
# Queries Radarr API for movies with status="deleted" (TMDb removal marker)
# Reports each found entry with file status and size
# Removes the movie record from Radarr
# Optionally deletes associated files (disabled by default — most have none)
# Optionally adds to Radarr's import exclusion list (default: true)
#
# ── SAFE DEFAULTS ─────────────────────────────────────────────────────────────────────────────
# Files are NOT deleted by default — use --delete-files to also remove from disk
# Import exclusion added by default — prevents Radarr re-adding dropped movies
# Per-deletion output always visible — deletions are never silently swallowed
#
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
# Radarr runs on HOST1 only. detect_hosts() sets RADARR_URL — if empty (HOST2)
# the script exits cleanly.
#
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
# RADARR_DROPPED_ADD_EXCLUSION — add removed movies to import exclusion (default: true)
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# radarr_tmdb_removed.sh — remove records, keep files, add exclusion
# radarr_tmdb_removed.sh --delete-files — also delete files from disk
# radarr_tmdb_removed.sh --dry-run — preview without removing anything
# radarr_tmdb_removed.sh --log — verbose output
# radarr_tmdb_removed.sh --status — show config and exit
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Parse --delete-files before standard parse_args ───────────────────────────────────────────
DELETE_FILES=false
FILTERED_ARGS=()
for arg in "$@"; do
if [[ "$arg" == "--delete-files" ]]; then
DELETE_FILES=true
else
FILTERED_ARGS+=("$arg")
fi
done
parse_args "${FILTERED_ARGS[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Setup ━━━"
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
error "curl not found"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found"
exit 1
fi
acquire_lock
detect_hosts
if [[ -z "$RADARR_URL" ]]; then
log "Radarr not configured for $MY_ID — nothing to do"
exit 0
fi
ADD_EXCLUSION="${RADARR_DROPPED_ADD_EXCLUSION:-true}"
echo " $MY_ID ($LOCAL_SERVER_NAME) — $RADARR_URL"
[[ "$DELETE_FILES" == true ]] && warn "DELETE FILES MODE — files will be removed from disk"
[[ "$DELETE_FILES" == false ]] && echo " Files: records only (use --delete-files to also remove from disk)"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Radarr URL: $RADARR_URL"
echo "$ICON_GEAR Add exclusion: $ADD_EXCLUSION"
echo "$ICON_GEAR Delete files: $DELETE_FILES"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Query Radarr ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Querying Radarr ━━━"
if ! curl -sf --connect-timeout 5 --max-time 10 \
"$RADARR_URL/api/v3/system/status?apikey=$RADARR_API_KEY" | jq -e '.version' >/dev/null 2>&1; then
error "Radarr API unreachable at $RADARR_URL"
exit 1
fi
MOVIES=$(curl -sf --connect-timeout 5 --max-time 30 \
"$RADARR_URL/api/v3/movie?apikey=$RADARR_API_KEY" 2>/dev/null)
if [[ -z "$MOVIES" || "$MOVIES" == "null" ]]; then
error "Radarr movie API returned empty"
exit 1
fi
TOTAL=$(echo "$MOVIES" | jq '. | length')
DROPPED=$(echo "$MOVIES" | jq '[.[] | select(.status == "deleted")] | length')
echo " $TOTAL movies total — $DROPPED dropped from TMDb"
if [[ "$DROPPED" -eq 0 ]]; then
log "No TMDb-removed movies found — nothing to do"
echo ""
echo "━━━━━ $ICON_SUMMARY RADARR TMDB REMOVED SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_DONE Status: nothing to remove"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Remove Dropped Movies ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_TRASH Remove TMDb-Dropped Movies ━━━"
START=$(date +%s)
REMOVED=()
FAILED=()
FILES_DELETED=0
FILES_SKIPPED=0
while IFS=$'\t' read -r id title year tmdb_id has_file file_size; do
[[ -z "$id" ]] && continue
SIZE_HUMAN=""
if [[ "$has_file" == "true" && "$file_size" -gt 0 ]]; then
SIZE_HUMAN=$(awk "BEGIN {printf \"%.1fGB\", $file_size / 1073741824}")
echo "$ICON_WARN $title ($year) [tmdbid $tmdb_id] — HAS FILE: $SIZE_HUMAN"
else
echo "$ICON_TRASH $title ($year) [tmdbid $tmdb_id] — no file"
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove: $title"
[[ "$DELETE_FILES" == true && "$has_file" == "true" ]] && \
warn "DRY RUN — would delete file: $SIZE_HUMAN"
REMOVED+=("$title")
continue
fi
DELETE_PARAM="false"
if [[ "$DELETE_FILES" == true && "$has_file" == "true" ]]; then
DELETE_PARAM="true"
fi
RESP=$(curl -sf -X DELETE --connect-timeout 5 --max-time 15 \
"$RADARR_URL/api/v3/movie/${id}?deleteFiles=${DELETE_PARAM}&addImportExclusion=${ADD_EXCLUSION}&apikey=$RADARR_API_KEY" \
2>/dev/null)
CURL_EXIT=$?
if [[ "$CURL_EXIT" -eq 0 ]]; then
log " Removed from Radarr ✅"
REMOVED+=("$title")
if [[ "$DELETE_PARAM" == "true" ]]; then
(( FILES_DELETED++ ))
elif [[ "$has_file" == "true" ]]; then
(( FILES_SKIPPED++ ))
fi
else
warn " Failed to remove $title (curl exit $CURL_EXIT)"
FAILED+=("$title")
fi
done < <(echo "$MOVIES" | jq -r '
.[] | select(.status == "deleted") |
[
(.id | tostring),
.title,
(.year | tostring),
(.tmdbId | tostring),
(if .hasFile then "true" else "false" end),
(if .movieFile.size? then (.movieFile.size | tostring) else "0" end)
] | @tsv
')
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY RADARR TMDB REMOVED SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo "$ICON_TRASH Removed: ${#REMOVED[@]} of $DROPPED"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
[[ "$FILES_DELETED" -gt 0 ]] && echo "$ICON_TRASH Files deleted: $FILES_DELETED"
[[ "$FILES_SKIPPED" -gt 0 ]] && echo "$ICON_WARN Files kept: $FILES_SKIPPED (had files — use --delete-files to remove)"
[[ "$ADD_EXCLUSION" == "true" ]] && echo "$ICON_GEAR Import exclusion added for removed entries"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
log "$ICON_DONE Status: done ✅"
else
warn "Status: ${#FAILED[@]} removal(s) failed"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ ${#FAILED[@]} -gt 0 ]] && exit 1
exit 0
+4 -7
View File
@@ -164,8 +164,7 @@ if [[ ! -d "$SONARR_TV_ROOT" ]]; then
exit 1
fi
log "Sonarr URL: $SONARR_URL"
log "TV root: $SONARR_TV_ROOT"
echo " $MY_ID ($LOCAL_SERVER_NAME) — $SONARR_URL"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted"
[[ "$I_KNOW" == true ]] && warn "OVERRIDE — --i-know-what-im-doing active"
@@ -298,7 +297,7 @@ fi
# Safety Layer 3 — API version check
check_arr_version "$SONARR_URL" "$SONARR_API_KEY" "v3" "$SONARR_VERSION_MAJOR" "Sonarr" || exit 1
log "Querying Sonarr API: $SONARR_URL"
echo " Querying Sonarr API..."
# Fetch all series
SERIES_RESPONSE=$(sonarr_api "series") || {
@@ -354,16 +353,14 @@ if [[ "$TRACKED_COUNT" -eq 0 ]]; then
exit 1
fi
warn "Sonarr tracks $TRACKED_COUNT episode files across $SERIES_COUNT series"
echo " $SERIES_COUNT series | $TRACKED_COUNT tracked episode files"
# ==============================================================================================
# ━━━ Scan TV Root ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CLEAN Scanning TV Root ━━━"
log "Root: $SONARR_TV_ROOT"
log "Orphan age: ${SONARR_ORPHAN_AGE} days"
log "Protected: ${SONARR_PROTECTED_PATTERNS[*]}"
echo " Root: $SONARR_TV_ROOT | Orphan age: ${SONARR_ORPHAN_AGE} days"
echo ""
START=$(date +%s)
+232
View File
@@ -0,0 +1,232 @@
#!/bin/bash
# ==============================================================================================
# ============================= Sonarr — TVDB Removed ==========================================
# ==============================================================================================
# Removes series from Sonarr that have been dropped from TVDB.
# Sonarr marks these with status="deleted" — they generate system health errors and
# can never be monitored or downloaded.
#
# ── WHAT IT DOES ──────────────────────────────────────────────────────────────────────────────
# Queries Sonarr API for series with status="deleted" (TVDB removal marker)
# Reports each found entry with file count and total size
# Removes the series record from Sonarr
# Optionally deletes associated files (disabled by default)
# Optionally adds to Sonarr's import exclusion list (default: true)
#
# ── SAFE DEFAULTS ─────────────────────────────────────────────────────────────────────────────
# Files are NOT deleted by default — use --delete-files to also remove from disk
# Import exclusion added by default — prevents Sonarr re-adding dropped series
# Per-deletion output always visible — deletions are never silently swallowed
#
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
# Sonarr runs on HOST1 only. detect_hosts() sets SONARR_URL — if empty (HOST2)
# the script exits cleanly.
#
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
# SONARR_DROPPED_ADD_EXCLUSION — add removed series to import exclusion (default: true)
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# sonarr_tvdb_removed.sh — remove records, keep files, add exclusion
# sonarr_tvdb_removed.sh --delete-files — also delete files from disk
# sonarr_tvdb_removed.sh --dry-run — preview without removing anything
# sonarr_tvdb_removed.sh --log — verbose output
# sonarr_tvdb_removed.sh --status — show config and exit
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
# ── Parse --delete-files before standard parse_args ───────────────────────────────────────────
DELETE_FILES=false
FILTERED_ARGS=()
for arg in "$@"; do
if [[ "$arg" == "--delete-files" ]]; then
DELETE_FILES=true
else
FILTERED_ARGS+=("$arg")
fi
done
parse_args "${FILTERED_ARGS[@]}"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Setup ━━━"
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
error "curl not found"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
error "jq not found"
exit 1
fi
acquire_lock
detect_hosts
if [[ -z "$SONARR_URL" ]]; then
log "Sonarr not configured for $MY_ID — nothing to do"
exit 0
fi
ADD_EXCLUSION="${SONARR_DROPPED_ADD_EXCLUSION:-true}"
echo " $MY_ID ($LOCAL_SERVER_NAME) — $SONARR_URL"
[[ "$DELETE_FILES" == true ]] && warn "DELETE FILES MODE — files will be removed from disk"
[[ "$DELETE_FILES" == false ]] && echo " Files: records only (use --delete-files to also remove from disk)"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Sonarr URL: $SONARR_URL"
echo "$ICON_GEAR Add exclusion: $ADD_EXCLUSION"
echo "$ICON_GEAR Delete files: $DELETE_FILES"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Query Sonarr ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Querying Sonarr ━━━"
if ! curl -sf --connect-timeout 5 --max-time 10 \
"$SONARR_URL/api/v3/system/status?apikey=$SONARR_API_KEY" | jq -e '.version' >/dev/null 2>&1; then
error "Sonarr API unreachable at $SONARR_URL"
exit 1
fi
SERIES=$(curl -sf --connect-timeout 5 --max-time 30 \
"$SONARR_URL/api/v3/series?apikey=$SONARR_API_KEY" 2>/dev/null)
if [[ -z "$SERIES" || "$SERIES" == "null" ]]; then
error "Sonarr series API returned empty"
exit 1
fi
TOTAL=$(echo "$SERIES" | jq '. | length')
DROPPED=$(echo "$SERIES" | jq '[.[] | select(.status == "deleted")] | length')
echo " $TOTAL series total — $DROPPED dropped from TVDB"
if [[ "$DROPPED" -eq 0 ]]; then
log "No TVDB-removed series found — nothing to do"
echo ""
echo "━━━━━ $ICON_SUMMARY SONARR TVDB REMOVED SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_DONE Status: nothing to remove"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ━━━ Remove Dropped Series ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_TRASH Remove TVDB-Dropped Series ━━━"
START=$(date +%s)
REMOVED=()
FAILED=()
FILES_DELETED=0
FILES_SKIPPED=0
while IFS=$'\t' read -r id title year tvdb_id episode_file_count size_on_disk; do
[[ -z "$id" ]] && continue
SIZE_HUMAN=""
if [[ "$size_on_disk" -gt 0 ]]; then
SIZE_HUMAN=$(awk "BEGIN {printf \"%.1fGB\", $size_on_disk / 1073741824}")
fi
if [[ "$episode_file_count" -gt 0 ]]; then
echo "$ICON_WARN $title ($year) [tvdbid $tvdb_id] — $episode_file_count episode files${SIZE_HUMAN:+, $SIZE_HUMAN}"
else
echo "$ICON_TRASH $title ($year) [tvdbid $tvdb_id] — no files"
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove: $title"
[[ "$DELETE_FILES" == true && "$episode_file_count" -gt 0 ]] && \
warn "DRY RUN — would delete $episode_file_count file(s)${SIZE_HUMAN:+, $SIZE_HUMAN}"
REMOVED+=("$title")
continue
fi
DELETE_PARAM="false"
if [[ "$DELETE_FILES" == true && "$episode_file_count" -gt 0 ]]; then
DELETE_PARAM="true"
fi
RESP=$(curl -sf -X DELETE --connect-timeout 5 --max-time 15 \
"$SONARR_URL/api/v3/series/${id}?deleteFiles=${DELETE_PARAM}&addImportListExclusion=${ADD_EXCLUSION}&apikey=$SONARR_API_KEY" \
2>/dev/null)
CURL_EXIT=$?
if [[ "$CURL_EXIT" -eq 0 ]]; then
log " Removed from Sonarr ✅"
REMOVED+=("$title")
if [[ "$DELETE_PARAM" == "true" ]]; then
(( FILES_DELETED++ ))
elif [[ "$episode_file_count" -gt 0 ]]; then
(( FILES_SKIPPED++ ))
fi
else
warn " Failed to remove $title (curl exit $CURL_EXIT)"
FAILED+=("$title")
fi
done < <(echo "$SERIES" | jq -r '
.[] | select(.status == "deleted") |
[
(.id | tostring),
.title,
(.year | tostring),
(.tvdbId | tostring),
(if .episodeFileCount? then (.episodeFileCount | tostring) else "0" end),
(if .sizeOnDisk? then (.sizeOnDisk | tostring) else "0" end)
] | @tsv
')
END=$(date +%s)
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY SONARR TVDB REMOVED SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
echo "$ICON_TRASH Removed: ${#REMOVED[@]} of $DROPPED"
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
[[ "$FILES_DELETED" -gt 0 ]] && echo "$ICON_TRASH Files deleted: $FILES_DELETED series worth"
[[ "$FILES_SKIPPED" -gt 0 ]] && echo "$ICON_WARN Files kept: $FILES_SKIPPED series (had files — use --delete-files to remove)"
[[ "$ADD_EXCLUSION" == "true" ]] && echo "$ICON_GEAR Import exclusion added for removed entries"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes made"
elif [[ ${#FAILED[@]} -eq 0 ]]; then
log "$ICON_DONE Status: done ✅"
else
warn "Status: ${#FAILED[@]} removal(s) failed"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
[[ ${#FAILED[@]} -gt 0 ]] && exit 1
exit 0