ignore any songs.json etc
This commit is contained in:
parent
96b1ecf7e6
commit
9a5db25087
3 changed files with 235 additions and 8 deletions
220
tools/probe-songs.py
Executable file
220
tools/probe-songs.py
Executable file
|
|
@ -0,0 +1,220 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue