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