MusicUtils / main.py
sathvik77's picture
feat: youtube stats endpoint + nav link
d1806e6
Raw
History Blame Contribute Delete
16.8 kB
import asyncio
import json
import os
import threading
import uuid
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from pathlib import Path
import aiofiles
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from cleanup import start_cleanup_scheduler
from models import JobState, JobStatus, UploadResponse, YoutubeJobRequest, YoutubeJobStatus
from processor import enqueue_job, get_job_status, init_queue, start_worker
BASE_DIR = Path(__file__).parent
UPLOAD_DIR = BASE_DIR / "tmp" / "uploads"
OUTPUT_DIR = BASE_DIR / "tmp" / "outputs"
# Prefer /data (HF Spaces persistent storage) so stats survive redeployments.
_HF_PERSISTENT = Path("/data")
if _HF_PERSISTENT.exists():
try:
_HF_PERSISTENT.mkdir(parents=True, exist_ok=True)
(_HF_PERSISTENT / ".write_test").write_text("ok")
(_HF_PERSISTENT / ".write_test").unlink()
DATA_DIR = _HF_PERSISTENT
except OSError:
DATA_DIR = BASE_DIR
else:
DATA_DIR = BASE_DIR
STATS_FILE = DATA_DIR / "stats.json"
DOWNLOAD_LOG_FILE = DATA_DIR / "download_log.json"
_stats_lock = threading.Lock()
_log_lock = threading.Lock()
# Baseline stats that survive redeployments (HF Spaces ephemeral filesystem).
_BASELINE_STATS = {"total_visits": 100, "total_downloads": 5}
def _read_stats() -> dict:
if not STATS_FILE.exists():
return dict(_BASELINE_STATS)
try:
with open(STATS_FILE, "r") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return dict(_BASELINE_STATS)
def _write_stats(stats: dict) -> None:
with open(STATS_FILE, "w") as f:
json.dump(stats, f)
def _increment_stat(key: str) -> None:
with _stats_lock:
stats = _read_stats()
stats[key] = stats.get(key, 0) + 1
_write_stats(stats)
def _read_download_log() -> list[dict]:
if not DOWNLOAD_LOG_FILE.exists():
return []
try:
with open(DOWNLOAD_LOG_FILE, "r") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return []
def _append_download_record(job_id: str, original_filename: str, stem: str) -> None:
with _log_lock:
log = _read_download_log()
log.append({
"job_id": job_id,
"original_filename": original_filename,
"stem": stem,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
with open(DOWNLOAD_LOG_FILE, "w") as f:
json.dump(log, f)
ALLOWED_CONTENT_TYPES = {"audio/mpeg", "audio/mp3"}
MAX_FILE_SIZE = 500 * 1024 * 1024
# Pitch/tempo shifting limits. Kept conservative so artifacts stay acceptable with the
# stock ffmpeg filter chain (no librubberband available in the slim image).
PITCH_SEMITONES_MIN = -12.0
PITCH_SEMITONES_MAX = 12.0
TEMPO_PCT_MIN = 50.0
TEMPO_PCT_MAX = 150.0
def _pitch_tempo_suffix(pitch_semitones: float, tempo_pct: float) -> str:
"""Suffix used to cache a transformed stem on disk. Empty when no transform."""
if abs(pitch_semitones) < 1e-3 and abs(tempo_pct - 100.0) < 1e-3:
return ""
# Round so near-identical slider positions share a cache file.
p_int = int(round(pitch_semitones * 10))
t_int = int(round(tempo_pct * 10))
return f"__p{p_int}_t{t_int}"
async def _apply_pitch_tempo(
input_file: Path,
output_file: Path,
pitch_semitones: float,
tempo_pct: float,
) -> None:
"""Transcode input into output with pitch/tempo adjustments via ffmpeg.
Pitch shift uses the asetrate + aresample trick so we don't need librubberband.
Tempo scaling uses atempo; the chain stays inside atempo's [0.5, 2.0] sweet spot
because PITCH_SEMITONES_MAX and TEMPO_PCT_MIN/MAX are capped accordingly.
"""
filters: list[str] = []
# Normalize input rate first so asetrate math doesn't depend on source sample rate.
filters.append("aresample=44100")
pitch_ratio = 2 ** (pitch_semitones / 12.0)
if abs(pitch_semitones) > 1e-3:
filters.append(f"asetrate=44100*{pitch_ratio:.6f}")
filters.append("aresample=44100")
filters.append(f"atempo={1 / pitch_ratio:.6f}")
if abs(tempo_pct - 100.0) > 1e-3:
tempo_ratio = tempo_pct / 100.0
filters.append(f"atempo={tempo_ratio:.6f}")
cmd = [
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(input_file),
"-filter:a",
",".join(filters),
"-codec:a",
"libmp3lame",
"-b:a",
"320k",
str(output_file),
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
output_file.unlink(missing_ok=True)
raise RuntimeError(stderr.decode(errors="replace")[:500] or "ffmpeg transform failed")
app = FastAPI(title="Karaoke Maker API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def startup():
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
start_cleanup_scheduler(UPLOAD_DIR, OUTPUT_DIR)
init_queue()
await start_worker()
@app.on_event("shutdown")
async def shutdown():
from cleanup import stop_cleanup_scheduler
stop_cleanup_scheduler()
@app.get("/health")
async def health():
return {"status": "ok"}
def _read_stats_locked() -> dict:
with _stats_lock:
return _read_stats()
@app.get("/stats")
async def get_stats():
return await asyncio.to_thread(_read_stats_locked)
@app.post("/stats/visit", status_code=204)
async def record_visit():
await asyncio.to_thread(_increment_stat, "total_visits")
@app.get("/status/{job_id}", response_model=JobStatus)
async def status(job_id: str):
job = get_job_status(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
return job
@app.post("/upload", response_model=UploadResponse)
async def upload(
file: UploadFile = File(...),
trim_start_sec: float | None = Form(default=None),
trim_end_sec: float | None = Form(default=None),
):
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(status_code=400, detail="Only MP3 files are accepted")
if trim_start_sec is not None and trim_start_sec < 0:
raise HTTPException(status_code=400, detail="trim_start_sec must be >= 0")
if trim_end_sec is not None and trim_end_sec <= 0:
raise HTTPException(status_code=400, detail="trim_end_sec must be > 0")
if trim_start_sec is not None and trim_end_sec is not None and trim_end_sec <= trim_start_sec:
raise HTTPException(status_code=400, detail="trim_end_sec must be greater than trim_start_sec")
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 500MB)")
is_mpeg_sync = len(content) >= 2 and content[0] == 0xFF and (content[1] & 0xE0) == 0xE0
is_id3 = len(content) >= 3 and content[:3] == b"ID3"
if not is_mpeg_sync and not is_id3:
raise HTTPException(status_code=400, detail="File does not appear to be a valid MP3")
job_id = str(uuid.uuid4())
input_path = UPLOAD_DIR / f"{job_id}.mp3"
job_output_dir = OUTPUT_DIR / job_id
original_output_path = job_output_dir / "original.mp3"
try:
job_output_dir.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(input_path, "wb") as f:
await f.write(content)
async with aiofiles.open(original_output_path, "wb") as f:
await f.write(content)
except OSError as e:
input_path.unlink(missing_ok=True)
original_output_path.unlink(missing_ok=True)
raise HTTPException(status_code=503, detail="Storage unavailable, please try again") from e
try:
await enqueue_job(
job_id,
str(input_path),
str(job_output_dir),
trim_start_sec=trim_start_sec,
trim_end_sec=trim_end_sec,
original_filename=file.filename or "unknown.mp3",
)
except RuntimeError as e:
input_path.unlink(missing_ok=True)
original_output_path.unlink(missing_ok=True)
raise HTTPException(status_code=503, detail=str(e)) from e
return UploadResponse(job_id=job_id)
def _resolve_download_path(job_id: str, stem_name: str | None) -> tuple[Path, str]:
job = get_job_status(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
if job.state != JobState.done:
raise HTTPException(status_code=409, detail="Job not complete yet")
# Provide source track for before/after comparison clips.
if stem_name == "original":
# Prefer output-dir copy so availability matches generated stems.
original_path = OUTPUT_DIR / job_id / "original.mp3"
if not original_path.exists():
# Backward compatibility for jobs created before original.mp3 copy.
original_path = UPLOAD_DIR / f"{job_id}.mp3"
if not original_path.exists():
raise HTTPException(status_code=404, detail="Original file not found")
return original_path, "original"
if not job.stems:
raise HTTPException(status_code=404, detail="No stems available for this job")
resolved_stem = stem_name or "instrumental"
if resolved_stem not in job.stems:
if stem_name is None:
resolved_stem = next(iter(job.stems.keys()))
else:
raise HTTPException(status_code=404, detail="Stem not found")
filename = job.stems[resolved_stem]
file_path = OUTPUT_DIR / job_id / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="Output file not found")
return file_path, resolved_stem
def _validate_pitch_tempo(pitch_semitones: float, tempo_pct: float) -> None:
if not PITCH_SEMITONES_MIN <= pitch_semitones <= PITCH_SEMITONES_MAX:
raise HTTPException(
status_code=400,
detail=f"pitch_semitones must be between {PITCH_SEMITONES_MIN} and {PITCH_SEMITONES_MAX}",
)
if not TEMPO_PCT_MIN <= tempo_pct <= TEMPO_PCT_MAX:
raise HTTPException(
status_code=400,
detail=f"tempo_pct must be between {TEMPO_PCT_MIN} and {TEMPO_PCT_MAX}",
)
async def _prepare_download_file(
source_path: Path,
job_id: str,
resolved_stem: str,
pitch_semitones: float,
tempo_pct: float,
) -> Path:
suffix = _pitch_tempo_suffix(pitch_semitones, tempo_pct)
if not suffix:
return source_path
transformed_dir = OUTPUT_DIR / job_id
transformed_dir.mkdir(parents=True, exist_ok=True)
transformed_path = transformed_dir / f"{resolved_stem}{suffix}.mp3"
if transformed_path.exists():
return transformed_path
try:
await _apply_pitch_tempo(
input_file=source_path,
output_file=transformed_path,
pitch_semitones=pitch_semitones,
tempo_pct=tempo_pct,
)
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Audio transform failed: {e}") from e
return transformed_path
def _download_filename(resolved_stem: str, pitch_semitones: float, tempo_pct: float) -> str:
if not _pitch_tempo_suffix(pitch_semitones, tempo_pct):
return f"{resolved_stem}.mp3"
parts = [resolved_stem]
if abs(pitch_semitones) >= 1e-3:
sign = "+" if pitch_semitones > 0 else ""
parts.append(f"{sign}{pitch_semitones:g}st")
if abs(tempo_pct - 100.0) >= 1e-3:
parts.append(f"{tempo_pct:g}pct")
return "_".join(parts) + ".mp3"
@app.get("/download/{job_id}")
async def download_default(
job_id: str,
pitch_semitones: float = Query(default=0.0),
tempo_pct: float = Query(default=100.0),
):
_validate_pitch_tempo(pitch_semitones, tempo_pct)
source_path, resolved_stem = _resolve_download_path(job_id, None)
file_path = await _prepare_download_file(
source_path, job_id, resolved_stem, pitch_semitones, tempo_pct
)
job = get_job_status(job_id)
original_name = (job.original_filename if job else None) or "unknown.mp3"
await asyncio.to_thread(_increment_stat, "total_downloads")
await asyncio.to_thread(_append_download_record, job_id, original_name, resolved_stem)
return FileResponse(
path=file_path,
media_type="audio/mpeg",
filename=_download_filename(resolved_stem, pitch_semitones, tempo_pct),
)
@app.get("/download/{job_id}/{stem_name}")
async def download_stem(
job_id: str,
stem_name: str,
pitch_semitones: float = Query(default=0.0),
tempo_pct: float = Query(default=100.0),
):
_validate_pitch_tempo(pitch_semitones, tempo_pct)
source_path, resolved_stem = _resolve_download_path(job_id, stem_name)
file_path = await _prepare_download_file(
source_path, job_id, resolved_stem, pitch_semitones, tempo_pct
)
job = get_job_status(job_id)
original_name = (job.original_filename if job else None) or "unknown.mp3"
await asyncio.to_thread(_increment_stat, "total_downloads")
await asyncio.to_thread(_append_download_record, job_id, original_name, resolved_stem)
return FileResponse(
path=file_path,
media_type="audio/mpeg",
filename=_download_filename(resolved_stem, pitch_semitones, tempo_pct),
)
@app.get("/admin/downloads")
async def get_download_log():
log = await asyncio.to_thread(_read_download_log)
return log
# ---------------------------------------------------------------------------
# YouTube karaoke pipeline endpoints
# ---------------------------------------------------------------------------
from supabase import create_client # noqa: E402
from db import SongRepo # noqa: E402
from storage import SupabaseStorage # noqa: E402
from youtube import extract_youtube_id, InvalidYoutubeUrl, fetch_metadata, download_audio # noqa: E402
from separate import separate_vocals_to_mp3 # noqa: E402
from youtube_pipeline import PipelineDeps, run_pipeline # noqa: E402
@lru_cache(maxsize=1)
def _youtube_deps() -> PipelineDeps:
sb = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_SERVICE_ROLE_KEY"])
repo = SongRepo(sb)
storage = SupabaseStorage(
client=sb,
bucket=os.environ.get("STORAGE_BUCKET", "karaoke"),
public_base_url=os.environ["STORAGE_PUBLIC_BASE_URL"],
)
return PipelineDeps(
repo=repo,
storage=storage,
fetch_metadata=fetch_metadata,
download_audio=download_audio,
separate_vocals=separate_vocals_to_mp3,
work_dir=Path(BASE_DIR) / "tmp" / "youtube",
max_duration_sec=int(os.environ.get("YT_MAX_DURATION_SEC", "600")),
)
@app.post("/jobs/youtube", response_model=YoutubeJobStatus)
async def create_youtube_job(req: YoutubeJobRequest):
try:
youtube_id = extract_youtube_id(req.url)
except InvalidYoutubeUrl:
raise HTTPException(status_code=400, detail="invalid youtube url")
deps = _youtube_deps()
job_id = deps.repo.create_job(youtube_id)
# Fire-and-forget via asyncio task; pipeline writes status into Supabase.
asyncio.create_task(asyncio.to_thread(run_pipeline, req.url, job_id, deps))
return YoutubeJobStatus(id=job_id, status="queued")
@app.get("/jobs/youtube/{job_id}", response_model=YoutubeJobStatus)
async def get_youtube_job(job_id: str):
deps = _youtube_deps()
row = deps.repo.get_job(job_id)
if not row:
raise HTTPException(status_code=404, detail="job not found")
slug = None
if row["status"] == "ready":
song = deps.repo.get_song(row["youtube_id"])
if song:
slug = song.get("slug")
return YoutubeJobStatus(
id=row["id"], status=row["status"], error=row.get("error"), slug=slug,
)
@app.get("/admin/youtube-stats")
def youtube_stats():
deps = _youtube_deps()
sb = deps.repo._c
since = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
res = sb.table("jobs").select("status").gte("created_at", since).execute()
counts: dict[str, int] = {}
for row in res.data or []:
counts[row["status"]] = counts.get(row["status"], 0) + 1
total = sum(counts.values()) or 1
return {
"window": "24h",
"total": total,
"counts": counts,
"failed_fetch_rate": counts.get("failed_fetch", 0) / total,
}