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 asyncio
import httpx import httpx
import urllib.parse import urllib.parse
import mimetypes
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, Response, JSONResponse, FileResponse 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])) sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
import yt_dlp import yt_dlp
app = FastAPI() app = FastAPI()
app.add_middleware( app.add_middleware(
@ -23,14 +26,18 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception): async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(status_code=500, content={"detail": str(exc)}) return JSONResponse(status_code=500, content={"detail": str(exc)})
DOWNLOAD_DIR = Path(__file__).parent / "downloads" DOWNLOAD_DIR = Path(__file__).parent / "downloads"
DOWNLOAD_DIR.mkdir(exist_ok=True) DOWNLOAD_DIR.mkdir(exist_ok=True)
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
FORMAT_PRESETS = { FORMAT_PRESETS = {
"best": "bestvideo+bestaudio/bestvideo/best", "best": "bestvideo+bestaudio/bestvideo/best",
"1080p": "bestvideo[height<=1080]+bestaudio/bestvideo[height<=1080]/best", "1080p": "bestvideo[height<=1080]+bestaudio/bestvideo[height<=1080]/best",
@ -39,15 +46,17 @@ FORMAT_PRESETS = {
"audio": "bestaudio/best", "audio": "bestaudio/best",
} }
# file_id -> { status, percent, eta, speed, filepath, ... } # file_id -> progress/meta
_progress: dict[str, dict] = {} _progress: dict[str, dict] = {}
# file_id -> asyncio.Task
# file_id -> running task
_tasks: dict[str, asyncio.Task] = {} _tasks: dict[str, asyncio.Task] = {}
class InfoRequest(BaseModel): class InfoRequest(BaseModel):
url: str url: str
class DownloadRequest(BaseModel): class DownloadRequest(BaseModel):
url: str url: str
preset: str = "best" preset: str = "best"
@ -62,24 +71,24 @@ async def index():
@app.post("/info") @app.post("/info")
async def get_info(req: InfoRequest): 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"}]} ydl_opts = {
info = None "quiet": True,
try: "no_warnings": True,
with yt_dlp.YoutubeDL(ydl_opts) as ydl: "skip_download": True,
info = await asyncio.to_thread(ydl.extract_info, req.url, download=False) }
except Exception:
pass
if not info:
try: try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = await asyncio.to_thread(ydl.extract_info, req.url, download=False) info = await asyncio.to_thread(ydl.extract_info, req.url, download=False)
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
if not info: if not info:
raise HTTPException(status_code=400, detail="Could not extract video info.") raise HTTPException(status_code=400, detail="Could not extract video info.")
thumbnails = info.get("thumbnails") or [] thumbnails = info.get("thumbnails") or []
thumbnail_url = None thumbnail_url = None
if thumbnails: if thumbnails:
ranked = sorted( ranked = sorted(
(t for t in thumbnails if t.get("url")), (t for t in thumbnails if t.get("url")),
@ -87,6 +96,7 @@ async def get_info(req: InfoRequest):
reverse=True, reverse=True,
) )
thumbnail_url = ranked[0]["url"] if ranked else None thumbnail_url = ranked[0]["url"] if ranked else None
thumbnail_url = thumbnail_url or info.get("thumbnail") thumbnail_url = thumbnail_url or info.get("thumbnail")
formats = [ formats = [
@ -128,33 +138,49 @@ async def proxy_thumbnail(url: str):
try: try:
async with httpx.AsyncClient(follow_redirects=True, timeout=10) as client: async with httpx.AsyncClient(follow_redirects=True, timeout=10) as client:
r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"}) 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: except Exception:
raise HTTPException(status_code=502, detail="Could not fetch thumbnail") raise HTTPException(status_code=502, detail="Could not fetch thumbnail")
async def _run_download(file_id: str, url: str, fmt: str, output_template: str): 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() cancelled = asyncio.Event()
def progress_hook(d): def progress_hook(d):
if cancelled.is_set(): if cancelled.is_set():
raise yt_dlp.utils.DownloadError("Cancelled") raise yt_dlp.utils.DownloadError("Cancelled")
status = d.get("status") status = d.get("status")
if status == "downloading": if status == "downloading":
downloaded = d.get("downloaded_bytes", 0) downloaded = d.get("downloaded_bytes", 0)
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0 total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
pct = round((downloaded / total) * 100, 1) if total else 0 pct = round((downloaded / total) * 100, 1) if total else 0
eta = d.get("eta") eta = d.get("eta")
speed = d.get("speed") speed = d.get("speed")
_progress[file_id].update({
if file_id in _progress:
_progress[file_id].update(
{
"status": "downloading", "status": "downloading",
"percent": pct, "percent": pct,
"eta": int(eta) if eta else None, "eta": int(eta) if eta else None,
"speed": round(speed / 1024 / 1024, 2) if speed else None, "speed": round(speed / 1024 / 1024, 2) if speed else None,
}) }
)
elif status == "finished": 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 = { ydl_opts = {
"quiet": True, "quiet": True,
@ -162,6 +188,12 @@ async def _run_download(file_id: str, url: str, fmt: str, output_template: str):
"format": fmt, "format": fmt,
"outtmpl": output_template, "outtmpl": output_template,
"merge_output_format": "mp4", "merge_output_format": "mp4",
"postprocessors": [
{
"key": "FFmpegVideoConvertor",
"preferedformat": "mp4",
}
],
"progress_hooks": [progress_hook], "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: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = await asyncio.to_thread(ydl.extract_info, url, download=True) info = await asyncio.to_thread(ydl.extract_info, url, download=True)
return info return info
except asyncio.CancelledError: except asyncio.CancelledError:
cancelled.set() cancelled.set()
if file_id in _progress:
_progress[file_id]["status"] = "cancelled" _progress[file_id]["status"] = "cancelled"
raise raise
except Exception as e: except Exception as e:
if file_id in _progress:
_progress[file_id]["status"] = "error" _progress[file_id]["status"] = "error"
_progress[file_id]["error"] = str(e) _progress[file_id]["error"] = str(e)
raise 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") @app.post("/download")
async def download_video(req: DownloadRequest): async def download_video(req: DownloadRequest):
file_id = req.file_id or str(uuid.uuid4()) 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"]) fmt = req.format_id if req.format_id else FORMAT_PRESETS.get(req.preset, FORMAT_PRESETS["best"])
_progress[file_id] = { _progress[file_id] = {
"status": "starting", "percent": 0, "eta": None, "speed": None, "status": "starting",
"url": req.url, "fmt": fmt, "output_template": output_template, "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)) task = asyncio.create_task(_run_download(file_id, req.url, fmt, output_template))
_tasks[file_id] = task _tasks[file_id] = task
def _done_callback(t: asyncio.Task):
try: try:
info = await task info = t.result()
_finalize_download(file_id, info)
except asyncio.CancelledError: 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: except Exception as e:
_tasks.pop(file_id, None) if file_id in _progress:
raise HTTPException(status_code=400, detail=_progress.get(file_id, {}).get("error", str(e))) _progress[file_id]["status"] = "error"
_progress[file_id]["error"] = str(e)
finally:
_tasks.pop(file_id, None) _tasks.pop(file_id, None)
matches = list(DOWNLOAD_DIR.glob(f"{file_id}.*")) task.add_done_callback(_done_callback)
if not matches:
raise HTTPException(status_code=500, detail="Download failed: file not found")
filepath = matches[0] return JSONResponse({"file_id": file_id, "status": "started"}, status_code=202)
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"})
@app.post("/cancel/{file_id}") @app.post("/cancel/{file_id}")
async def cancel_download(file_id: str): async def cancel_download(file_id: str):
task = _tasks.get(file_id) task = _tasks.get(file_id)
if task and not task.done(): if task and not task.done():
task.cancel() task.cancel()
# Clean up partial files
for f in DOWNLOAD_DIR.glob(f"{file_id}*"): for f in DOWNLOAD_DIR.glob(f"{file_id}*"):
f.unlink(missing_ok=True) f.unlink(missing_ok=True)
_progress[file_id] = {"status": "cancelled", "percent": 0} _progress[file_id] = {"status": "cancelled", "percent": 0}
return JSONResponse({"status": "cancelled"}) return JSONResponse({"status": "cancelled"})
return JSONResponse({"status": "not_found"}) return JSONResponse({"status": "not_found"})
@ -243,7 +303,7 @@ async def cancel_download(file_id: str):
async def serve_file(file_id: str): async def serve_file(file_id: str):
meta = _progress.get(file_id) meta = _progress.get(file_id)
if not meta or meta.get("status") != "ready": 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"]) filepath = Path(meta["filepath"])
if not filepath.exists(): if not filepath.exists():
@ -251,22 +311,16 @@ async def serve_file(file_id: str):
filename_ascii = meta["filename_ascii"] filename_ascii = meta["filename_ascii"]
filename_encoded = meta["filename_encoded"] filename_encoded = meta["filename_encoded"]
media_type = mimetypes.guess_type(str(filepath))[0] or "application/octet-stream"
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
return FileResponse( return FileResponse(
path=filepath, path=filepath,
media_type=media_type, media_type=media_type,
filename=filename_ascii, filename=filename_ascii,
headers={ 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: while True:
if await request.is_disconnected(): if await request.is_disconnected():
break break
data = _progress.get(file_id) data = _progress.get(file_id)
if data: if data:
yield f"data: {json.dumps(data)}\n\n" yield f"data: {json.dumps(data)}\n\n"
if data.get("status") in ("ready", "cancelled", "error"): if data.get("status") in ("ready", "cancelled", "error"):
break break
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
return StreamingResponse( return StreamingResponse(
event_generator(), event_generator(),
media_type="text/event-stream", 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 currentAbort = null;
let currentSse = null; let currentSse = null;
// --- Cancel ---
cancelBtn.addEventListener('click', async (e) => { cancelBtn.addEventListener('click', async (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// Abort the pending fetch if (currentAbort) {
if (currentAbort) { currentAbort.abort(); currentAbort = null; } currentAbort.abort();
// Close SSE currentAbort = null;
if (currentSse) { currentSse.close(); currentSse = null; } }
// Tell backend to kill the task and clean up
if (currentSse) {
currentSse.close();
currentSse = null;
}
if (currentFileId) { if (currentFileId) {
fetch(`${API}/cancel/${currentFileId}`, { method: 'POST' }).catch(() => {}); fetch(`${API}/cancel/${currentFileId}`, { method: 'POST' }).catch(() => {});
currentFileId = null; currentFileId = null;
@ -41,7 +45,6 @@ cancelBtn.addEventListener('click', async (e) => {
setLoading(downloadBtn, false); setLoading(downloadBtn, false);
}); });
// --- Reset UI ---
function resetUI() { function resetUI() {
urlInput.value = ''; urlInput.value = '';
clearBtn.style.display = 'none'; clearBtn.style.display = 'none';
@ -52,20 +55,27 @@ function resetUI() {
progressWrap.style.display = 'none'; progressWrap.style.display = 'none';
thumbnail.src = ''; thumbnail.src = '';
} }
window.addEventListener('pageshow', resetUI); window.addEventListener('pageshow', resetUI);
// --- Helpers ---
function showError(msg) { function showError(msg) {
errorMsg.textContent = msg; errorMsg.textContent = msg;
errorMsg.style.display = 'block'; errorMsg.style.display = 'block';
} }
function hideError() { errorMsg.style.display = 'none'; }
function hideError() {
errorMsg.style.display = 'none';
}
function setLoading(btn, loading, text) { function setLoading(btn, loading, text) {
btn.disabled = loading; btn.disabled = loading;
btn.style.opacity = loading ? '0.6' : '1'; btn.style.opacity = loading ? '0.6' : '1';
btn.style.cursor = loading ? 'wait' : ''; 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) { if (loading && text) {
btn.textContent = text; btn.textContent = text;
} else if (!loading && btn.dataset.label) { } else if (!loading && btn.dataset.label) {
@ -83,6 +93,7 @@ function formatDuration(secs) {
const h = Math.floor(secs / 3600); const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60); const m = Math.floor((secs % 3600) / 60);
const s = secs % 60; const s = secs % 60;
return h > 0 return h > 0
? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
: `${m}:${String(s).padStart(2, '0')}`; : `${m}:${String(s).padStart(2, '0')}`;
@ -105,14 +116,19 @@ function formatBytes(bytes) {
function formatLabel(f) { function formatLabel(f) {
const parts = []; const parts = [];
const res = f.resolution || ''; const res = f.resolution || '';
if (res && res !== 'unknown') parts.push(res); if (res && res !== 'unknown') parts.push(res);
if (f.ext) parts.push(f.ext.toUpperCase()); if (f.ext) parts.push(f.ext.toUpperCase());
const hasVideo = f.vcodec && f.vcodec !== 'none'; const hasVideo = f.vcodec && f.vcodec !== 'none';
const hasAudio = f.acodec && f.acodec !== 'none'; const hasAudio = f.acodec && f.acodec !== 'none';
if (!hasVideo && hasAudio) parts.push('audio only'); if (!hasVideo && hasAudio) parts.push('audio only');
if (f.fps && hasVideo) parts.push(`${f.fps}fps`); if (f.fps && hasVideo) parts.push(`${f.fps}fps`);
const size = formatBytes(f.filesize); const size = formatBytes(f.filesize);
if (size) parts.push(`~${size}`); if (size) parts.push(`~${size}`);
return parts.join(' · ') || f.format_id; return parts.join(' · ') || f.format_id;
} }
@ -124,22 +140,32 @@ function setProgress(pct, label) {
function formatEta(secs) { function formatEta(secs) {
if (secs == null || secs < 0) return ''; if (secs == null || secs < 0) return '';
if (secs < 60) return `ETA ${secs}s`; if (secs < 60) return `ETA ${secs}s`;
const m = Math.floor(secs / 60); const m = Math.floor(secs / 60);
const s = secs % 60; const s = secs % 60;
return `ETA ${m}m ${s}s`; return `ETA ${m}m ${s}s`;
} }
// --- URL input ---
urlInput.addEventListener('input', () => { urlInput.addEventListener('input', () => {
clearBtn.style.display = urlInput.value ? 'flex' : 'none'; 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 () => { fetchBtn.addEventListener('click', async () => {
const url = urlInput.value.trim(); 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(); hideError();
preview.style.display = 'none'; preview.style.display = 'none';
@ -154,15 +180,20 @@ fetchBtn.addEventListener('click', async () => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }), body: JSON.stringify({ url }),
}); });
const data = await res.json(); 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); renderPreview(data);
renderFormats(data.formats || []); renderFormats(data.formats || []);
preview.style.display = 'flex'; preview.style.display = 'flex';
formatSection.style.display = 'flex'; formatSection.style.display = 'flex';
downloadBtn.style.display = 'flex'; downloadBtn.style.display = 'flex';
} catch (err) { } catch (err) {
showError(err.message); showError(err.message || 'Failed to fetch video info.');
} finally { } finally {
setLoading(fetchBtn, false); setLoading(fetchBtn, false);
} }
@ -172,9 +203,12 @@ function renderPreview(info) {
thumbnail.src = info.thumbnail || ''; thumbnail.src = info.thumbnail || '';
thumbnail.alt = info.title || 'Video thumbnail'; thumbnail.alt = info.title || 'Video thumbnail';
thumbnail.style.display = info.thumbnail ? 'block' : 'none'; thumbnail.style.display = info.thumbnail ? 'block' : 'none';
videoTitle.textContent = info.title || 'Unknown title'; videoTitle.textContent = info.title || 'Unknown title';
uploaderEl.textContent = info.uploader || ''; uploaderEl.textContent = info.uploader || '';
uploaderEl.style.display = info.uploader ? '' : 'none'; uploaderEl.style.display = info.uploader ? '' : 'none';
const dur = formatDuration(info.duration); const dur = formatDuration(info.duration);
durationEl.textContent = dur || ''; durationEl.textContent = dur || '';
durationEl.style.display = dur ? '' : 'none'; durationEl.style.display = dur ? '' : 'none';
@ -182,13 +216,23 @@ function renderPreview(info) {
const viewsEl = document.getElementById('views'); const viewsEl = document.getElementById('views');
const likesEl = document.getElementById('likes'); const likesEl = document.getElementById('likes');
const dateEl = document.getElementById('upload-date'); const dateEl = document.getElementById('upload-date');
viewsEl.textContent = info.view_count ? '👁 ' + formatCount(info.view_count) : ''; viewsEl.textContent = info.view_count ? '👁 ' + formatCount(info.view_count) : '';
viewsEl.style.display = info.view_count ? '' : 'none'; viewsEl.style.display = info.view_count ? '' : 'none';
likesEl.textContent = info.like_count ? '👍 ' + formatCount(info.like_count) : ''; likesEl.textContent = info.like_count ? '👍 ' + formatCount(info.like_count) : '';
likesEl.style.display = info.like_count ? '' : 'none'; likesEl.style.display = info.like_count ? '' : 'none';
if (info.upload_date && info.upload_date.length === 8) { if (info.upload_date && info.upload_date.length === 8) {
const d = info.upload_date; 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.textContent = '📅 ' + formatted;
dateEl.style.display = ''; dateEl.style.display = '';
} else { } else {
@ -198,38 +242,61 @@ function renderPreview(info) {
function renderFormats(formats) { function renderFormats(formats) {
formatSelect.innerHTML = ''; 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; if (!items.length) return;
const group = document.createElement('optgroup'); const group = document.createElement('optgroup');
group.label = label; group.label = label;
items.forEach(f => {
items.forEach((f) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = f.format_id; opt.value = transformValue ? transformValue(f) : f.format_id;
opt.textContent = formatLabel(f); opt.textContent = formatLabel(f);
group.appendChild(opt); group.appendChild(opt);
}); });
formatSelect.appendChild(group); formatSelect.appendChild(group);
}; };
if ((videoOnly[0]?.height || 0) >= (combined[0]?.height || 0)) { addGroup(
addGroup('Video only', videoOnly); 'Video only',
videoOnly,
(f) => `${f.format_id}+bestaudio[ext=m4a]/bestaudio`
);
addGroup('Video + Audio', combined); addGroup('Video + Audio', combined);
} else {
addGroup('Video + Audio', combined);
addGroup('Video only', videoOnly);
}
addGroup('Audio only', audioOnly); 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 () => { downloadBtn.addEventListener('click', async () => {
const url = urlInput.value.trim(); const url = urlInput.value.trim();
if (!url) return; if (!url) return;
@ -242,31 +309,72 @@ downloadBtn.addEventListener('click', async () => {
const fileId = crypto.randomUUID(); const fileId = crypto.randomUUID();
currentFileId = fileId; currentFileId = fileId;
// SSE for live progress
const sse = new EventSource(`${API}/progress/${fileId}`); const sse = new EventSource(`${API}/progress/${fileId}`);
currentSse = sse; currentSse = sse;
sse.onmessage = (e) => { sse.onmessage = (e) => {
const d = JSON.parse(e.data); 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 pct = d.percent || 0;
const eta = d.eta != null ? formatEta(d.eta) : ''; const eta = d.eta != null ? formatEta(d.eta) : '';
const speed = d.speed != null ? `${d.speed} MB/s` : ''; const speed = d.speed != null ? `${d.speed} MB/s` : '';
const meta = [speed, eta].filter(Boolean).join(' · '); 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') { } 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') { } else if (d.status === 'cancelled') {
sse.close(); sse.close();
currentSse = null;
currentAbort = null;
progressWrap.style.display = 'none';
currentFileId = null;
setLoading(downloadBtn, false);
} else if (d.status === 'error') { } else if (d.status === 'error') {
sse.close(); sse.close();
currentSse = null;
currentAbort = null;
showError(d.error || 'Download failed.'); showError(d.error || 'Download failed.');
progressWrap.style.display = 'none'; progressWrap.style.display = 'none';
currentFileId = null;
setLoading(downloadBtn, false); setLoading(downloadBtn, false);
} }
}; };
sse.onerror = () => sse.close();
// AbortController so cancel can kill the fetch sse.onerror = () => {
sse.close();
currentSse = null;
};
const abort = new AbortController(); const abort = new AbortController();
currentAbort = abort; currentAbort = abort;
@ -274,36 +382,40 @@ downloadBtn.addEventListener('click', async () => {
const res = await fetch(`${API}/download`, { const res = await fetch(`${API}/download`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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, signal: abort.signal,
}); });
sse.close(); const data = await res.json().catch(() => ({}));
currentSse = null;
currentAbort = null;
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({ detail: 'Download failed.' }));
throw new Error(data.detail || 'Download failed.'); throw new Error(data.detail || 'Download failed.');
} }
// Only trigger file download if not cancelled if (data.status !== 'started') {
if (currentFileId === fileId) { throw new Error('Unexpected server response.');
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);
} }
} catch (err) { } catch (err) {
sse.close(); if (currentSse) {
currentSse.close();
currentSse = null; currentSse = null;
}
currentAbort = null; currentAbort = null;
if (err.name !== 'AbortError') { if (err.name !== 'AbortError') {
showError(err.message); showError(err.message || 'Download failed.');
progressWrap.style.display = 'none'; progressWrap.style.display = 'none';
} }
} finally {
if (currentFileId === fileId) currentFileId = null; if (currentFileId === fileId) {
currentFileId = null;
}
setLoading(downloadBtn, false); setLoading(downloadBtn, false);
} }
}); });