Update app.py
Browse files
app.py
CHANGED
|
@@ -3,13 +3,12 @@ import re
|
|
| 3 |
import uuid
|
| 4 |
import json
|
| 5 |
import time
|
| 6 |
-
import queue
|
| 7 |
import threading
|
| 8 |
import subprocess
|
| 9 |
import sqlite3
|
| 10 |
from pathlib import Path
|
| 11 |
from urllib.parse import urlparse, unquote
|
| 12 |
-
from flask import Flask, request, jsonify, send_from_directory, render_template_string
|
| 13 |
|
| 14 |
app = Flask(__name__)
|
| 15 |
|
|
@@ -21,24 +20,6 @@ DB_FILE = Path("swiftload.db")
|
|
| 21 |
_live_cache = {}
|
| 22 |
_cache_lock = threading.Lock()
|
| 23 |
|
| 24 |
-
# SSE subscriber queues β each connected client gets one queue
|
| 25 |
-
_sse_subscribers: list[queue.Queue] = []
|
| 26 |
-
_sse_lock = threading.Lock()
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def sse_broadcast(event_type: str, data: dict):
|
| 30 |
-
"""Push an SSE event to all connected clients. Dead queues are pruned."""
|
| 31 |
-
msg = f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
| 32 |
-
with _sse_lock:
|
| 33 |
-
alive = []
|
| 34 |
-
for q in _sse_subscribers:
|
| 35 |
-
try:
|
| 36 |
-
q.put_nowait(msg)
|
| 37 |
-
alive.append(q)
|
| 38 |
-
except queue.Full:
|
| 39 |
-
pass # client too slow β drop it
|
| 40 |
-
_sse_subscribers[:] = alive
|
| 41 |
-
|
| 42 |
|
| 43 |
# βββ Database ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 44 |
|
|
@@ -81,10 +62,6 @@ def db_upsert_task(task_id, url, filename, save_path, status="queued", progress=
|
|
| 81 |
updated_at=excluded.updated_at
|
| 82 |
""", (task_id, url, filename, str(save_path), status, progress, size, error, now, now))
|
| 83 |
conn.commit()
|
| 84 |
-
# Notify all open clients about the new task immediately
|
| 85 |
-
task = db_get_task(task_id)
|
| 86 |
-
if task:
|
| 87 |
-
sse_broadcast("task_update", {**task, "speed": "β", "eta": "β"})
|
| 88 |
|
| 89 |
|
| 90 |
def db_update_task(task_id, **kwargs):
|
|
@@ -96,15 +73,6 @@ def db_update_task(task_id, **kwargs):
|
|
| 96 |
with get_db() as conn:
|
| 97 |
conn.execute(f"UPDATE tasks SET {set_clause} WHERE task_id=?", values)
|
| 98 |
conn.commit()
|
| 99 |
-
# Push live update to all SSE clients β no client polling needed
|
| 100 |
-
task = db_get_task(task_id)
|
| 101 |
-
if task:
|
| 102 |
-
with _cache_lock:
|
| 103 |
-
live = _live_cache.get(task_id, {})
|
| 104 |
-
sse_broadcast("task_update", {**task,
|
| 105 |
-
"speed": live.get("speed", "β") if task["status"] == "downloading" else "β",
|
| 106 |
-
"eta": live.get("eta", "β") if task["status"] == "downloading" else "β",
|
| 107 |
-
})
|
| 108 |
|
| 109 |
|
| 110 |
def db_get_task(task_id):
|
|
@@ -206,16 +174,6 @@ def download_with_wget(task_id, url, save_path):
|
|
| 206 |
_live_cache[task_id]["speed"] = speed_match.group(1)
|
| 207 |
if eta_match:
|
| 208 |
_live_cache[task_id]["eta"] = eta_match.group(1)
|
| 209 |
-
live_snap = dict(_live_cache.get(task_id, {}))
|
| 210 |
-
|
| 211 |
-
# Push speed/eta live (no DB write needed for these)
|
| 212 |
-
if speed_match or eta_match:
|
| 213 |
-
task = db_get_task(task_id)
|
| 214 |
-
if task:
|
| 215 |
-
sse_broadcast("task_update", {**task,
|
| 216 |
-
"speed": live_snap.get("speed", "β"),
|
| 217 |
-
"eta": live_snap.get("eta", "β"),
|
| 218 |
-
})
|
| 219 |
|
| 220 |
process.wait()
|
| 221 |
|
|
@@ -297,46 +255,21 @@ def all_tasks():
|
|
| 297 |
for t in tasks:
|
| 298 |
with _cache_lock:
|
| 299 |
live = _live_cache.get(t["task_id"], {})
|
| 300 |
-
result.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
"speed": live.get("speed", "β") if t["status"] == "downloading" else "β",
|
| 302 |
-
"eta":
|
| 303 |
})
|
| 304 |
return jsonify(result)
|
| 305 |
|
| 306 |
|
| 307 |
-
@app.route("/stream")
|
| 308 |
-
def sse_stream():
|
| 309 |
-
"""Server-Sent Events endpoint. Clients connect once; server pushes all updates."""
|
| 310 |
-
q: queue.Queue = queue.Queue(maxsize=64)
|
| 311 |
-
with _sse_lock:
|
| 312 |
-
_sse_subscribers.append(q)
|
| 313 |
-
|
| 314 |
-
def generate():
|
| 315 |
-
# Immediately send full current state so the client is in sync
|
| 316 |
-
tasks = db_all_tasks()
|
| 317 |
-
for t in tasks:
|
| 318 |
-
with _cache_lock:
|
| 319 |
-
live = _live_cache.get(t["task_id"], {})
|
| 320 |
-
payload = {**t,
|
| 321 |
-
"speed": live.get("speed", "β") if t["status"] == "downloading" else "β",
|
| 322 |
-
"eta": live.get("eta", "β") if t["status"] == "downloading" else "β",
|
| 323 |
-
}
|
| 324 |
-
yield f"event: task_update\ndata: {json.dumps(payload)}\n\n"
|
| 325 |
-
|
| 326 |
-
# Then stream subsequent events as they arrive
|
| 327 |
-
while True:
|
| 328 |
-
try:
|
| 329 |
-
msg = q.get(timeout=25) # 25s timeout β send keepalive
|
| 330 |
-
yield msg
|
| 331 |
-
except queue.Empty:
|
| 332 |
-
yield ": keepalive\n\n" # prevents proxy/browser timeout
|
| 333 |
-
|
| 334 |
-
resp = Response(stream_with_context(generate()), mimetype="text/event-stream")
|
| 335 |
-
resp.headers["Cache-Control"] = "no-cache"
|
| 336 |
-
resp.headers["X-Accel-Buffering"] = "no"
|
| 337 |
-
return resp
|
| 338 |
-
|
| 339 |
-
|
| 340 |
@app.route("/dl/<task_id>")
|
| 341 |
def download_file(task_id):
|
| 342 |
task = db_get_task(task_id)
|
|
@@ -755,138 +688,125 @@ input[type="text"]::placeholder{color:var(--muted);}
|
|
| 755 |
</div>
|
| 756 |
|
| 757 |
<script>
|
| 758 |
-
// ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
let
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 767 |
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
|
|
|
| 773 |
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
setTimeout(connectSSE, 3000);
|
| 778 |
-
};
|
| 779 |
}
|
| 780 |
|
| 781 |
-
// ββ
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
// Reconnect when tab regains visibility (SSE may have been killed by browser)
|
| 785 |
-
document.addEventListener('visibilitychange', () => {
|
| 786 |
-
if (!document.hidden && (!evtSource || evtSource.readyState === 2)) {
|
| 787 |
-
connectSSE();
|
| 788 |
-
}
|
| 789 |
-
});
|
| 790 |
-
|
| 791 |
-
// ββ Render ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 792 |
-
function renderAll() {
|
| 793 |
-
const all = Object.values(taskMap).sort((a,b) => b.created_at.localeCompare(a.created_at));
|
| 794 |
-
const active = all.filter(t => t.status === 'downloading' || t.status === 'queued');
|
| 795 |
-
const hist = all.filter(t => t.status !== 'downloading' && t.status !== 'queued');
|
| 796 |
-
|
| 797 |
document.getElementById('active-count').textContent = active.length;
|
| 798 |
-
document.getElementById('
|
| 799 |
-
|
| 800 |
-
const activeEl = document.getElementById('active-list');
|
| 801 |
-
const histEl = document.getElementById('history-list');
|
| 802 |
-
|
| 803 |
if (!active.length) {
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
// Update cards in-place when possible to avoid flicker
|
| 807 |
-
active.forEach(t => {
|
| 808 |
-
const existing = document.getElementById(`card-${t.task_id}`);
|
| 809 |
-
if (existing) {
|
| 810 |
-
updateCardInPlace(existing, t);
|
| 811 |
-
} else {
|
| 812 |
-
activeEl.innerHTML = active.map(buildCard).join('');
|
| 813 |
-
}
|
| 814 |
-
});
|
| 815 |
-
// Remove stale cards
|
| 816 |
-
activeEl.querySelectorAll('[id^="card-"]').forEach(el => {
|
| 817 |
-
const id = el.id.replace('card-', '');
|
| 818 |
-
if (!active.find(t => t.task_id === id)) el.remove();
|
| 819 |
-
});
|
| 820 |
}
|
| 821 |
-
|
| 822 |
-
histEl.innerHTML = hist.length
|
| 823 |
-
? hist.map(buildCard).join('')
|
| 824 |
-
: '<div class="empty"><div class="empty-icon">π</div>No download history</div>';
|
| 825 |
}
|
| 826 |
|
| 827 |
-
function
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
const
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
fill.style.width = pct + '%';
|
| 835 |
-
fill.className = 'progress-fill ' + (t.status === 'downloading' ? 'downloading' : t.status === 'done' ? 'done' : t.status === 'error' ? 'error' : 'interrupted');
|
| 836 |
-
}
|
| 837 |
-
if (badge) {
|
| 838 |
-
const labels = {downloading:'DOWNLOADING',done:'DONE',error:'ERROR',interrupted:'INTERRUPTED',queued:'QUEUED'};
|
| 839 |
-
const classes = {downloading:'badge-downloading',done:'badge-done',error:'badge-error',interrupted:'badge-interrupted',queued:'badge-queued'};
|
| 840 |
-
badge.textContent = labels[t.status] || t.status.toUpperCase();
|
| 841 |
-
badge.className = 'badge ' + (classes[t.status] || 'badge-queued');
|
| 842 |
-
}
|
| 843 |
-
|
| 844 |
-
const speedEl = el.querySelector('.speed-val');
|
| 845 |
-
const etaEl = el.querySelector('.eta-val');
|
| 846 |
-
const pctEl = el.querySelector('.pct-val');
|
| 847 |
-
const sizeEl = el.querySelector('.size-val');
|
| 848 |
-
if (speedEl) speedEl.textContent = t.speed || 'β';
|
| 849 |
-
if (etaEl) etaEl.textContent = t.eta || 'β';
|
| 850 |
-
if (pctEl) pctEl.textContent = pct + '%';
|
| 851 |
-
if (sizeEl && t.size) sizeEl.textContent = t.size;
|
| 852 |
-
|
| 853 |
-
// If status changed to done/error, refresh whole card to show actions
|
| 854 |
-
const oldStatus = el.dataset.status;
|
| 855 |
-
if (oldStatus !== t.status) {
|
| 856 |
-
el.outerHTML = buildCard(t);
|
| 857 |
-
} else {
|
| 858 |
-
el.dataset.status = t.status;
|
| 859 |
}
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
const
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 868 |
|
| 869 |
let metaHtml = `
|
| 870 |
-
<span>π¦ <span class="val
|
| 871 |
-
<span class="pct
|
| 872 |
`;
|
| 873 |
if (t.status === 'downloading') {
|
| 874 |
metaHtml += `
|
| 875 |
-
<span>β‘ <span class="val
|
| 876 |
-
<span>β± <span class="val
|
| 877 |
`;
|
| 878 |
}
|
| 879 |
-
if (t.created_at)
|
|
|
|
|
|
|
| 880 |
|
| 881 |
let actionsHtml = '';
|
| 882 |
if (t.status === 'done') {
|
| 883 |
actionsHtml = `<a class="btn-sm primary" href="/dl/${t.task_id}" download>β¬ Save File</a>`;
|
| 884 |
} else if (t.status === 'error' || t.status === 'interrupted') {
|
| 885 |
-
actionsHtml = `<button class="btn-sm retry" onclick="retryDownload('${esc(t.url)}')">βΊ Retry</button>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 886 |
}
|
| 887 |
|
| 888 |
return `
|
| 889 |
-
<div class="dl-card status-${t.status}" id="card-${t.task_id}"
|
| 890 |
<div class="dl-header">
|
| 891 |
<div>
|
| 892 |
<div class="dl-filename">${fileIcon(t.filename)} ${esc(t.filename)}</div>
|
|
@@ -895,34 +815,38 @@ function buildCard(t) {
|
|
| 895 |
<span class="badge ${badgeClass}">${badgeLabel}</span>
|
| 896 |
</div>
|
| 897 |
<div class="progress-track">
|
| 898 |
-
<div class="progress-fill ${fillClass}" style="width:${
|
| 899 |
</div>
|
| 900 |
<div class="dl-meta">${metaHtml}</div>
|
| 901 |
-
${
|
| 902 |
${actionsHtml ? `<div class="dl-actions">${actionsHtml}</div>` : ''}
|
| 903 |
-
</div>
|
|
|
|
| 904 |
}
|
| 905 |
|
| 906 |
-
// ββ Download
|
| 907 |
async function startDownload() {
|
| 908 |
const input = document.getElementById('url-input');
|
| 909 |
-
const btn
|
| 910 |
-
const url
|
| 911 |
if (!url) return;
|
| 912 |
|
| 913 |
btn.disabled = true;
|
| 914 |
btn.textContent = 'Startingβ¦';
|
|
|
|
| 915 |
try {
|
| 916 |
-
const res
|
| 917 |
method: 'POST',
|
| 918 |
-
headers: {'Content-Type':'application/json'},
|
| 919 |
body: JSON.stringify({url}),
|
| 920 |
});
|
| 921 |
const data = await res.json();
|
| 922 |
if (data.error) { showToast('Error: ' + data.error, true); return; }
|
|
|
|
| 923 |
input.value = '';
|
| 924 |
showToast('Download started!');
|
| 925 |
-
|
|
|
|
| 926 |
} catch(e) {
|
| 927 |
showToast('Request failed', true);
|
| 928 |
} finally {
|
|
@@ -931,19 +855,23 @@ async function startDownload() {
|
|
| 931 |
}
|
| 932 |
}
|
| 933 |
|
| 934 |
-
async function retryDownload(url) {
|
| 935 |
-
|
|
|
|
|
|
|
|
|
|
| 936 |
await startDownload();
|
| 937 |
}
|
| 938 |
|
|
|
|
| 939 |
document.getElementById('url-input').addEventListener('keydown', e => {
|
| 940 |
if (e.key === 'Enter') startDownload();
|
| 941 |
});
|
| 942 |
|
| 943 |
-
// ββ Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 944 |
function esc(str) {
|
| 945 |
return String(str||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
| 946 |
}
|
|
|
|
| 947 |
function fileIcon(name) {
|
| 948 |
const ext = (name||'').split('.').pop().toLowerCase();
|
| 949 |
if (['mp4','mkv','avi','mov','webm'].includes(ext)) return 'π¬';
|
|
@@ -955,13 +883,19 @@ function fileIcon(name) {
|
|
| 955 |
if (['py','js','ts','html','css','json','sh'].includes(ext)) return 'π»';
|
| 956 |
return 'π';
|
| 957 |
}
|
| 958 |
-
|
|
|
|
| 959 |
const t = document.createElement('div');
|
| 960 |
t.className = 'toast' + (isErr ? ' err' : '');
|
| 961 |
t.textContent = msg;
|
| 962 |
document.body.appendChild(t);
|
| 963 |
setTimeout(() => t.remove(), 3500);
|
| 964 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 965 |
</script>
|
| 966 |
</body>
|
| 967 |
</html>"""
|
|
|
|
| 3 |
import uuid
|
| 4 |
import json
|
| 5 |
import time
|
|
|
|
| 6 |
import threading
|
| 7 |
import subprocess
|
| 8 |
import sqlite3
|
| 9 |
from pathlib import Path
|
| 10 |
from urllib.parse import urlparse, unquote
|
| 11 |
+
from flask import Flask, request, jsonify, send_from_directory, render_template_string
|
| 12 |
|
| 13 |
app = Flask(__name__)
|
| 14 |
|
|
|
|
| 20 |
_live_cache = {}
|
| 21 |
_cache_lock = threading.Lock()
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
# βββ Database ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
|
|
|
|
| 62 |
updated_at=excluded.updated_at
|
| 63 |
""", (task_id, url, filename, str(save_path), status, progress, size, error, now, now))
|
| 64 |
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def db_update_task(task_id, **kwargs):
|
|
|
|
| 73 |
with get_db() as conn:
|
| 74 |
conn.execute(f"UPDATE tasks SET {set_clause} WHERE task_id=?", values)
|
| 75 |
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
|
| 78 |
def db_get_task(task_id):
|
|
|
|
| 174 |
_live_cache[task_id]["speed"] = speed_match.group(1)
|
| 175 |
if eta_match:
|
| 176 |
_live_cache[task_id]["eta"] = eta_match.group(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
process.wait()
|
| 179 |
|
|
|
|
| 255 |
for t in tasks:
|
| 256 |
with _cache_lock:
|
| 257 |
live = _live_cache.get(t["task_id"], {})
|
| 258 |
+
result.append({
|
| 259 |
+
"task_id": t["task_id"],
|
| 260 |
+
"url": t["url"],
|
| 261 |
+
"filename": t["filename"],
|
| 262 |
+
"status": t["status"],
|
| 263 |
+
"progress": t["progress"],
|
| 264 |
+
"size": t["size"],
|
| 265 |
+
"error": t["error"],
|
| 266 |
+
"created_at": t["created_at"],
|
| 267 |
"speed": live.get("speed", "β") if t["status"] == "downloading" else "β",
|
| 268 |
+
"eta": live.get("eta", "β") if t["status"] == "downloading" else "β",
|
| 269 |
})
|
| 270 |
return jsonify(result)
|
| 271 |
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
@app.route("/dl/<task_id>")
|
| 274 |
def download_file(task_id):
|
| 275 |
task = db_get_task(task_id)
|
|
|
|
| 688 |
</div>
|
| 689 |
|
| 690 |
<script>
|
| 691 |
+
// ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 692 |
+
const POLL_INTERVAL = 900; // ms between full refresh
|
| 693 |
+
let allTasks = []; // latest snapshot from server
|
| 694 |
+
let pollTimer = null;
|
| 695 |
+
let activePolling = new Set(); // task_ids currently downloading
|
| 696 |
+
|
| 697 |
+
// ββ Boot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 698 |
+
(async function init() {
|
| 699 |
+
await refreshAll();
|
| 700 |
+
startPolling();
|
| 701 |
+
})();
|
| 702 |
+
|
| 703 |
+
function startPolling() {
|
| 704 |
+
if (pollTimer) return;
|
| 705 |
+
pollTimer = setInterval(async () => {
|
| 706 |
+
if (activePolling.size > 0) {
|
| 707 |
+
await refreshAll();
|
| 708 |
+
}
|
| 709 |
+
}, POLL_INTERVAL);
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
// ββ Full refresh ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 713 |
+
async function refreshAll() {
|
| 714 |
+
try {
|
| 715 |
+
const res = await fetch('/tasks/all');
|
| 716 |
+
allTasks = await res.json();
|
| 717 |
|
| 718 |
+
activePolling.clear();
|
| 719 |
+
allTasks.forEach(t => {
|
| 720 |
+
if (t.status === 'downloading' || t.status === 'queued') {
|
| 721 |
+
activePolling.add(t.task_id);
|
| 722 |
+
}
|
| 723 |
+
});
|
| 724 |
|
| 725 |
+
renderActive();
|
| 726 |
+
renderHistory();
|
| 727 |
+
} catch(e) {}
|
|
|
|
|
|
|
| 728 |
}
|
| 729 |
|
| 730 |
+
// ββ Render helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 731 |
+
function renderActive() {
|
| 732 |
+
const active = allTasks.filter(t => t.status === 'downloading' || t.status === 'queued');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 733 |
document.getElementById('active-count').textContent = active.length;
|
| 734 |
+
const container = document.getElementById('active-list');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
if (!active.length) {
|
| 736 |
+
container.innerHTML = '<div class="empty"><div class="empty-icon">π‘</div>No active downloads</div>';
|
| 737 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 738 |
}
|
| 739 |
+
container.innerHTML = active.map(t => buildCard(t, true)).join('');
|
|
|
|
|
|
|
|
|
|
| 740 |
}
|
| 741 |
|
| 742 |
+
function renderHistory() {
|
| 743 |
+
const hist = allTasks.filter(t => t.status !== 'downloading' && t.status !== 'queued');
|
| 744 |
+
document.getElementById('hist-count').textContent = hist.length;
|
| 745 |
+
const container = document.getElementById('history-list');
|
| 746 |
+
if (!hist.length) {
|
| 747 |
+
container.innerHTML = '<div class="empty"><div class="empty-icon">π</div>No download history</div>';
|
| 748 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
}
|
| 750 |
+
container.innerHTML = hist.map(t => buildCard(t, false)).join('');
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
function buildCard(t, isActive) {
|
| 754 |
+
const badgeClass = {
|
| 755 |
+
downloading: 'badge-downloading',
|
| 756 |
+
done: 'badge-done',
|
| 757 |
+
error: 'badge-error',
|
| 758 |
+
interrupted: 'badge-interrupted',
|
| 759 |
+
queued: 'badge-queued',
|
| 760 |
+
}[t.status] || 'badge-queued';
|
| 761 |
+
|
| 762 |
+
const badgeLabel = {
|
| 763 |
+
downloading: 'DOWNLOADING',
|
| 764 |
+
done: 'DONE',
|
| 765 |
+
error: 'ERROR',
|
| 766 |
+
interrupted: 'INTERRUPTED',
|
| 767 |
+
queued: 'QUEUED',
|
| 768 |
+
}[t.status] || t.status.toUpperCase();
|
| 769 |
+
|
| 770 |
+
const fillClass = {
|
| 771 |
+
downloading: 'downloading',
|
| 772 |
+
done: 'done',
|
| 773 |
+
error: 'error',
|
| 774 |
+
interrupted: 'interrupted',
|
| 775 |
+
}[t.status] || '';
|
| 776 |
+
|
| 777 |
+
const pct = t.progress || 0;
|
| 778 |
+
const displayPct = t.status === 'done' ? 100 : pct;
|
| 779 |
+
|
| 780 |
+
const shortUrl = t.url.length > 60 ? t.url.slice(0, 57) + 'β¦' : t.url;
|
| 781 |
|
| 782 |
let metaHtml = `
|
| 783 |
+
<span>π¦ <span class="val">${t.size || 'β'}</span></span>
|
| 784 |
+
<span class="pct">${displayPct}%</span>
|
| 785 |
`;
|
| 786 |
if (t.status === 'downloading') {
|
| 787 |
metaHtml += `
|
| 788 |
+
<span>β‘ <span class="val">${t.speed || 'β'}</span></span>
|
| 789 |
+
<span>β± ETA: <span class="val">${t.eta || 'β'}</span></span>
|
| 790 |
`;
|
| 791 |
}
|
| 792 |
+
if (t.created_at) {
|
| 793 |
+
metaHtml += `<span>${t.created_at}</span>`;
|
| 794 |
+
}
|
| 795 |
|
| 796 |
let actionsHtml = '';
|
| 797 |
if (t.status === 'done') {
|
| 798 |
actionsHtml = `<a class="btn-sm primary" href="/dl/${t.task_id}" download>β¬ Save File</a>`;
|
| 799 |
} else if (t.status === 'error' || t.status === 'interrupted') {
|
| 800 |
+
actionsHtml = `<button class="btn-sm retry" onclick="retryDownload('${esc(t.task_id)}','${esc(t.url)}')">βΊ Retry</button>`;
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
let errorHtml = '';
|
| 804 |
+
if (t.error) {
|
| 805 |
+
errorHtml = `<div class="error-msg">β ${esc(t.error)}</div>`;
|
| 806 |
}
|
| 807 |
|
| 808 |
return `
|
| 809 |
+
<div class="dl-card status-${t.status}" id="card-${t.task_id}">
|
| 810 |
<div class="dl-header">
|
| 811 |
<div>
|
| 812 |
<div class="dl-filename">${fileIcon(t.filename)} ${esc(t.filename)}</div>
|
|
|
|
| 815 |
<span class="badge ${badgeClass}">${badgeLabel}</span>
|
| 816 |
</div>
|
| 817 |
<div class="progress-track">
|
| 818 |
+
<div class="progress-fill ${fillClass}" style="width:${displayPct}%"></div>
|
| 819 |
</div>
|
| 820 |
<div class="dl-meta">${metaHtml}</div>
|
| 821 |
+
${errorHtml}
|
| 822 |
${actionsHtml ? `<div class="dl-actions">${actionsHtml}</div>` : ''}
|
| 823 |
+
</div>
|
| 824 |
+
`;
|
| 825 |
}
|
| 826 |
|
| 827 |
+
// ββ Download βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 828 |
async function startDownload() {
|
| 829 |
const input = document.getElementById('url-input');
|
| 830 |
+
const btn = document.getElementById('dl-btn');
|
| 831 |
+
const url = input.value.trim();
|
| 832 |
if (!url) return;
|
| 833 |
|
| 834 |
btn.disabled = true;
|
| 835 |
btn.textContent = 'Startingβ¦';
|
| 836 |
+
|
| 837 |
try {
|
| 838 |
+
const res = await fetch('/download', {
|
| 839 |
method: 'POST',
|
| 840 |
+
headers: {'Content-Type': 'application/json'},
|
| 841 |
body: JSON.stringify({url}),
|
| 842 |
});
|
| 843 |
const data = await res.json();
|
| 844 |
if (data.error) { showToast('Error: ' + data.error, true); return; }
|
| 845 |
+
|
| 846 |
input.value = '';
|
| 847 |
showToast('Download started!');
|
| 848 |
+
activePolling.add(data.task_id);
|
| 849 |
+
await refreshAll();
|
| 850 |
} catch(e) {
|
| 851 |
showToast('Request failed', true);
|
| 852 |
} finally {
|
|
|
|
| 855 |
}
|
| 856 |
}
|
| 857 |
|
| 858 |
+
async function retryDownload(taskId, url) {
|
| 859 |
+
showToast('Retryingβ¦');
|
| 860 |
+
// Reuse startDownload logic via POST
|
| 861 |
+
const input = document.getElementById('url-input');
|
| 862 |
+
input.value = url;
|
| 863 |
await startDownload();
|
| 864 |
}
|
| 865 |
|
| 866 |
+
// ββ Utils βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 867 |
document.getElementById('url-input').addEventListener('keydown', e => {
|
| 868 |
if (e.key === 'Enter') startDownload();
|
| 869 |
});
|
| 870 |
|
|
|
|
| 871 |
function esc(str) {
|
| 872 |
return String(str||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
| 873 |
}
|
| 874 |
+
|
| 875 |
function fileIcon(name) {
|
| 876 |
const ext = (name||'').split('.').pop().toLowerCase();
|
| 877 |
if (['mp4','mkv','avi','mov','webm'].includes(ext)) return 'π¬';
|
|
|
|
| 883 |
if (['py','js','ts','html','css','json','sh'].includes(ext)) return 'π»';
|
| 884 |
return 'π';
|
| 885 |
}
|
| 886 |
+
|
| 887 |
+
function showToast(msg, isErr = false) {
|
| 888 |
const t = document.createElement('div');
|
| 889 |
t.className = 'toast' + (isErr ? ' err' : '');
|
| 890 |
t.textContent = msg;
|
| 891 |
document.body.appendChild(t);
|
| 892 |
setTimeout(() => t.remove(), 3500);
|
| 893 |
}
|
| 894 |
+
|
| 895 |
+
// Immediately poll if tab becomes visible again
|
| 896 |
+
document.addEventListener('visibilitychange', () => {
|
| 897 |
+
if (!document.hidden) refreshAll();
|
| 898 |
+
});
|
| 899 |
</script>
|
| 900 |
</body>
|
| 901 |
</html>"""
|