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.
This commit is contained in:
andrew
2026-07-08 13:10:55 -06:00
commit 68cb007e4c
48 changed files with 9261 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
#!/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())