diff --git a/web/backend/main.py b/web/backend/main.py
index e606349eb..a8c923ed0 100644
--- a/web/backend/main.py
+++ b/web/backend/main.py
@@ -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,31 +26,37 @@ 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",
+ "best": "bestvideo+bestaudio/bestvideo/best",
"1080p": "bestvideo[height<=1080]+bestaudio/bestvideo[height<=1080]/best",
- "720p": "bestvideo[height<=720]+bestaudio/bestvideo[height<=720]/best",
- "480p": "bestvideo[height<=480]+bestaudio/bestvideo[height<=480]/best",
+ "720p": "bestvideo[height<=720]+bestaudio/bestvideo[height<=720]/best",
+ "480p": "bestvideo[height<=480]+bestaudio/bestvideo[height<=480]/best",
"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
+ 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:
- pass
- if not info:
- 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))
+ 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({
- "status": "downloading",
- "percent": pct,
- "eta": int(eta) if eta else None,
- "speed": round(speed / 1024 / 1024, 2) if speed else None,
- })
+
+ 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()
- _progress[file_id]["status"] = "cancelled"
+ if file_id in _progress:
+ _progress[file_id]["status"] = "cancelled"
raise
+
except Exception as e:
- _progress[file_id]["status"] = "error"
- _progress[file_id]["error"] = str(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
- try:
- info = await task
- except asyncio.CancelledError:
- return JSONResponse({"file_id": 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)))
+ def _done_callback(t: asyncio.Task):
+ try:
+ info = t.result()
+ _finalize_download(file_id, info)
+ except asyncio.CancelledError:
+ if file_id in _progress:
+ _progress[file_id]["status"] = "cancelled"
+ except Exception as e:
+ if file_id in _progress:
+ _progress[file_id]["status"] = "error"
+ _progress[file_id]["error"] = str(e)
+ finally:
+ _tasks.pop(file_id, None)
- _tasks.pop(file_id, None)
+ task.add_done_callback(_done_callback)
- matches = list(DOWNLOAD_DIR.glob(f"{file_id}.*"))
- if not matches:
- raise HTTPException(status_code=500, detail="Download failed: file not found")
-
- 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",
+ },
)
diff --git a/web/frontend/app.js b/web/frontend/app.js
index cd35afd0b..9d669f7d1 100644
--- a/web/frontend/app.js
+++ b/web/frontend/app.js
@@ -1,36 +1,40 @@
const API = '';
-const urlInput = document.getElementById('url-input');
-const clearBtn = document.getElementById('clear-btn');
-const fetchBtn = document.getElementById('fetch-btn');
-const errorMsg = document.getElementById('error-msg');
-const preview = document.getElementById('preview');
-const thumbnail = document.getElementById('thumbnail');
-const videoTitle = document.getElementById('video-title');
-const uploaderEl = document.getElementById('uploader');
-const durationEl = document.getElementById('duration');
+const urlInput = document.getElementById('url-input');
+const clearBtn = document.getElementById('clear-btn');
+const fetchBtn = document.getElementById('fetch-btn');
+const errorMsg = document.getElementById('error-msg');
+const preview = document.getElementById('preview');
+const thumbnail = document.getElementById('thumbnail');
+const videoTitle = document.getElementById('video-title');
+const uploaderEl = document.getElementById('uploader');
+const durationEl = document.getElementById('duration');
const formatSection = document.getElementById('format-section');
-const formatSelect = document.getElementById('format-select');
-const downloadBtn = document.getElementById('download-btn');
-const progressWrap = document.getElementById('progress-wrap');
-const progressFill = document.getElementById('progress-fill');
+const formatSelect = document.getElementById('format-select');
+const downloadBtn = document.getElementById('download-btn');
+const progressWrap = document.getElementById('progress-wrap');
+const progressFill = document.getElementById('progress-fill');
const progressLabel = document.getElementById('progress-label');
-const cancelBtn = document.getElementById('cancel-btn');
+const cancelBtn = document.getElementById('cancel-btn');
-let currentFileId = null;
-let currentAbort = null;
-let currentSse = null;
+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();
+ btn.style.cursor = loading ? 'wait' : '';
+
+ 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,23 +203,36 @@ 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';
const viewsEl = document.getElementById('views');
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.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 + Audio', combined);
- } else {
- addGroup('Video + Audio', combined);
- addGroup('Video only', videoOnly);
- }
+ addGroup(
+ 'Video only',
+ videoOnly,
+ (f) => `${f.format_id}+bestaudio[ext=m4a]/bestaudio`
+ );
+
+ addGroup('Video + Audio', combined);
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, 'Starting…');
+ } 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, `${pct}%${meta ? ` ${meta}` : ''}`);
+
+ setProgress(
+ pct,
+ `${pct}%${meta ? ` ${meta}` : ''}`
+ );
} else if (d.status === 'finished') {
- setProgress(100, '100% Processing…');
+ setProgress(
+ 100,
+ '100% Processing…'
+ );
+ } else if (d.status === 'ready') {
+ sse.close();
+ currentSse = null;
+ currentAbort = null;
+
+ if (currentFileId === fileId) {
+ setProgress(
+ 100,
+ '✓ Done — saving to your downloads'
+ );
+
+ 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, '✓ Done — saving to your downloads');
- // 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();
- currentSse = null;
+ 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);
}
-});
+});
\ No newline at end of file