Files
Varaverk/Arrs_Stack/lidarr_duplicate_artist_cleanup.sh
T
Gmer4Lfe c7ecf99c3f Generalize tracked-data cache from Lidarr-only to all three arrs
Shared cache/rescan-duration logic in common.sh now takes an arr_type
param instead of being Lidarr-specific, so Sonarr and Radarr cleanup
scripts get the same cache-first fetch + rescan-aware retry Lidarr had.
Avoids redundant full-library API calls across scripts run back to back,
and stops false failures when a fetch lands mid-rescan.
2026-07-16 15:34:56 -04:00

314 lines
15 KiB
Bash
Executable File

#!/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