soxogvv commited on
Commit
6467e80
Β·
verified Β·
1 Parent(s): 1211ac6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +198 -132
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": 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,125 +755,138 @@ input[type="text"]::placeholder{color:var(--muted);}
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,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:${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,23 +931,19 @@ async function startDownload() {
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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>"""