1
0
Fork 0
yt-mp3-ui/web/backend/main.py
Julien Riou 9c6360e9c4
Fork to yt-mp3-ui
Signed-off-by: Julien Riou <julien@riou.xyz>
2026-07-26 10:25:35 +02:00

331 lines
No EOL
9.6 KiB
Python

import sys
import json
import uuid
import asyncio
import logging
import httpx
import urllib.parse
import mimetypes
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, Response, JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
import yt_dlp
logger = logging.getLogger("yt_dlp_web")
GENERIC_ERROR = "Une erreur est survenue. Veuillez réessayer."
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
return JSONResponse(status_code=500, content={"detail": GENERIC_ERROR})
DOWNLOAD_DIR = Path(__file__).parent / "downloads"
DOWNLOAD_DIR.mkdir(exist_ok=True)
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
# file_id -> progress/meta
_progress: dict[str, dict] = {}
# file_id -> running task
_tasks: dict[str, asyncio.Task] = {}
class InfoRequest(BaseModel):
url: str
class DownloadRequest(BaseModel):
url: str
file_id: str | None = None
@app.get("/")
async def index():
return FileResponse(FRONTEND_DIR / "index.html")
@app.post("/info")
async def get_info(req: InfoRequest):
ydl_opts = {
"quiet": True,
"no_warnings": True,
"skip_download": True,
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = await asyncio.to_thread(ydl.extract_info, req.url, download=False)
except Exception:
logger.exception("Info extraction failed for url=%s", req.url)
raise HTTPException(status_code=400, detail="Impossible d'extraire les infos de la vidéo.")
if not info:
raise HTTPException(status_code=400, detail="Impossible d'extraire les infos de la vidéo.")
thumbnails = info.get("thumbnails") or []
thumbnail_url = None
if thumbnails:
ranked = sorted(
(t for t in thumbnails if t.get("url")),
key=lambda t: (t.get("width") or 0) * (t.get("height") or 0),
reverse=True,
)
thumbnail_url = ranked[0]["url"] if ranked else None
thumbnail_url = thumbnail_url or info.get("thumbnail")
return {
"title": info.get("title"),
"thumbnail": f"/thumbnail?url={thumbnail_url}" if thumbnail_url else None,
"duration": info.get("duration"),
"uploader": info.get("uploader"),
"view_count": info.get("view_count"),
"like_count": info.get("like_count"),
"upload_date": info.get("upload_date"),
}
@app.get("/thumbnail")
async def proxy_thumbnail(url: str):
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=10) as client:
r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
return Response(
content=r.content,
media_type=r.headers.get("content-type", "image/jpeg"),
)
except Exception:
logger.exception("Thumbnail fetch failed for url=%s", url)
raise HTTPException(status_code=502, detail="Impossible de récupérer la miniature")
async def _run_download(file_id: str, url: str, output_template: str):
cancelled = asyncio.Event()
def progress_hook(d):
if cancelled.is_set():
raise yt_dlp.utils.DownloadError("Cancelled")
status = d.get("status")
if status == "downloading":
downloaded = d.get("downloaded_bytes", 0)
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
pct = round((downloaded / total) * 100, 1) if total else 0
eta = d.get("eta")
speed = d.get("speed")
if file_id in _progress:
_progress[file_id].update(
{
"status": "downloading",
"percent": pct,
"eta": int(eta) if eta else None,
"speed": round(speed / 1024 / 1024, 2) if speed else None,
}
)
elif status == "finished":
if file_id in _progress:
_progress[file_id].update(
{
"status": "finished",
"percent": 100,
"eta": 0,
}
)
ydl_opts = {
"quiet": True,
"no_warnings": True,
"format": "bestaudio/best",
"outtmpl": output_template,
"restrictfilenames": True,
"writethumbnail": True,
"postprocessors": [
{
"key": "FFmpegExtractAudio",
"preferredcodec": "mp3",
"preferredquality": "320",
},
{"key": "FFmpegMetadata"},
{"key": "EmbedThumbnail"},
],
"progress_hooks": [progress_hook],
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = await asyncio.to_thread(ydl.extract_info, url, download=True)
return info
except asyncio.CancelledError:
cancelled.set()
if file_id in _progress:
_progress[file_id]["status"] = "cancelled"
raise
except Exception:
logger.exception("Download failed for file_id=%s url=%s", file_id, url)
if file_id in _progress:
_progress[file_id]["status"] = "error"
_progress[file_id]["error"] = GENERIC_ERROR
raise
def _finalize_download(file_id: str, info: dict):
matches = list(DOWNLOAD_DIR.glob(f"{file_id}.*"))
if not matches:
_progress[file_id]["status"] = "error"
_progress[file_id]["error"] = "Échec du téléchargement : fichier introuvable"
return
filepath = next((m for m in matches if m.suffix == ".mp3"), matches[0])
title = info.get("title") or file_id
filename_ascii = "".join(
c for c in title if c.isascii() and (c.isalnum() or c in " ._-()")
).strip()
if not filename_ascii:
filename_ascii = file_id
filename_ascii = f"{filename_ascii}{filepath.suffix}"
filename_encoded = urllib.parse.quote(f"{title}{filepath.suffix}")
_progress[file_id].update(
{
"status": "ready",
"percent": 100,
"filepath": str(filepath),
"filename_ascii": filename_ascii,
"filename_encoded": filename_encoded,
}
)
@app.post("/download")
async def download_video(req: DownloadRequest):
file_id = req.file_id or str(uuid.uuid4())
output_template = str(DOWNLOAD_DIR / f"{file_id}.%(ext)s")
_progress[file_id] = {
"status": "starting",
"percent": 0,
"eta": None,
"speed": None,
"url": req.url,
"output_template": output_template,
}
task = asyncio.create_task(_run_download(file_id, req.url, output_template))
_tasks[file_id] = task
def _done_callback(t: asyncio.Task):
try:
info = t.result()
_finalize_download(file_id, info)
except asyncio.CancelledError:
if file_id in _progress:
_progress[file_id]["status"] = "cancelled"
except Exception:
if file_id in _progress:
_progress[file_id]["status"] = "error"
_progress[file_id]["error"] = GENERIC_ERROR
finally:
_tasks.pop(file_id, None)
task.add_done_callback(_done_callback)
return JSONResponse({"file_id": file_id, "status": "started"}, status_code=202)
@app.post("/cancel/{file_id}")
async def cancel_download(file_id: str):
task = _tasks.get(file_id)
if task and not task.done():
task.cancel()
for f in DOWNLOAD_DIR.glob(f"{file_id}*"):
f.unlink(missing_ok=True)
_progress[file_id] = {"status": "cancelled", "percent": 0}
return JSONResponse({"status": "cancelled"})
return JSONResponse({"status": "not_found"})
@app.get("/file/{file_id}")
async def serve_file(file_id: str):
meta = _progress.get(file_id)
if not meta or meta.get("status") != "ready":
raise HTTPException(status_code=404, detail="Fichier pas encore prêt")
filepath = Path(meta["filepath"])
if not filepath.exists():
raise HTTPException(status_code=404, detail="Fichier introuvable")
filename_ascii = meta["filename_ascii"]
filename_encoded = meta["filename_encoded"]
media_type = mimetypes.guess_type(str(filepath))[0] or "application/octet-stream"
return FileResponse(
path=filepath,
media_type=media_type,
filename=filename_ascii,
headers={
"Content-Disposition": f"attachment; filename=\"{filename_ascii}\"; filename*=UTF-8''{filename_encoded}",
"Accept-Ranges": "bytes",
},
)
@app.get("/progress/{file_id}")
async def progress_stream(file_id: str, request: Request):
async def event_generator():
while True:
if await request.is_disconnected():
break
data = _progress.get(file_id)
if data:
yield f"data: {json.dumps(data)}\n\n"
if data.get("status") in ("ready", "cancelled", "error"):
break
await asyncio.sleep(0.5)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")