#!/bin/bash # Walk a "stranded" download directory (files beets rejected as duplicates), # and for each file, compare against the existing library entry. If the new # file is higher quality (FLAC > MP3, then larger size), REPLACE the library # version. Otherwise delete the new file. # # Usage: # replace-with-better.sh # default scan: ${MUSIC_DATA_DIR:-/data/music}/downloads # replace-with-better.sh # custom scan dir # replace-with-better.sh --apply # actually replace (default is dry-run) # # Run after sync-bandcamp.sh when the FLAC > MP3 dedup didn't pick up new # imports (beets quietly skipped them and left files in /downloads). set -u # shellcheck source=${PIPELINE_DIR:-/app/pipeline}/lib/prep-audio.sh source ${PIPELINE_DIR:-/app/pipeline}/lib/prep-audio.sh APPLY=0 COPY_TAGS=0 SCAN_DIR=${MUSIC_DATA_DIR:-/data/music}/downloads for arg in "$@"; do case "$arg" in --apply) APPLY=1 ;; --copy-tags-from-existing) COPY_TAGS=1 ;; *) SCAN_DIR="$arg" ;; esac done # Copy canonical tags from an existing (already spotify-retagged) library track # onto a new FLAC, replacing whatever uploader-supplied tags it came with. # Uses beets as the source of truth — same fields beets/Navidrome care about. copy_tags_from_existing() { local existing_id="$1" # beets item id of the existing library track local new_file="$2" # host path of new FLAC [[ "${new_file,,}" == *.flac ]] || return 0 # \x1f separator, NOT \t: tab is IFS whitespace, so consecutive tabs # (an empty field like $genres) collapse and shift every later field left — # GROUPING landing in GENRE etc. \x1f is non-whitespace, so empty fields # survive the read. Query by id: path queries are unreliable against the # mixed absolute/relative storage the beets 2.11 upgrade left in the DB. local raw raw=$(beet ls -f \ $'$artist\x1f$albumartist\x1f$album\x1f$title\x1f$track\x1f$disc\x1f$year\x1f$genres\x1f$grouping' \ "id:${existing_id}" 2>/dev/null | head -1) [[ -z "$raw" ]] && return 0 local artist albumartist album title track disc year genre grouping IFS=$'\x1f' read -r artist albumartist album title track disc year genre grouping <<< "$raw" local tag for tag in ARTIST ALBUMARTIST ALBUM TITLE TRACKNUMBER DISCNUMBER DATE GENRE GROUPING; do metaflac --remove-tag="$tag" "$new_file" 2>/dev/null done [[ -n "$artist" ]] && metaflac --set-tag="ARTIST=$artist" "$new_file" 2>/dev/null [[ -n "$albumartist" ]] && metaflac --set-tag="ALBUMARTIST=$albumartist" "$new_file" 2>/dev/null [[ -n "$album" ]] && metaflac --set-tag="ALBUM=$album" "$new_file" 2>/dev/null [[ -n "$title" ]] && metaflac --set-tag="TITLE=$title" "$new_file" 2>/dev/null [[ -n "$track" && "$track" != "0" ]] && metaflac --set-tag="TRACKNUMBER=$track" "$new_file" 2>/dev/null [[ -n "$disc" && "$disc" != "0" ]] && metaflac --set-tag="DISCNUMBER=$disc" "$new_file" 2>/dev/null [[ -n "$year" && "$year" != "0" ]] && metaflac --set-tag="DATE=$year" "$new_file" 2>/dev/null [[ -n "$genre" ]] && metaflac --set-tag="GENRE=$genre" "$new_file" 2>/dev/null [[ -n "$grouping" ]] && metaflac --set-tag="GROUPING=$grouping" "$new_file" 2>/dev/null } LOG=${ALEMBIC_CONFIG_DIR:-/config}/logs/replace-$(date +%Y%m%d-%H%M%S).log mkdir -p "$(dirname "$LOG")" if [[ $APPLY -eq 1 ]]; then echo "[$(date -Iseconds)] === REPLACE (APPLY) scan=$SCAN_DIR ===" | tee -a "$LOG" # Radio prep (ReplayGain + autocue liq_* tags) BEFORE any import. Every # other import path preps first (run-playlist.sh, import-track.sh); this # one didn't, so replacement FLACs and leftover imports reached the library # unprepped for AzuraCast (found 2026-07-02: the July upgrade FLACs and a # 39-track leftover batch all lacked RG/autocue). Apply-mode only — dry # runs must not modify files. PREPPED=$(prep_audio "$SCAN_DIR" "$LOG" || echo 0) echo "[prep-audio] pre-tagged $PREPPED file(s) with ReplayGain + autocue" | tee -a "$LOG" else echo "[$(date -Iseconds)] === REPLACE (DRY RUN — pass --apply to act) scan=$SCAN_DIR ===" | tee -a "$LOG" fi # A FLAC file always beats an MP3 file regardless of size. Among same-format # files, larger wins. Returns "new" or "existing". better() { local new="$1" existing="$2" local new_flac=0 ex_flac=0 [[ "${new,,}" == *.flac ]] && new_flac=1 [[ "${existing,,}" == *.flac ]] && ex_flac=1 if [[ $new_flac -ne $ex_flac ]]; then if [[ $new_flac -eq 1 ]]; then echo "new"; else echo "existing"; fi return fi local ns es ns=$(stat -c '%s' "$new" 2>/dev/null || echo 0) es=$(stat -c '%s' "$existing" 2>/dev/null || echo 0) if [[ $ns -gt $es ]]; then echo "new"; else echo "existing"; fi } # Read tag from FLAC or MP3 — return artist/album/title joined with newlines. read_tags() { local f="$1" case "${f,,}" in *.flac) local artist album title artist=$(metaflac --show-tag=ALBUMARTIST "$f" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1) [[ -z "$artist" ]] && artist=$(metaflac --show-tag=ARTIST "$f" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1) album=$(metaflac --show-tag=ALBUM "$f" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1) title=$(metaflac --show-tag=TITLE "$f" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1) printf "%s\n%s\n%s\n" "$artist" "$album" "$title" ;; *.mp3) local artist album title artist=$(id3v2 -l "$f" 2>/dev/null | sed -n 's/^TPE2[^:]*: //p' | head -1) [[ -z "$artist" ]] && artist=$(id3v2 -l "$f" 2>/dev/null | sed -n 's/^TPE1[^:]*: //p' | head -1) album=$(id3v2 -l "$f" 2>/dev/null | sed -n 's/^TALB[^:]*: //p' | head -1) title=$(id3v2 -l "$f" 2>/dev/null | sed -n 's/^TIT2[^:]*: //p' | head -1) printf "%s\n%s\n%s\n" "$artist" "$album" "$title" ;; esac } # Beets queries (case-insensitive exact-substring on a field). Try progressively # looser matches until we find exactly one library track to compare against. # Echoes "\x1f" of the unique match; "" if no unique match found. beet_find_match() { local artist="$1" album="$2" title="$3" # Sanitize quotes artist="${artist//\"/}"; album="${album//\"/}"; title="${title//\"/}" local fmt=$'$id\x1f$path' local hits # 1. Strictest: albumartist + album + title hits=$(beet ls -f "$fmt" \ "albumartist:$artist" "album:$album" "title:$title" 2>/dev/null) [[ "$(echo "$hits" | wc -l)" == "1" && -n "$hits" ]] && { echo "$hits"; return; } # 2. Drop album: maybe Bandcamp's "Artist | Album" doesn't match library's "Album" hits=$(beet ls -f "$fmt" \ "albumartist:$artist" "title:$title" 2>/dev/null) [[ "$(echo "$hits" | wc -l)" == "1" && -n "$hits" ]] && { echo "$hits"; return; } # 3. albumartist may be empty in old library entries — fall back to artist field hits=$(beet ls -f "$fmt" \ "artist:$artist" "title:$title" 2>/dev/null) [[ "$(echo "$hits" | wc -l)" == "1" && -n "$hits" ]] && { echo "$hits"; return; } # 4. Last resort: title alone — anchored exact match (case-insensitive), # NOT the substring match levels 1-3 use: a new track titled "Home" # must not "uniquely" match a library track titled "Coming Home Tonight" # and get it deleted. Only safe if exactly one library track carries # exactly this title. local escaped_title escaped_title=$(python3 -c \ "import re,sys; print(re.escape(sys.argv[1]))" "$title" 2>/dev/null || echo "") if [[ -n "$escaped_title" ]]; then hits=$(beet ls -f "$fmt" \ "title::(?i)^${escaped_title}\$" 2>/dev/null) [[ "$(echo "$hits" | wc -l)" == "1" && -n "$hits" ]] && { echo "$hits"; return; } fi echo "" # no unique match } REPLACED=0 KEPT=0 SKIPPED_NOMATCH=0 while IFS= read -r -d '' new_file; do # mapfile preserves empty lines (read with IFS=$'\n' would collapse them) mapfile -t _fields < <(read_tags "$new_file") artist="${_fields[0]:-}" album="${_fields[1]:-}" title="${_fields[2]:-}" if [[ -z "$artist" || -z "$title" ]]; then echo "[skip-untagged] $new_file" | tee -a "$LOG" continue fi # Find the matching library track via beets (progressive loosening) match=$(beet_find_match "$artist" "$album" "$title" | head -1) if [[ -z "$match" || "$match" != *$'\x1f'* ]]; then echo "[no-match] $artist - $album - $title (file: $new_file)" | tee -a "$LOG" SKIPPED_NOMATCH=$((SKIPPED_NOMATCH + 1)) continue fi existing_id="${match%%$'\x1f'*}" existing_container="${match#*$'\x1f'}" existing="${MUSIC_DATA_DIR:-/data/music}/Library${existing_container#/music}" if [[ ! -f "$existing" ]]; then echo "[stale-db] beets has $existing_container but file missing" | tee -a "$LOG" continue fi winner=$(better "$new_file" "$existing") ns=$(numfmt --to=iec --suffix=B "$(stat -c '%s' "$new_file" 2>/dev/null || echo 0)") es=$(numfmt --to=iec --suffix=B "$(stat -c '%s' "$existing" 2>/dev/null || echo 0)") if [[ "$winner" == "new" ]]; then echo "[REPLACE] $artist - $title ($es → $ns)" | tee -a "$LOG" echo " ex: $existing" | tee -a "$LOG" echo " new: $new_file" | tee -a "$LOG" if [[ $APPLY -eq 1 ]]; then # If asked, copy canonical tags from the existing track to the new FLAC # BEFORE removing the existing entry. Used by upgrade-mp3-to-flac.sh so # the new FLAC inherits the MP3's spotify-retagged metadata (incl. the # GROUPING tag that drives playlist M3U regeneration). if [[ $COPY_TAGS -eq 1 ]]; then copy_tags_from_existing "$existing_id" "$new_file" >> "$LOG" 2>&1 fi # Remove old library file + beets DB entry, then re-import the new one. # id query, not path: — see copy_tags_from_existing for why. beet remove -d -f "id:$existing_id" >> "$LOG" 2>&1 beet import -q -s "$new_file" >> "$LOG" 2>&1 fi REPLACED=$((REPLACED + 1)) else echo "[KEEP] $artist - $title (existing $es ≥ new $ns)" | tee -a "$LOG" if [[ $APPLY -eq 1 ]]; then rm -f "$new_file" fi KEPT=$((KEPT + 1)) fi done < <(find "$SCAN_DIR" -type f \( -iname "*.flac" -o -iname "*.mp3" \) -print0 2>/dev/null) echo "" | tee -a "$LOG" echo "=== Summary: $REPLACED replaced, $KEPT existing-kept, $SKIPPED_NOMATCH no-match ===" | tee -a "$LOG" [[ $APPLY -eq 0 ]] && echo "DRY RUN — re-run with --apply to act" | tee -a "$LOG" # After replaces, anything left in the scan dir is genuinely NEW (no library # match — not a duplicate of anything we already have). Import it as a new # track. Beets path templates will place it under /music//... # alongside everything else. if [[ $APPLY -eq 1 ]]; then remaining=$(find "$SCAN_DIR" -type f \( -iname "*.flac" -o -iname "*.mp3" \) 2>/dev/null | wc -l) if [[ "$remaining" -gt 0 ]]; then echo "[import-leftover] $remaining new file(s) under $SCAN_DIR — importing as fresh tracks" | tee -a "$LOG" beet import -q -s "$SCAN_DIR" >> "$LOG" 2>&1 fi find "$SCAN_DIR" -mindepth 1 -type d -empty -delete 2>>"$LOG" echo "[cleanup] removed empty subfolders under $SCAN_DIR" | tee -a "$LOG" fi