"use strict"; /* ============================================================ Transmission Manager — frontend Vanilla SPA. Auto-refreshes every 3s. ============================================================ */ 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 $ = (sel) => document.querySelector(sel); /* ---------- Fetching ---------- */ async function fetchJSON(url) { const res = await fetch(url, { cache: "no-store" }); if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); return res.json(); } async function loadTorrents() { const data = await fetchJSON("/api/torrents"); // Transmission returns { torrents: [...] } state.torrents = (data && data.torrents) || []; } async function loadStats() { const data = await fetchJSON("/api/stats"); state.stats = data || {}; } async function sync() { setSyncing(true); try { await Promise.all([loadTorrents(), loadStats()]); state.lastError = null; renderAll(); } catch (err) { state.lastError = err.message || String(err); renderError(); } finally { setSyncing(false); $("#last-updated").textContent = `updated ${formatTime(new Date())}`; } } 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"; } /* ---------- 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 }; } } /* ---------- Sorting ---------- */ const STATUS_RANK = { error: 0, downloading: 1, seeding: 2, queued: 3, checking: 4, finished: 5, paused: 6, idle: 7 }; function sortTorrents(list) { const key = state.sortKey; const sorted = list.slice(); sorted.sort((a, b) => { switch (key) { case "name": return a.name.localeCompare(b.name); case "progress": return (b.percentDone || 0) - (a.percentDone || 0); case "ratio": return (b.uploadRatio || 0) - (a.uploadRatio || 0); case "size": return (b.totalSize || 0) - (a.totalSize || 0); 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); } default: return 0; } }); return sorted; } /* ---------- Rendering ---------- */ function renderHeader() { const s = state.stats || {}; 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 badgeHTML(cls) { return `${escapeHTML(cls.label)}`; } function escapeHTML(s) { if (s == null) return ""; return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[c]); } function initial(name) { const n = (name || "").trim(); if (!n) return "?"; // try to grab a meaningful token from the filename const base = n.replace(/\.[a-z0-9]+$/i, ""); const m = base.match(/[A-Za-z0-9]/); return m ? m[0].toUpperCase() : "?"; } function renderCard(t) { 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); let barClass = ""; if (cls.key === "error") barClass = "error"; else if (isDone) barClass = "done"; else if (cls.key === "paused" || cls.key === "finished") barClass = "paused"; const dl = t.rateDownload || 0; const ul = t.rateUpload || 0; const eta = (dl > 0 && !isDone) ? formatETA(t.eta) : (isDone ? "—" : "∞"); const hasError = cls.key === "error"; return `
${escapeHTML(initial(t.name))}
${escapeHTML(t.name)}
Size ${escapeHTML(humanSize(t.totalSize))} Ratio ${escapeHTML(humanRatio(t.uploadRatio))} Peers ${escapeHTML(ps.label)} ${t.doneDate ? `Done ${escapeHTML(formatDate(t.doneDate))}` : ""}
${badgeHTML(cls)} ${escapeHTML(pctText)}
Downloaded ${escapeHTML(humanSize(t.downloadedEver))}${isDone ? ` / ${escapeHTML(humanSize(t.totalSize))}` : ""}
Uploaded ${escapeHTML(humanSize(t.uploadedEver))}
↓ Speed ${escapeHTML(humanSpeed(dl))}
↑ Speed ${escapeHTML(humanSpeed(ul))}
ETA ${escapeHTML(eta)}
Peers @ 100% ${escapeHTML(`${ps.at100}/${ps.connected}`)}
Finished ${escapeHTML(formatDate(t.doneDate))}
${hasError && t.errorString ? `
${escapeHTML(t.errorString)}
` : ""}
`; } function renderList() { const list = $("#torrents"); const empty = $("#empty-state"); const errEl = $("#error-state"); let torrents = sortTorrents(state.torrents || []); const f = state.filter.trim().toLowerCase(); if (f) torrents = torrents.filter((t) => (t.name || "").toLowerCase().includes(f)); errEl.classList.add("hidden"); if (state.lastError) { list.innerHTML = ""; empty.classList.add("hidden"); errEl.classList.remove("hidden"); $("#error-msg").textContent = state.lastError; return; } if (torrents.length === 0) { list.innerHTML = ""; empty.classList.remove("hidden"); return; } empty.classList.add("hidden"); list.innerHTML = torrents.map(renderCard).join(""); } function renderError() { // keep stats list emptied on error $("#torrents").innerHTML = ""; $("#empty-state").classList.add("hidden"); const errEl = $("#error-state"); errEl.classList.remove("hidden"); $("#error-msg").textContent = state.lastError || "Could not reach the Transmission daemon."; } 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(); }); }); document.addEventListener("visibilitychange", () => { if (!document.hidden) { // immediate refresh when returning to the tab sync(); } }); } /* ---------- Init ---------- */ function boot() { wireEvents(); sync(); setInterval(sync, REFRESH_MS); } document.addEventListener("DOMContentLoaded", boot);