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>
410 lines
18 KiB
Bash
410 lines
18 KiB
Bash
#!/bin/bash
|
|
# ==============================================================================================
|
|
# ================================= Lidarr Missing Art =========================================
|
|
# ==============================================================================================
|
|
# Fetches missing album and artist artwork for the Lidarr music library.
|
|
# Reads from Lidarr API to discover album/artist paths, then downloads only
|
|
# what is missing — never overwrites existing files.
|
|
#
|
|
# ── SAFE DESIGN ───────────────────────────────────────────────────────────────────────────────
|
|
# READS from Lidarr only — no writes back to Lidarr
|
|
# NEVER modifies audio tags or renames media files
|
|
# NEVER overwrites existing artwork
|
|
# ONLY writes missing artwork files to existing album/artist directories
|
|
#
|
|
# ── ARTWORK TARGETS ───────────────────────────────────────────────────────────────────────────
|
|
# Album folder: cover.jpg cdart.png back.jpg
|
|
# Artist folder: folder.jpg fanart.jpg logo.png banner.jpg
|
|
#
|
|
# ── SOURCES ───────────────────────────────────────────────────────────────────────────────────
|
|
# Album covers: fanart.tv → iTunes fallback
|
|
# Artist art: fanart.tv → Deezer fallback → Last.fm fallback
|
|
#
|
|
# ── HOST AWARENESS ────────────────────────────────────────────────────────────────────────────
|
|
# 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
|
|
# API reachability — verified before processing begins
|
|
# Skip existing — never overwrites, idempotent re-runs are safe
|
|
# Min file size — rejects corrupt/placeholder downloads (LIDARR_ART_MIN_SIZE)
|
|
# Parallel jobs — capped at LIDARR_ART_MAX_PARALLEL to avoid hammering APIs
|
|
# Download retries — LIDARR_ART_RETRIES attempts per image before giving up
|
|
# Dry-run mode — logs what would be downloaded without writing anything
|
|
#
|
|
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
|
|
# FANART_API_KEY — fanart.tv API key
|
|
# LASTFM_API_KEY — last.fm API key
|
|
# LIDARR_ART_MIN_SIZE — minimum valid download size in bytes
|
|
# LIDARR_ART_MAX_PARALLEL — concurrent background download jobs
|
|
# LIDARR_ART_RETRIES — download retry attempts per image
|
|
# LIDARR_ART_SLEEP_BETWEEN — seconds between fanart.tv API calls
|
|
#
|
|
# ── CONFIGURATION (master_host*.conf) ─────────────────────────────────────────────────────────
|
|
# HOST1_LIDARR_URL — Lidarr base URL
|
|
# HOST1_LIDARR_API_KEY — Lidarr API key
|
|
# All aliased by detect_hosts() — script uses unprefixed LIDARR_URL / LIDARR_API_KEY
|
|
#
|
|
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
|
|
# lidarr_missing_art.sh — fetch all missing artwork
|
|
# lidarr_missing_art.sh --dry-run — preview without downloading
|
|
# lidarr_missing_art.sh --log — verbose per-item output
|
|
# lidarr_missing_art.sh --status — show config and exit
|
|
# ==============================================================================================
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
source "$SCRIPT_DIR/../load_config.sh"
|
|
|
|
parse_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 — required for API calls"
|
|
exit 1
|
|
fi
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
error "jq not found — required for JSON parsing"
|
|
exit 1
|
|
fi
|
|
|
|
acquire_lock
|
|
|
|
detect_hosts
|
|
|
|
# HOST guard — Lidarr runs on HOST1 only
|
|
if [[ -z "$LIDARR_URL" ]]; then
|
|
log "Lidarr not configured for $MY_ID — nothing to do"
|
|
exit 0
|
|
fi
|
|
|
|
echo " $MY_ID ($LOCAL_SERVER_NAME) — tools OK"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Status ━━━
|
|
# ==============================================================================================
|
|
if [[ "$SHOW_STATUS" == true ]]; then
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
|
|
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
|
echo "$ICON_GEAR Lidarr URL: $LIDARR_URL"
|
|
echo "$ICON_NET Fanart key: $([[ -n "${FANART_API_KEY:-}" ]] && echo "set" || echo "not set")"
|
|
echo "$ICON_NET LastFM key: $([[ -n "${LASTFM_API_KEY:-}" ]] && echo "set" || echo "not set")"
|
|
echo "$ICON_GEAR Min size: ${LIDARR_ART_MIN_SIZE} bytes"
|
|
echo "$ICON_GEAR Parallel: $LIDARR_ART_MAX_PARALLEL jobs"
|
|
echo "$ICON_RETRY Retries: $LIDARR_ART_RETRIES"
|
|
echo "$ICON_TIME API sleep: ${LIDARR_ART_SLEEP_BETWEEN}s"
|
|
echo "$ICON_NOTIFY Notify: unRAID=${NOTIFY_UNRAID:-false} Discord=$([[ -n "${MY_DISCORD_WEBHOOK:-}" ]] && echo enabled || echo disabled)"
|
|
echo "$ICON_GEAR Dry Run: $DRY_RUN"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
|
exit 0
|
|
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 ─────────────────────────────────────────────────────────────────────────────────
|
|
# ==============================================================================================
|
|
|
|
curl_json() {
|
|
curl -s --connect-timeout 5 --max-time 20 "$1"
|
|
}
|
|
|
|
job_count() {
|
|
jobs -rp | wc -l
|
|
}
|
|
|
|
wait_for_slot() {
|
|
while (( $(job_count) >= LIDARR_ART_MAX_PARALLEL )); do
|
|
sleep 0.2
|
|
done
|
|
}
|
|
|
|
# Downloads URL to dest only if dest doesn't exist and downloaded size >= MIN_SIZE.
|
|
# Returns 0 on success or skip (file already exists), 1 on failure.
|
|
download_if_valid() {
|
|
local url="$1"
|
|
local dest="$2"
|
|
|
|
[[ -z "$url" || "$url" == "null" ]] && return 1
|
|
[[ -f "$dest" ]] && return 0
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
log "DRY RUN — would fetch: $(basename "$dest")"
|
|
return 0
|
|
fi
|
|
|
|
local tmp="${dest}.tmp"
|
|
local i
|
|
for (( i=0; i<=LIDARR_ART_RETRIES; i++ )); do
|
|
curl -s --connect-timeout 5 --max-time 20 -L -o "$tmp" "$url"
|
|
local size
|
|
size=$(stat -c%s "$tmp" 2>/dev/null || echo 0)
|
|
if (( size > LIDARR_ART_MIN_SIZE )); then
|
|
mv "$tmp" "$dest"
|
|
log " Fetched: $(basename "$dest")"
|
|
return 0
|
|
fi
|
|
rm -f "$tmp"
|
|
sleep 1
|
|
done
|
|
|
|
warn "Failed to fetch: $(basename "$dest")"
|
|
return 1
|
|
}
|
|
|
|
deezer_artist_image() {
|
|
local artist="$1"
|
|
local query
|
|
query=$(printf "%s" "$artist" | sed 's/ /+/g')
|
|
curl_json "https://api.deezer.com/search/artist?q=$query" |
|
|
jq -r '.data[0].picture_xl // empty'
|
|
}
|
|
|
|
lastfm_artist_image() {
|
|
local artist="$1"
|
|
local encoded
|
|
encoded=$(printf "%s" "$artist" | sed 's/ /%20/g')
|
|
curl_json "https://ws.audioscrobbler.com/2.0/?method=artist.getinfo&artist=$encoded&api_key=$LASTFM_API_KEY&format=json" |
|
|
jq -r '.artist.image[-1]["#text"] // empty'
|
|
}
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Verify Lidarr reachable ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_NET Lidarr API ━━━"
|
|
|
|
if ! curl_json "$LIDARR_URL/api/v1/system/status?apikey=$LIDARR_API_KEY" | jq -e '.version' >/dev/null 2>&1; then
|
|
error "Lidarr API unreachable at $LIDARR_URL — aborting"
|
|
notify "lidarr_missing_art failed — Lidarr API unreachable on $(hostname)" "Lidarr Missing Art" "warning"
|
|
exit 1
|
|
fi
|
|
echo " Reachable — $LIDARR_URL"
|
|
|
|
START=$(date +%s)
|
|
|
|
ALBUMS_CHECKED=0
|
|
ALBUMS_COMPLETE=0
|
|
|
|
ARTISTS_CHECKED=0
|
|
ARTISTS_COMPLETE=0
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Albums ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_EMBY Albums ━━━"
|
|
|
|
albums=$(curl_json "$LIDARR_URL/api/v1/album?apikey=$LIDARR_API_KEY")
|
|
|
|
if [[ -z "$albums" || "$albums" == "null" ]]; then
|
|
error "Lidarr album API returned empty — aborting"
|
|
notify "lidarr_missing_art failed — album API empty on $(hostname)" "Lidarr Missing Art" "warning"
|
|
exit 1
|
|
fi
|
|
|
|
total_albums=$(echo "$albums" | jq '. | length')
|
|
echo " Processing $total_albums albums..."
|
|
|
|
while IFS=$'\t' read -r local_path mbid artist_name album_name; do
|
|
(( ALBUMS_CHECKED++ ))
|
|
|
|
[[ ! -d "$local_path" ]] && continue
|
|
|
|
log "[$ALBUMS_CHECKED/$total_albums] $artist_name — $album_name"
|
|
|
|
if [[ -f "$local_path/cover.jpg" &&
|
|
-f "$local_path/cdart.png" &&
|
|
-f "$local_path/back.jpg" ]]; then
|
|
(( ALBUMS_COMPLETE++ ))
|
|
log " complete — skipping"
|
|
continue
|
|
fi
|
|
|
|
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")
|
|
sleep "$LIDARR_ART_SLEEP_BETWEEN"
|
|
fi
|
|
|
|
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
|
|
(( _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/')
|
|
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')
|
|
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')
|
|
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 ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
echo "━━━ $ICON_EMBY Artists ━━━"
|
|
|
|
artists=$(curl_json "$LIDARR_URL/api/v1/artist?apikey=$LIDARR_API_KEY")
|
|
|
|
if [[ -z "$artists" || "$artists" == "null" ]]; then
|
|
error "Lidarr artist API returned empty — aborting"
|
|
notify "lidarr_missing_art failed — artist API empty on $(hostname)" "Lidarr Missing Art" "warning"
|
|
exit 1
|
|
fi
|
|
|
|
total_artists=$(echo "$artists" | jq '. | length')
|
|
echo " Processing $total_artists artists..."
|
|
|
|
while IFS=$'\t' read -r local_path mbid name; do
|
|
(( ARTISTS_CHECKED++ ))
|
|
|
|
[[ ! -d "$local_path" ]] && continue
|
|
[[ -z "$mbid" || "$mbid" == "null" ]] && continue
|
|
|
|
log "[$ARTISTS_CHECKED/$total_artists] $name"
|
|
|
|
if [[ -f "$local_path/folder.jpg" &&
|
|
-f "$local_path/fanart.jpg" &&
|
|
-f "$local_path/logo.png" &&
|
|
-f "$local_path/banner.jpg" ]]; then
|
|
(( ARTISTS_COMPLETE++ ))
|
|
log " complete — skipping"
|
|
continue
|
|
fi
|
|
|
|
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
|
|
(( _fetches++ ))
|
|
else
|
|
IMG=$(deezer_artist_image "$name")
|
|
if download_if_valid "$IMG" "$local_path/folder.jpg"; then
|
|
(( _fetches++ ))
|
|
else
|
|
IMG=$(lastfm_artist_image "$name")
|
|
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
|
|
(( _fetches++ ))
|
|
else
|
|
IMG=$(deezer_artist_image "$name")
|
|
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')
|
|
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')
|
|
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)
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Summary ━━━
|
|
# ==============================================================================================
|
|
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 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"
|
|
else
|
|
echo "$ICON_DONE Status: $ICON_SUCCESS ALL DONE"
|
|
notify "Lidarr art fetch complete on $(hostname) — ${ALBUMS_CHECKED} albums, ${ARTISTS_CHECKED} artists processed" "Lidarr Missing Art" "normal"
|
|
fi
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
|
|
exit 0
|