"use strict"; /* ============================================================ Transmission Manager - frontend Vanilla SPA. Auto-refreshes every 3s with in-place updates. ============================================================ */ const REFRESH_MS = 3000; 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", lastError: null, }; const torrentCards = new Map(); const actionStates = new Map(); let syncInFlight = false; let syncQueued = false; 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); const t = el.querySelector(".refresh-text"); if (t) t.textContent = on ? "syncing" : "live"; } 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 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 "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 = `
Size Ratio Peers
Downloaded
Uploaded
↓ Speed
↑ Speed
ETA
Peers @ 100%
Finished
`; card.addEventListener("animationend", () => card.classList.remove("is-new"), { once: true }); return card; } function field(card, name) { return card.querySelector(`[data-field="${name}"]`); } function setText(el, value) { const text = value == null ? "" : String(value); if (el.textContent !== text) el.textContent = text; } function setValueClass(el, className) { el.classList.remove("accent", "success", "warn", "dim"); if (className) el.classList.add(className); } function updateCard(card, t) { const id = Number(t.id); const cls = classify(t); const pct = humanPct(t.percentDone); const pctText = (pct * 100).toFixed(pct === 1 ? 0 : 1) + "%"; const isDone = pct >= 1.0; const ps = peerStatus(t); const dl = t.rateDownload || 0; const ul = t.rateUpload || 0; const eta = (dl > 0 && !isDone) ? formatETA(t.eta) : (isDone ? "—" : "∞"); const hasError = cls.key === "error"; let barClass = ""; if (hasError) barClass = "error"; else if (isDone) barClass = "done"; else if (cls.key === "paused" || cls.key === "finished") barClass = "paused"; card.dataset.id = String(id); card.classList.toggle("is-error", hasError); setText(field(card, "initial"), initial(t.name)); const nameEl = field(card, "name"); setText(nameEl, t.name || "Unnamed torrent"); nameEl.title = t.name || ""; setText(field(card, "size"), humanSize(t.totalSize)); setText(field(card, "ratio"), humanRatio(t.uploadRatio)); setText(field(card, "peers"), ps.label); const doneMetaWrap = field(card, "done-meta-wrap"); doneMetaWrap.classList.toggle("hidden", !t.doneDate); setText(field(card, "done-meta"), formatDate(t.doneDate)); const badge = field(card, "badge"); badge.className = `badge ${cls.key}`; setText(field(card, "status-label"), cls.label); setText(field(card, "pct"), pctText); const progress = field(card, "progress"); progress.className = `progress-bar ${barClass}`.trim(); progress.style.width = `${(pct * 100).toFixed(2)}%`; setText(field(card, "downloaded"), isDone ? `${humanSize(t.downloadedEver)} / ${humanSize(t.totalSize)}` : humanSize(t.downloadedEver)); setText(field(card, "uploaded"), humanSize(t.uploadedEver)); const dlEl = field(card, "download-speed"); setText(dlEl, humanSpeed(dl)); setValueClass(dlEl, dl > 0 ? "accent" : "dim"); const ulEl = field(card, "upload-speed"); setText(ulEl, humanSpeed(ul)); setValueClass(ulEl, ul > 0 ? "success" : "dim"); setText(field(card, "eta"), eta); setText(field(card, "peers-100"), `${ps.at100}/${ps.connected}`); setText(field(card, "finished"), formatDate(t.doneDate)); const errorEl = field(card, "error-string"); const errorText = hasError && t.errorString ? t.errorString : ""; setText(errorEl, errorText); errorEl.classList.toggle("hidden", !errorText); const action = actionForStatus(cls); const actionState = actionStates.get(id); const button = card.querySelector("[data-action-button]"); button.dataset.action = action.action; button.classList.toggle("pause", action.action === "pause"); button.classList.toggle("resume", action.action === "resume"); button.disabled = Boolean(actionState && actionState.pending); button.setAttribute("aria-label", `${action.label} ${t.name || "torrent"}`); setText(button, actionState && actionState.pending ? actionState.pendingText : action.label); const actionError = field(card, "action-error"); const actionErrorText = actionState && actionState.error ? actionState.error : ""; setText(actionError, actionErrorText); actionError.classList.toggle("hidden", !actionErrorText); } function updateCardByID(id) { const card = torrentCards.get(String(id)); const torrent = (state.torrents || []).find((t) => Number(t.id) === id); if (card && torrent) updateCard(card, torrent); } function reconcileCards(torrents) { const list = $("#torrents"); const visible = new Set(); torrents.forEach((t, index) => { const id = String(t.id); visible.add(id); let card = torrentCards.get(id); if (!card) { card = createCard(); torrentCards.set(id, card); } updateCard(card, t); const current = list.children[index]; if (current !== card) { list.insertBefore(card, current || null); } }); for (const [id, card] of torrentCards) { if (!visible.has(id)) { card.remove(); torrentCards.delete(id); actionStates.delete(Number(id)); } } } function renderList() { const empty = $("#empty-state"); const errEl = $("#error-state"); const errMsg = $("#error-msg"); errEl.classList.toggle("hidden", !state.lastError); if (state.lastError) { errMsg.textContent = state.lastError; } if (state.lastError && (!state.torrents || state.torrents.length === 0)) { reconcileCards([]); empty.classList.add("hidden"); return; } let torrents = sortTorrents(state.torrents || []); const f = state.filter.trim().toLowerCase(); if (f) torrents = torrents.filter((t) => (t.name || "").toLowerCase().includes(f)); reconcileCards(torrents); empty.classList.toggle("hidden", torrents.length !== 0 || Boolean(state.lastError)); } function renderAll() { renderHeader(); renderList(); } /* ---------- Events ---------- */ function wireEvents() { const filterInput = $("#filter"); filterInput.addEventListener("input", (e) => { state.filter = e.target.value; renderList(); }); document.querySelectorAll(".sort-btn").forEach((btn) => { btn.addEventListener("click", () => { document.querySelectorAll(".sort-btn").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); state.sortKey = btn.dataset.sort; renderList(); }); }); $("#torrents").addEventListener("click", (e) => { const button = e.target.closest("[data-action-button]"); if (!button || button.disabled) return; const card = button.closest(".card"); const id = Number(card && card.dataset.id); const action = button.dataset.action; if (!id || !["pause", "resume"].includes(action)) return; runTorrentAction(id, action); }); document.addEventListener("visibilitychange", () => { if (!document.hidden) { sync(); } }); } /* ---------- Init ---------- */ function boot() { wireEvents(); sync(); setInterval(sync, REFRESH_MS); } document.addEventListener("DOMContentLoaded", boot);