Files
Varaverk/Arrs_Stack/lidarr_missing_art.sh
T
Gmer4Lfe b4bc9267e9 Move arr stack scripts from Media/ to Arrs_Stack/
Media/ now holds only media-level scripts (cleaner, permissions, play_state_sync).
All arr management scripts (cleanup, discovery, sync, webhooks, release fixer) live in Arrs_Stack/.
2026-06-27 18:39:33 -04:00

474 lines
20 KiB
Bash
Executable File

#!/bin/bash
# ==============================================================================================
# ================================= Lidarr Missing Art =========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Fetch missing album and artist artwork for the Lidarr music library. Downloads
# only what is absent — never overwrites existing files. Idempotent re-runs are
# safe.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# Artwork targets per album folder: cover.jpg cdart.png back.jpg
# Artwork targets per artist folder: folder.jpg fanart.jpg clearlogo.png banner.jpg
#
# Sources (tried in order, first success wins):
# Album covers: fanart.tv → iTunes fallback
# Artist art: fanart.tv → Deezer fallback → Last.fm fallback
#
# Reads from Lidarr API only — no writes back to Lidarr. Never modifies audio
# tags or renames media files. Only writes missing artwork files to existing
# album/artist directories.
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Additive Only
# The script only adds missing files — it never overwrites existing artwork
# or touches audio files. Re-running after a partial fetch completes exactly
# where it left off with no side effects.
#
# Source Fallback Chain
# Multiple sources are tried in order of quality preference. fanart.tv is
# primary; fallbacks exist so partial coverage is better than none. A failed
# primary never blocks the fallback from running.
#
# External API Courtesy
# Rate limiting and parallel job caps prevent hammering fanart.tv and other
# external APIs. Burst behaviour during large initial runs would risk
# temporary blocks that break future scheduled fetches.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# acquire_lock — prevents concurrent runs during large library scans
# curl + jq check — fail fast if tools missing
# API reachability — verified before processing begins
# detect_hosts() — exits cleanly if LIDARR_URL empty (HOST2, no Lidarr)
# Skip existing — never overwrites, idempotent re-runs are safe
# Min file size check — rejects corrupt/placeholder downloads (LIDARR_ART_MIN_SIZE)
# Parallel job cap — LIDARR_ART_MAX_PARALLEL — avoids hammering external APIs
# Download retries — LIDARR_ART_RETRIES attempts per image before giving up
# Rate limiting — LIDARR_ART_SLEEP_BETWEEN between fanart.tv API calls
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
#
# HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY
# Aliased by detect_hosts() — script uses LIDARR_URL / LIDARR_API_KEY
#
# 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
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# 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 ━━━
# ==============================================================================================
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
echo "Lidarr not configured for $MY_ID — skipping"
exit 0
fi
# Build path map from MY_ID's Lidarr path map
declare -A ARR_PATH_MAP
local_path_map_var="${MY_ID}_LIDARR_PATH_MAP"
eval "for _key in \"\${!${local_path_map_var}[@]}\"; do
ARR_PATH_MAP[\"\$_key\"]=\"\${${local_path_map_var}[\$_key]}\"
done"
unset _key
info "$MY_ID ($LOCAL_SERVER_NAME) — tools OK"
log "$ICON_GEAR Config: url=${LIDARR_URL}"
# ==============================================================================================
# ━━━ 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 '_release_all_locks; rm -rf "$LIDARR_TMP"' EXIT
touch "$LIDARR_TMP/album_fetches" "$LIDARR_TMP/album_fails" \
"$LIDARR_TMP/artist_fetches" "$LIDARR_TMP/artist_fails"
# ==============================================================================================
# ── 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' 2>/dev/null
}
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' 2>/dev/null
}
# ==============================================================================================
# ━━━ 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
info "Lidarr reachable — $LIDARR_URL"
START=$(date +%s)
ALBUMS_CHECKED=0
ALBUMS_COMPLETE=0
ARTISTS_CHECKED=0
ARTISTS_COMPLETE=0
# ==============================================================================================
# ━━━ Build Album Directory Map ━━━
# ==============================================================================================
# Lidarr's album API never populates .path — derive album dirs from track file paths instead.
echo ""
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")
_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
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
info "Mapped ${#ALBUM_DIR_MAP[@]} albums with local tracks"
# ==============================================================================================
# ━━━ 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')
info "Processing $total_albums albums..."
while IFS=$'\t' read -r mbid artist_name album_name album_id; do
raw_dir="${ALBUM_DIR_MAP[$album_id]:-}"
[[ -z "$raw_dir" ]] && continue # not downloaded, skip
local_path=$(translate_path "$raw_dir")
(( 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' 2>/dev/null)
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' 2>/dev/null | 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' 2>/dev/null)
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' 2>/dev/null)
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 '.[] | [(.foreignAlbumId // ""), (.artist.artistName // ""), (.title // ""), (.id | tostring)] | @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 ))
info "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')
info "Processing $total_artists artists..."
while IFS=$'\t' read -r local_path mbid name; do
local_path=$(translate_path "$local_path")
(( 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/clearlogo.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' 2>/dev/null)
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' 2>/dev/null)
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/clearlogo.png" ]]; then
IMG=$(echo "$JSON" | jq -r '.hdmusiclogo[0].url // empty' 2>/dev/null)
if download_if_valid "$IMG" "$local_path/clearlogo.png"; then (( _fetches++ )); else (( _fails++ )); fi
fi
if [[ ! -f "$local_path/banner.jpg" ]]; then
IMG=$(echo "$JSON" | jq -r '.musicbanner[0].url // empty' 2>/dev/null)
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 ))
info "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