1
0
Fork 0

fix frontend and backend source code

This commit is contained in:
Samive 2026-04-08 11:07:45 +08:00
commit d4292beebb
2 changed files with 321 additions and 150 deletions

View file

@ -4,7 +4,9 @@ import uuid
import asyncio
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
@ -14,6 +16,7 @@ from pydantic import BaseModel
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
import yt_dlp
app = FastAPI()
app.add_middleware(
@ -23,14 +26,18 @@ app.add_middleware(
allow_headers=["*"],
)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(status_code=500, content={"detail": str(exc)})
DOWNLOAD_DIR = Path(__file__).parent / "downloads"
DOWNLOAD_DIR.mkdir(exist_ok=True)
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
FORMAT_PRESETS = {
"best": "bestvideo+bestaudio/bestvideo/best",
"1080p": "bestvideo[height<=1080]+bestaudio/bestvideo[height<=1080]/best",
@ -39,15 +46,17 @@ FORMAT_PRESETS = {
"audio": "bestaudio/best",
}
# file_id -> { status, percent, eta, speed, filepath, ... }
# file_id -> progress/meta
_progress: dict[str, dict] = {}
# file_id -> asyncio.Task
# file_id -> running task
_tasks: dict[str, asyncio.Task] = {}
class InfoRequest(BaseModel):
url: str
class DownloadRequest(BaseModel):
url: str
preset: str = "best"
@ -62,24 +71,24 @@ async def index():
@app.post("/info")
async def get_info(req: InfoRequest):
ydl_opts = {"quiet": True, "no_warnings": True, "skip_download": True, "outtmpl": output_template, "merge_output_format": "mp4", "postprocessors": [{"key": "FFmpegVideoConvertor","preferedformat": "mp4"}]}
info = None
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = await asyncio.to_thread(ydl.extract_info, req.url, download=False)
except Exception:
pass
if not info:
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 as e:
raise HTTPException(status_code=400, detail=str(e))
if not info:
raise HTTPException(status_code=400, detail="Could not extract video info.")
thumbnails = info.get("thumbnails") or []
thumbnail_url = None
if thumbnails:
ranked = sorted(
(t for t in thumbnails if t.get("url")),
@ -87,6 +96,7 @@ async def get_info(req: InfoRequest):
reverse=True,
)
thumbnail_url = ranked[0]["url"] if ranked else None
thumbnail_url = thumbnail_url or info.get("thumbnail")
formats = [
@ -128,33 +138,49 @@ 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"))
return Response(
content=r.content,
media_type=r.headers.get("content-type", "image/jpeg"),
)
except Exception:
raise HTTPException(status_code=502, detail="Could not fetch thumbnail")
async def _run_download(file_id: str, url: str, fmt: str, output_template: str):
"""Runs yt-dlp in a thread and updates _progress. Cancellable via task cancellation."""
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")
_progress[file_id].update({
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":
_progress[file_id].update({"status": "finished", "percent": 100, "eta": 0})
if file_id in _progress:
_progress[file_id].update(
{
"status": "finished",
"percent": 100,
"eta": 0,
}
)
ydl_opts = {
"quiet": True,
@ -162,6 +188,12 @@ async def _run_download(file_id: str, url: str, fmt: str, output_template: str):
"format": fmt,
"outtmpl": output_template,
"merge_output_format": "mp4",
"postprocessors": [
{
"key": "FFmpegVideoConvertor",
"preferedformat": "mp4",
}
],
"progress_hooks": [progress_hook],
}
@ -169,16 +201,50 @@ async def _run_download(file_id: str, url: str, fmt: str, output_template: str):
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 as e:
if file_id in _progress:
_progress[file_id]["status"] = "error"
_progress[file_id]["error"] = str(e)
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"] = "Download failed: file not found"
return
filepath = 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())
@ -186,56 +252,50 @@ async def download_video(req: DownloadRequest):
fmt = req.format_id if req.format_id else FORMAT_PRESETS.get(req.preset, FORMAT_PRESETS["best"])
_progress[file_id] = {
"status": "starting", "percent": 0, "eta": None, "speed": None,
"url": req.url, "fmt": fmt, "output_template": output_template,
"status": "starting",
"percent": 0,
"eta": None,
"speed": None,
"url": req.url,
"fmt": fmt,
"output_template": output_template,
}
task = asyncio.create_task(_run_download(file_id, req.url, fmt, output_template))
_tasks[file_id] = task
def _done_callback(t: asyncio.Task):
try:
info = await task
info = t.result()
_finalize_download(file_id, info)
except asyncio.CancelledError:
return JSONResponse({"file_id": file_id, "status": "cancelled"})
if file_id in _progress:
_progress[file_id]["status"] = "cancelled"
except Exception as e:
_tasks.pop(file_id, None)
raise HTTPException(status_code=400, detail=_progress.get(file_id, {}).get("error", str(e)))
if file_id in _progress:
_progress[file_id]["status"] = "error"
_progress[file_id]["error"] = str(e)
finally:
_tasks.pop(file_id, None)
matches = list(DOWNLOAD_DIR.glob(f"{file_id}.*"))
if not matches:
raise HTTPException(status_code=500, detail="Download failed: file not found")
task.add_done_callback(_done_callback)
filepath = 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 += filepath.suffix
filename_encoded = urllib.parse.quote(title + filepath.suffix)
_progress[file_id].update({
"status": "ready",
"percent": 100,
"filepath": str(filepath),
"filename_ascii": filename_ascii,
"filename_encoded": filename_encoded,
})
return JSONResponse({"file_id": file_id, "status": "ready"})
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()
# Clean up partial files
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"})
@ -243,7 +303,7 @@ async def cancel_download(file_id: str):
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="File not ready or already downloaded")
raise HTTPException(status_code=404, detail="File not ready")
filepath = Path(meta["filepath"])
if not filepath.exists():
@ -251,22 +311,16 @@ async def serve_file(file_id: str):
filename_ascii = meta["filename_ascii"]
filename_encoded = meta["filename_encoded"]
def iterfile():
with open(filepath, "rb") as f:
yield from f
filepath.unlink(missing_ok=True)
_progress.pop(file_id, None)
from fastapi.responses import FileResponse
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*=UTF-8''{filename_encoded}"
}
"Content-Disposition": f"attachment; filename=\"{filename_ascii}\"; filename*=UTF-8''{filename_encoded}",
"Accept-Ranges": "bytes",
},
)
@ -276,17 +330,22 @@ async def progress_stream(file_id: str, request: Request):
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"},
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)

View file

@ -21,16 +21,20 @@ let currentFileId = null;
let currentAbort = null;
let currentSse = null;
// --- Cancel ---
cancelBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
// Abort the pending fetch
if (currentAbort) { currentAbort.abort(); currentAbort = null; }
// Close SSE
if (currentSse) { currentSse.close(); currentSse = null; }
// Tell backend to kill the task and clean up
if (currentAbort) {
currentAbort.abort();
currentAbort = null;
}
if (currentSse) {
currentSse.close();
currentSse = null;
}
if (currentFileId) {
fetch(`${API}/cancel/${currentFileId}`, { method: 'POST' }).catch(() => {});
currentFileId = null;
@ -41,7 +45,6 @@ cancelBtn.addEventListener('click', async (e) => {
setLoading(downloadBtn, false);
});
// --- Reset UI ---
function resetUI() {
urlInput.value = '';
clearBtn.style.display = 'none';
@ -52,20 +55,27 @@ function resetUI() {
progressWrap.style.display = 'none';
thumbnail.src = '';
}
window.addEventListener('pageshow', resetUI);
// --- Helpers ---
function showError(msg) {
errorMsg.textContent = msg;
errorMsg.style.display = 'block';
}
function hideError() { errorMsg.style.display = 'none'; }
function hideError() {
errorMsg.style.display = 'none';
}
function setLoading(btn, loading, text) {
btn.disabled = loading;
btn.style.opacity = loading ? '0.6' : '1';
btn.style.cursor = loading ? 'wait' : '';
if (text) btn.dataset.label = btn.dataset.label || btn.textContent.trim();
if (text) {
btn.dataset.label = btn.dataset.label || btn.textContent.trim();
}
if (loading && text) {
btn.textContent = text;
} else if (!loading && btn.dataset.label) {
@ -83,9 +93,10 @@ function formatDuration(secs) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
return h > 0
? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
: `${m}:${String(s).padStart(2,'0')}`;
? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
: `${m}:${String(s).padStart(2, '0')}`;
}
function formatCount(n) {
@ -105,14 +116,19 @@ function formatBytes(bytes) {
function formatLabel(f) {
const parts = [];
const res = f.resolution || '';
if (res && res !== 'unknown') parts.push(res);
if (f.ext) parts.push(f.ext.toUpperCase());
const hasVideo = f.vcodec && f.vcodec !== 'none';
const hasAudio = f.acodec && f.acodec !== 'none';
if (!hasVideo && hasAudio) parts.push('audio only');
if (f.fps && hasVideo) parts.push(`${f.fps}fps`);
const size = formatBytes(f.filesize);
if (size) parts.push(`~${size}`);
return parts.join(' · ') || f.format_id;
}
@ -124,22 +140,32 @@ function setProgress(pct, label) {
function formatEta(secs) {
if (secs == null || secs < 0) return '';
if (secs < 60) return `ETA ${secs}s`;
const m = Math.floor(secs / 60);
const s = secs % 60;
return `ETA ${m}m ${s}s`;
}
// --- URL input ---
urlInput.addEventListener('input', () => {
clearBtn.style.display = urlInput.value ? 'flex' : 'none';
});
clearBtn.addEventListener('click', () => { resetUI(); urlInput.focus(); });
urlInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') fetchBtn.click(); });
// --- Fetch info ---
clearBtn.addEventListener('click', () => {
resetUI();
urlInput.focus();
});
urlInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') fetchBtn.click();
});
fetchBtn.addEventListener('click', async () => {
const url = urlInput.value.trim();
if (!url) { showError('Please paste a video URL first.'); return; }
if (!url) {
showError('Please paste a video URL first.');
return;
}
hideError();
preview.style.display = 'none';
@ -154,15 +180,20 @@ fetchBtn.addEventListener('click', async () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Failed to fetch video info.');
if (!res.ok) {
throw new Error(data.detail || 'Failed to fetch video info.');
}
renderPreview(data);
renderFormats(data.formats || []);
preview.style.display = 'flex';
formatSection.style.display = 'flex';
downloadBtn.style.display = 'flex';
} catch (err) {
showError(err.message);
showError(err.message || 'Failed to fetch video info.');
} finally {
setLoading(fetchBtn, false);
}
@ -172,9 +203,12 @@ function renderPreview(info) {
thumbnail.src = info.thumbnail || '';
thumbnail.alt = info.title || 'Video thumbnail';
thumbnail.style.display = info.thumbnail ? 'block' : 'none';
videoTitle.textContent = info.title || 'Unknown title';
uploaderEl.textContent = info.uploader || '';
uploaderEl.style.display = info.uploader ? '' : 'none';
const dur = formatDuration(info.duration);
durationEl.textContent = dur || '';
durationEl.style.display = dur ? '' : 'none';
@ -182,13 +216,23 @@ function renderPreview(info) {
const viewsEl = document.getElementById('views');
const likesEl = document.getElementById('likes');
const dateEl = document.getElementById('upload-date');
viewsEl.textContent = info.view_count ? '👁 ' + formatCount(info.view_count) : '';
viewsEl.style.display = info.view_count ? '' : 'none';
likesEl.textContent = info.like_count ? '👍 ' + formatCount(info.like_count) : '';
likesEl.style.display = info.like_count ? '' : 'none';
if (info.upload_date && info.upload_date.length === 8) {
const d = info.upload_date;
const formatted = new Date(`${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}`).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
const formatted = new Date(
`${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`
).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
dateEl.textContent = '📅 ' + formatted;
dateEl.style.display = '';
} else {
@ -198,38 +242,61 @@ function renderPreview(info) {
function renderFormats(formats) {
formatSelect.innerHTML = '';
const sorted = [...formats].sort((a, b) =>
(b.height || 0) - (a.height || 0) || (b.tbr || 0) - (a.tbr || 0)
);
const combined = sorted.filter(f => f.vcodec !== 'none' && f.acodec !== 'none');
const videoOnly = sorted.filter(f => f.vcodec !== 'none' && f.acodec === 'none');
const audioOnly = sorted.filter(f => f.vcodec === 'none' && f.acodec !== 'none');
const addGroup = (label, items) => {
const sorted = [...formats].sort(
(a, b) => (b.height || 0) - (a.height || 0) || (b.tbr || 0) - (a.tbr || 0)
);
const combined = sorted.filter((f) => f.vcodec !== 'none' && f.acodec !== 'none');
const videoOnly = sorted.filter((f) => f.vcodec !== 'none' && f.acodec === 'none');
const audioOnly = sorted.filter((f) => f.vcodec === 'none' && f.acodec !== 'none');
const addGroup = (label, items, transformValue = null) => {
if (!items.length) return;
const group = document.createElement('optgroup');
group.label = label;
items.forEach(f => {
items.forEach((f) => {
const opt = document.createElement('option');
opt.value = f.format_id;
opt.value = transformValue ? transformValue(f) : f.format_id;
opt.textContent = formatLabel(f);
group.appendChild(opt);
});
formatSelect.appendChild(group);
};
if ((videoOnly[0]?.height || 0) >= (combined[0]?.height || 0)) {
addGroup('Video only', videoOnly);
addGroup(
'Video only',
videoOnly,
(f) => `${f.format_id}+bestaudio[ext=m4a]/bestaudio`
);
addGroup('Video + Audio', combined);
} else {
addGroup('Video + Audio', combined);
addGroup('Video only', videoOnly);
}
addGroup('Audio only', audioOnly);
if (sorted.length) formatSelect.value = sorted[0].format_id;
// Prefer highest MP4 video-only first, because backend can merge audio into MP4
const bestMp4VideoOnly = videoOnly.find((f) => f.ext === 'mp4');
// Fallback to highest combined MP4
const bestMp4Combined = combined.find((f) => f.ext === 'mp4');
// Fallback to any top combined format
const bestCombined = combined[0];
// Final fallback
const bestDefault =
(bestMp4VideoOnly && `${bestMp4VideoOnly.format_id}+bestaudio[ext=m4a]/bestaudio`) ||
(bestMp4Combined && bestMp4Combined.format_id) ||
(bestCombined && bestCombined.format_id) ||
(sorted[0] && sorted[0].format_id);
if (bestDefault) {
formatSelect.value = bestDefault;
}
}
// --- Download ---
downloadBtn.addEventListener('click', async () => {
const url = urlInput.value.trim();
if (!url) return;
@ -242,31 +309,72 @@ downloadBtn.addEventListener('click', async () => {
const fileId = crypto.randomUUID();
currentFileId = fileId;
// SSE for live progress
const sse = new EventSource(`${API}/progress/${fileId}`);
currentSse = sse;
sse.onmessage = (e) => {
const d = JSON.parse(e.data);
if (d.status === 'downloading') {
if (d.status === 'starting') {
setProgress(0, '<span class="meta">Starting…</span>');
} else if (d.status === 'downloading') {
const pct = d.percent || 0;
const eta = d.eta != null ? formatEta(d.eta) : '';
const speed = d.speed != null ? `${d.speed} MB/s` : '';
const meta = [speed, eta].filter(Boolean).join(' · ');
setProgress(pct, `<span class="pct">${pct}%</span>${meta ? `&ensp;<span class="meta">${meta}</span>` : ''}`);
setProgress(
pct,
`<span class="pct">${pct}%</span>${meta ? `&ensp;<span class="meta">${meta}</span>` : ''}`
);
} else if (d.status === 'finished') {
setProgress(100, '<span class="pct">100%</span>&ensp;<span class="meta">Processing…</span>');
setProgress(
100,
'<span class="pct">100%</span>&ensp;<span class="meta">Processing…</span>'
);
} else if (d.status === 'ready') {
sse.close();
currentSse = null;
currentAbort = null;
if (currentFileId === fileId) {
setProgress(
100,
'<span class="pct">✓</span>&ensp;<span class="meta">Done — saving to your downloads</span>'
);
window.location.href = `${API}/file/${fileId}`;
setTimeout(() => {
progressWrap.style.display = 'none';
}, 2500);
}
currentFileId = null;
setLoading(downloadBtn, false);
} else if (d.status === 'cancelled') {
sse.close();
currentSse = null;
currentAbort = null;
progressWrap.style.display = 'none';
currentFileId = null;
setLoading(downloadBtn, false);
} else if (d.status === 'error') {
sse.close();
currentSse = null;
currentAbort = null;
showError(d.error || 'Download failed.');
progressWrap.style.display = 'none';
currentFileId = null;
setLoading(downloadBtn, false);
}
};
sse.onerror = () => sse.close();
// AbortController so cancel can kill the fetch
sse.onerror = () => {
sse.close();
currentSse = null;
};
const abort = new AbortController();
currentAbort = abort;
@ -274,36 +382,40 @@ downloadBtn.addEventListener('click', async () => {
const res = await fetch(`${API}/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, format_id: formatSelect.value, file_id: fileId }),
body: JSON.stringify({
url,
format_id: formatSelect.value,
file_id: fileId,
}),
signal: abort.signal,
});
sse.close();
currentSse = null;
currentAbort = null;
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const data = await res.json().catch(() => ({ detail: 'Download failed.' }));
throw new Error(data.detail || 'Download failed.');
}
// Only trigger file download if not cancelled
if (currentFileId === fileId) {
setProgress(100, '<span class="pct">✓</span>&ensp;<span class="meta">Done — saving to your downloads</span>');
// Open in new tab so current page never navigates away
window.open(`${API}/file/${fileId}`, '_blank');
setTimeout(() => { progressWrap.style.display = 'none'; }, 2500);
if (data.status !== 'started') {
throw new Error('Unexpected server response.');
}
} catch (err) {
sse.close();
if (currentSse) {
currentSse.close();
currentSse = null;
}
currentAbort = null;
if (err.name !== 'AbortError') {
showError(err.message);
showError(err.message || 'Download failed.');
progressWrap.style.display = 'none';
}
} finally {
if (currentFileId === fileId) currentFileId = null;
if (currentFileId === fileId) {
currentFileId = null;
}
setLoading(downloadBtn, false);
}
});