From d58ea8c633ae6e8e5aba25ef5785420dfdbcf872 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 11:36:19 +0000 Subject: [PATCH] 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 --- lightsync/api/audio.py | 74 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/lightsync/api/audio.py b/lightsync/api/audio.py index d5dffb4..3543c7e 100644 --- a/lightsync/api/audio.py +++ b/lightsync/api/audio.py @@ -1,8 +1,13 @@ """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 @@ -10,6 +15,11 @@ 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") @@ -116,3 +126,67 @@ async def get_waveform(path: str, peaks: int = 1000): 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")