summaryrefslogtreecommitdiff
path: root/prompts/skills/creating-cd-mixes
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-25 09:16:41 +0300
committerPaul Buetow <paul@buetow.org>2026-05-25 09:16:41 +0300
commit9ac61087ecdd97fe67c8217c1a8c1b6d361ef2ef (patch)
treeb17000d8510ca1013a4832e30fbed53ac6c3de51 /prompts/skills/creating-cd-mixes
parentb48ef4c3104b746e045e351eff1a50a68de1c609 (diff)
sync skills: add check-shopping-status, protonbridge-imap, sync-skills; add creating-cd-mixes scripts
Diffstat (limited to 'prompts/skills/creating-cd-mixes')
-rwxr-xr-xprompts/skills/creating-cd-mixes/scripts/build_cds.py176
1 files changed, 176 insertions, 0 deletions
diff --git a/prompts/skills/creating-cd-mixes/scripts/build_cds.py b/prompts/skills/creating-cd-mixes/scripts/build_cds.py
new file mode 100755
index 0000000..089d9ff
--- /dev/null
+++ b/prompts/skills/creating-cd-mixes/scripts/build_cds.py
@@ -0,0 +1,176 @@
+#!/usr/bin/env python3
+"""Pack FLAC tracks into N audio CDs, shuffled and deduplicated by title.
+
+Reads a JSON config from stdin or from --config FILE:
+
+ {
+ "music_root": "/data/nfs/k3svolumes/navidrome/music",
+ "dest": "/home/paul/Desktop/Music",
+ "name_prefix": "CD", # output dirs become CD1-Mix, CD2-Mix, ...
+ "name_suffix": "Mix",
+ "title": "Focus / House Mix",
+ "num_cds": 3,
+ "max_seconds": 4380, # 73:00 — safe under 74-min CD limit
+ "seed": 42,
+ "source_dirs": [
+ "Miscellaneous/Compilations/Deep House Study Mix_ Electronic Music for Studying, Concentration",
+ "Miscellaneous/Compilations/Chill House Cafe Playlist"
+ ],
+ "extra_files": [
+ "Miscellaneous/David Guetta, Marten Hørger/The Freaks/01 The Freaks (Edit).flac"
+ ],
+ "exclude_titles": ["Eternal", "Tunnel"], # optional: skip these (case-insensitive)
+ "recursive": false, # optional: glob *.flac recursively
+ "start_index": 1 # optional: start CD numbering (e.g. 2)
+ }
+
+Disc-length presets (max_seconds):
+ 74-min CD: 4380 (73:00, ~1 min headroom)
+ 80-min CD: 4700 (78:20, ~1:40 headroom)
+
+Output per CD:
+ <dest>/<name_prefix><N>-<name_suffix>/01 - Title.flac ...
+ <dest>/<name_prefix><N>-<name_suffix>.txt (summary)
+"""
+
+import argparse
+import json
+import random
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+
+def duration(p: Path) -> float:
+ out = subprocess.check_output(
+ ["ffprobe", "-v", "error",
+ "-show_entries", "format=duration",
+ "-of", "default=nw=1:nk=1", str(p)],
+ text=True,
+ )
+ return float(out.strip())
+
+
+def mmss(secs: float) -> str:
+ m, s = divmod(int(round(secs)), 60)
+ return f"{m}:{s:02d}"
+
+
+def clean_title(stem: str) -> str:
+ """Strip a leading 'NN ' track-number prefix."""
+ if len(stem) > 3 and stem[:2].isdigit() and stem[2] == " ":
+ return stem[3:].strip()
+ return stem.strip()
+
+
+def collect_pool(cfg: dict) -> list[dict]:
+ music_root = Path(cfg["music_root"])
+ recursive = bool(cfg.get("recursive", False))
+ glob_pat = "**/*.flac" if recursive else "*.flac"
+ paths: list[Path] = []
+ for d in cfg.get("source_dirs", []):
+ base = music_root / d
+ if base.exists():
+ paths.extend(sorted(base.glob(glob_pat)))
+ for f in cfg.get("extra_files", []):
+ p = music_root / f
+ if p.exists():
+ paths.append(p)
+
+ excluded = {t.lower().strip() for t in cfg.get("exclude_titles", [])}
+ seen: set[str] = set()
+ pool: list[dict] = []
+ for flac in paths:
+ title = clean_title(flac.stem)
+ key = title.lower()
+ if key in seen or key in excluded:
+ continue
+ seen.add(key)
+ pool.append({
+ "path": flac,
+ "title": title,
+ "source": flac.parent.name,
+ "duration": duration(flac),
+ })
+ return pool
+
+
+def pack(pool: list[dict], num_cds: int, max_seconds: int):
+ """Greedy fit into least-filled CD that still has room."""
+ cds: list[list[dict]] = [[] for _ in range(num_cds)]
+ totals = [0.0] * num_cds
+ leftover: list[dict] = []
+ for tr in pool:
+ placed = False
+ for i in sorted(range(num_cds), key=lambda x: totals[x]):
+ if totals[i] + tr["duration"] <= max_seconds:
+ cds[i].append(tr)
+ totals[i] += tr["duration"]
+ placed = True
+ break
+ if not placed:
+ leftover.append(tr)
+ return cds, totals, leftover
+
+
+def write_cd(idx: int, tracks: list[dict], total: float, cfg: dict) -> None:
+ dest = Path(cfg["dest"])
+ prefix = cfg.get("name_prefix", "CD")
+ suffix = cfg.get("name_suffix", "Mix")
+ title = cfg.get("title", "Mix")
+ max_s = cfg["max_seconds"]
+ start = int(cfg.get("start_index", 1))
+ name = f"{prefix}{idx + start}-{suffix}"
+ out_dir = dest / name
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ summary = [
+ f"CD {idx + start} — {title}",
+ f"Total tracks: {len(tracks)}",
+ f"Total time: {mmss(total)} (limit {mmss(max_s)})",
+ "",
+ f"{'#':>2} {'TIME':>5} TITLE [source]",
+ "-" * 78,
+ ]
+ for i, tr in enumerate(tracks, 1):
+ num = f"{i:02d}"
+ dest_name = f"{num} - {tr['title']}.flac".replace("/", "_")
+ shutil.copy2(tr["path"], out_dir / dest_name)
+ summary.append(
+ f"{num} {mmss(tr['duration']):>5} {tr['title']} [{tr['source']}]"
+ )
+ (dest / f"{name}.txt").write_text("\n".join(summary) + "\n", encoding="utf-8")
+ print(f" -> {name}: {len(tracks)} tracks, {mmss(total)}")
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--config", "-c", help="JSON config file (default: stdin)")
+ args = ap.parse_args()
+
+ raw = Path(args.config).read_text() if args.config else sys.stdin.read()
+ cfg = json.loads(raw)
+
+ cfg.setdefault("num_cds", 3)
+ cfg.setdefault("max_seconds", 4380)
+ cfg.setdefault("seed", 42)
+ cfg.setdefault("title", "Mix")
+
+ random.seed(cfg["seed"])
+ Path(cfg["dest"]).mkdir(parents=True, exist_ok=True)
+
+ pool = collect_pool(cfg)
+ print(f"pool: {len(pool)} tracks, "
+ f"{mmss(sum(t['duration'] for t in pool))} total")
+
+ random.shuffle(pool)
+ cds, totals, leftover = pack(pool, cfg["num_cds"], cfg["max_seconds"])
+ if leftover:
+ print(f" leftover (did not fit): {len(leftover)}")
+ for i, (tracks, total) in enumerate(zip(cds, totals)):
+ write_cd(i, tracks, total, cfg)
+
+
+if __name__ == "__main__":
+ main()