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 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",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,9 +93,10 @@ 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')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCount(n) {
|
function formatCount(n) {
|
||||||
|
|
@ -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 ? ` <span class="meta">${meta}</span>` : ''}`);
|
|
||||||
|
setProgress(
|
||||||
|
pct,
|
||||||
|
`<span class="pct">${pct}%</span>${meta ? ` <span class="meta">${meta}</span>` : ''}`
|
||||||
|
);
|
||||||
} else if (d.status === 'finished') {
|
} 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') {
|
} 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> <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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue