| 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") |
|
|
| |
| _live_cache = {} |
| _cache_lock = threading.Lock() |
|
|
|
|
| |
|
|
| 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() |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| 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)) |
|
|
|
|
| |
|
|
| @app.route("/") |
| def index(): |
| return render_template_string(HTML) |
|
|
|
|
| @app.route("/download", methods=["POST"]) |
| 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}) |
|
|
|
|
| @app.route("/progress/<task_id>") |
| 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 "β", |
| }) |
|
|
|
|
| @app.route("/tasks/all") |
| 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) |
|
|
|
|
| @app.route("/dl/<task_id>") |
| 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 = 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>""" |
|
|
|
|
| |
|
|
| 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) |