Files
led2/lightsync/api/audio.py
Claude d58ea8c633 feat(06-01): add YouTube download endpoint with SSE progress streaming
- POST /api/audio/fetch accepts {url: string}
- Spawns yt-dlp subprocess via sys.executable -m yt_dlp
- Streams SSE progress events with PROGRESS_RE line-by-line parsing
- Uses --newline flag for reliable progress output
- Uses yt_%(id)s.%(ext)s template for predictable filenames
- Runs beat analysis on completion and caches in app.state.beats
- Returns done event with path and filename on success
2026-04-07 11:36:19 +00:00

193 lines
6.9 KiB
Python

"""Audio upload, file listing, waveform, and beat analysis endpoints."""
import asyncio
import json
import re
import shutil
import sys
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from lightsync.audio.waveform import extract_peaks
from lightsync.audio.beats import analyze_async
router = APIRouter()
SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg"}
SHOWS_DIR = Path("/app/shows")
PROGRESS_RE = re.compile(r'\[download\]\s+([\d.]+)%')
class YouTubeFetchRequest(BaseModel):
url: str
@router.get("/files")
async def list_audio_files():
"""List audio files available in the shows directory."""
SHOWS_DIR.mkdir(parents=True, exist_ok=True)
files = sorted(
p.name for p in SHOWS_DIR.iterdir()
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
)
return {"files": files, "directory": str(SHOWS_DIR)}
@router.post("/upload")
async def upload_audio(request: Request, file: UploadFile = File(...)):
"""Upload an audio file to the shows directory and run beat analysis.
After upload, librosa beat detection runs in a thread and the result is
cached in app.state.beats keyed by the absolute file path.
"""
suffix = Path(file.filename).suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Unsupported format: {suffix}. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
)
SHOWS_DIR.mkdir(parents=True, exist_ok=True)
dest = SHOWS_DIR / file.filename
with dest.open("wb") as f:
shutil.copyfileobj(file.file, f)
# Run beat analysis in background — cache result keyed by filepath
dest_str = str(dest)
try:
beat_data = await analyze_async(dest_str)
if not hasattr(request.app.state, "beats") or request.app.state.beats is None:
request.app.state.beats = {}
request.app.state.beats[dest_str] = beat_data
except Exception as exc:
# Beat analysis failure is non-fatal — upload still succeeds
beat_data = None
import logging
logging.getLogger(__name__).warning("Beat analysis failed for %s: %s", dest_str, exc)
return {
"status": "uploaded",
"path": dest_str,
"filename": file.filename,
"beats": beat_data,
}
@router.get("/beats")
async def get_beats(request: Request, path: str):
"""Return cached beat data for a loaded file.
Query params:
path: absolute path to the audio file (must have been uploaded/analyzed)
Returns:
{"tempo": float, "beats": [float, ...], "file": str}
"""
beats_cache: dict = getattr(request.app.state, "beats", None) or {}
if path not in beats_cache:
# Not cached — run analysis now (may be slow)
p = Path(path)
if not p.exists():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
if p.suffix.lower() not in SUPPORTED_EXTENSIONS:
raise HTTPException(status_code=400, detail=f"Unsupported format: {p.suffix}")
try:
beat_data = await analyze_async(path)
if not hasattr(request.app.state, "beats") or request.app.state.beats is None:
request.app.state.beats = {}
request.app.state.beats[path] = beat_data
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Beat analysis failed: {exc}") from exc
else:
beat_data = beats_cache[path]
return {**beat_data, "file": path}
@router.get("/waveform")
async def get_waveform(path: str, peaks: int = 1000):
"""Get waveform peak data for a file (AUD-05).
Query params:
path: absolute path to audio file
peaks: number of peak samples (default 1000, max 5000)
"""
p = Path(path)
if not p.exists():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
num_peaks = min(max(peaks, 100), 5000)
try:
data = await asyncio.to_thread(extract_peaks, path, num_peaks)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Waveform extraction failed: {e}")
return {"peaks": data, "count": len(data), "file": path}
@router.post("/fetch")
async def fetch_youtube(request: Request, body: YouTubeFetchRequest):
"""Download audio from YouTube URL via yt-dlp. Streams SSE progress events."""
async def event_stream():
filename = None
try:
proc = await asyncio.create_subprocess_exec(
sys.executable, '-m', 'yt_dlp',
'--extract-audio',
'--audio-format', 'mp3',
'--audio-quality', '0',
'--newline',
'--no-playlist',
'-o', str(SHOWS_DIR / 'yt_%(id)s.%(ext)s'),
body.url,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line_bytes in proc.stdout:
line = line_bytes.decode('utf-8', errors='replace').rstrip()
# Progress line
m = PROGRESS_RE.search(line)
if m:
pct = float(m.group(1))
yield f"data: {json.dumps({'type': 'progress', 'pct': pct})}\n\n"
# Destination line — captures actual output filename
if 'Destination:' in line:
filename = line.split('Destination:')[-1].strip()
await proc.wait()
if proc.returncode != 0:
yield f"data: {json.dumps({'type': 'error', 'msg': f'yt-dlp exited with code {proc.returncode}'})}\n\n"
return
if not filename:
# Fallback: find most recently created mp3 in SHOWS_DIR
mp3s = sorted(SHOWS_DIR.glob('yt_*.mp3'), key=lambda p: p.stat().st_mtime, reverse=True)
if mp3s:
filename = str(mp3s[0])
if filename:
# Run beat analysis in background and cache
try:
beat_data = await analyze_async(filename)
if not hasattr(request.app.state, 'beats') or request.app.state.beats is None:
request.app.state.beats = {}
request.app.state.beats[filename] = beat_data
except Exception:
pass # Non-fatal — beats can be loaded later
fname = Path(filename).name
yield f"data: {json.dumps({'type': 'done', 'path': filename, 'filename': fname})}\n\n"
else:
yield f"data: {json.dumps({'type': 'error', 'msg': 'Download completed but output file not found'})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'msg': str(e)})}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")