Add lidarr_release_fixer.sh — daily fix for wrong MusicBrainz release editions

Reads MUSICBRAINZ_ALBUMID from FLAC (vorbis block type 4) and MP3 (ID3v2 TXXX)
files, matches against Lidarr's known releases, switches monitored=true to the
correct one, and queues RefreshArtist. Runs before lidarr_cleanup.sh in the
daily job list so the strike system doesn't act on files that just needed a
release correction.
This commit is contained in:
Gmer4Lfe
2026-06-27 18:06:22 -04:00
parent 26fb409da5
commit eb64e52815
4 changed files with 514 additions and 0 deletions
+462
View File
@@ -0,0 +1,462 @@
#!/bin/bash
# ==============================================================================================
# ============================= Lidarr Release Fixer ===========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Fix Lidarr albums where the wrong MusicBrainz release edition was selected,
# causing on-disk files to appear as unimported despite being present.
#
# Root cause: Lidarr tracks one specific release edition per album using
# foreignReleaseId. When this doesn't match the MUSICBRAINZ_ALBUMID embedded
# in the actual files, track ID lookup fails and RescanFolders imports 0 tracks
# even with perfect, fully tagged files.
#
# Fix: Read the MusicBrainz Album ID from the first FLAC or MP3 found in each
# album directory, look that release up in Lidarr's known releases for that
# album, switch monitored=true to the correct one, then queue a RefreshArtist
# to re-sync track IDs and trigger Lidarr's own import scan.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# For each monitored album with 0 tracked files:
# 1. Locate the album directory under the artist's root path (title glob match)
# 2. Find the first FLAC or MP3 file in that directory
# 3. Read MUSICBRAINZ_ALBUMID from the file's tags
# 4. Fetch the album's available releases from Lidarr
# 5. If the file's release exists and differs from Lidarr's current selection:
# — PUT the album with the correct release set to monitored=true
# — Mark the artist for a RefreshArtist command
#
# RefreshArtist is batched — one per artist, even if multiple albums were fixed.
# Lidarr handles the post-refresh rescan and import automatically.
#
# ==============================================================================================
# TAG READING
# ==============================================================================================
#
# FLAC — Vorbis comment block (block type 4), key MUSICBRAINZ_ALBUMID
# MP3 — ID3v2 TXXX frame, description "MusicBrainz Album Id"
# Supports Latin-1, UTF-8 (enc 0/3) and UTF-16 (enc 1/2) encodings
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# host*.conf
# HOST1_LIDARR_URL / HOST1_LIDARR_API_KEY / HOST1_LIDARR_MUSIC_ROOT
# HOST1_LIDARR_PATH_MAP — container path → host path translation
#
# master.conf
# LIDARR_RELEASE_FIXER_ENABLED — set false to disable without removing from job list
# LIDARR_VERSION_MAJOR — expected Lidarr major version for API safety check
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# lidarr_release_fixer.sh — normal run
# lidarr_release_fixer.sh --dry-run — preview, no API writes
# lidarr_release_fixer.sh --log — verbose output
# lidarr_release_fixer.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
for _cmd in curl jq perl; do
if ! command -v "$_cmd" >/dev/null 2>&1; then
error "$_cmd not found — required"
exit 1
fi
done
unset _cmd
acquire_lock "skip"
trap "_release_all_locks" EXIT
detect_hosts
if [[ -z "${LIDARR_URL:-}" ]] || [[ -z "${LIDARR_API_KEY:-}" ]]; then
info "Lidarr not configured on $MY_ID — skipping"
exit 0
fi
if [[ "${LIDARR_RELEASE_FIXER_ENABLED:-true}" == "false" ]]; then
info "LIDARR_RELEASE_FIXER_ENABLED=false — skipping"
exit 0
fi
declare -A ARR_PATH_MAP
local_path_map_var="${MY_ID}_LIDARR_PATH_MAP"
eval "for key in \"\${!${local_path_map_var}[@]}\"; do
ARR_PATH_MAP[\"\$key\"]=\"\${${local_path_map_var}[\$key]}\"
done"
require_var LIDARR_URL
require_var LIDARR_API_KEY
require_var LIDARR_MUSIC_ROOT
# ── 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 Music root: $LIDARR_MUSIC_ROOT"
echo "$ICON_GEAR Enabled: ${LIDARR_RELEASE_FIXER_ENABLED:-true}"
echo "$ICON_GEAR Dry run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ── API helpers ───────────────────────────────────────────────────────────────────────────────
lidarr_api() {
local endpoint="$1"
local response http_code body
response=$(curl -sf --max-time 30 \
-H "X-Api-Key: $LIDARR_API_KEY" \
-w "\n%{http_code}" \
"${LIDARR_URL}/api/v1/${endpoint}" 2>/dev/null)
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | head -n -1)
if [[ "$http_code" != "200" ]]; then
error "Lidarr API HTTP $http_code for: $endpoint"
return 1
fi
echo "$body"
}
lidarr_api_put() {
local endpoint="$1"
local payload="$2"
local http_code
http_code=$(curl -sf --max-time 30 -X PUT \
-H "X-Api-Key: $LIDARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$payload" \
-w "%{http_code}" -o /dev/null \
"${LIDARR_URL}/api/v1/${endpoint}" 2>/dev/null)
if [[ "$http_code" != "202" ]] && [[ "$http_code" != "200" ]]; then
error "Lidarr PUT HTTP $http_code for: $endpoint"
return 1
fi
}
lidarr_api_post() {
local endpoint="$1"
local payload="$2"
curl -sf --max-time 30 -X POST \
-H "X-Api-Key: $LIDARR_API_KEY" \
-H "Content-Type: application/json" \
-d "$payload" \
-o /dev/null \
"${LIDARR_URL}/api/v1/${endpoint}" 2>/dev/null
}
# ── Tag readers ───────────────────────────────────────────────────────────────────────────────
# Read MUSICBRAINZ_ALBUMID from a FLAC file's Vorbis comment block (block type 4)
read_flac_mbid() {
perl -e '
open(my $fh, "<:raw", $ARGV[0]) or exit;
read($fh, my $magic, 4); substr($magic, 0, 4) eq "fLaC" or exit;
while (1) {
read($fh, my $hdr, 4) == 4 or last;
my $w = unpack("N", $hdr);
my $last = ($w >> 31) & 1;
my $type = ($w >> 24) & 0x7f;
my $len = $w & 0xffffff;
read($fh, my $data, $len);
if ($type == 4) {
my $pos = 0;
my $vl = unpack("V", substr($data, $pos, 4)); $pos += 4 + $vl;
my $n = unpack("V", substr($data, $pos, 4)); $pos += 4;
for (1..$n) {
my $cl = unpack("V", substr($data, $pos, 4)); $pos += 4;
my $c = substr($data, $pos, $cl); $pos += $cl;
if ($c =~ /^MUSICBRAINZ_ALBUMID=(.+)$/i) { print "$1\n"; exit; }
}
}
last if $last;
}
' "$1" 2>/dev/null
}
# Read MusicBrainz Album Id from an MP3's ID3v2 TXXX frame.
# Handles Latin-1/UTF-8 (enc 0/3) with single-null separator and
# UTF-16 (enc 1/2) by stripping null bytes and pattern-matching.
read_mp3_mbid() {
perl -e '
open(my $fh, "<:raw", $ARGV[0]) or exit;
read($fh, my $hdr, 10) == 10 or exit;
substr($hdr, 0, 3) eq "ID3" or exit;
my $ver = ord(substr($hdr, 3, 1));
my $sz = 0; $sz = ($sz << 7) | ord($_) for split //, substr($hdr, 6, 4);
read($fh, my $data, $sz) == $sz or exit;
my $pos = 0;
while ($pos + 10 <= $sz) {
my $id = substr($data, $pos, 4); $pos += 4;
last unless $id =~ /^[A-Z][A-Z0-9]{3}$/;
my $fs;
if ($ver >= 4) {
my $n = 0; $n = ($n << 7) | ord($_) for split //, substr($data, $pos, 4);
$fs = $n;
} else {
$fs = unpack("N", substr($data, $pos, 4));
}
$pos += 6;
last if $fs < 1 || $pos + $fs > $sz;
if ($id eq "TXXX") {
my $enc = ord(substr($data, $pos, 1));
my $body = substr($data, $pos + 1, $fs - 1);
if ($enc == 1 || $enc == 2) {
# UTF-16: strip BOM, collapse to ASCII, pattern match
$body =~ s/^\xff\xfe|^\xfe\xff//;
(my $flat = $body) =~ s/\x00//g;
if ($flat =~ /^MusicBrainz Album Id([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i) {
print "$1\n"; exit;
}
} else {
my ($desc, $val) = split /\x00/, $body, 2;
if (defined $desc && lc($desc) eq "musicbrainz album id" &&
defined $val &&
$val =~ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) {
print "$val\n"; exit;
}
}
}
$pos += $fs;
}
' "$1" 2>/dev/null
}
read_file_mbid() {
local file="$1"
case "${file##*.}" in
[Ff][Ll][Aa][Cc]) read_flac_mbid "$file" ;;
[Mm][Pp]3) read_mp3_mbid "$file" ;;
esac
}
translate_container_path() {
local cpath="$1"
for cp in "${!ARR_PATH_MAP[@]}"; do
if [[ "$cpath" == "${cp}"* ]]; then
echo "${ARR_PATH_MAP[$cp]}${cpath#$cp}"
return
fi
done
echo "$cpath"
}
# ── Safety checks ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SHIELD Safety Checks ━━━"
if ! check_api "$LIDARR_URL" "Lidarr" 10; then
notify "Lidarr release fixer aborted on $(hostname) — API unreachable" \
"Lidarr Release Fixer" "warning"
exit 1
fi
check_arr_version "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "$LIDARR_VERSION_MAJOR" "Lidarr" || exit 1
info "API reachable and version OK"
# ── Fetch all artists and build path cache ────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SYNC Fetching artist paths ━━━"
declare -A ARTIST_PATH_CACHE
declare -A ARTIST_NAME_CACHE
ALL_ARTISTS=$(lidarr_api "artist") || {
error "Failed to fetch artists"
exit 1
}
while IFS= read -r artist; do
aid=$(echo "$artist" | jq -r '.id')
apath=$(echo "$artist" | jq -r '.path // empty')
aname=$(echo "$artist" | jq -r '.artistName // empty')
[[ -n "$apath" ]] && ARTIST_PATH_CACHE[$aid]=$(translate_container_path "$apath")
[[ -n "$aname" ]] && ARTIST_NAME_CACHE[$aid]="$aname"
done < <(echo "$ALL_ARTISTS" | jq -c '.[]' 2>/dev/null)
unset ALL_ARTISTS
ARTIST_COUNT="${#ARTIST_PATH_CACHE[@]}"
info "Loaded $ARTIST_COUNT artists"
if [[ "$ARTIST_COUNT" -eq 0 ]]; then
error "No artists returned — aborting"
exit 1
fi
# ── Fetch zero-file albums ────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SYNC Fetching zero-file monitored albums ━━━"
ALL_ALBUMS=$(lidarr_api "album") || {
error "Failed to fetch albums"
exit 1
}
ZERO_FILE_ALBUMS=$(echo "$ALL_ALBUMS" | jq -c \
'[.[] | select(.monitored == true and .statistics.trackFileCount == 0)]' 2>/dev/null)
unset ALL_ALBUMS
TOTAL_ZERO=$(echo "$ZERO_FILE_ALBUMS" | jq 'length' 2>/dev/null)
info "Monitored albums with 0 tracked files: $TOTAL_ZERO"
# ── Process albums ────────────────────────────────────────────────────────────────────────────
START=$(date +%s)
FIXED=0
SKIPPED_NO_DIR=0
SKIPPED_NO_FILE=0
SKIPPED_NO_MBID=0
SKIPPED_NO_MATCH=0
SKIPPED_CORRECT=0
ERRORS=0
declare -A ARTISTS_TO_REFRESH
echo ""
echo "━━━ $ICON_GEAR Processing albums ━━━"
while IFS= read -r album; do
album_id=$(echo "$album" | jq -r '.id')
album_title=$(echo "$album" | jq -r '.title')
artist_id=$(echo "$album" | jq -r '.artistId')
artist_name="${ARTIST_NAME_CACHE[$artist_id]:-artist $artist_id}"
log "Checking: $artist_name$album_title (album $album_id)"
artist_host_path="${ARTIST_PATH_CACHE[$artist_id]:-}"
if [[ -z "$artist_host_path" ]] || [[ ! -d "$artist_host_path" ]]; then
log "Artist dir not found: $artist_host_path"
(( SKIPPED_NO_DIR++ ))
continue
fi
# Find album directory — case-insensitive prefix match on title
album_dir=$(find "$artist_host_path" -maxdepth 1 -type d -iname "${album_title}*" \
2>/dev/null | head -1)
if [[ -z "$album_dir" ]]; then
log "Album dir not found: $album_title"
(( SKIPPED_NO_DIR++ ))
continue
fi
# Find first FLAC or MP3
music_file=$(find "$album_dir" -maxdepth 2 -type f \
\( -iname "*.flac" -o -iname "*.mp3" \) 2>/dev/null | head -1)
if [[ -z "$music_file" ]]; then
log "No FLAC or MP3 in: $album_dir"
(( SKIPPED_NO_FILE++ ))
continue
fi
# Read MBID from file tags
file_mbid=$(read_file_mbid "$music_file")
if [[ -z "$file_mbid" ]]; then
log "No MBID tag in: $music_file"
(( SKIPPED_NO_MBID++ ))
continue
fi
log "File MBID: $file_mbid"
# Fetch full album JSON (includes releases array)
album_full=$(lidarr_api "album/${album_id}") || { (( ERRORS++ )); sleep 0.2; continue; }
sleep 0.1
# Currently selected release
current_release_id=$(echo "$album_full" | jq -r \
'.releases[] | select(.monitored == true) | .foreignReleaseId' 2>/dev/null | head -1)
if [[ "$current_release_id" == "$file_mbid" ]]; then
log "Release already correct: $file_mbid"
(( SKIPPED_CORRECT++ ))
continue
fi
# Verify file's MBID is a known release for this album
release_known=$(echo "$album_full" | jq -r \
--arg rid "$file_mbid" \
'.releases[] | select(.foreignReleaseId == $rid) | .foreignReleaseId' 2>/dev/null)
if [[ -z "$release_known" ]]; then
log "File MBID $file_mbid not in Lidarr release list for: $album_title"
(( SKIPPED_NO_MATCH++ ))
continue
fi
# Build updated album object with correct release selected
updated_album=$(echo "$album_full" | jq \
--arg target "$file_mbid" \
'.releases = [.releases[] | .monitored = (.foreignReleaseId == $target)]' 2>/dev/null)
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would fix: $artist_name$album_title"
warn " $current_release_id$file_mbid"
(( FIXED++ ))
continue
fi
if lidarr_api_put "album/${album_id}" "$updated_album"; then
warn "$ICON_GEAR Fixed: $artist_name$album_title"
warn " $current_release_id$file_mbid"
ARTISTS_TO_REFRESH[$artist_id]="$artist_id"
(( FIXED++ ))
else
(( ERRORS++ ))
fi
sleep 0.2
done < <(echo "$ZERO_FILE_ALBUMS" | jq -c '.[]')
# ── Queue RefreshArtist for all fixed artists ─────────────────────────────────────────────────
if [[ "${#ARTISTS_TO_REFRESH[@]}" -gt 0 ]] && [[ "$DRY_RUN" == false ]]; then
echo ""
echo "━━━ $ICON_SYNC Queuing RefreshArtist ━━━"
for artist_id in "${!ARTISTS_TO_REFRESH[@]}"; do
if lidarr_api_post "command" \
"{\"name\": \"RefreshArtist\", \"artistId\": ${artist_id}}"; then
log "Queued RefreshArtist for: ${ARTIST_NAME_CACHE[$artist_id]:-$artist_id}"
else
error "Failed to queue RefreshArtist for artist $artist_id"
fi
sleep 0.1
done
info "Refresh queued for ${#ARTISTS_TO_REFRESH[@]} artist(s)"
fi
# ── Summary ───────────────────────────────────────────────────────────────────────────────────
END=$(date +%s)
echo ""
echo "━━━━━ $ICON_SUMMARY LIDARR RELEASE FIXER SUMMARY ━━━━━"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_GEAR Candidates: $TOTAL_ZERO zero-file albums"
echo "$ICON_DONE Fixed: $FIXED"
echo "$ICON_SKIP Already correct: $SKIPPED_CORRECT"
echo "$ICON_SKIP No dir on disk: $SKIPPED_NO_DIR"
echo "$ICON_SKIP No music file: $SKIPPED_NO_FILE"
echo "$ICON_SKIP No MBID tag: $SKIPPED_NO_MBID"
echo "$ICON_SKIP MBID not in list: $SKIPPED_NO_MATCH"
[[ "$ERRORS" -gt 0 ]] && echo " Errors: $ERRORS"
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [[ "$FIXED" -gt 0 ]] && [[ "$DRY_RUN" == false ]]; then
notify "Lidarr release fixer on $(hostname) — corrected $FIXED album release(s)" \
"Lidarr Release Fixer" "normal"
fi
exit 0