#!/bin/bash # ============================================================================================== # ======================= Lidarr Duplicate Artist Cleanup ======================================= # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Detects and resolves duplicate artist entries in Lidarr's library — cases where the # same display name (case-insensitive) is backed by two different MusicBrainz artist # IDs. This happens when a search or list sync matches a same-named-but-different real # artist and adds it alongside the one already in the library. From that point on, # every completed download for that display name throws MultipleArtistsFoundException # and can never import — Lidarr correctly refuses to guess which of the two it means. # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # For each case-insensitive duplicate artist name found: # # Only one side has any tracked files # → The zero-file side is a phantom — delete it (deleteFiles=false, nothing on disk # to lose) and add its MusicBrainz ID to Lidarr's Import List Exclusions so it # can't be silently re-added by a future list sync. Then trigger a refresh on the # surviving artist so anything that was stuck on this exact ambiguity resolves # immediately instead of waiting for Lidarr's own next check cycle. # # Both sides have files, but their album titles don't overlap at all # → Genuinely two different real artists sharing a name (e.g. a band's classic # lineup vs. a later solo era with the same stage name). Not a bug — left alone. # # Both sides have files AND overlapping album titles # → The one case actually risky to automate: could mean real content is split # across two entries and needs an actual merge, not a delete. Untouched, # notified for manual review. # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Never Delete Real Content # Only the zero-tracked-file side of a pair is ever deleted. Anything with files is # either left alone (disjoint albums) or flagged for a human (overlapping albums) — # never auto-removed. # # Block Re-Addition At The Source # A phantom that keeps coming back is worse than one that was never cleaned — # Import List Exclusion is Lidarr's own mechanism for "never auto-add this again." # # Silent When Clean # No duplicates found, or all duplicates are the simple phantom case — minimal # output. Only ambiguous (overlapping-album) pairs produce a notification. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # host*.conf # # HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY — aliased by detect_hosts() # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # lidarr_duplicate_artist_cleanup.sh — normal run # lidarr_duplicate_artist_cleanup.sh --dry-run — preview, no deletions or exclusions # lidarr_duplicate_artist_cleanup.sh --log — verbose per-pair output # lidarr_duplicate_artist_cleanup.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 jq >/dev/null 2>&1; then error "jq not found — required for JSON parsing" notify "lidarr_duplicate_artist_cleanup failed on $(hostname) — jq not installed" \ "Lidarr Duplicate Cleanup" "warning" exit 1 fi acquire_lock "wait" # detect_hosts() sets MY_ID and aliases LIDARR_URL, LIDARR_API_KEY detect_hosts if [[ -z "${LIDARR_URL:-}" ]] || [[ -z "${LIDARR_API_KEY:-}" ]]; then info "Lidarr not configured on $MY_ID ($LOCAL_SERVER_NAME) — skipping" exit 0 fi [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no artists will be deleted or excluded" # ============================================================================================== # ━━━ 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_GEAR Lidarr ver: v${LIDARR_VERSION_MAJOR:-3} expected" echo "$ICON_GEAR Dry Run: $DRY_RUN" echo "━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi # ============================================================================================== # ━━━ Safety Checks ━━━ # ============================================================================================== check_api "$LIDARR_URL" "Lidarr" 10 || { notify "Lidarr duplicate cleanup aborted on $(hostname) — API unreachable" \ "Lidarr Duplicate Cleanup" "warning" exit 1 } check_arr_version "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "${LIDARR_VERSION_MAJOR:-3}" "Lidarr" || exit 1 TMP_DIR=$(mktemp -d) trap 'rm -rf "$TMP_DIR"' EXIT # ============================================================================================== # ━━━ Fetch Artists (cache-aware — waits out an active rescan rather than trusting a # mid-scan number, falls back to cache if Lidarr's still busy after the strike limit) ━━━ # ============================================================================================== ARTISTS=$(arr_get_tracked_data "lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1") if [[ -z "$ARTISTS" ]]; then warn "Lidarr busy and no usable cache — deferring to next scheduled run" exit 0 fi ARTIST_COUNT=$(echo "$ARTISTS" | jq 'length' 2>/dev/null) if [[ -z "$ARTIST_COUNT" || "$ARTIST_COUNT" -eq 0 ]]; then error "API returned 0 artists — aborting to avoid acting on empty data" notify "Lidarr duplicate cleanup aborted on $(hostname) — 0 artists returned" \ "Lidarr Duplicate Cleanup" "warning" exit 1 fi log "$ICON_GEAR Fetched $ARTIST_COUNT artists" # Safety Layer — same shared baseline as lidarr_cleanup.sh. A library-wide desync (e.g. mid # full-rescan) can make trackFileCount read far lower than reality for many artists at once — # confirmed 2026-07-16, where this exact scenario would have made the script see both sides # of a genuinely-real duplicate (ROMES) as 0-file phantoms and delete the wrong thing entirely. # Reuses lidarr_cleanup.sh's own baseline file — one shared "is Lidarr's data trustworthy # right now" answer for every script that depends on tracked counts, not a separate opinion # per script. TOTAL_TRACKED=$(echo "$ARTISTS" | jq '[.[].statistics.trackFileCount] | add' 2>/dev/null) check_tracked_count_floor "${TOTAL_TRACKED:-0}" "$LIDARR_TRACKED_COUNT_FILE" "${LIDARR_MIN_TRACKED_PCT:-50}" "Lidarr Duplicate Cleanup" # ============================================================================================== # ━━━ Find Case-Insensitive Duplicate Names ━━━ # ============================================================================================== DUP_NAMES=$(echo "$ARTISTS" | jq -r '.[].artistName' | tr '[:upper:]' '[:lower:]' | sort | uniq -d) if [[ -z "$DUP_NAMES" ]]; then echo "Lidarr — clean ✅ no duplicate artist names" exit 0 fi DUP_COUNT=$(echo "$DUP_NAMES" | grep -c .) warn "Found $DUP_COUNT duplicate artist name(s)" DELETED=0 EXCLUDED=0 LEFT_ALONE=0 FLAGGED=0 FLAGGED_NAMES=() # ============================================================================================== # ━━━ Resolve Each Duplicate ━━━ # ============================================================================================== while IFS= read -r lname; do [[ -z "$lname" ]] && continue MEMBERS=$(echo "$ARTISTS" | jq -c --arg n "$lname" '[.[] | select((.artistName|ascii_downcase)==$n)]') NONZERO_IDS=() ZERO_MEMBERS_FILE="$TMP_DIR/zero_${RANDOM}.jsonl" : > "$ZERO_MEMBERS_FILE" while IFS= read -r member; do [[ -z "$member" ]] && continue fc=$(echo "$member" | jq -r '.statistics.trackFileCount // 0') if [[ "$fc" -gt 0 ]]; then NONZERO_IDS+=("$(echo "$member" | jq -r '.id')") else echo "$member" >> "$ZERO_MEMBERS_FILE" fi done < <(echo "$MEMBERS" | jq -c '.[]') if [[ "${#NONZERO_IDS[@]}" -le 1 ]]; then # Simple phantom case — delete every zero-file member, keep the real one (if any) while IFS= read -r zmember; do [[ -z "$zmember" ]] && continue zid=$(echo "$zmember" | jq -r '.id') zname=$(echo "$zmember" | jq -r '.artistName') zmbid=$(echo "$zmember" | jq -r '.foreignArtistId') if [[ "$DRY_RUN" == true ]]; then warn " DRY RUN — would delete phantom: $zname ($zid) and exclude MBID $zmbid" continue fi if curl -sf --max-time 15 -X DELETE \ "${LIDARR_URL}/api/v1/artist/${zid}?deleteFiles=false" \ -H "X-Api-Key: $LIDARR_API_KEY" >/dev/null 2>&1; then (( DELETED++ )) log " $ICON_TRASH Deleted phantom: $zname ($zid)" else warn " Failed to delete phantom: $zname ($zid)" continue fi EXCL_PAYLOAD=$(jq -c -n --arg fid "$zmbid" --arg name "$zname" \ '{foreignId:$fid, artistName:$name}') if curl -sf --max-time 15 -X POST \ "${LIDARR_URL}/api/v1/importlistexclusion" \ -H "X-Api-Key: $LIDARR_API_KEY" -H "Content-Type: application/json" \ -d "$EXCL_PAYLOAD" >/dev/null 2>&1; then (( EXCLUDED++ )) log " Added to import list exclusions: $zmbid" else warn " Could not add exclusion for $zname ($zmbid) — may already exist" fi done < "$ZERO_MEMBERS_FILE" # Nudge the surviving real artist so anything stuck on this ambiguity # resolves now rather than waiting for Lidarr's own next check cycle. if [[ "${#NONZERO_IDS[@]}" -eq 1 && "$DRY_RUN" != true ]]; then curl -sf --max-time 15 -X POST \ "${LIDARR_URL}/api/v1/command" \ -H "X-Api-Key: $LIDARR_API_KEY" -H "Content-Type: application/json" \ -d "{\"name\":\"RefreshArtist\",\"artistId\":${NONZERO_IDS[0]}}" >/dev/null 2>&1 fi else # 2+ members have real content — same name, need to know if it's the same # artist actually split (album overlap) or genuinely different acts (no overlap). # # Titles must be deduped WITHIN each artist before checking overlap ACROSS artists — # a single artist can legitimately list the same album title twice (a reissue, a # deluxe edition under an unchanged title). Confirmed 2026-07-16: Alice Cooper's own # catalog has "Lace and Whiskey" and "School's Out" each listed twice under one # artist ID — treating that as "overlap" false-flagged a genuinely disjoint pair # (band-era vs. solo-era) as ambiguous when it wasn't. OVERLAP=false declare -A GLOBAL_TITLES for nid in "${NONZERO_IDS[@]}"; do declare -A THIS_ARTIST_TITLES while IFS= read -r title; do [[ -z "$title" ]] && continue THIS_ARTIST_TITLES["$title"]=1 done < <(curl -sf --max-time 15 "${LIDARR_URL}/api/v1/album?artistId=${nid}" \ -H "X-Api-Key: $LIDARR_API_KEY" 2>/dev/null | jq -r '.[].title') for title in "${!THIS_ARTIST_TITLES[@]}"; do [[ -n "${GLOBAL_TITLES[$title]:-}" ]] && OVERLAP=true GLOBAL_TITLES["$title"]=1 done unset THIS_ARTIST_TITLES done unset GLOBAL_TITLES if [[ "$OVERLAP" == true ]]; then warn " $ICON_WARN Ambiguous duplicate needs manual review: $lname (both have files, albums overlap)" FLAGGED_NAMES+=("$lname") (( FLAGGED++ )) else log " $lname — different real artists sharing a name, no album overlap, no action" (( LEFT_ALONE++ )) fi fi done <<< "$DUP_NAMES" # ============================================================================================== # ━━━ Summary ━━━ # ============================================================================================== echo "" echo "━━━━━ $ICON_SUMMARY LIDARR DUPLICATE ARTIST CLEANUP SUMMARY ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_TRASH Deleted: $DELETED phantom artist(s)" echo "$ICON_GEAR Excluded: $EXCLUDED (blocked from future re-add)" echo "$ICON_SKIP Left alone: $LEFT_ALONE (different real artists, no overlap)" echo "$ICON_WARN Flagged: $FLAGGED (needs manual review)" for n in "${FLAGGED_NAMES[@]}"; do echo " - $n" done echo "" if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — no changes made" elif [[ "$FLAGGED" -gt 0 ]]; then notify "Lidarr duplicate cleanup on $(hostname) — $DELETED phantom(s) removed, $FLAGGED artist(s) need manual review: ${FLAGGED_NAMES[*]}" \ "Lidarr Duplicate Cleanup" "warning" elif [[ "$DELETED" -gt 0 ]]; then echo "$ICON_DONE Status: cleaned $DELETED phantom artist(s), nothing needs review" else echo "$ICON_DONE Status: no action needed" fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 0