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
This commit is contained in:
Claude
2026-04-07 11:36:19 +00:00
parent 165cdb6f23
commit d58ea8c633

View File

@@ -1,8 +1,13 @@
"""Audio upload, file listing, waveform, and beat analysis endpoints.""" """Audio upload, file listing, waveform, and beat analysis endpoints."""
import asyncio import asyncio
import json
import re
import shutil import shutil
import sys
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, UploadFile, File 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.waveform import extract_peaks
from lightsync.audio.beats import analyze_async from lightsync.audio.beats import analyze_async
@@ -10,6 +15,11 @@ router = APIRouter()
SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg"} SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg"}
SHOWS_DIR = Path("/app/shows") SHOWS_DIR = Path("/app/shows")
PROGRESS_RE = re.compile(r'\[download\]\s+([\d.]+)%')
class YouTubeFetchRequest(BaseModel):
url: str
@router.get("/files") @router.get("/files")
@@ -116,3 +126,67 @@ async def get_waveform(path: str, peaks: int = 1000):
raise HTTPException(status_code=500, detail=f"Waveform extraction failed: {e}") raise HTTPException(status_code=500, detail=f"Waveform extraction failed: {e}")
return {"peaks": data, "count": len(data), "file": path} 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")