#!/usr/bin/env python3 """Probe audio files with ffprobe and generate songs.json for Pay2Play! Usage: ./tools/probe-songs.py public/audio/*.mp3 ./tools/probe-songs.py -d public/audio ./tools/probe-songs.py -o public/audio/songs.json track1.mp3 track2.mp3 Extracts duration, title, and artist from each file's metadata (ID3 tags). Falls back to filename for title and "Unknown Artist" when tags are missing. Outputs JSON compatible with the Pay2Play! SONGS array format. """ import argparse import json import os import subprocess import sys from pathlib import Path COLORS = [ "#e74c3c", "#9b59b6", "#f39c12", "#3498db", "#1abc9c", "#e67e22", "#2ecc71", "#d35400", "#2980b9", "#8e44ad", "#16a085", "#c0392b", ] AUDIO_EXTENSIONS = { ".mp3", ".ogg", ".wav", ".flac", ".m4a", ".aac", ".opus", ".weba", ".webm", } def ffprobe(path: str) -> dict | None: """Run ffprobe and return format metadata as a dict.""" try: result = subprocess.run( [ "ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", str(path), ], capture_output=True, text=True, timeout=10, ) if result.returncode != 0: print(f" warning: ffprobe failed for {path}", file=sys.stderr) return None return json.loads(result.stdout).get("format") except FileNotFoundError: print("error: ffprobe not found — install ffmpeg", file=sys.stderr) sys.exit(1) except (subprocess.TimeoutExpired, json.JSONDecodeError) as e: print(f" warning: {e} for {path}", file=sys.stderr) return None def title_from_filename(path: str) -> str: """Derive a display title from a filename.""" name = Path(path).stem # Replace common separators with spaces, then title-case for ch in "-_": name = name.replace(ch, " ") return name.strip().title() def probe_file(path: str, audio_root: str | None, index: int) -> dict | None: """Probe a single audio file and return a song entry dict.""" fmt = ffprobe(path) if fmt is None: return None tags = fmt.get("tags", {}) # ffprobe tag keys can be uppercase or lowercase tag = lambda key: tags.get(key) or tags.get(key.upper()) or tags.get(key.title()) duration = float(fmt.get("duration", 0)) if duration <= 0: print(f" warning: zero duration for {path}, skipping", file=sys.stderr) return None title = tag("title") or title_from_filename(path) artist = tag("artist") or tag("album_artist") or "Unknown Artist" # Build URL relative to the served public/ directory abspath = os.path.abspath(path) if audio_root: relpath = os.path.relpath(abspath, os.path.abspath(audio_root)) url = f"/audio/{relpath}" else: url = f"/audio/{os.path.basename(path)}" return { "title": title, "artist": artist, "duration": int(duration), "url": url, "color": COLORS[index % len(COLORS)], } def collect_files(paths: list[str], scan_dir: str | None) -> list[str]: """Resolve input paths and optionally scan a directory for audio files.""" files = [] if scan_dir: d = Path(scan_dir) if not d.is_dir(): print(f"error: {scan_dir} is not a directory", file=sys.stderr) sys.exit(1) files.extend( sorted(str(p) for p in d.iterdir() if p.suffix.lower() in AUDIO_EXTENSIONS) ) for p in paths: if os.path.isfile(p): files.append(p) elif os.path.isdir(p): files.extend( sorted( str(f) for f in Path(p).iterdir() if f.suffix.lower() in AUDIO_EXTENSIONS ) ) else: print(f" warning: {p} not found, skipping", file=sys.stderr) return files def main(): parser = argparse.ArgumentParser( description="Probe audio files and generate songs.json for Pay2Play!" ) parser.add_argument("files", nargs="*", help="Audio files or directories to probe") parser.add_argument("-d", "--dir", help="Directory to scan for audio files") parser.add_argument( "-o", "--output", help="Output file (default: stdout, or public/audio/songs.json with --dir)", ) parser.add_argument( "--audio-root", help="Path that maps to /audio/ in the served app (default: public/)", default=None, ) args = parser.parse_args() if not args.files and not args.dir: parser.print_help() print("\nExamples:", file=sys.stderr) print(" ./tools/probe-songs.py public/audio/*.mp3", file=sys.stderr) print(" ./tools/probe-songs.py -d public/audio", file=sys.stderr) print( " ./tools/probe-songs.py -o public/audio/songs.json track1.mp3", file=sys.stderr, ) sys.exit(1) # Determine the root that maps to /audio/ in the browser audio_root = args.audio_root if not audio_root: # Default: assume files live in or will be copied to public/audio/ audio_root = "public" files = collect_files(args.files, args.dir) if not files: print("No audio files found.", file=sys.stderr) sys.exit(1) print(f"Probing {len(files)} file(s)...", file=sys.stderr) songs = [] for i, f in enumerate(files): print(f" {os.path.basename(f)}...", file=sys.stderr, end=" ") entry = probe_file(f, audio_root, i) if entry: songs.append(entry) print( f"{entry['duration']}s — {entry['artist']} - {entry['title']}", file=sys.stderr, ) else: print("skipped", file=sys.stderr) output_path = args.output if not output_path and args.dir: output_path = os.path.join(args.dir, "songs.json") result = json.dumps(songs, indent=2, ensure_ascii=False) if output_path: with open(output_path, "w") as f: f.write(result + "\n") print(f"\nWrote {len(songs)} song(s) to {output_path}", file=sys.stderr) else: print(result) print(f"\n{len(songs)} song(s) ready for Pay2Play!", file=sys.stderr) if __name__ == "__main__": main()