Wget / app.py
soxogvv's picture
Update app.py
a3fcf56 verified
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 ──────────────────────────────────────────────────────────────────
@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 ─────────────────────────────────────────────────────────────────────
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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)