Headers claimed protections the code never had, and several destructive paths had no guard against a collapsed config value.
364 lines
17 KiB
Bash
Executable File
364 lines
17 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.
|
|
#
|
|
# ==============================================================================================
|
|
# OPERATIONAL SAFEGUARDS
|
|
# ==============================================================================================
|
|
#
|
|
# Root Enforcement
|
|
# Required by the container interaction and state writes.
|
|
#
|
|
# Lock Acquisition
|
|
# acquire_lock prevents overlapping runs racing on the same artist IDs.
|
|
#
|
|
# Host Detection
|
|
# detect_hosts() aliases LIDARR_URL / LIDARR_API_KEY.
|
|
#
|
|
# jq Dependency Check
|
|
# Fails fast if jq is missing — duplicate detection and every file-count read
|
|
# depend on it, and an absent jq would evaluate counts to empty and make every
|
|
# artist look like a zero-file phantom.
|
|
#
|
|
# API Reachability + Version Gate
|
|
# check_api then check_arr_version against LIDARR_VERSION_MAJOR before any read.
|
|
#
|
|
# Empty Library Abort
|
|
# A response of 0 artists aborts. An empty list is indistinguishable from
|
|
# "no duplicates" and must never be read as a clean result.
|
|
#
|
|
# Tracked-Count Floor
|
|
# check_tracked_count_floor against the baseline shared with lidarr_cleanup.sh.
|
|
# A library-wide desync — mid full-rescan, for example — makes trackFileCount read
|
|
# far below reality for many artists at once. Confirmed 2026-07-16: without this,
|
|
# both sides of a genuinely-real duplicate (ROMES) read as 0-file phantoms and the
|
|
# wrong one would have been deleted. Deliberately reuses lidarr_cleanup.sh's own
|
|
# baseline so every script depending on tracked counts shares one answer to "is
|
|
# Lidarr's data trustworthy right now" rather than forming a separate opinion.
|
|
#
|
|
# Files Never Deleted
|
|
# Removal passes deleteFiles=false. Only the phantom Lidarr entry is dropped;
|
|
# nothing on disk is touched, so a wrong call costs a re-add, not media.
|
|
#
|
|
# Phantom-Only Deletion
|
|
# Only the zero-file side of a duplicate pair is ever removed. If both sides hold
|
|
# files, or neither does unambiguously, the pair is flagged for manual review
|
|
# instead — the script never picks a winner between two real artists.
|
|
#
|
|
# Manual Review Reporting
|
|
# Flagged pairs are named in the summary and notification so an ambiguous
|
|
# duplicate surfaces as a decision to make rather than disappearing silently.
|
|
#
|
|
# Dry Run Support
|
|
# --dry-run reports every deletion and exclusion and performs none.
|
|
#
|
|
# ==============================================================================================
|
|
# 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
|