Download app.py from Vvbbnnn/Wget: direct link, hf CLI and curl.
- Browser
- Download file 28.1 kB
-
https://huggingface.co/spaces/Vvbbnnn/Wget/resolve/main/app.py
- Command line
-
hf download hf://spaces/Vvbbnnn/Wget/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Vvbbnnn/Wget/resolve/main/app.py
28.1 kB
| import os | |
| import re | |
| import uuid | |
| import json | |
| import time | |
| import threading | |
| import subprocess | |
| import sqlite3 | |
| from pathlib import Path | |
| from urllib.parse import urlparse, unquote | |
| from flask import Flask, request, jsonify, send_from_directory, render_template_string | |
| app = Flask(__name__) | |
| DOWNLOAD_DIR = Path("downloads") | |
| DOWNLOAD_DIR.mkdir(exist_ok=True) | |
| DB_FILE = Path("swiftload.db") | |
| # In-memory speed/eta cache (ephemeral, recomputed on restart for active tasks) | |
| _live_cache = {} | |
| _cache_lock = threading.Lock() | |
| # βββ Database ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_db(): | |
| conn = sqlite3.connect(str(DB_FILE)) | |
| conn.row_factory = sqlite3.Row | |
| return conn | |
| def init_db(): | |
| with get_db() as conn: | |
| conn.execute(""" | |
| CREATE TABLE IF NOT EXISTS tasks ( | |
| task_id TEXT PRIMARY KEY, | |
| url TEXT NOT NULL, | |
| filename TEXT NOT NULL, | |
| save_path TEXT NOT NULL, | |
| status TEXT NOT NULL DEFAULT 'queued', | |
| progress INTEGER DEFAULT 0, | |
| size TEXT DEFAULT '', | |
| error TEXT DEFAULT '', | |
| created_at TEXT NOT NULL, | |
| updated_at TEXT NOT NULL | |
| ) | |
| """) | |
| conn.commit() | |
| def db_upsert_task(task_id, url, filename, save_path, status="queued", progress=0, size="", error=""): | |
| now = time.strftime("%Y-%m-%d %H:%M:%S") | |
| with get_db() as conn: | |
| conn.execute(""" | |
| INSERT INTO tasks (task_id, url, filename, save_path, status, progress, size, error, created_at, updated_at) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| ON CONFLICT(task_id) DO UPDATE SET | |
| status=excluded.status, | |
| progress=excluded.progress, | |
| size=excluded.size, | |
| error=excluded.error, | |
| updated_at=excluded.updated_at | |
| """, (task_id, url, filename, str(save_path), status, progress, size, error, now, now)) | |
| conn.commit() | |
| def db_update_task(task_id, **kwargs): | |
| if not kwargs: | |
| return | |
| kwargs["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S") | |
| set_clause = ", ".join(f"{k}=?" for k in kwargs) | |
| values = list(kwargs.values()) + [task_id] | |
| with get_db() as conn: | |
| conn.execute(f"UPDATE tasks SET {set_clause} WHERE task_id=?", values) | |
| conn.commit() | |
| def db_get_task(task_id): | |
| with get_db() as conn: | |
| row = conn.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone() | |
| return dict(row) if row else None | |
| def db_all_tasks(): | |
| with get_db() as conn: | |
| rows = conn.execute("SELECT * FROM tasks ORDER BY created_at DESC").fetchall() | |
| return [dict(r) for r in rows] | |
| def db_resume_incomplete(): | |
| """On startup, mark any 'downloading' tasks as interrupted so user knows.""" | |
| with get_db() as conn: | |
| conn.execute(""" | |
| UPDATE tasks SET status='interrupted', error='Server restarted while downloading' | |
| WHERE status IN ('downloading', 'queued') | |
| """) | |
| conn.commit() | |
| # βββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def format_size(size_bytes): | |
| for unit in ["B", "KB", "MB", "GB", "TB"]: | |
| if size_bytes < 1024: | |
| return f"{size_bytes:.1f} {unit}" | |
| size_bytes /= 1024 | |
| return f"{size_bytes:.1f} PB" | |
| def get_filename_from_url(url): | |
| parsed = urlparse(url) | |
| name = unquote(parsed.path.split("/")[-1]) | |
| if not name or "." not in name: | |
| name = f"file_{uuid.uuid4().hex[:8]}" | |
| return name | |
| def unique_save_path(filename): | |
| save_path = DOWNLOAD_DIR / filename | |
| counter = 1 | |
| stem = save_path.stem | |
| suffix = save_path.suffix | |
| while save_path.exists(): | |
| save_path = DOWNLOAD_DIR / f"{stem}_{counter}{suffix}" | |
| counter += 1 | |
| return save_path | |
| # βββ Download Worker βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def download_with_wget(task_id, url, save_path): | |
| db_update_task(task_id, status="downloading", progress=0, error="") | |
| with _cache_lock: | |
| _live_cache[task_id] = {"speed": "connecting...", "eta": "β"} | |
| cmd = [ | |
| "wget", | |
| "--no-check-certificate", | |
| "--retry-connrefused", | |
| "--tries=5", | |
| "--timeout=30", | |
| "--waitretry=3", | |
| "--limit-rate=0", | |
| "--progress=dot:mega", | |
| "-c", | |
| "--content-disposition", | |
| "--trust-server-names", | |
| url | |
| ] | |
| try: | |
| process = subprocess.Popen( | |
| cmd, | |
| stderr=subprocess.PIPE, | |
| stdout=subprocess.PIPE, | |
| text=True, | |
| bufsize=1, | |
| ) | |
| for line in process.stderr: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| pct_match = re.search(r'(\d+)%', line) | |
| speed_match = re.search(r'([\d.]+\s*[KMG]B/s)', line) | |
| eta_match = re.search(r'(\d+[smhd]+)\s*$', line) | |
| if pct_match: | |
| pct = int(pct_match.group(1)) | |
| db_update_task(task_id, progress=pct) | |
| with _cache_lock: | |
| if speed_match: | |
| _live_cache[task_id]["speed"] = speed_match.group(1) | |
| if eta_match: | |
| _live_cache[task_id]["eta"] = eta_match.group(1) | |
| process.wait() | |
| if process.returncode == 0 and Path(save_path).exists(): | |
| size_str = format_size(Path(save_path).stat().st_size) | |
| db_update_task(task_id, status="done", progress=100, size=size_str, error="") | |
| with _cache_lock: | |
| _live_cache.pop(task_id, None) | |
| else: | |
| db_update_task(task_id, status="error", | |
| error=f"wget exited with code {process.returncode}") | |
| if Path(save_path).exists(): | |
| Path(save_path).unlink() | |
| except FileNotFoundError: | |
| db_update_task(task_id, status="error", | |
| error="wget not found. Install: sudo apt install wget") | |
| except Exception as e: | |
| db_update_task(task_id, status="error", error=str(e)) | |
| # βββ Routes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def index(): | |
| return render_template_string(HTML) | |
| def start_download(): | |
| data = request.get_json() | |
| url = (data or {}).get("url", "").strip() | |
| if not url: | |
| return jsonify({"error": "No URL provided"}), 400 | |
| if not url.startswith(("http://", "https://", "ftp://")): | |
| return jsonify({"error": "Invalid URL"}), 400 | |
| task_id = uuid.uuid4().hex | |
| filename = get_filename_from_url(url) | |
| save_path = unique_save_path(filename) | |
| db_upsert_task(task_id, url, save_path.name, save_path, status="queued") | |
| thread = threading.Thread( | |
| target=download_with_wget, | |
| args=(task_id, url, save_path), | |
| daemon=True, | |
| ) | |
| thread.start() | |
| return jsonify({"task_id": task_id, "filename": save_path.name}) | |
| def get_progress(task_id): | |
| task = db_get_task(task_id) | |
| if not task: | |
| return jsonify({"status": "not_found"}), 404 | |
| with _cache_lock: | |
| live = _live_cache.get(task_id, {}) | |
| return jsonify({ | |
| "status": task["status"], | |
| "progress": task["progress"], | |
| "size": task["size"], | |
| "error": task["error"], | |
| "filename": task["filename"], | |
| "speed": live.get("speed", "β") if task["status"] == "downloading" else "β", | |
| "eta": live.get("eta", "β") if task["status"] == "downloading" else "β", | |
| }) | |
| def all_tasks(): | |
| """Return all tasks sorted newest first for full page restore.""" | |
| tasks = db_all_tasks() | |
| result = [] | |
| for t in tasks: | |
| with _cache_lock: | |
| live = _live_cache.get(t["task_id"], {}) | |
| result.append({ | |
| "task_id": t["task_id"], | |
| "url": t["url"], | |
| "filename": t["filename"], | |
| "status": t["status"], | |
| "progress": t["progress"], | |
| "size": t["size"], | |
| "error": t["error"], | |
| "created_at": t["created_at"], | |
| "speed": live.get("speed", "β") if t["status"] == "downloading" else "β", | |
| "eta": live.get("eta", "β") if t["status"] == "downloading" else "β", | |
| }) | |
| return jsonify(result) | |
| def download_file(task_id): | |
| task = db_get_task(task_id) | |
| if not task or task["status"] != "done": | |
| return jsonify({"error": "File not found or not ready"}), 404 | |
| p = Path(task["save_path"]) | |
| if not p.exists(): | |
| return jsonify({"error": "File missing from disk"}), 404 | |
| return send_from_directory(p.parent.resolve(), p.name, as_attachment=True) | |
| # βββ HTML βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| HTML = r"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | |
| <title>β‘ SwiftLoad</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet"/> | |
| <style> | |
| :root { | |
| --bg: #060610; | |
| --surface: #0d0d1c; | |
| --border: #181830; | |
| --border2: #252545; | |
| --accent: #00f5a0; | |
| --accent2: #00c8ff; | |
| --accent3: #7b61ff; | |
| --danger: #ff4d6d; | |
| --warn: #ffbe0b; | |
| --text: #dde0f0; | |
| --muted: #484868; | |
| --card: #0b0b1a; | |
| --glow: rgba(0,245,160,0.12); | |
| } | |
| *{box-sizing:border-box;margin:0;padding:0;} | |
| body{ | |
| background:var(--bg); | |
| color:var(--text); | |
| font-family:'Syne',sans-serif; | |
| min-height:100vh; | |
| overflow-x:hidden; | |
| } | |
| /* Animated grid */ | |
| body::before{ | |
| content:''; | |
| position:fixed;inset:0; | |
| background-image: | |
| linear-gradient(rgba(0,245,160,0.025) 1px, transparent 1px), | |
| linear-gradient(90deg, rgba(0,245,160,0.025) 1px, transparent 1px); | |
| background-size:48px 48px; | |
| pointer-events:none; | |
| z-index:0; | |
| animation:gridPan 60s linear infinite; | |
| } | |
| @keyframes gridPan{from{background-position:0 0;}to{background-position:48px 48px;}} | |
| /* Radial glow center */ | |
| body::after{ | |
| content:''; | |
| position:fixed; | |
| top:-30vh;left:50%; | |
| transform:translateX(-50%); | |
| width:80vw;height:60vh; | |
| background:radial-gradient(ellipse, rgba(0,245,160,0.06) 0%, transparent 70%); | |
| pointer-events:none; | |
| z-index:0; | |
| } | |
| .container{ | |
| max-width:900px; | |
| margin:0 auto; | |
| padding:52px 24px 100px; | |
| position:relative; | |
| z-index:1; | |
| } | |
| /* Header */ | |
| header{ | |
| text-align:center; | |
| margin-bottom:56px; | |
| position:relative; | |
| } | |
| .logo{ | |
| font-size:56px; | |
| font-weight:800; | |
| letter-spacing:-3px; | |
| line-height:1; | |
| background:linear-gradient(135deg, var(--accent) 0%, var(--accent2) 50%, var(--accent3) 100%); | |
| -webkit-background-clip:text; | |
| -webkit-text-fill-color:transparent; | |
| background-clip:text; | |
| filter:drop-shadow(0 0 32px rgba(0,245,160,0.3)); | |
| } | |
| .tagline{ | |
| font-family:'Space Mono',monospace; | |
| font-size:11px; | |
| letter-spacing:4px; | |
| text-transform:uppercase; | |
| color:var(--muted); | |
| margin-top:10px; | |
| } | |
| .live-indicator{ | |
| display:inline-flex; | |
| align-items:center; | |
| gap:6px; | |
| font-family:'Space Mono',monospace; | |
| font-size:10px; | |
| color:var(--accent); | |
| letter-spacing:2px; | |
| text-transform:uppercase; | |
| margin-top:12px; | |
| background:rgba(0,245,160,0.08); | |
| border:1px solid rgba(0,245,160,0.2); | |
| padding:4px 12px; | |
| border-radius:99px; | |
| } | |
| .live-dot{ | |
| width:6px;height:6px; | |
| border-radius:50%; | |
| background:var(--accent); | |
| animation:pulse 1.5s ease-in-out infinite; | |
| } | |
| @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}} | |
| /* Input section */ | |
| .input-section{ | |
| background:var(--surface); | |
| border:1px solid var(--border2); | |
| border-radius:18px; | |
| padding:28px; | |
| margin-bottom:36px; | |
| position:relative; | |
| overflow:hidden; | |
| } | |
| .input-section::before{ | |
| content:''; | |
| position:absolute; | |
| top:0;left:0;right:0;height:2px; | |
| background:linear-gradient(90deg,var(--accent),var(--accent2),var(--accent3)); | |
| } | |
| .input-row{display:flex;gap:12px;} | |
| input[type="text"]{ | |
| flex:1; | |
| background:var(--bg); | |
| border:1px solid var(--border2); | |
| border-radius:12px; | |
| padding:15px 20px; | |
| color:var(--text); | |
| font-family:'Space Mono',monospace; | |
| font-size:13px; | |
| outline:none; | |
| transition:border-color 0.2s, box-shadow 0.2s; | |
| } | |
| input[type="text"]:focus{ | |
| border-color:var(--accent); | |
| box-shadow:0 0 0 3px rgba(0,245,160,0.1); | |
| } | |
| input[type="text"]::placeholder{color:var(--muted);} | |
| .dl-btn{ | |
| background:linear-gradient(135deg,var(--accent),var(--accent2)); | |
| color:#000; | |
| border:none; | |
| border-radius:12px; | |
| padding:15px 32px; | |
| font-family:'Syne',sans-serif; | |
| font-weight:700; | |
| font-size:14px; | |
| letter-spacing:0.5px; | |
| cursor:pointer; | |
| transition:opacity 0.2s,transform 0.1s,box-shadow 0.2s; | |
| white-space:nowrap; | |
| box-shadow:0 4px 24px rgba(0,245,160,0.25); | |
| } | |
| .dl-btn:hover{opacity:0.92;transform:translateY(-1px);box-shadow:0 8px 32px rgba(0,245,160,0.35);} | |
| .dl-btn:active{transform:translateY(0);} | |
| .dl-btn:disabled{opacity:0.35;cursor:not-allowed;transform:none;box-shadow:none;} | |
| /* Sections */ | |
| .section-head{ | |
| display:flex; | |
| align-items:center; | |
| gap:10px; | |
| margin-bottom:16px; | |
| } | |
| .section-label{ | |
| font-family:'Space Mono',monospace; | |
| font-size:10px; | |
| letter-spacing:3px; | |
| text-transform:uppercase; | |
| color:var(--muted); | |
| } | |
| .section-count{ | |
| background:var(--border2); | |
| color:var(--muted); | |
| font-family:'Space Mono',monospace; | |
| font-size:10px; | |
| padding:2px 8px; | |
| border-radius:99px; | |
| } | |
| /* Cards */ | |
| .dl-card{ | |
| background:var(--card); | |
| border:1px solid var(--border); | |
| border-radius:14px; | |
| padding:20px 22px; | |
| margin-bottom:12px; | |
| transition:border-color 0.2s; | |
| animation:fadeSlide 0.3s ease; | |
| } | |
| .dl-card:hover{border-color:var(--border2);} | |
| @keyframes fadeSlide{ | |
| from{opacity:0;transform:translateY(-8px);} | |
| to{opacity:1;transform:translateY(0);} | |
| } | |
| .dl-card.status-done{border-color:rgba(0,245,160,0.12);} | |
| .dl-card.status-error{border-color:rgba(255,77,109,0.12);} | |
| .dl-card.status-interrupted{border-color:rgba(255,190,11,0.12);} | |
| .dl-card.status-downloading{border-color:rgba(0,200,255,0.12);} | |
| .dl-header{ | |
| display:flex; | |
| justify-content:space-between; | |
| align-items:flex-start; | |
| gap:12px; | |
| margin-bottom:14px; | |
| } | |
| .dl-filename{ | |
| font-weight:700; | |
| font-size:14px; | |
| word-break:break-all; | |
| flex:1; | |
| line-height:1.4; | |
| } | |
| .dl-url{ | |
| font-family:'Space Mono',monospace; | |
| font-size:10px; | |
| color:var(--muted); | |
| margin-top:3px; | |
| white-space:nowrap; | |
| overflow:hidden; | |
| text-overflow:ellipsis; | |
| max-width:400px; | |
| } | |
| .badge{ | |
| font-family:'Space Mono',monospace; | |
| font-size:10px; | |
| font-weight:700; | |
| letter-spacing:1px; | |
| padding:3px 10px; | |
| border-radius:99px; | |
| white-space:nowrap; | |
| flex-shrink:0; | |
| } | |
| .badge-downloading{background:rgba(0,200,255,0.12);color:var(--accent2);border:1px solid rgba(0,200,255,0.25);} | |
| .badge-done{background:rgba(0,245,160,0.1);color:var(--accent);border:1px solid rgba(0,245,160,0.25);} | |
| .badge-error{background:rgba(255,77,109,0.1);color:var(--danger);border:1px solid rgba(255,77,109,0.25);} | |
| .badge-interrupted{background:rgba(255,190,11,0.1);color:var(--warn);border:1px solid rgba(255,190,11,0.25);} | |
| .badge-queued{background:rgba(123,97,255,0.1);color:var(--accent3);border:1px solid rgba(123,97,255,0.25);} | |
| /* Progress bar */ | |
| .progress-track{ | |
| background:var(--border); | |
| border-radius:99px; | |
| height:5px; | |
| overflow:hidden; | |
| margin-bottom:11px; | |
| } | |
| .progress-fill{ | |
| height:100%; | |
| border-radius:99px; | |
| background:linear-gradient(90deg,var(--accent),var(--accent2)); | |
| transition:width 0.5s ease; | |
| position:relative; | |
| min-width:2px; | |
| } | |
| .progress-fill.done{background:var(--accent);} | |
| .progress-fill.error{background:var(--danger);} | |
| .progress-fill.interrupted{background:var(--warn);} | |
| .progress-fill.downloading::after{ | |
| content:''; | |
| position:absolute;right:0;top:0;bottom:0;width:30px; | |
| background:linear-gradient(90deg,transparent,rgba(255,255,255,0.5)); | |
| border-radius:99px; | |
| animation:scan 1.2s ease-in-out infinite; | |
| } | |
| @keyframes scan{0%,100%{opacity:0;}50%{opacity:1;}} | |
| /* Meta row */ | |
| .dl-meta{ | |
| display:flex; | |
| gap:18px; | |
| font-family:'Space Mono',monospace; | |
| font-size:11px; | |
| color:var(--muted); | |
| flex-wrap:wrap; | |
| align-items:center; | |
| } | |
| .dl-meta .val{color:var(--accent2);} | |
| .dl-meta .pct{ | |
| color:var(--text); | |
| font-weight:700; | |
| font-size:12px; | |
| } | |
| .error-msg{ | |
| font-family:'Space Mono',monospace; | |
| font-size:11px; | |
| color:var(--danger); | |
| margin-top:10px; | |
| background:rgba(255,77,109,0.06); | |
| padding:9px 14px; | |
| border-radius:8px; | |
| border-left:3px solid var(--danger); | |
| word-break:break-all; | |
| } | |
| .dl-actions{ | |
| display:flex; | |
| gap:8px; | |
| margin-top:12px; | |
| } | |
| .btn-sm{ | |
| font-family:'Space Mono',monospace; | |
| font-size:11px; | |
| padding:6px 14px; | |
| border-radius:7px; | |
| border:1px solid var(--border2); | |
| background:transparent; | |
| color:var(--text); | |
| cursor:pointer; | |
| text-decoration:none; | |
| transition:all 0.15s; | |
| display:inline-block; | |
| } | |
| .btn-sm:hover{border-color:var(--accent);color:var(--accent);} | |
| .btn-sm.primary{border-color:var(--accent);color:var(--accent);background:rgba(0,245,160,0.05);} | |
| .btn-sm.retry{border-color:var(--accent3);color:var(--accent3);} | |
| /* Separator */ | |
| .history-sep{ | |
| border:none; | |
| border-top:1px solid var(--border); | |
| margin:32px 0; | |
| } | |
| .empty{ | |
| text-align:center; | |
| padding:40px; | |
| color:var(--muted); | |
| font-family:'Space Mono',monospace; | |
| font-size:12px; | |
| letter-spacing:1px; | |
| } | |
| .empty-icon{font-size:28px;margin-bottom:10px;opacity:0.3;} | |
| /* Toast */ | |
| .toast{ | |
| position:fixed; | |
| bottom:28px;right:28px; | |
| background:var(--surface); | |
| border:1px solid var(--accent); | |
| color:var(--accent); | |
| font-family:'Space Mono',monospace; | |
| font-size:12px; | |
| padding:12px 20px; | |
| border-radius:11px; | |
| z-index:999; | |
| animation:toastIn 0.3s ease; | |
| box-shadow:0 8px 40px rgba(0,0,0,0.5),0 0 20px rgba(0,245,160,0.1); | |
| max-width:320px; | |
| } | |
| .toast.err{border-color:var(--danger);color:var(--danger);box-shadow:0 8px 40px rgba(0,0,0,0.5),0 0 20px rgba(255,77,109,0.1);} | |
| @keyframes toastIn{from{opacity:0;transform:translateY(16px);}to{opacity:1;transform:translateY(0);}} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <header> | |
| <div class="logo">β‘ SwiftLoad</div> | |
| <div class="tagline">High-Speed File Downloader</div> | |
| <div class="live-indicator"><span class="live-dot"></span>Live Sync Active</div> | |
| </header> | |
| <div class="input-section"> | |
| <div class="input-row"> | |
| <input type="text" id="url-input" placeholder="Paste any URL to downloadβ¦" autocomplete="off"/> | |
| <button class="dl-btn" id="dl-btn" onclick="startDownload()">Download</button> | |
| </div> | |
| </div> | |
| <!-- Active Downloads --> | |
| <div id="active-section" style="margin-bottom:32px;"> | |
| <div class="section-head"> | |
| <div class="section-label">Active Downloads</div> | |
| <div class="section-count" id="active-count">0</div> | |
| </div> | |
| <div id="active-list"><div class="empty"><div class="empty-icon">π‘</div>No active downloads</div></div> | |
| </div> | |
| <hr class="history-sep"/> | |
| <!-- History --> | |
| <div id="history-section"> | |
| <div class="section-head"> | |
| <div class="section-label">All Downloads</div> | |
| <div class="section-count" id="hist-count">0</div> | |
| </div> | |
| <div id="history-list"><div class="empty"><div class="empty-icon">π</div>No download history</div></div> | |
| </div> | |
| </div> | |
| <script> | |
| // ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const POLL_INTERVAL = 900; // ms between full refresh | |
| let allTasks = []; // latest snapshot from server | |
| let pollTimer = null; | |
| let activePolling = new Set(); // task_ids currently downloading | |
| // ββ Boot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| (async function init() { | |
| await refreshAll(); | |
| startPolling(); | |
| })(); | |
| function startPolling() { | |
| if (pollTimer) return; | |
| pollTimer = setInterval(async () => { | |
| if (activePolling.size > 0) { | |
| await refreshAll(); | |
| } | |
| }, POLL_INTERVAL); | |
| } | |
| // ββ Full refresh ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function refreshAll() { | |
| try { | |
| const res = await fetch('/tasks/all'); | |
| allTasks = await res.json(); | |
| activePolling.clear(); | |
| allTasks.forEach(t => { | |
| if (t.status === 'downloading' || t.status === 'queued') { | |
| activePolling.add(t.task_id); | |
| } | |
| }); | |
| renderActive(); | |
| renderHistory(); | |
| } catch(e) {} | |
| } | |
| // ββ Render helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function renderActive() { | |
| const active = allTasks.filter(t => t.status === 'downloading' || t.status === 'queued'); | |
| document.getElementById('active-count').textContent = active.length; | |
| const container = document.getElementById('active-list'); | |
| if (!active.length) { | |
| container.innerHTML = '<div class="empty"><div class="empty-icon">π‘</div>No active downloads</div>'; | |
| return; | |
| } | |
| container.innerHTML = active.map(t => buildCard(t, true)).join(''); | |
| } | |
| function renderHistory() { | |
| const hist = allTasks.filter(t => t.status !== 'downloading' && t.status !== 'queued'); | |
| document.getElementById('hist-count').textContent = hist.length; | |
| const container = document.getElementById('history-list'); | |
| if (!hist.length) { | |
| container.innerHTML = '<div class="empty"><div class="empty-icon">π</div>No download history</div>'; | |
| return; | |
| } | |
| container.innerHTML = hist.map(t => buildCard(t, false)).join(''); | |
| } | |
| function buildCard(t, isActive) { | |
| const badgeClass = { | |
| downloading: 'badge-downloading', | |
| done: 'badge-done', | |
| error: 'badge-error', | |
| interrupted: 'badge-interrupted', | |
| queued: 'badge-queued', | |
| }[t.status] || 'badge-queued'; | |
| const badgeLabel = { | |
| downloading: 'DOWNLOADING', | |
| done: 'DONE', | |
| error: 'ERROR', | |
| interrupted: 'INTERRUPTED', | |
| queued: 'QUEUED', | |
| }[t.status] || t.status.toUpperCase(); | |
| const fillClass = { | |
| downloading: 'downloading', | |
| done: 'done', | |
| error: 'error', | |
| interrupted: 'interrupted', | |
| }[t.status] || ''; | |
| const pct = t.progress || 0; | |
| const displayPct = t.status === 'done' ? 100 : pct; | |
| const shortUrl = t.url.length > 60 ? t.url.slice(0, 57) + 'β¦' : t.url; | |
| let metaHtml = ` | |
| <span>π¦ <span class="val">${t.size || 'β'}</span></span> | |
| <span class="pct">${displayPct}%</span> | |
| `; | |
| if (t.status === 'downloading') { | |
| metaHtml += ` | |
| <span>β‘ <span class="val">${t.speed || 'β'}</span></span> | |
| <span>β± ETA: <span class="val">${t.eta || 'β'}</span></span> | |
| `; | |
| } | |
| if (t.created_at) { | |
| metaHtml += `<span>${t.created_at}</span>`; | |
| } | |
| let actionsHtml = ''; | |
| if (t.status === 'done') { | |
| actionsHtml = `<a class="btn-sm primary" href="/dl/${t.task_id}" download>β¬ Save File</a>`; | |
| } else if (t.status === 'error' || t.status === 'interrupted') { | |
| actionsHtml = `<button class="btn-sm retry" onclick="retryDownload('${esc(t.task_id)}','${esc(t.url)}')">βΊ Retry</button>`; | |
| } | |
| let errorHtml = ''; | |
| if (t.error) { | |
| errorHtml = `<div class="error-msg">β ${esc(t.error)}</div>`; | |
| } | |
| return ` | |
| <div class="dl-card status-${t.status}" id="card-${t.task_id}"> | |
| <div class="dl-header"> | |
| <div> | |
| <div class="dl-filename">${fileIcon(t.filename)} ${esc(t.filename)}</div> | |
| <div class="dl-url" title="${esc(t.url)}">${esc(shortUrl)}</div> | |
| </div> | |
| <span class="badge ${badgeClass}">${badgeLabel}</span> | |
| </div> | |
| <div class="progress-track"> | |
| <div class="progress-fill ${fillClass}" style="width:${displayPct}%"></div> | |
| </div> | |
| <div class="dl-meta">${metaHtml}</div> | |
| ${errorHtml} | |
| ${actionsHtml ? `<div class="dl-actions">${actionsHtml}</div>` : ''} | |
| </div> | |
| `; | |
| } | |
| // ββ Download βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function startDownload() { | |
| const input = document.getElementById('url-input'); | |
| const btn = document.getElementById('dl-btn'); | |
| const url = input.value.trim(); | |
| if (!url) return; | |
| btn.disabled = true; | |
| btn.textContent = 'Startingβ¦'; | |
| try { | |
| const res = await fetch('/download', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({url}), | |
| }); | |
| const data = await res.json(); | |
| if (data.error) { showToast('Error: ' + data.error, true); return; } | |
| input.value = ''; | |
| showToast('Download started!'); | |
| activePolling.add(data.task_id); | |
| await refreshAll(); | |
| } catch(e) { | |
| showToast('Request failed', true); | |
| } finally { | |
| btn.disabled = false; | |
| btn.textContent = 'Download'; | |
| } | |
| } | |
| async function retryDownload(taskId, url) { | |
| showToast('Retryingβ¦'); | |
| // Reuse startDownload logic via POST | |
| const input = document.getElementById('url-input'); | |
| input.value = url; | |
| await startDownload(); | |
| } | |
| // ββ Utils βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.getElementById('url-input').addEventListener('keydown', e => { | |
| if (e.key === 'Enter') startDownload(); | |
| }); | |
| function esc(str) { | |
| return String(str||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); | |
| } | |
| function fileIcon(name) { | |
| const ext = (name||'').split('.').pop().toLowerCase(); | |
| if (['mp4','mkv','avi','mov','webm'].includes(ext)) return 'π¬'; | |
| if (['mp3','wav','flac','ogg','m4a'].includes(ext)) return 'π΅'; | |
| if (['jpg','jpeg','png','gif','webp','svg'].includes(ext)) return 'πΌ'; | |
| if (['zip','tar','gz','rar','7z','bz2'].includes(ext)) return 'π¦'; | |
| if (['pdf'].includes(ext)) return 'π'; | |
| if (['exe','dmg','deb','apk','msi'].includes(ext)) return 'β'; | |
| if (['py','js','ts','html','css','json','sh'].includes(ext)) return 'π»'; | |
| return 'π'; | |
| } | |
| function showToast(msg, isErr = false) { | |
| const t = document.createElement('div'); | |
| t.className = 'toast' + (isErr ? ' err' : ''); | |
| t.textContent = msg; | |
| document.body.appendChild(t); | |
| setTimeout(() => t.remove(), 3500); | |
| } | |
| // Immediately poll if tab becomes visible again | |
| document.addEventListener('visibilitychange', () => { | |
| if (!document.hidden) refreshAll(); | |
| }); | |
| </script> | |
| </body> | |
| </html>""" | |
| # βββ Entry ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| init_db() | |
| db_resume_incomplete() | |
| print("β‘ SwiftLoad running at http://localhost:7860") | |
| app.run(debug=False, host="0.0.0.0", port=7860, threaded=True) |