220 lines
6.4 KiB
Python
220 lines
6.4 KiB
Python
|
|
#!/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()
|