Files
alembic/pipeline/lib/clear-bad-genres.py
T
andrew 68cb007e4c Stage 0: migrate pipeline scripts from /opt/sldl, scaffold repo
Moves all ~30 pipeline scripts, configs, and the vendored sldl binary into
this repo (source /opt/sldl left untouched). Removes all docker exec/docker
compose dependencies now that beets and sldl run in-process/as a subprocess
of this container instead of via soulbeet/on-demand sldl containers.
Replaces hardcoded host paths, Navidrome credentials, and Spotify credential
sourcing with env-var-driven paths and shared credential loaders. Adds
Dockerfile, entrypoint.sh, requirements.txt, docker-compose.snippet.yml, and
the initial app DB schema.
2026-07-08 13:10:55 -06:00

322 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""
clear-bad-genres.py — normalize or blank GENRE tags so every file ends up
with a clean "; "-joined list of whitelist entries (max 3 parts).
Two-tier behavior per file:
1. NORMALIZE: if every part of the GENRE value canonicalizes to a
whitelist entry (after splitting on ; , / or " - " and resolving aliases
like "Rnb""R&B", "Hip Hop""Hip-Hop"), and there are ≤3 parts,
REWRITE the file's GENRE in canonical "Display1; Display2" form. This
fixes formatting issues like "Dance;Pop" (no space) → "Dance; Pop"
without losing the data.
2. BLANK: if any part doesn't map to a whitelist canonical, OR there are
too many parts, BLANK the GENRE field. Run spotify-genre.py --apply
--force afterwards to refill what Spotify can.
Catches:
- Mojibake (Cyrillic-as-ASCII like '5:B@>==0O <C7K:0')
- Wrong delimiters: comma ('Pop, Rock'), slash ('Indie Rock/Rock Pop'),
space-dash-space ('Dance - Electro - Vocal'), missing space after
semicolon ('Dance;Pop')
- Mixed-delimiter chains ('Pop, Rock; Alternative')
- Bracketed values ('[Drum n Bass]')
- Over-tagging: more than 3 parts (matches spotify-genre.py's MAX_GENRES)
- Spelling variants: 'Rnb''R&B', 'Hip Hop''Hip-Hop',
'Drum n Bass' / 'Drum and Bass''Drum & Bass', 'K Pop''K-Pop'
- Niche values not in the whitelist
Already-clean tracks are left untouched (zero file writes).
Usage:
clear-bad-genres.py # dry run, show planned normalize+blank ops
clear-bad-genres.py --apply # actually rewrite/blank
"""
import sys, os, re, argparse, subprocess
from mutagen import File as MFile
from mutagen.id3 import ID3, ID3NoHeaderError, TCON
from mutagen.flac import FLAC
from mutagen.mp4 import MP4
from mutagen.oggopus import OggOpus
from mutagen.oggvorbis import OggVorbis
# OGG/OPUS use Vorbis-comment-style tags (lowercase keys, multi-value), same
# shape as FLAC. Treat them as a family so the read/write paths are shared.
VORBIS_LIKE = (FLAC, OggOpus, OggVorbis)
ALEMBIC_CONFIG_DIR = os.environ.get("ALEMBIC_CONFIG_DIR", "/config")
WHITELIST_FILE = f"{ALEMBIC_CONFIG_DIR}/pipeline/genres-whitelist.txt"
MAX_PARTS = 3 # mirrors spotify-genre.py's MAX_GENRES
SEPARATOR = "; "
# Map normalized variant → normalized whitelist entry. Spotify and various
# Soulseek uploaders use spelling variants of the same genre — we want all of
# them to land on a single canonical whitelist string.
ALIASES = {
"rnb": "rb", # Rnb → R&B
"randb": "rb", # R and B
"hiphop": "hiphop", # noop after normalization
"hiphopalt": "hiphop",
"hphop": "hiphop",
"drumnbass": "drumbass", # Drum n Bass → Drum & Bass
"drumandbass": "drumbass", # Drum and Bass
"dnb": "drumbass", # DnB / D&B
"kpop": "kpop",
"jpop": "jpop",
"trance": "trance",
"idm": "idm",
"edm": "edm",
"ukgarage": "ukgarage",
"rocknroll": "rockroll", # Rock n Roll → Rock & Roll
"rockandroll": "rockroll",
"popfilm": "popfilm",
"popsoundtrack": "popfilm",
}
def normed(s):
"""lowercase + strip whitespace/hyphens/underscores/&/slashes/dots."""
return re.sub(r"[\s\-_&/.]+", "", s).lower()
def load_whitelist():
"""Return dict: normalized form → canonical display string from the file."""
out = {}
with open(WHITELIST_FILE) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
out[normed(line)] = line
return out
# Splits on: ; , / space-dash-space (any of these = multi-genre delimiter)
SPLIT_RE = re.compile(r"\s+-\s+|[;,/]")
def canonicalize(value, wl):
"""Try to parse value into ≤MAX_PARTS canonical whitelist entries.
Returns:
(parts_canonical, all_mapped) where:
parts_canonical = list of whitelist display strings (deduped, original
order), possibly empty
all_mapped = True iff every parsed part landed on a whitelist entry
AND the part count is within MAX_PARTS
"""
raw_parts = [p.strip(" \t[]()<>") for p in SPLIT_RE.split(value)]
raw_parts = [p for p in raw_parts if p]
if not raw_parts or len(raw_parts) > MAX_PARTS:
return [], False
seen = set()
out = []
for p in raw_parts:
n = normed(p)
# Try alias first, then direct whitelist hit
canon_norm = ALIASES.get(n, n)
display = wl.get(canon_norm)
if not display:
return [], False
if display not in seen:
out.append(display)
seen.add(display)
return out, True
def assess(value, wl):
"""Return ('clean'|'rewrite'|'blank', new_value).
- 'clean' : current value already canonical, no write needed
- 'rewrite' : all parts map cleanly; rewrite file as new_value
- 'blank' : at least one part is junk; blank the field
"""
if not value or not value.strip():
return "clean", None
canon_parts, ok = canonicalize(value, wl)
if not ok:
return "blank", None
new_value = SEPARATOR.join(canon_parts)
if new_value == value:
return "clean", None
return "rewrite", new_value
def is_genre_locked(m):
"""True if the file has GENRE_LOCK=1 (set by fix-genre.sh).
Manually-overridden genres often won't match the whitelist (e.g. 'Bass'
isn't in the whitelist but G Jones genuinely needs it). The lock means
'this is intentional, leave it alone'."""
if not m or not m.tags:
return False
try:
if isinstance(m, VORBIS_LIKE):
val = (m.tags.get("genre_lock") or m.tags.get("GENRE_LOCK") or [None])[0]
return val == "1"
if isinstance(m, MP4):
return False # fix-genre.sh doesn't set lock on MP4 yet
for frame in m.tags.getall("TXXX"):
if frame.desc.upper() == "GENRE_LOCK" and frame.text and frame.text[0] == "1":
return True
except (AttributeError, IndexError, TypeError):
pass
return False
def get_genre(filepath):
"""Return (mutagen_obj, joined_genre_string).
Vorbis comments (FLAC) and ID3v2.4 TCON both allow multiple GENRE values
per file. Beets joins them with '; ' for display. We mirror that so a file
with 8 separate GENRE tags is correctly seen as an 8-part chain (and gets
flagged as over-tagged), not just whatever the first tag happened to be.
"""
try:
m = MFile(filepath)
except Exception:
return None, None
if not m or not m.tags:
return m, None
if isinstance(m, VORBIS_LIKE):
vals = m.tags.get("genre") or []
return m, ("; ".join(str(v) for v in vals) if vals else None)
if isinstance(m, MP4):
vals = m.tags.get("©gen") or []
return m, ("; ".join(str(v) for v in vals) if vals else None)
if "TCON" in m.tags and m.tags["TCON"].text:
vals = m.tags["TCON"].text
return m, "; ".join(str(v) for v in vals)
return m, None
def clear_genre(filepath, m):
try:
if isinstance(m, VORBIS_LIKE):
if "genre" in m.tags:
del m.tags["genre"]
m.save()
elif isinstance(m, MP4):
if "©gen" in m.tags:
del m.tags["©gen"]
m.save()
else:
tags = ID3(filepath)
tags.delall("TCON")
tags.save(filepath)
return True
except (AttributeError, ID3NoHeaderError, Exception):
return False
def write_genre(filepath, m, value):
"""Overwrite GENRE with a single string value (replacing any multi-value
set the file might have had — that's the whole point of this rewrite)."""
try:
if isinstance(m, VORBIS_LIKE):
m.tags["genre"] = [value]
m.save()
elif isinstance(m, MP4):
m.tags["©gen"] = [value]
m.save()
else:
try:
tags = ID3(filepath)
except ID3NoHeaderError:
tags = ID3()
tags.delall("TCON")
tags.add(TCON(encoding=3, text=value))
tags.save(filepath)
return True
except (AttributeError, ID3NoHeaderError, Exception):
return False
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
wl = load_whitelist()
print(f"[clear-bad-genres] whitelist has {len(wl)} entries")
# beets now runs in-process in this same container, so the paths it
# reports are already valid here directly — no container-to-host
# translation needed.
r = subprocess.run(
["beet", "ls", "-f", "$path"],
capture_output=True, text=True, timeout=120
)
paths = [p for p in r.stdout.splitlines() if p.strip()]
print(f"[clear-bad-genres] {len(paths)} tracks to scan")
rewritten = blanked = failed = locked = 0
rewrite_examples = {} # original value -> (sample filename, canonical form)
blank_examples = {} # original value -> sample filename
for p in paths:
if not os.path.exists(p):
continue
m, current = get_genre(p)
if m is None or current is None:
continue
if is_genre_locked(m):
locked += 1
continue
action, new_value = assess(current, wl)
if action == "clean":
continue
if action == "rewrite":
if current not in rewrite_examples:
rewrite_examples[current] = (os.path.basename(p), new_value)
if args.apply:
if write_genre(p, m, new_value):
rewritten += 1
else:
failed += 1
else:
rewritten += 1
else: # blank
if current not in blank_examples:
blank_examples[current] = os.path.basename(p)
if args.apply:
if clear_genre(p, m):
blanked += 1
else:
failed += 1
else:
blanked += 1
verb = "rewrote" if args.apply else "would-rewrite"
bverb = "blanked" if args.apply else "would-blank"
print(f"\n[clear-bad-genres] {verb}: {rewritten} tracks, {bverb}: {blanked} tracks, "
f"locked-skipped: {locked}, failed: {failed}")
if rewrite_examples:
print(f"\nNormalize-rewrites ({len(rewrite_examples)} distinct):")
for old, (sample, new) in sorted(rewrite_examples.items())[:50]:
print(f" {old!r:40s}{new!r:30s} e.g. {sample}")
if len(rewrite_examples) > 50:
print(f" ...and {len(rewrite_examples)-50} more")
if blank_examples:
print(f"\nBlanked (will be refilled by spotify-genre) ({len(blank_examples)} distinct):")
for value, sample in sorted(blank_examples.items())[:50]:
print(f" {value!r:60s} e.g. {sample}")
if len(blank_examples) > 50:
print(f" ...and {len(blank_examples)-50} more")
if args.apply and (rewritten or blanked):
print("\n[clear-bad-genres] now run:")
print(" beet update")
if blanked:
print(" spotify-genre.py --apply --force")
if __name__ == "__main__":
sys.exit(main())