"use strict"; /* ============================================================ Transmission Manager - frontend Vanilla SPA. Auto-refreshes on a selectable interval with in-place updates. ============================================================ */ const DEFAULT_REFRESH_MS = 3000; const REFRESH_INTERVALS = new Set([1000, 3000, 5000, 10000]); const TRANSMISSION_STATUSES = { 0: "stopped", 1: "check-wait", 2: "checking", 3: "download-wait", 4: "downloading", 5: "seed-wait", 6: "seeding", }; const state = { torrents: [], stats: {}, filter: "", sortKey: "status", refreshMs: DEFAULT_REFRESH_MS, lastError: null, }; const torrentCards = new Map(); const actionStates = new Map(); let syncInFlight = false; let syncQueued = false; let refreshTimer = null; const $ = (sel) => document.querySelector(sel); /* ---------- Fetching ---------- */ async function fetchJSON(url, options = {}) { const res = await fetch(url, { cache: "no-store", ...options }); const text = await res.text(); let data = null; if (text) { try { data = JSON.parse(text); } catch { data = text; } } if (!res.ok) { const message = data && data.error ? data.error : `HTTP ${res.status} for ${url}`; throw new Error(message); } return data; } async function loadTorrents() { const data = await fetchJSON("/api/torrents"); state.torrents = (data && data.torrents) || []; } async function loadStats() { const data = await fetchJSON("/api/stats"); state.stats = data || {}; } async function sync() { if (syncInFlight) { syncQueued = true; return; } syncInFlight = true; setSyncing(true); try { await Promise.all([loadTorrents(), loadStats()]); state.lastError = null; } catch (err) { state.lastError = err.message || String(err); } finally { renderAll(); setSyncing(false); const updated = $("#last-updated"); if (updated) updated.textContent = `updated ${formatTime(new Date())}`; syncInFlight = false; } if (syncQueued) { syncQueued = false; sync(); } } function setSyncing(on) { const el = $("#refresh-indicator"); if (!el) return; el.classList.toggle("syncing", on); updateRefreshMetadata(on); } function refreshLabel() { return `${state.refreshMs / 1000}s`; } function updateRefreshMetadata(syncing = false) { const label = refreshLabel(); const indicator = $("#refresh-indicator"); if (indicator) { indicator.title = `Auto-refreshes every ${label}`; indicator.setAttribute( "aria-label", syncing ? `Syncing; auto-refresh every ${label}` : `Auto-refresh active every ${label}`, ); } const hint = $("#error-hint"); if (hint) hint.textContent = `Retrying in ${label}...`; } function setRefreshInterval(ms) { state.refreshMs = REFRESH_INTERVALS.has(ms) ? ms : DEFAULT_REFRESH_MS; updateRefreshMetadata(syncInFlight); if (refreshTimer) clearInterval(refreshTimer); refreshTimer = setInterval(sync, state.refreshMs); } async function runTorrentAction(id, action) { const pendingText = action === "pause" ? "Pausing..." : "Resuming..."; actionStates.set(id, { pending: action, error: null, pendingText }); updateCardByID(id); try { await fetchJSON(`/api/torrents/${id}/${action}`, { method: "POST" }); actionStates.delete(id); await sync(); } catch (err) { actionStates.set(id, { pending: null, error: err.message || String(err), pendingText: "", }); updateCardByID(id); } } /* ---------- Formatting helpers ---------- */ function humanSize(bytes) { if (bytes == null || isNaN(bytes) || bytes < 0) return "—"; if (bytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB", "PB"]; const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); const v = bytes / Math.pow(1024, i); const digits = i === 0 ? 0 : v < 10 ? 2 : v < 100 ? 1 : 0; return `${v.toFixed(digits)} ${units[i]}`; } function humanSpeed(bytesPerSec) { if (!bytesPerSec || bytesPerSec <= 0) return "0 kB/s"; return humanSize(bytesPerSec) + "/s"; } function humanRatio(ratio) { if (ratio == null || ratio < 0 || isNaN(ratio)) return "—"; if (ratio === -1) return "∞"; return ratio.toFixed(2); } function humanPct(p) { if (p == null || isNaN(p)) return 0; if (p < 0) return 0; if (p >= 1) return 1; return p; } function formatETA(sec) { if (sec == null || sec < 0 || isNaN(sec)) return "—"; if (sec === 0) return "—"; if (sec < 60) return `${sec}s`; const m = Math.floor(sec / 60); if (m < 60) return `${m}m ${sec % 60}s`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ${m % 60}m`; const d = Math.floor(h / 24); return `${d}d ${h % 24}h`; } function formatDate(unix) { if (!unix || unix <= 0) return "—"; const d = new Date(unix * 1000); if (isNaN(d.getTime())) return "—"; return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", }); } function formatTime(d) { return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } function peerStatus(t) { const peers = Array.isArray(t.peers) ? t.peers : []; const connected = t.peersConnected || peers.length || 0; const at100 = peers.filter((p) => p && p.progress === 1.0).length; if (connected === 0) return { connected: 0, at100: 0, label: "no peers" }; return { connected, at100, label: `${at100}/${connected} @ 100%`, }; } /* ---------- Torrent status classification ---------- */ function classify(t) { if (t.error && t.error !== 0) { return { key: "error", label: "Error", dot: true }; } const tr = TRANSMISSION_STATUSES[t.status]; switch (tr) { case "downloading": return { key: "downloading", label: "Downloading", dot: true }; case "seeding": return { key: "seeding", label: "Seeding", dot: true }; case "stopped": if (t.isFinished || (t.percentDone >= 1.0)) return { key: "finished", label: "Finished", dot: false }; return { key: "paused", label: "Paused", dot: false }; case "checking": return { key: "checking", label: "Verifying", dot: true }; case "check-wait": return { key: "queued", label: "Queued", dot: true }; case "download-wait": return { key: "queued", label: "Queued ↓", dot: true }; case "seed-wait": return { key: "queued", label: "Queued ↑", dot: true }; default: if (t.isFinished || (t.percentDone >= 1.0)) return { key: "finished", label: "Finished", dot: false }; return { key: "idle", label: "Idle", dot: false }; } } function actionForStatus(cls) { const pausable = ["checking", "downloading", "queued", "seeding"].includes(cls.key); if (pausable) { return { action: "pause", label: "Pause", pendingText: "Pausing..." }; } return { action: "resume", label: "Resume", pendingText: "Resuming..." }; } /* ---------- Sorting ---------- */ const STATUS_RANK = { error: 0, downloading: 1, seeding: 2, queued: 3, checking: 4, finished: 5, paused: 6, idle: 7 }; function compareName(a, b) { return (a.name || "").localeCompare(b.name || ""); } function activitySpeed(t) { return (t.rateDownload || 0) + (t.rateUpload || 0); } function sortTorrents(list) { const key = state.sortKey; const sorted = list.slice(); sorted.sort((a, b) => { switch (key) { case "name": return compareName(a, b); case "progress": return (b.percentDone || 0) - (a.percentDone || 0) || compareName(a, b); case "ratio": return (b.uploadRatio || 0) - (a.uploadRatio || 0) || compareName(a, b); case "size": return (b.totalSize || 0) - (a.totalSize || 0) || compareName(a, b); case "speed": return (activitySpeed(b) - activitySpeed(a)) || ((b.rateDownload || 0) - (a.rateDownload || 0)) || ((b.rateUpload || 0) - (a.rateUpload || 0)) || compareName(a, b); case "peer100": { const pa = peerStatus(a); const pb = peerStatus(b); return (pb.at100 - pa.at100) || (pb.connected - pa.connected) || ((b.percentDone || 0) - (a.percentDone || 0)) || compareName(a, b); } case "status": { const ra = STATUS_RANK[classify(a).key] ?? 99; const rb = STATUS_RANK[classify(b).key] ?? 99; if (ra !== rb) return ra - rb; return (b.percentDone || 0) - (a.percentDone || 0) || compareName(a, b); } default: return 0; } }); return sorted; } /* ---------- Rendering ---------- */ function renderHeader() { const torrents = state.torrents || []; let dl = 0, ul = 0; for (const t of torrents) { dl += t.rateDownload || 0; ul += t.rateUpload || 0; } $("#stat-torrents").textContent = String(torrents.length); $("#stat-dl").textContent = humanSpeed(dl); $("#stat-ul").textContent = humanSpeed(ul); } function initial(name) { const n = (name || "").trim(); if (!n) return "?"; const base = n.replace(/\.[a-z0-9]+$/i, ""); const m = base.match(/[A-Za-z0-9]/); return m ? m[0].toUpperCase() : "?"; } function createCard() { const card = document.createElement("article"); card.className = "card is-new"; card.innerHTML = `