Update app.py
Browse files
app.py
CHANGED
|
@@ -3,12 +3,13 @@ import re
|
|
| 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,6 +21,24 @@ DB_FILE = Path("swiftload.db")
|
|
| 20 |
_live_cache = {}
|
| 21 |
_cache_lock = threading.Lock()
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
# βββ Database ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
|
|
@@ -62,6 +81,10 @@ def db_upsert_task(task_id, url, filename, save_path, status="queued", progress=
|
|
| 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,6 +96,15 @@ 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,6 +206,16 @@ def download_with_wget(task_id, url, save_path):
|
|
| 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,21 +297,46 @@ def all_tasks():
|
|
| 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":
|
| 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,125 +755,138 @@ input[type="text"]::placeholder{color:var(--muted);}
|
|
| 688 |
</div>
|
| 689 |
|
| 690 |
<script>
|
| 691 |
-
// ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
let
|
| 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 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
}
|
| 723 |
-
});
|
| 724 |
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 728 |
}
|
| 729 |
|
| 730 |
-
// ββ
|
| 731 |
-
|
| 732 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 733 |
document.getElementById('active-count').textContent = active.length;
|
| 734 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
if (!active.length) {
|
| 736 |
-
|
| 737 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 738 |
}
|
| 739 |
-
|
|
|
|
|
|
|
|
|
|
| 740 |
}
|
| 741 |
|
| 742 |
-
function
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
const
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
}
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
const
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 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">${
|
| 785 |
`;
|
| 786 |
if (t.status === 'downloading') {
|
| 787 |
metaHtml += `
|
| 788 |
-
<span>β‘ <span class="val">${t.speed || '
|
| 789 |
-
<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.
|
| 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,38 +895,34 @@ function buildCard(t, isActive) {
|
|
| 815 |
<span class="badge ${badgeClass}">${badgeLabel}</span>
|
| 816 |
</div>
|
| 817 |
<div class="progress-track">
|
| 818 |
-
<div class="progress-fill ${fillClass}" style="width:${
|
| 819 |
</div>
|
| 820 |
<div class="dl-meta">${metaHtml}</div>
|
| 821 |
-
${
|
| 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
|
| 831 |
-
const url
|
| 832 |
if (!url) return;
|
| 833 |
|
| 834 |
btn.disabled = true;
|
| 835 |
btn.textContent = 'Startingβ¦';
|
| 836 |
-
|
| 837 |
try {
|
| 838 |
-
const res
|
| 839 |
method: 'POST',
|
| 840 |
-
headers: {'Content-Type':
|
| 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 |
-
|
| 849 |
-
await refreshAll();
|
| 850 |
} catch(e) {
|
| 851 |
showToast('Request failed', true);
|
| 852 |
} finally {
|
|
@@ -855,23 +931,19 @@ async function startDownload() {
|
|
| 855 |
}
|
| 856 |
}
|
| 857 |
|
| 858 |
-
async function retryDownload(
|
| 859 |
-
|
| 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,19 +955,13 @@ function fileIcon(name) {
|
|
| 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>"""
|
|
|
|
| 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, Response, stream_with_context
|
| 13 |
|
| 14 |
app = Flask(__name__)
|
| 15 |
|
|
|
|
| 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 |
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 |
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 |
_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 |
for t in tasks:
|
| 298 |
with _cache_lock:
|
| 299 |
live = _live_cache.get(t["task_id"], {})
|
| 300 |
+
result.append({**t,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
"speed": live.get("speed", "β") if t["status"] == "downloading" else "β",
|
| 302 |
+
"eta": live.get("eta", "β") if t["status"] == "downloading" else "β",
|
| 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 |
</div>
|
| 756 |
|
| 757 |
<script>
|
| 758 |
+
// ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 759 |
+
// taskMap: task_id β task object (latest snapshot from server)
|
| 760 |
+
const taskMap = {};
|
| 761 |
+
let evtSource = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 762 |
|
| 763 |
+
// ββ SSE Connection βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 764 |
+
function connectSSE() {
|
| 765 |
+
if (evtSource) evtSource.close();
|
| 766 |
+
evtSource = new EventSource('/stream');
|
|
|
|
|
|
|
| 767 |
|
| 768 |
+
evtSource.addEventListener('task_update', e => {
|
| 769 |
+
const task = JSON.parse(e.data);
|
| 770 |
+
taskMap[task.task_id] = task;
|
| 771 |
+
renderAll();
|
| 772 |
+
});
|
| 773 |
+
|
| 774 |
+
evtSource.onerror = () => {
|
| 775 |
+
// Connection dropped β reconnect after 3s
|
| 776 |
+
evtSource.close();
|
| 777 |
+
setTimeout(connectSSE, 3000);
|
| 778 |
+
};
|
| 779 |
}
|
| 780 |
|
| 781 |
+
// ββ Boot ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 782 |
+
connectSSE();
|
| 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('hist-count').textContent = hist.length;
|
| 799 |
+
|
| 800 |
+
const activeEl = document.getElementById('active-list');
|
| 801 |
+
const histEl = document.getElementById('history-list');
|
| 802 |
+
|
| 803 |
if (!active.length) {
|
| 804 |
+
activeEl.innerHTML = '<div class="empty"><div class="empty-icon">π‘</div>No active downloads</div>';
|
| 805 |
+
} else {
|
| 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 updateCardInPlace(el, t) {
|
| 828 |
+
// Only update fields that change during download β avoids full re-render flicker
|
| 829 |
+
const pct = t.status === 'done' ? 100 : (t.progress || 0);
|
| 830 |
+
const fill = el.querySelector('.progress-fill');
|
| 831 |
+
const badge = el.querySelector('.badge');
|
| 832 |
+
|
| 833 |
+
if (fill) {
|
| 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 |
+
function buildCard(t) {
|
| 863 |
+
const badgeClass = {downloading:'badge-downloading',done:'badge-done',error:'badge-error',interrupted:'badge-interrupted',queued:'badge-queued'}[t.status] || 'badge-queued';
|
| 864 |
+
const badgeLabel = {downloading:'DOWNLOADING',done:'DONE',error:'ERROR',interrupted:'INTERRUPTED',queued:'QUEUED'}[t.status] || t.status.toUpperCase();
|
| 865 |
+
const fillClass = {downloading:'downloading',done:'done',error:'error',interrupted:'interrupted'}[t.status] || '';
|
| 866 |
+
const pct = t.status === 'done' ? 100 : (t.progress || 0);
|
| 867 |
+
const shortUrl = t.url.length > 60 ? t.url.slice(0,57) + 'β¦' : t.url;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 868 |
|
| 869 |
let metaHtml = `
|
| 870 |
+
<span>π¦ <span class="val size-val">${t.size || 'β'}</span></span>
|
| 871 |
+
<span class="pct-val pct">${pct}%</span>
|
| 872 |
`;
|
| 873 |
if (t.status === 'downloading') {
|
| 874 |
metaHtml += `
|
| 875 |
+
<span>β‘ <span class="val speed-val">${t.speed || 'connectingβ¦'}</span></span>
|
| 876 |
+
<span>β± <span class="val eta-val">${t.eta || 'β'}</span></span>
|
| 877 |
`;
|
| 878 |
}
|
| 879 |
+
if (t.created_at) metaHtml += `<span class="ts">${t.created_at}</span>`;
|
|
|
|
|
|
|
| 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}" data-status="${t.status}">
|
| 890 |
<div class="dl-header">
|
| 891 |
<div>
|
| 892 |
<div class="dl-filename">${fileIcon(t.filename)} ${esc(t.filename)}</div>
|
|
|
|
| 895 |
<span class="badge ${badgeClass}">${badgeLabel}</span>
|
| 896 |
</div>
|
| 897 |
<div class="progress-track">
|
| 898 |
+
<div class="progress-fill ${fillClass}" style="width:${pct}%"></div>
|
| 899 |
</div>
|
| 900 |
<div class="dl-meta">${metaHtml}</div>
|
| 901 |
+
${t.error ? `<div class="error-msg">β ${esc(t.error)}</div>` : ''}
|
| 902 |
${actionsHtml ? `<div class="dl-actions">${actionsHtml}</div>` : ''}
|
| 903 |
+
</div>`;
|
|
|
|
| 904 |
}
|
| 905 |
|
| 906 |
+
// ββ Download action ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 907 |
async function startDownload() {
|
| 908 |
const input = document.getElementById('url-input');
|
| 909 |
+
const btn = document.getElementById('dl-btn');
|
| 910 |
+
const url = input.value.trim();
|
| 911 |
if (!url) return;
|
| 912 |
|
| 913 |
btn.disabled = true;
|
| 914 |
btn.textContent = 'Startingβ¦';
|
|
|
|
| 915 |
try {
|
| 916 |
+
const res = await fetch('/download', {
|
| 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 |
+
// SSE will push the new task automatically β no manual refresh needed
|
|
|
|
| 926 |
} catch(e) {
|
| 927 |
showToast('Request failed', true);
|
| 928 |
} finally {
|
|
|
|
| 931 |
}
|
| 932 |
}
|
| 933 |
|
| 934 |
+
async function retryDownload(url) {
|
| 935 |
+
document.getElementById('url-input').value = url;
|
|
|
|
|
|
|
|
|
|
| 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 |
if (['py','js','ts','html','css','json','sh'].includes(ext)) return 'π»';
|
| 956 |
return 'π';
|
| 957 |
}
|
| 958 |
+
function showToast(msg, isErr=false) {
|
|
|
|
| 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>"""
|