Files
alembic/pipeline/lib/tag-guard.sh
T
andrew 1c7cf83a1b 0.6.15: Fix false-positive quarantine of ID3v2.4-tagged MP3s
tag-guard.sh checked MP3 tags with the id3v2 CLI (id3lib-3.8.3, last released
in 2013), which cannot parse ID3v2.4 tags at all. It reported "No ID3 tag" on
files that were completely and correctly tagged, just written in the modern
tag version most taggers default to now (Mp3tag, yt-dlp, foobar2000). Every
manual import of ID3v2.4 MP3s got its files quarantined as untagged and the
whole import failed once nothing survived the quarantine pass.

Replaced the id3v2-based reads in tag-guard.sh with mutagen, which handles
both tag versions correctly. Also replaced the ALBUMARTIST-fallback write
(previously done via `id3v2 --TPE2`) with a mutagen-based write, because
testing turned up something worse: writing a single frame with the id3v2 CLI
to a file that already has an ID3v2.4 tag silently destroys every other
frame on the file (title, album, genre, date, artwork, all of it), since
id3lib doesn't understand v2.4 and rewrites the whole tag as v2.3 containing
only the frame it was told to set. The mutagen write sets just the one frame
and leaves everything else untouched.

Other id3v2 CLI writes still exist elsewhere in the pipeline (playlist
grouping tags, genre fixes, buy-url tagging) and carry the same corruption
risk against ID3v2.4 files; not touched in this release.
2026-07-23 15:40:04 -06:00

136 lines
5.1 KiB
Bash
Executable File

#!/bin/bash
# ${PIPELINE_DIR:-/app/pipeline}/lib/tag-guard.sh
#
# Shared helper sourced by run-playlist.sh and import-track.sh.
# Moves audio files with missing ARTIST or TITLE tags into a quarantine dir,
# so they never reach beets and rot the library as "[Unknown Album]".
#
# Usage: quarantine_untagged <scan_dir> <quarantine_dir> <log_file>
# Returns: number of files moved (via stdout)
# Separator pattern matching Navidrome's artist split list in navidrome.toml.
# Used by set_albumartist_fallback to extract the primary artist from a multi-
# artist string. Keep these two lists in sync.
#
# Only unambiguous separators here. ", " and " & " are NOT included because they
# appear inside real band names ("Tyler, The Creator", "Simon & Garfunkel",
# "piri & tommy"). For collab strings using those separators, ALBUMARTIST falls
# back to the full string — Navidrome will show one combined artist for the album,
# which is the lesser of two evils vs. corrupting real band names.
_PRIMARY_ARTIST_SEP_PATTERN=' / | feat\. | feat | ft\. | ft |; '
# Read one ID3 text frame (e.g. TPE1, TIT2, TPE2) via mutagen instead of the
# `id3v2` CLI. id3v2 is id3lib-3.8.3 (last released 2013) and cannot parse
# ID3v2.4 tags at all -- it reports "No ID3 tag" on a file that has complete,
# valid tags, just written in the modern version most taggers (Mp3tag,
# yt-dlp, foobar2000) default to. That false negative used to send fully-
# tagged mp3s to quarantine. Usage: _mp3_tag <file> <frame>
_mp3_tag() {
python3 - "$1" "$2" 2>/dev/null <<'PY'
import sys
from mutagen.id3 import ID3
try:
tags = ID3(sys.argv[1])
frame = tags.get(sys.argv[2])
print(str(frame) if frame else "")
except Exception:
print("")
PY
}
# Set one ID3 text frame via mutagen, preserving every other frame on the
# file. The `id3v2` CLI cannot be used for this: writing even a single frame
# with it to a file that already has an ID3v2.4 tag silently drops every
# other frame (title, album, genre, date, artwork -- all of it), because
# id3lib doesn't understand v2.4 and rewrites the whole tag as v2.3 with only
# the frame(s) it was told to set. Usage: _mp3_set_tag <file> <frame> <value>
_mp3_set_tag() {
python3 - "$1" "$2" "$3" <<'PY'
import sys
from mutagen.id3 import ID3, Frames
tags = ID3(sys.argv[1])
frame_cls = Frames[sys.argv[2]]
tags.setall(sys.argv[2], [frame_cls(encoding=3, text=[sys.argv[3]])])
tags.save(sys.argv[1])
PY
}
# For each audio file under $scan_dir that has no ALBUMARTIST tag, set
# ALBUMARTIST to the primary artist (everything before the first separator
# in the ARTIST tag). Prevents Navidrome from coining ghost album-artists
# like "Fred Again, The Blessed Madonna" when a track is a collab.
#
# Usage: set_albumartist_fallback <scan_dir> <log_file>
# Returns: number of files updated (via stdout)
set_albumartist_fallback() {
local scan_dir="$1"
local logfile="$2"
local count=0
while IFS= read -r -d '' file; do
case "${file##*.}" in
flac|FLAC)
local cur_aa artist primary
cur_aa=$(metaflac --show-tag=ALBUMARTIST "$file" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
[[ -n "$cur_aa" ]] && continue
artist=$(metaflac --show-tag=ARTIST "$file" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
[[ -z "$artist" ]] && continue
primary=$(echo "$artist" | sed -E "s#(${_PRIMARY_ARTIST_SEP_PATTERN}).*##")
metaflac --set-tag="ALBUMARTIST=${primary}" "$file" 2>>"$logfile"
count=$((count + 1))
;;
mp3|MP3)
local cur_aa artist primary
cur_aa=$(_mp3_tag "$file" TPE2)
[[ -n "$cur_aa" ]] && continue
artist=$(_mp3_tag "$file" TPE1)
[[ -z "$artist" ]] && continue
primary=$(echo "$artist" | sed -E "s#(${_PRIMARY_ARTIST_SEP_PATTERN}).*##")
_mp3_set_tag "$file" TPE2 "$primary" 2>>"$logfile"
count=$((count + 1))
;;
esac
done < <(find "$scan_dir" -type f \( -iname "*.flac" -o -iname "*.mp3" \) -print0 2>/dev/null)
echo "$count"
}
quarantine_untagged() {
local scan_dir="$1"
local quarantine_dir="$2"
local logfile="$3"
local moved=0
mkdir -p "$quarantine_dir"
_has_flac_tags() {
local artist title
artist=$(metaflac --show-tag=ARTIST "$1" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
title=$(metaflac --show-tag=TITLE "$1" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
[[ -n "$artist" && -n "$title" ]]
}
_has_mp3_tags() {
local artist title
artist=$(_mp3_tag "$1" TPE1)
title=$(_mp3_tag "$1" TIT2)
[[ -n "$artist" && -n "$title" ]]
}
while IFS= read -r -d '' file; do
local ok=1
case "${file##*.}" in
flac|FLAC) _has_flac_tags "$file" || ok=0 ;;
mp3|MP3) _has_mp3_tags "$file" || ok=0 ;;
*) ok=0 ;; # unknown format — beets can't handle it anyway
esac
if [[ $ok -eq 0 ]]; then
echo "[$(date -Iseconds)] QUARANTINE: missing artist/title — $(basename "$file")" >> "$logfile"
mv -n "$file" "$quarantine_dir/" 2>>"$logfile"
moved=$((moved + 1))
fi
done < <(find "$scan_dir" -type f \( -iname "*.flac" -o -iname "*.mp3" -o -iname "*.m4a" \) -print0 2>/dev/null)
echo "$moved"
}