#!/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. # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Fixer Before Cleanup # Runs before lidarr_cleanup.sh in the daily job list. The strike system in # cleanup provides a protection window, but correcting releases first means # files that were wrongly treated as orphans get imported rather than aged out. # # No Blind Fixes # Only switches to a release that is already in Lidarr's known release list for # that album. If the file's MBID isn't a recognized release, the album is skipped # rather than guessed at. False corrections are worse than leaving it alone. # # Files Are Never Touched # This script only modifies the Lidarr database record (release selection). Audio # files, tags, and directory structure are read-only. All actual importing is # handled by Lidarr after RefreshArtist runs. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # check_api — API reachability verified before any processing # check_arr_version — aborts if Lidarr major version doesn't match LIDARR_VERSION_MAJOR # artist count > 0 — aborts if artist fetch returns empty (API anomaly guard) # release list validation — only fixes to releases explicitly known to Lidarr for that album # acquire_lock "skip" — skips if another instance is already running # detect_hosts() — exits cleanly on hosts without Lidarr configured # curl + jq + perl check — fail fast if any required tool is missing # # ============================================================================================== # 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 # All aliased by detect_hosts() — script uses unprefixed names # # 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