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
+2
View File
@@ -381,6 +381,7 @@
"Media/media_shares_permissions.sh" # apply permissions — runs before cleaners
"Media/media_cleaner.sh anime" # remove junk from anime shares
"Media/media_cleaner.sh media" # remove junk from media shares
"Media/lidarr_release_fixer.sh" # fix wrong release editions before cleanup runs
"Media/lidarr_cleanup.sh" # remove orphaned music files
"Media/sonarr_cleanup.sh" # remove orphaned TV files
"Media/radarr_cleanup.sh" # remove orphaned movie files
@@ -1063,6 +1064,7 @@
ARR_RADARR_DEFAULT_PROFILE="Any (mine)"
# Lidarr shared settings
LIDARR_RELEASE_FIXER_ENABLED=true # set false to disable without removing from job list
LIDARR_LOCK_WARN_AGE=3600 # 1hr — large libraries take time, not stuck
LIDARR_ORPHAN_AGE=7 # days — files must be older than this before eligible for deletion
LIDARR_MAX_DELETE_GB=5 # require --i-know-what-im-doing if deletion exceeds this
+2
View File
@@ -376,6 +376,7 @@
"Media/media_shares_permissions.sh" # apply permissions — runs before cleaners
"Media/media_cleaner.sh anime" # remove junk from anime shares
"Media/media_cleaner.sh media" # remove junk from media shares
#"Media/lidarr_release_fixer.sh" # fix wrong release editions before cleanup runs — enable when ready
#"Media/lidarr_cleanup.sh" # remove orphaned music files — enable when ready
#"Media/sonarr_cleanup.sh" # remove orphaned TV files — enable when ready
"Media/radarr_cleanup.sh" # remove orphaned movie files
@@ -1057,6 +1058,7 @@
ARR_RADARR_DEFAULT_PROFILE="Any (mine)"
# Lidarr shared settings
LIDARR_RELEASE_FIXER_ENABLED=true # set false to disable without removing from job list
LIDARR_LOCK_WARN_AGE=3600 # 1hr — large libraries take time, not stuck
LIDARR_ORPHAN_AGE=7 # days — files must be older than this before eligible for deletion
LIDARR_MAX_DELETE_GB=5 # require --i-know-what-im-doing if deletion exceeds this
+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
+48
View File
@@ -4,8 +4,17 @@ claude
need a script that check file locations. like if a kids movie is in movies. it needs to detect this and update the location in the arrs. so it would tell radarr to move shrek from movies to kids movies and trck the new file
look at a way to better determine when a container was manually shutdown for docker watchdog
we may need to add this to our preflight before lidarr cleanup. check if lidarr need corrected before
removal. since we have a strike list we can save results of previous scan then run this script then
continue. lidarr has been wierd for the last year. maybe we
────────────────────────────────────────────────────────────────
@@ -27,6 +36,45 @@ claude
Saved. Here's where we land and what to pick up:
What happened: Lidarr was silently discarding valid files because it selected the wrong MusicBrainz release edition (Brazil CD, Japan Digital, etc.) while your files were tagged for a different one (US CD, Vinyl). The track IDs don't match across editions, so RescanFolders always returned "Importing 0 tracks." Not a file problem — a database mismatch. The 5GB threshold in lidarr_cleanup.sh was doing exactly what it should.
Fixed this session: 123 albums bulk-corrected (read MUSICBRAINZ_ALBUMID from FLAC tags, switched Lidarr's release selection, RefreshArtist). Full root rescan still running in the background.
Next session — pick up here:
1. Design the daily fixer script (Media/lidarr_release_fixer.sh) — FLAC + MP3 (ID3 TXXX) tag reading, same bulk-fix logic, runs from daily orchestrator. Strike system in lidarr_cleanup.sh already provides the protection window.
2. 42 albums where file MBID isn't in Lidarr at all (unofficial/re-tagged) — manual import or ignore.
3. 217 MP3 albums — need ID3 TXXX frame reader added to the fixer.
4. Check the full root rescan completed and see if zero-file count dropped further below 927.