You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
408 lines
15 KiB
408 lines
15 KiB
"""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 = """<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>YouTube Downloader</title>
|
|
<style>
|
|
body { align-items: center; background: #101114; color: #f4f4f5; display: flex;
|
|
font-family: system-ui, sans-serif; justify-content: center; min-height: 100vh; margin: 0; }
|
|
main { max-width: 36rem; padding: 2rem; width: 100%; }
|
|
input { box-sizing: border-box; font-size: 1rem; padding: .8rem; width: 100%; }
|
|
button { cursor: pointer; font-size: 1rem; margin: 1rem .5rem 0 0; padding: .75rem 1rem; }
|
|
#progress-wrap { padding-top: 1rem; }
|
|
#progress-bar { width: 100%; }
|
|
#progress-text { min-height: 1.5rem; padding-top: .35rem; }
|
|
#status { color: #fca5a5; min-height: 1.5rem; padding-top: 1rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>YouTube Downloader</h1>
|
|
<input id="url" type="url" placeholder="Paste YouTube URL…" autocomplete="off">
|
|
<div>
|
|
<button type="button" onclick="downloadFile('mp3')">Download MP3</button>
|
|
<button type="button" onclick="downloadFile('video')">Download Video</button>
|
|
</div>
|
|
<div id="progress-wrap" hidden>
|
|
<progress id="progress-bar" max="100" value="0"></progress>
|
|
<div id="progress-text"></div>
|
|
</div>
|
|
<div id="status" role="status"></div>
|
|
</main>
|
|
<script>
|
|
let activeDownload = false;
|
|
|
|
function formatBytes(value) {
|
|
if (!Number.isFinite(value) || value < 0) return '';
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
let unit = 0;
|
|
let size = value;
|
|
while (size >= 1024 && unit < units.length - 1) {
|
|
size /= 1024;
|
|
unit += 1;
|
|
}
|
|
return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
|
}
|
|
|
|
function formatSpeed(value) {
|
|
const formatted = formatBytes(value);
|
|
return formatted ? `${formatted}/s` : '';
|
|
}
|
|
|
|
function filenameFromResponse(response) {
|
|
const disposition = response.headers.get('content-disposition') || '';
|
|
const match = /filename\\*?=(?:UTF-8''|")?([^";]+)/i.exec(disposition);
|
|
return match ? decodeURIComponent(match[1].replace(/"/g, '')) : 'download';
|
|
}
|
|
|
|
function setButtonsDisabled(disabled) {
|
|
document.querySelectorAll('button').forEach((button) => {
|
|
button.disabled = disabled;
|
|
});
|
|
}
|
|
|
|
function hideProgress() {
|
|
const progressWrap = document.getElementById('progress-wrap');
|
|
document.getElementById('progress-bar').value = 0;
|
|
document.getElementById('progress-text').textContent = '';
|
|
progressWrap.hidden = true;
|
|
}
|
|
|
|
async function saveDownload(jobId) {
|
|
const response = await fetch(`/download/${jobId}/file`);
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.message || 'The download could not be delivered.');
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = objectUrl;
|
|
link.download = filenameFromResponse(response);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
|
}
|
|
|
|
function pollProgress(jobId) {
|
|
const status = document.getElementById('status');
|
|
const progressBar = document.getElementById('progress-bar');
|
|
const progressText = document.getElementById('progress-text');
|
|
let polling = false;
|
|
const interval = setInterval(async () => {
|
|
if (polling) return;
|
|
polling = true;
|
|
try {
|
|
const response = await fetch(`/progress/${jobId}`);
|
|
const progress = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(progress.message || 'The download progress could not be checked.');
|
|
}
|
|
|
|
progressBar.value = Number.isFinite(progress.percent) ? progress.percent : 0;
|
|
const downloaded = formatBytes(progress.downloaded_bytes);
|
|
const total = formatBytes(progress.total_bytes);
|
|
const sizeText = total ? `${downloaded} / ${total}` : downloaded;
|
|
progressText.textContent = [formatSpeed(progress.speed), sizeText].filter(Boolean).join(' · ');
|
|
|
|
if (progress.status === 'finished') {
|
|
clearInterval(interval);
|
|
await saveDownload(jobId);
|
|
hideProgress();
|
|
status.textContent = '';
|
|
activeDownload = false;
|
|
setButtonsDisabled(false);
|
|
} else if (progress.status === 'error') {
|
|
clearInterval(interval);
|
|
hideProgress();
|
|
status.textContent = progress.error || 'The download could not be prepared.';
|
|
activeDownload = false;
|
|
setButtonsDisabled(false);
|
|
}
|
|
} catch (error) {
|
|
clearInterval(interval);
|
|
hideProgress();
|
|
status.textContent = error.message || 'The download could not be prepared.';
|
|
activeDownload = false;
|
|
setButtonsDisabled(false);
|
|
} finally {
|
|
polling = false;
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
async function downloadFile(mode) {
|
|
if (activeDownload) return;
|
|
const url = document.getElementById('url').value.trim();
|
|
const status = document.getElementById('status');
|
|
if (!url) {
|
|
status.textContent = 'Please paste a YouTube URL.';
|
|
return;
|
|
}
|
|
|
|
activeDownload = true;
|
|
setButtonsDisabled(true);
|
|
status.textContent = 'Preparing your download…';
|
|
const data = new FormData();
|
|
data.append('url', url);
|
|
data.append('mode', mode);
|
|
try {
|
|
const response = await fetch('/download', { method: 'POST', body: data });
|
|
const result = await response.json();
|
|
if (!response.ok) {
|
|
const error = result;
|
|
throw new Error(error.message || 'The download could not be prepared.');
|
|
}
|
|
if (!result.job_id) {
|
|
throw new Error('The download could not be started.');
|
|
}
|
|
document.getElementById('progress-wrap').hidden = false;
|
|
status.textContent = '';
|
|
pollProgress(result.job_id);
|
|
} catch (error) {
|
|
status.textContent = error.message || 'The download could not be prepared.';
|
|
activeDownload = false;
|
|
setButtonsDisabled(false);
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
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"],
|
|
# Prefer clients that can avoid the web client's PO-token requirement.
|
|
"extractor_args": {"youtube": {"player_client": ["android", "ios", "tv", "web"]}},
|
|
}
|
|
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),
|
|
)
|
|
|