"""FastAPI application serving a small YouTube download interface."""
import asyncio
import re
import shutil
import tempfile
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from uuid import uuid4
import yt_dlp
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from starlette.background import BackgroundTask
_FILENAME_ALLOWED = re.compile(r"[^\w\s.,'()\-]", re.UNICODE)
_WHITESPACE = re.compile(r"\s+")
HTML = """
YouTube Downloader
YouTube Downloader
"""
app = FastAPI()
@dataclass
class JobState:
"""Transient state for a browser-requested download."""
status: Literal["downloading", "finished", "error"]
percent: float = 0
speed: float | None = None
downloaded_bytes: int = 0
total_bytes: int | None = None
temp_dir: str = ""
file_path: str | None = None
filename: str | None = None
error_message: str | None = None
mode: Literal["mp3", "video"] = "mp3"
created_at: float = 0
_JOB_TTL_SECONDS = 30 * 60
_jobs: dict[str, JobState] = {}
_jobs_lock = threading.Lock()
_pending_jobs: set[asyncio.Future[None]] = set()
def sanitize_filename(title: str, fallback_id: str) -> str:
"""Return a safe, human-readable download filename stem."""
cleaned = _FILENAME_ALLOWED.sub("", title or "")
cleaned = _WHITESPACE.sub(" ", cleaned).strip()
return cleaned or fallback_id
def cleanup_tmpdir(path: str) -> None:
"""Remove a request's temporary download directory after streaming."""
shutil.rmtree(path, ignore_errors=True)
def cleanup_job(job_id: str) -> None:
"""Remove a delivered job and its temporary download directory."""
with _jobs_lock:
job = _jobs.pop(job_id, None)
if job:
cleanup_tmpdir(job.temp_dir)
def cleanup_stale_jobs() -> None:
"""Best-effort removal of abandoned download jobs."""
cutoff = time.monotonic() - _JOB_TTL_SECONDS
with _jobs_lock:
stale_jobs = [
_jobs.pop(job_id)
for job_id, job in list(_jobs.items())
if job.created_at < cutoff
]
for job in stale_jobs:
cleanup_tmpdir(job.temp_dir)
@app.exception_handler(HTTPException)
async def http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse:
"""Expose errors in the format expected by the browser client."""
message = exc.detail if isinstance(exc.detail, str) else "The request could not be processed."
return JSONResponse(status_code=exc.status_code, content={"message": message})
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(_: Request, __: RequestValidationError) -> JSONResponse:
"""Return malformed form submissions as a client-readable error."""
return JSONResponse(status_code=400, content={"message": "A URL and download mode are required."})
@app.get("/", response_class=HTMLResponse)
async def home() -> str:
"""Serve the single-page downloader interface."""
return HTML
@app.post("/download")
async def download(
url: str = Form(...), mode: Literal["mp3", "video"] = Form(...)
) -> JSONResponse:
"""Start a download job and return its identifier immediately."""
if not url.strip() or not url.lower().startswith("http"):
raise HTTPException(status_code=400, detail="Please provide a valid URL starting with http.")
cleanup_stale_jobs()
job_id = str(uuid4())
temp_dir = tempfile.mkdtemp(prefix="youtube-download-")
output_template = str(Path(temp_dir) / "%(title)s.%(ext)s")
with _jobs_lock:
_jobs[job_id] = JobState(
status="downloading", temp_dir=temp_dir, mode=mode, created_at=time.monotonic()
)
def progress_hook(progress: dict[str, object]) -> None:
"""Copy yt-dlp progress updates into the job visible to the browser."""
with _jobs_lock:
job = _jobs.get(job_id)
if not job:
return
if progress.get("status") == "error":
job.status = "error"
job.error_message = "The download could not be prepared."
return
if progress.get("status") != "downloading":
return
downloaded = progress.get("downloaded_bytes")
total = progress.get("total_bytes") or progress.get("total_bytes_estimate")
speed = progress.get("speed")
job.downloaded_bytes = int(downloaded) if isinstance(downloaded, (int, float)) else 0
job.total_bytes = int(total) if isinstance(total, (int, float)) else None
job.speed = float(speed) if isinstance(speed, (int, float)) else None
if job.total_bytes:
job.percent = min(100, job.downloaded_bytes / job.total_bytes * 100)
ydl_opts: dict[str, object] = {
"outtmpl": output_template,
"noplaylist": True,
"progress_hooks": [progress_hook],
"js_runtimes": {"deno": {"path": shutil.which("deno") or "deno"}},
# Current YouTube extraction may need yt-dlp's official EJS solver scripts.
"remote_components": ["ejs:github"],
}
if mode == "mp3":
ydl_opts.update(
{
"format": "bestaudio/best",
"postprocessors": [
{
"key": "FFmpegExtractAudio",
"preferredcodec": "mp3",
"preferredquality": "0",
}
],
}
)
else:
ydl_opts.update({"format": "bestvideo+bestaudio/best", "merge_output_format": "mp4"})
def run_job() -> None:
try:
with yt_dlp.YoutubeDL(ydl_opts) as downloader:
info = downloader.extract_info(url, download=True)
downloads = [path for path in Path(temp_dir).iterdir() if path.is_file()]
if len(downloads) != 1:
raise RuntimeError("The downloaded file could not be identified.")
media_path = downloads[0]
title = str(info.get("title") or "")
video_id = str(info.get("id") or "download")
display_name = sanitize_filename(title, video_id) + media_path.suffix
with _jobs_lock:
job = _jobs.get(job_id)
if job:
job.status = "finished"
job.percent = 100
job.file_path = str(media_path)
job.filename = display_name
except Exception as exc:
cleanup_tmpdir(temp_dir)
with _jobs_lock:
job = _jobs.get(job_id)
if job:
job.status = "error"
job.error_message = str(exc)
future = asyncio.get_running_loop().run_in_executor(None, run_job)
_pending_jobs.add(future)
future.add_done_callback(_pending_jobs.discard)
return JSONResponse({"job_id": job_id})
@app.get("/progress/{job_id}")
async def progress(job_id: str) -> JSONResponse:
"""Return the browser-safe state of an active download job."""
with _jobs_lock:
job = _jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Download job not found.")
payload = {
"status": job.status,
"percent": job.percent,
"speed": job.speed,
"downloaded_bytes": job.downloaded_bytes,
"total_bytes": job.total_bytes,
"error": job.error_message,
}
return JSONResponse(payload)
@app.get("/download/{job_id}/file")
async def download_file(job_id: str) -> FileResponse:
"""Stream a completed job's file, cleaning its state after delivery."""
with _jobs_lock:
job = _jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Download job not found.")
if job.status != "finished" or not job.file_path or not job.filename:
raise HTTPException(status_code=409, detail="The download is not ready yet.")
file_path = job.file_path
filename = job.filename
return FileResponse(
file_path,
filename=filename,
background=BackgroundTask(cleanup_job, job_id),
)