#!/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 logo.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. # # ============================================================================================== # 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 # ============================================================================================== # # master_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 ━━━ # ============================================================================================== 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 # Build path map for translate_path() — container path → host path declare -A ARR_PATH_MAP for _key in "${!HOST1_LIDARR_PATH_MAP[@]}"; do ARR_PATH_MAP["$_key"]="${HOST1_LIDARR_PATH_MAP[$_key]}" done unset _key # 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 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 echo " 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') echo " 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 echo " 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') echo " 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 )) 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 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/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' 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/logo.png" ]]; then IMG=$(echo "$JSON" | jq -r '.hdmusiclogo[0].url // empty' 2>/dev/null) 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' 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 )) 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