mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
149 lines
5.9 KiB
Python
149 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
# ==============================================================================
|
|
# COPYRIGHT NO ONE EVERYWHERE LLC (WYOMING HOLDING COMPANY)
|
|
# PROJECT: SOVEREIGN STACK
|
|
# This artifact is entirely proprietary and cryptographically proven.
|
|
# Open-Source usage requires explicit permission from Brandon Scott Schneider.
|
|
# ==============================================================================
|
|
"""
|
|
normalize_flac_genres.py — Non-destructive FLAC genre/date normalization audit + fix.
|
|
|
|
Usage:
|
|
python3 normalize_flac_genres.py <music_dir> [--fix] [--report <out.json>]
|
|
|
|
Modes:
|
|
(default) dry-run: print what would change, touch nothing
|
|
--fix write normalized GENRE tags back into FLAC files (metaflac)
|
|
--report write JSON audit report to file
|
|
|
|
Normalization rules:
|
|
1. Non-ASCII / locale-specific → English equivalent
|
|
2. Multi-value (slash / comma-separated) → canonical single value
|
|
3. Overly specific MusicBrainz subgenre → standard parent genre
|
|
4. DATE year-only → leaves untouched (no good source of truth), reports only
|
|
"""
|
|
|
|
import argparse, collections, json, os, re, subprocess, sys
|
|
|
|
# ── Normalisation map ───────────────────────────────────────────────────────
|
|
NORM_MAP = {
|
|
# French MusicBrainz locale genres → English
|
|
"Électronique": "Electronic",
|
|
"Électronique, Dance": "Electronic",
|
|
"Électronique, Pop": "Electropop",
|
|
"Alternatif et Indé": "Alternative",
|
|
"Pop, Rock, Alternatif et Indé":"Alternative Rock",
|
|
"Rock & Roll, Rhythm 'n' Blues":"Rock",
|
|
"Musique du monde": "World",
|
|
"Métal": "Metal",
|
|
"Jazz & Blues": "Jazz",
|
|
|
|
# MusicBrainz subgenres → broader standard
|
|
"Space Rock Revival": "Rock",
|
|
"Post-Grunge": "Rock",
|
|
"Rock Music": "Rock",
|
|
"Nu Metal": "Metal",
|
|
"Industrial Metal": "Metal",
|
|
"Stage And Screen": "Soundtrack",
|
|
"Films/Games": "Soundtrack",
|
|
"Video Game Music": "Soundtrack",
|
|
|
|
# Multi-value / slash genres
|
|
"Alternative / Rock / Metal": "Alternative Metal",
|
|
"Rock / Metal": "Metal",
|
|
"Pop / Rock": "Pop Rock",
|
|
"Jazz / Blues": "Jazz",
|
|
"Electronic / Dance": "Electronic",
|
|
}
|
|
|
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
def read_tags(path):
|
|
r = subprocess.run(["metaflac", "--export-tags-to=-", path],
|
|
capture_output=True, text=True)
|
|
tags = {}
|
|
for line in r.stdout.splitlines():
|
|
if "=" in line:
|
|
k, _, v = line.partition("=")
|
|
tags[k.upper()] = v.strip()
|
|
return tags
|
|
|
|
|
|
def write_tag(path, field, value):
|
|
subprocess.run(
|
|
["metaflac", f"--remove-tag={field}", f"--set-tag={field}={value}", path],
|
|
check=True
|
|
)
|
|
|
|
|
|
def classify(genre):
|
|
if not genre:
|
|
return "missing", None
|
|
if genre in NORM_MAP:
|
|
return "normalize", NORM_MAP[genre]
|
|
if re.search(r"[^\x00-\x7F]", genre):
|
|
return "non_ascii", None
|
|
if "/" in genre or (", " in genre and not genre.startswith("R&")):
|
|
return "multi_value", None
|
|
return "ok", None
|
|
|
|
|
|
# ── Main ────────────────────────────────────────────────────────────────────
|
|
|
|
def audit(music_dir, fix=False, report_path=None):
|
|
results = []
|
|
stats = collections.Counter()
|
|
|
|
for root, _, files in os.walk(music_dir):
|
|
for fname in sorted(files):
|
|
if not fname.endswith(".flac"):
|
|
continue
|
|
path = os.path.join(root, fname)
|
|
rel = os.path.relpath(path, music_dir)
|
|
tags = read_tags(path)
|
|
genre = tags.get("GENRE", "")
|
|
date = tags.get("DATE", "")
|
|
|
|
issue, suggestion = classify(genre)
|
|
stats[issue] += 1
|
|
|
|
date_issue = bool(re.match(r"^\d{4}$", date))
|
|
if date_issue:
|
|
stats["year_only_date"] += 1
|
|
|
|
entry = {
|
|
"path": rel, "genre": genre, "issue": issue,
|
|
"suggestion": suggestion, "date": date, "date_issue": date_issue,
|
|
}
|
|
results.append(entry)
|
|
|
|
if issue != "ok" or date_issue:
|
|
tag_note = f" {genre!r:<40} → {suggestion!r}" if suggestion else f" {genre!r}"
|
|
date_note = f" DATE={date} (year-only)" if date_issue else ""
|
|
action = ""
|
|
if fix and suggestion:
|
|
write_tag(path, "GENRE", suggestion)
|
|
action = " [FIXED]"
|
|
print(f"{'FIX' if fix and suggestion else 'WARN'}{action} {rel}")
|
|
if tag_note.strip(): print(tag_note)
|
|
if date_note: print(date_note)
|
|
|
|
print(f"\n{'='*64}")
|
|
print(f"Scanned : {len(results)} tracks")
|
|
for k, v in sorted(stats.items()):
|
|
print(f" {k:<20}: {v}")
|
|
print(f"{'='*64}")
|
|
|
|
if report_path:
|
|
with open(report_path, "w") as f:
|
|
json.dump({"stats": dict(stats), "tracks": results}, f, indent=2)
|
|
print(f"Report : {report_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("music_dir")
|
|
ap.add_argument("--fix", action="store_true", help="Write normalized genres back to files")
|
|
ap.add_argument("--report", metavar="FILE", help="Write JSON audit report")
|
|
args = ap.parse_args()
|
|
audit(args.music_dir, fix=args.fix, report_path=args.report)
|