Files
alembic/pipeline/lib/replace-with-better.sh
T
andrew 62251d764e 0.6.9: Finish the Stage 4 path revisit in the three remaining scripts
The 2026-07-08 path rewrite changed beets' displayed paths from the
transitional /music/... mount to real /data/music/Library/... paths.
dedup-library.sh was fixed in 0.6.8; three more scripts still assumed
the old prefix, each failing silently:

- replace-with-better.sh resolved every library match to a doubled
  nonexistent path, logged [stale-db], skipped the in-place replace,
  and then imported the staged FLAC as a NEW track via the
  leftover-import step. Net effect: the mp3-to-flac upgrade flow
  produced flac+mp3 twin pairs in the library (surfacing in the dedup
  queue) instead of replacing the MP3 in place.

- fix-track-metadata.py queried beets only by the legacy /music/...
  form, matched nothing, and silently skipped beet update/move after
  retagging.

- export-laptop-playlists.py filtered out every library track (no
  displayed path starts with /music/ anymore), producing empty
  exports.

All three now use the real displayed path and keep the /music/... form
only as a legacy fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:51:55 -06:00

270 lines
13 KiB
Bash
Executable File

#!/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 <scan_dir> # 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 -eu, deliberately WITHOUT pipefail. This script reads tags through many
# `cmd | head -1` pipelines; under pipefail, head closing the pipe early makes
# the producer exit with SIGPIPE, so a perfectly good read would count as a
# failed pipeline. pipefail also adds no safety here, since every piped read
# feeds a value that is immediately checked for emptiness. -e still aborts on a
# real command error, and destructive beet/rm calls below are guarded so one
# failure logs a warning instead of stranding the library mid-replace.
set -eu
# 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 "<id>\x1f<path>" 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
# `|| true` on each beet read: an empty/failed query must fall through to the
# next, looser query, not abort the function under set -e.
hits=$(beet ls -f "$fmt" \
"albumartist:$artist" "album:$album" "title:$title" 2>/dev/null) || true
[[ "$(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) || true
[[ "$(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) || true
[[ "$(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) || true
[[ "$(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'}"
# beets displays real ${MUSIC_DATA_DIR}/Library/... paths since the
# 2026-07-08 path rewrite -- use them as-is; only a legacy /music/...
# display path still needs the prefix swap. Blindly prepending (the old
# behavior) doubled the prefix, so every match logged [stale-db], the MP3
# never got replaced, and the leftover-import step at the bottom imported
# the staged FLAC as a NEW track -- producing exactly the flac+mp3 twins
# this script exists to prevent.
if [[ "$existing_container" == /music/* ]]; then
existing="${MUSIC_DATA_DIR:-/data/music}/Library${existing_container#/music}"
else
existing="$existing_container"
fi
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).
# `|| true` also suspends set -e inside copy_tags_from_existing (its
# per-tag metaflac calls are individually best-effort).
if [[ $COPY_TAGS -eq 1 ]]; then
copy_tags_from_existing "$existing_id" "$new_file" >> "$LOG" 2>&1 || true
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. Guarded so a
# single failed remove/import logs a warning instead of aborting the whole
# scan; the leftover-import step at the end re-imports anything stranded.
beet remove -d -f "id:$existing_id" >> "$LOG" 2>&1 || echo "[WARN] beet remove id:$existing_id failed" | tee -a "$LOG"
beet import -q -s "$new_file" >> "$LOG" 2>&1 || echo "[WARN] beet import failed: $new_file" | tee -a "$LOG"
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" || echo "[WARN] rm failed: $new_file" | tee -a "$LOG"
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/<albumartist>/...
# 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 || echo "[WARN] leftover import failed under $SCAN_DIR" | tee -a "$LOG"
fi
find "$SCAN_DIR" -mindepth 1 -type d -empty -delete 2>>"$LOG" || true
echo "[cleanup] removed empty subfolders under $SCAN_DIR" | tee -a "$LOG"
fi
exit 0 # explicit success; any real failure above aborts earlier under set -e