68cb007e4c
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.
111 lines
3.5 KiB
Python
Executable File
111 lines
3.5 KiB
Python
Executable File
#!/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())
|