Files
Varaverk/Arrs_Stack/lidarr_duplicate_artist_cleanup.sh
Gmer4Lfe e8b114094a Bring script headers onto the template and close safeguard gaps
Headers claimed protections the code never had, and several destructive paths had no
guard against a collapsed config value.
2026-08-01 20:37:59 -04:00

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