1
0
Fork 0

initial commit

This commit is contained in:
Samive 2026-04-01 10:32:02 +08:00
commit 54184439a4
95 changed files with 1289 additions and 22591 deletions

8
web/.dockerignore Normal file
View file

@ -0,0 +1,8 @@
web/backend/downloads/
web/backend/cookies.txt
__pycache__/
*.pyc
*.pyo
.venv/
.git/
*.egg-info/

24
web/README.md Normal file
View file

@ -0,0 +1,24 @@
# yt-dlp Web UI
## Setup
**1. Install backend dependencies** (from the `web/backend` folder):
```bash
pip install -r web/backend/requirements.txt
```
**2. Install yt-dlp** (from the repo root):
```bash
pip install -e .
```
**3. Start the backend:**
```bash
uvicorn web.backend.main:app --reload --port 8000
```
**4. Open the frontend:**
Just open `web/frontend/index.html` in your browser — no build step needed.
> Make sure `ffmpeg` is installed and on your PATH for merging video+audio streams.

290
web/backend/main.py Normal file
View file

@ -0,0 +1,290 @@
import sys
import json
import uuid
import asyncio
import httpx
import urllib.parse
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, Response, JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
import yt_dlp
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
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",
"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, ... }
_progress: dict[str, dict] = {}
# file_id -> asyncio.Task
_tasks: dict[str, asyncio.Task] = {}
class InfoRequest(BaseModel):
url: str
class DownloadRequest(BaseModel):
url: str
preset: str = "best"
format_id: str | None = None
file_id: str | None = None
@app.get("/")
async def index():
return FileResponse(FRONTEND_DIR / "index.html")
@app.post("/info")
async def get_info(req: InfoRequest):
ydl_opts = {"quiet": True, "no_warnings": True, "skip_download": True}
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:
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")),
key=lambda t: (t.get("width") or 0) * (t.get("height") or 0),
reverse=True,
)
thumbnail_url = ranked[0]["url"] if ranked else None
thumbnail_url = thumbnail_url or info.get("thumbnail")
formats = [
{
"format_id": f.get("format_id"),
"ext": f.get("ext"),
"resolution": f.get("resolution") or f.get("format_note") or "unknown",
"filesize": f.get("filesize") or f.get("filesize_approx"),
"vcodec": f.get("vcodec"),
"acodec": f.get("acodec"),
"fps": f.get("fps"),
"height": f.get("height") or 0,
"tbr": f.get("tbr") or 0,
}
for f in (info.get("formats") or [])
if f.get("ext") != "mhtml"
and f.get("protocol") != "mhtml"
and (f.get("vcodec", "none") != "none" or f.get("acodec", "none") != "none")
]
formats.sort(key=lambda f: (f["height"], f["tbr"]), reverse=True)
if not formats:
raise HTTPException(status_code=400, detail="No downloadable formats found.")
return {
"title": info.get("title"),
"thumbnail": f"/thumbnail?url={thumbnail_url}" if thumbnail_url else None,
"duration": info.get("duration"),
"uploader": info.get("uploader"),
"view_count": info.get("view_count"),
"like_count": info.get("like_count"),
"upload_date": info.get("upload_date"),
"formats": formats,
}
@app.get("/thumbnail")
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"))
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,
})
elif status == "finished":
_progress[file_id].update({"status": "finished", "percent": 100, "eta": 0})
ydl_opts = {
"quiet": True,
"no_warnings": True,
"format": fmt,
"outtmpl": output_template,
"merge_output_format": "mp4",
"progress_hooks": [progress_hook],
}
try:
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"
raise
except Exception as e:
_progress[file_id]["status"] = "error"
_progress[file_id]["error"] = str(e)
raise
@app.post("/download")
async def download_video(req: DownloadRequest):
file_id = req.file_id or str(uuid.uuid4())
output_template = str(DOWNLOAD_DIR / f"{file_id}.%(ext)s")
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,
}
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)))
_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")
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"})
@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"})
@app.get("/file/{file_id}")
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")
filepath = Path(meta["filepath"])
if not filepath.exists():
raise HTTPException(status_code=404, detail="File not found")
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)
return StreamingResponse(
iterfile(),
media_type="video/mp4",
headers={
"Content-Disposition": f"attachment; filename=\"{filename_ascii}\"; filename*=UTF-8''{filename_encoded}",
},
)
@app.get("/progress/{file_id}")
async def progress_stream(file_id: str, request: Request):
async def event_generator():
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"},
)
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")

View file

@ -0,0 +1,3 @@
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
httpx>=0.27.0

309
web/frontend/app.js Normal file
View file

@ -0,0 +1,309 @@
const API = 'http://localhost:8000';
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 progressLabel = document.getElementById('progress-label');
const cancelBtn = document.getElementById('cancel-btn');
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 (currentFileId) {
fetch(`${API}/cancel/${currentFileId}`, { method: 'POST' }).catch(() => {});
currentFileId = null;
}
progressWrap.style.display = 'none';
setProgress(0, '');
setLoading(downloadBtn, false);
});
// --- Reset UI ---
function resetUI() {
urlInput.value = '';
clearBtn.style.display = 'none';
errorMsg.style.display = 'none';
preview.style.display = 'none';
formatSection.style.display = 'none';
downloadBtn.style.display = 'none';
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 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 (loading && text) {
btn.textContent = text;
} else if (!loading && btn.dataset.label) {
if (btn.id === 'download-btn') {
btn.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Download`;
} else {
btn.textContent = btn.dataset.label;
}
delete btn.dataset.label;
}
}
function formatDuration(secs) {
if (!secs) return null;
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')}`;
}
function formatCount(n) {
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
return n.toString();
}
function formatBytes(bytes) {
if (!bytes) return null;
if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + ' GB';
if (bytes >= 1e6) return (bytes / 1e6).toFixed(1) + ' MB';
return (bytes / 1e3).toFixed(0) + ' KB';
}
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;
}
function setProgress(pct, label) {
progressFill.style.width = pct + '%';
progressLabel.innerHTML = 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 ---
fetchBtn.addEventListener('click', async () => {
const url = urlInput.value.trim();
if (!url) { showError('Please paste a video URL first.'); return; }
hideError();
preview.style.display = 'none';
formatSection.style.display = 'none';
downloadBtn.style.display = 'none';
progressWrap.style.display = 'none';
setLoading(fetchBtn, true, 'Fetching…');
try {
const res = await fetch(`${API}/info`, {
method: 'POST',
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.');
renderPreview(data);
renderFormats(data.formats || []);
preview.style.display = 'flex';
formatSection.style.display = 'flex';
downloadBtn.style.display = 'flex';
} catch (err) {
showError(err.message);
} finally {
setLoading(fetchBtn, false);
}
});
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');
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' });
dateEl.textContent = '📅 ' + formatted;
dateEl.style.display = '';
} else {
dateEl.style.display = 'none';
}
}
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) => {
if (!items.length) return;
const group = document.createElement('optgroup');
group.label = label;
items.forEach(f => {
const opt = document.createElement('option');
opt.value = 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('Audio only', audioOnly);
if (sorted.length) formatSelect.value = sorted[0].format_id;
}
// --- Download ---
downloadBtn.addEventListener('click', async () => {
const url = urlInput.value.trim();
if (!url) return;
hideError();
setLoading(downloadBtn, true, 'Downloading…');
progressWrap.style.display = 'block';
setProgress(0, '<span class="meta">Starting…</span>');
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') {
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 ? `&ensp;<span class="meta">${meta}</span>` : ''}`);
} else if (d.status === 'finished') {
setProgress(100, '<span class="pct">100%</span>&ensp;<span class="meta">Processing…</span>');
} else if (d.status === 'cancelled') {
sse.close();
} else if (d.status === 'error') {
sse.close();
showError(d.error || 'Download failed.');
progressWrap.style.display = 'none';
setLoading(downloadBtn, false);
}
};
sse.onerror = () => sse.close();
// AbortController so cancel can kill the fetch
const abort = new AbortController();
currentAbort = abort;
try {
const res = await fetch(`${API}/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, format_id: formatSelect.value, file_id: fileId }),
signal: abort.signal,
});
sse.close();
currentSse = null;
currentAbort = null;
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>&ensp;<span class="meta">Done — saving to your downloads</span>');
// Open in new tab so current page never navigates away
window.open(`${API}/file/${fileId}`, '_blank');
setTimeout(() => { progressWrap.style.display = 'none'; }, 2500);
}
} catch (err) {
sse.close();
currentSse = null;
currentAbort = null;
if (err.name !== 'AbortError') {
showError(err.message);
progressWrap.style.display = 'none';
}
} finally {
if (currentFileId === fileId) currentFileId = null;
setLoading(downloadBtn, false);
}
});

135
web/frontend/index.html Normal file
View file

@ -0,0 +1,135 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>yt-dlp Web</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'><rect width='36' height='36' rx='10' fill='%236366f1'/><path d='M11 12l14 6-14 6V12z' fill='white'/></svg>" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<div class="bg-orbs">
<div class="orb orb-1"></div>
<div class="orb orb-2"></div>
<div class="orb orb-3"></div>
</div>
<main class="container">
<header>
<div class="logo">
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">
<rect width="36" height="36" rx="10" fill="url(#grad)"/>
<path d="M11 12l14 6-14 6V12z" fill="white"/>
<defs>
<linearGradient id="grad" x1="0" y1="0" x2="36" y2="36" gradientUnits="userSpaceOnUse">
<stop stop-color="#6366f1"/>
<stop offset="1" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
</svg>
<span>yt-dlp web</span>
</div>
</header>
<section class="hero">
<h1>Download any video,<br /><span class="gradient-text">instantly.</span></h1>
<p class="subtitle">Paste a YouTube (or any supported) link below and grab your video.</p>
</section>
<div class="card" id="main-card">
<div class="input-row">
<div class="input-wrap">
<svg class="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/>
</svg>
<input
type="url"
id="url-input"
placeholder="https://www.youtube.com/watch?v=..."
autocomplete="off"
spellcheck="false"
aria-label="Video URL"
/>
<button class="clear-btn" id="clear-btn" aria-label="Clear input">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<button class="btn-primary" id="fetch-btn" aria-label="Fetch video info">
Fetch
</button>
</div>
<div id="error-msg" class="error-msg" role="alert" hidden></div>
<!-- Video preview -->
<div id="preview" class="preview" hidden>
<img id="thumbnail" class="thumbnail" alt="Video thumbnail" />
<div class="meta">
<h2 id="video-title" class="video-title"></h2>
<div class="meta-row">
<span id="uploader" class="tag"></span>
<span id="duration" class="tag"></span>
<span id="upload-date" class="tag"></span>
<span id="views" class="tag"></span>
<span id="likes" class="tag"></span>
</div>
</div>
</div>
<!-- Format selector -->
<div id="format-section" class="format-section" hidden>
<label for="format-select" class="format-label">Format</label>
<div class="select-wrap">
<select id="format-select" aria-label="Select download format"></select>
<svg class="select-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
</div>
<!-- Download button -->
<button class="btn-download" id="download-btn" hidden aria-label="Download video">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download
</button>
<!-- Progress -->
<div id="progress-wrap" class="progress-wrap" hidden>
<div class="progress-header">
<span id="progress-label" class="progress-label"></span>
</div>
<div class="progress-row">
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
<button id="cancel-btn" type="button" class="progress-btn cancel" aria-label="Cancel download">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Cancel
</button>
</div>
</div>
</div>
<footer>
<p>Powered by <a href="https://github.com/yt-dlp/yt-dlp" target="_blank" rel="noopener">yt-dlp</a></p>
</footer>
</main>
<script>
// Runs before app.js — resets any browser-restored state immediately
window.addEventListener('DOMContentLoaded', () => {
const input = document.getElementById('url-input');
if (input) input.value = '';
});
</script>
<script src="/static/app.js?v=4"></script>
</body>
</html>

380
web/frontend/style.css Normal file
View file

@ -0,0 +1,380 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0f;
--surface: rgba(255,255,255,0.04);
--surface-hover: rgba(255,255,255,0.07);
--border: rgba(255,255,255,0.08);
--border-focus: rgba(99,102,241,0.6);
--text: #f1f1f5;
--text-muted: #8b8b9e;
--primary: #6366f1;
--primary-hover: #4f52e0;
--success: #22c55e;
--error: #f87171;
--radius: 14px;
--radius-sm: 8px;
}
html { font-size: 16px; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow-x: hidden;
}
/* Ambient background orbs */
.bg-orbs { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
.orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.18;
}
.orb-1 { width: 500px; height: 500px; background: #6366f1; top: -150px; left: -100px; }
.orb-2 { width: 400px; height: 400px; background: #8b5cf6; bottom: -100px; right: -80px; }
.orb-3 { width: 300px; height: 300px; background: #06b6d4; top: 40%; left: 50%; transform: translateX(-50%); }
.container {
position: relative;
z-index: 1;
width: 100%;
max-width: 640px;
padding: 24px 16px 48px;
display: flex;
flex-direction: column;
gap: 32px;
}
/* Header */
header { display: flex; justify-content: center; }
.logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 1.1rem;
letter-spacing: -0.02em;
}
/* Hero */
.hero { text-align: center; }
.hero h1 {
font-size: clamp(2rem, 6vw, 2.8rem);
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.03em;
}
.gradient-text {
background: linear-gradient(135deg, #6366f1, #a78bfa, #38bdf8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
margin-top: 12px;
color: var(--text-muted);
font-size: 1rem;
line-height: 1.6;
}
/* Card */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* Input row */
.input-row { display: flex; gap: 10px; }
.input-wrap {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.input-icon {
position: absolute;
left: 14px;
color: var(--text-muted);
pointer-events: none;
flex-shrink: 0;
}
#url-input {
width: 100%;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 0.9rem;
padding: 12px 40px 12px 42px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
#url-input::placeholder { color: var(--text-muted); }
#url-input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(99,102,241,0.15);
}
.clear-btn {
position: absolute;
right: 10px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: none;
align-items: center;
transition: color 0.15s;
}
.clear-btn:hover { color: var(--text); }
/* Buttons */
.btn-primary {
background: var(--primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
padding: 12px 22px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
transition: background 0.2s, transform 0.1s;
flex-shrink: 0;
}
.btn-primary:hover { background: var(--primary-hover); }
.btn-primary:active { transform: scale(0.97); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn-download {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
border: none;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 1rem;
font-weight: 600;
padding: 14px 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: opacity 0.2s, transform 0.1s, box-shadow 0.2s;
box-shadow: 0 4px 20px rgba(99,102,241,0.3);
}
.btn-download:hover { opacity: 0.9; box-shadow: 0 6px 28px rgba(99,102,241,0.45); }
.btn-download:active { transform: scale(0.98); }
.btn-download:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
/* Spinner */
.btn-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Error */
.error-msg {
background: rgba(248,113,113,0.1);
border: 1px solid rgba(248,113,113,0.25);
color: var(--error);
border-radius: var(--radius-sm);
padding: 12px 14px;
font-size: 0.875rem;
line-height: 1.5;
}
/* Preview */
.preview {
display: none;
gap: 16px;
align-items: flex-start;
animation: fadeIn 0.3s ease;
}
.preview.visible { display: flex; }.thumbnail {
width: 140px;
height: 80px;
object-fit: cover;
border-radius: var(--radius-sm);
flex-shrink: 0;
background: var(--surface-hover);
}
.meta { flex: 1; min-width: 0; }
.video-title {
font-size: 0.95rem;
font-weight: 600;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.meta-row { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
.tag {
background: rgba(255,255,255,0.07);
border: 1px solid var(--border);
border-radius: 20px;
padding: 3px 10px;
font-size: 0.75rem;
color: var(--text-muted);
}
/* Format selector */
.format-section { display: flex; flex-direction: column; gap: 10px; }
.format-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
.select-wrap { position: relative; }
.select-wrap select {
width: 100%;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 0.875rem;
padding: 11px 40px 11px 14px;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.select-wrap select:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(99,102,241,0.15);
}
.select-wrap select option { background: #1a1a2e; color: var(--text); }
.select-arrow {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}
/* Progress */
.progress-wrap {
display: flex;
flex-direction: column;
gap: 10px;
animation: fadeIn 0.3s ease;
background: rgba(99,102,241,0.06);
border: 1px solid rgba(99,102,241,0.15);
border-radius: var(--radius-sm);
padding: 14px 16px;
}
.progress-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.progress-row {
display: flex;
align-items: center;
gap: 12px;
}
.progress-bar {
flex: 1;
height: 6px;
background: rgba(255,255,255,0.07);
border-radius: 99px;
overflow: hidden;
}
.progress-actions { display: flex; gap: 8px; flex-shrink: 0; }
.progress-btn {
display: flex;
align-items: center;
gap: 5px;
border: none;
border-radius: 6px;
font-family: inherit;
font-size: 0.75rem;
font-weight: 600;
padding: 5px 10px;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.progress-btn:hover { opacity: 0.8; }
.progress-btn:active { transform: scale(0.96); }
.progress-btn.cancel {
background: rgba(248,113,113,0.12);
border: 1px solid rgba(248,113,113,0.25);
color: var(--error);
}
.progress-bar {
height: 6px;
background: rgba(255,255,255,0.07);
border-radius: 99px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #a78bfa, #38bdf8);
background-size: 200% 100%;
border-radius: 99px;
width: 0%;
transition: width 0.5s ease;
animation: shimmer 2s linear infinite;
}
@keyframes shimmer {
0% { background-position: 200% center; }
100% { background-position: -200% center; }
}
.progress-label {
font-size: 0.82rem;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 0.01em;
font-variant-numeric: tabular-nums;
}
.progress-label .pct {
font-size: 1rem;
font-weight: 700;
color: var(--text);
letter-spacing: -0.02em;
}
.progress-label .meta {
color: var(--text-muted);
font-size: 0.78rem;
}
/* Footer */
footer { text-align: center; font-size: 0.8rem; color: var(--text-muted); }
footer a { color: var(--text-muted); text-decoration: underline; text-underline-offset: 3px; }
footer a:hover { color: var(--text); }
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@media (max-width: 480px) {
.input-row { flex-direction: column; }
.btn-primary { width: 100%; justify-content: center; }
.preview { flex-direction: column; }
.thumbnail { width: 100%; height: 180px; }
}