#!/bin/bash # fix-genre.sh — quickly override the GENRE tag for every track by an artist. # # Use when spotify-genre.py picks something wrong (e.g. it tagged Yasuha as # "Asian Music" when "Anime; Soundtrack" is what you actually want), or when # you want to set a genre on an artist Spotify doesn't have data for. # # The new value is written to file tags AND beets's DB. The next nightly # spotify-genre run will leave these alone (they already match the whitelist # format), unless you pass --force. # # Usage: # fix-genre.sh "" "" # apply # fix-genre.sh --dry "" "" # preview only # # Examples: # sudo fix-genre.sh "Yasuha" "Anime; Soundtrack" # sudo fix-genre.sh "Aphex Twin" "IDM; Electronic; Ambient" # sudo fix-genre.sh --dry "Mac DeMarco" "Indie; Indie Rock" set -euo pipefail DRY=0 if [[ "${1:-}" == "--dry" ]]; then DRY=1 shift fi ARTIST="${1:?Usage: $0 [--dry] }" GENRE="${2:?Usage: $0 [--dry] }" LIBRARY=${MUSIC_DATA_DIR:-/data/music}/Library # Pull paths via beets (in-process, same container). mapfile -t paths < <(beet ls -f '$path' "artist:$ARTIST" 2>/dev/null) if [[ "${#paths[@]}" -eq 0 ]]; then echo "[fix-genre] no tracks found in beets for artist:$ARTIST" exit 1 fi echo "[fix-genre] artist=\"$ARTIST\"" echo "[fix-genre] new GENRE=\"$GENRE\"" echo "[fix-genre] ${#paths[@]} track(s) match" [[ $DRY -eq 1 ]] && echo "[fix-genre] DRY RUN — no tags will be written" && exit 0 ok=0 fail=0 for cp in "${paths[@]}"; do hp="${LIBRARY}${cp#/music}" if [[ ! -f "$hp" ]]; then echo " MISS $hp" fail=$((fail + 1)) continue fi case "${hp,,}" in *.flac) # remove-tag returns non-zero when the tag isn't present; `|| true` so # that expected case doesn't abort under set -e. metaflac --remove-tag=GENRE "$hp" 2>/dev/null || true metaflac --set-tag="GENRE=$GENRE" "$hp" 2>/dev/null && ok=$((ok + 1)) || fail=$((fail + 1)) metaflac --remove-tag=GENRE_LOCK "$hp" 2>/dev/null || true metaflac --set-tag="GENRE_LOCK=1" "$hp" 2>/dev/null || true ;; *.mp3) # mid3v2 / id3v2 don't have a clean way to set TCON via plain id3v2 here, # so use mutagen via a one-liner. python3 - "$hp" "$GENRE" <<'PY' && ok=$((ok + 1)) || fail=$((fail + 1)) import sys from mutagen.id3 import ID3, TCON, TXXX, ID3NoHeaderError path, g = sys.argv[1], sys.argv[2] try: t = ID3(path) except ID3NoHeaderError: t = ID3() t.delall("TCON") t.add(TCON(encoding=3, text=g)) t.delall("TXXX:GENRE_LOCK") t.add(TXXX(encoding=3, desc="GENRE_LOCK", text="1")) t.save(path) PY ;; *.m4a|*.mp4) python3 - "$hp" "$GENRE" <<'PY' && ok=$((ok + 1)) || fail=$((fail + 1)) import sys from mutagen.mp4 import MP4 path, g = sys.argv[1], sys.argv[2] m = MP4(path) m["\xa9gen"] = g m.save() PY ;; *.opus|*.ogg) python3 - "$hp" "$GENRE" <<'PY' && ok=$((ok + 1)) || fail=$((fail + 1)) import sys from mutagen import File as MFile path, g = sys.argv[1], sys.argv[2] m = MFile(path) m.tags["genre"] = [g] m.save() PY ;; *) echo " SKIP unsupported format: $hp" fail=$((fail + 1)) ;; esac done echo "[fix-genre] tagged ok=$ok, failed=$fail" # Sync beets DB so its $genres column matches the file tags. # `-q` is NOT a flag for `beet update` (only `beet ls`); leaving it off # lets beets actually apply the diff non-interactively. echo "[fix-genre] syncing beets DB" beet update "artist:$ARTIST" 2>&1 | grep -E '^\s+[+-]' || true # Verify echo "[fix-genre] new state:" # `|| true`: head closes the pipe after 5 lines, so beet exits with SIGPIPE for # any artist with more than 5 tracks; that's expected, not a failure. beet ls -f ' $title — $genres' "artist:$ARTIST" | head -5 || true remaining=$(beet ls "artist:$ARTIST" | wc -l) shown=5 [[ "$remaining" -gt "$shown" ]] && echo " ...and $((remaining - shown)) more" echo "[fix-genre] done. Trigger a Navidrome scan to surface the change in the UI."