#!/usr/bin/env python3 """ normalize-artist-casing.py — enforce canonical artist-name casing in beets. Reads $ALEMBIC_CONFIG_DIR/pipeline/artist-canonical.list (one canonical name per line, `#` comments allowed). For each distinct $albumartist in the library that matches a canonical name case-insensitively but differs in casing, runs `beet modify` to rewrite the albumartist tag — which moves the album folder to the canonical path as a side effect. Idempotent. Cheap to run (one `beet ls` + one `beet modify` per mismatch). Why: prevents Linux → Windows Syncthing case-conflicts. Linux treats `Mall Grab/` and `MALL GRAB/` as distinct folders; Windows collapses them. Usage: normalize-artist-casing.py # dry-run, prints diffs normalize-artist-casing.py --apply # do the work """ from __future__ import annotations import argparse import os import re import subprocess import sys import time CONFIG = f"{os.environ.get('ALEMBIC_CONFIG_DIR', '/config')}/pipeline/artist-canonical.list" BEET = ["beet"] def load_canonical(path: str) -> list[str]: out: list[str] = [] with open(path, encoding="utf-8") as f: for ln in f: ln = ln.split("#", 1)[0].strip() if ln: out.append(ln) return out def all_albumartists() -> set[str]: res = subprocess.run(BEET + ["ls", "-f", "$albumartist"], capture_output=True, text=True, check=True) return {ln.strip() for ln in res.stdout.splitlines() if ln.strip()} def apply_fix(old: str, new: str, verbose: bool) -> bool: query = f"albumartist::^{re.escape(old)}$" cmd = BEET + ["modify", "-y", query, f"albumartist={new}"] r = subprocess.run(cmd, capture_output=True, text=True) if verbose: sys.stdout.write(r.stdout) if r.returncode != 0: sys.stderr.write(f" ERROR ({old!r} → {new!r}): {r.stderr.strip()}\n") return False return True def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--apply", action="store_true", help="execute the renames (default: dry run)") ap.add_argument("--verbose", action="store_true") args = ap.parse_args() stamp = time.strftime("%Y-%m-%dT%H:%M:%S") canonical = load_canonical(CONFIG) by_lower: dict[str, str] = {} for n in canonical: key = n.lower() if key in by_lower and by_lower[key] != n: print(f"[{stamp}] WARN: {CONFIG} has two entries with same lowercased " f"key: {by_lower[key]!r} and {n!r}; using the latter", file=sys.stderr) by_lower[key] = n existing = all_albumartists() mismatches: list[tuple[str, str]] = [] for aa in sorted(existing): want = by_lower.get(aa.lower()) if want and aa != want: mismatches.append((aa, want)) print(f"[{stamp}] normalize-artist-casing: {len(canonical)} canonical, " f"{len(existing)} distinct album-artists in library, " f"{len(mismatches)} mismatched") if not mismatches: return 0 for old, new in mismatches: print(f" {old!r} -> {new!r}") if not args.apply: print(f"[{stamp}] dry run; pass --apply to execute") return 0 fixed = 0 for old, new in mismatches: if apply_fix(old, new, args.verbose): fixed += 1 print(f"[{stamp}] done: {fixed}/{len(mismatches)} applied") return 0 if fixed == len(mismatches) else 1 if __name__ == "__main__": sys.exit(main())