Files
2026-06-12 15:29:30 -07:00

184 lines
5.4 KiB
Python

from __future__ import annotations
import traceback
from concurrent.futures import ThreadPoolExecutor
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from typing import Any
from uuid import uuid4
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from backend.services.converter import (
ConversionError,
clean_source_files,
convert_audio,
download_audio,
normalize_format,
normalize_quality,
sanitize_filename,
)
from backend.services.youtube import YouTubeServiceError, fetch_video_info, validate_youtube_url
BASE_DIR = Path(__file__).resolve().parent
PROJECT_DIR = BASE_DIR.parent
FRONTEND_DIR = PROJECT_DIR / "frontend"
DOWNLOADS_DIR = BASE_DIR / "downloads"
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="Local YouTube Audio Converter")
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
executor = ThreadPoolExecutor(max_workers=2)
jobs_lock = Lock()
jobs: dict[str, "Job"] = {}
class InfoRequest(BaseModel):
url: str
class ConvertRequest(BaseModel):
url: str
format: str
quality: str | None = "192"
@dataclass
class Job:
id: str
url: str
format: str
quality: str
status: str = "queued"
message: str = "Queued"
metadata: dict[str, Any] | None = None
filename: str | None = None
file_path: str | None = None
error: str | None = None
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
@app.get("/")
def index() -> FileResponse:
return FileResponse(FRONTEND_DIR / "index.html")
@app.post("/api/info")
def api_info(payload: InfoRequest) -> dict[str, Any]:
try:
validate_youtube_url(payload.url)
return fetch_video_info(payload.url)
except YouTubeServiceError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/convert")
def api_convert(payload: ConvertRequest) -> dict[str, str]:
try:
valid_url = validate_youtube_url(payload.url)
format_name = normalize_format(payload.format)
quality = normalize_quality(format_name, payload.quality)
except (YouTubeServiceError, ConversionError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
job_id = str(uuid4())
job = Job(id=job_id, url=valid_url, format=format_name, quality=quality)
with jobs_lock:
jobs[job_id] = job
executor.submit(_run_conversion, job_id)
return {"job_id": job_id}
@app.get("/api/status/{job_id}")
def api_status(job_id: str) -> dict[str, Any]:
job = _get_job(job_id)
payload = asdict(job)
payload.pop("file_path", None)
if job.status == "complete":
payload["download_url"] = f"/api/download/{job_id}"
return payload
@app.get("/api/download/{job_id}")
def api_download(job_id: str) -> FileResponse:
job = _get_job(job_id)
if job.status != "complete" or not job.file_path or not job.filename:
raise HTTPException(status_code=404, detail="This conversion is not ready for download.")
path = Path(job.file_path)
if not path.exists() or not path.is_file():
raise HTTPException(status_code=404, detail="The converted file no longer exists.")
return FileResponse(path, filename=job.filename, media_type="application/octet-stream")
def _run_conversion(job_id: str) -> None:
job = _get_job(job_id)
workdir = DOWNLOADS_DIR / job_id
try:
_update_job(job_id, status="validating", message="Validating URL")
validate_youtube_url(job.url)
_update_job(job_id, status="fetching_info", message="Fetching video info")
metadata = fetch_video_info(job.url)
_update_job(job_id, metadata=metadata)
base_name = sanitize_filename(metadata.get("title") or "youtube-audio")
filename = f"{base_name}.{job.format}"
output_path = workdir / filename
_update_job(job_id, status="downloading", message="Downloading audio")
source_path = download_audio(job.url, workdir)
_update_job(job_id, status="converting", message="Converting")
final_path = convert_audio(source_path, output_path, job.format, job.quality)
clean_source_files(workdir)
_update_job(
job_id,
status="complete",
message="Ready to download",
filename=filename,
file_path=str(final_path),
error=None,
)
except (YouTubeServiceError, ConversionError) as exc:
clean_source_files(workdir)
_update_job(job_id, status="error", message="Error", error=str(exc))
except Exception as exc:
clean_source_files(workdir)
traceback.print_exc()
_update_job(job_id, status="error", message="Error", error=f"Unexpected error: {exc}")
def _get_job(job_id: str) -> Job:
with jobs_lock:
job = jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found.")
return job
def _update_job(job_id: str, **changes: Any) -> None:
with jobs_lock:
job = jobs.get(job_id)
if not job:
return
for key, value in changes.items():
setattr(job, key, value)
job.updated_at = datetime.now(timezone.utc).isoformat()