soxogvv commited on
Commit
6cd9ae8
Β·
verified Β·
1 Parent(s): 6467e80

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +132 -198
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, Response, stream_with_context
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({**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,138 +688,125 @@ input[type="text"]::placeholder{color:var(--muted);}
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,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:${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,19 +855,23 @@ async function startDownload() {
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,13 +883,19 @@ function fileIcon(name) {
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>"""
 
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,'&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
  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>"""