fix frontend and backend source code
This commit is contained in:
parent
ba8d79910c
commit
d4292beebb
2 changed files with 321 additions and 150 deletions
|
|
@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +93,7 @@ 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')}`;
|
||||
|
|
@ -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 ? ` <span class="meta">${meta}</span>` : ''}`);
|
||||
|
||||
setProgress(
|
||||
pct,
|
||||
`<span class="pct">${pct}%</span>${meta ? ` <span class="meta">${meta}</span>` : ''}`
|
||||
);
|
||||
} else if (d.status === 'finished') {
|
||||
setProgress(100, '<span class="pct">100%</span> <span class="meta">Processing…</span>');
|
||||
setProgress(
|
||||
100,
|
||||
'<span class="pct">100%</span> <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> <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> <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);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue