feat: transmission-manager — Go SPA for Transmission RPC
This commit is contained in:
366
web/app.js
Normal file
366
web/app.js
Normal file
@@ -0,0 +1,366 @@
|
||||
"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 `<span class="badge ${cls.key}"><span class="dotb"></span>${escapeHTML(cls.label)}</span>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<article class="card ${hasError ? "is-error" : ""}" data-id="${t.id}">
|
||||
<div class="card-top">
|
||||
<div class="favicon">${escapeHTML(initial(t.name))}</div>
|
||||
<div class="card-body">
|
||||
<div class="card-name" title="${escapeHTML(t.name)}">${escapeHTML(t.name)}</div>
|
||||
<div class="card-meta">
|
||||
<span class="meta-item"><span class="meta-label">Size</span> ${escapeHTML(humanSize(t.totalSize))}</span>
|
||||
<span class="meta-item"><span class="meta-label">Ratio</span> ${escapeHTML(humanRatio(t.uploadRatio))}</span>
|
||||
<span class="meta-item"><span class="meta-label">Peers</span> ${escapeHTML(ps.label)}</span>
|
||||
${t.doneDate ? `<span class="meta-item"><span class="meta-label">Done</span> ${escapeHTML(formatDate(t.doneDate))}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-status-col">
|
||||
${badgeHTML(cls)}
|
||||
<span class="pct">${escapeHTML(pctText)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar ${barClass}" style="width:${(pct * 100).toFixed(2)}%"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-bottom">
|
||||
<div class="stat-cell">
|
||||
<span class="k">Downloaded</span>
|
||||
<span class="v">${escapeHTML(humanSize(t.downloadedEver))}${isDone ? ` <span class="dim">/ ${escapeHTML(humanSize(t.totalSize))}</span>` : ""}</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">Uploaded</span>
|
||||
<span class="v">${escapeHTML(humanSize(t.uploadedEver))}</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">↓ Speed</span>
|
||||
<span class="v ${dl > 0 ? "accent" : "dim"}">${escapeHTML(humanSpeed(dl))}</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">↑ Speed</span>
|
||||
<span class="v ${ul > 0 ? "success" : "dim"}">${escapeHTML(humanSpeed(ul))}</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">ETA</span>
|
||||
<span class="v">${escapeHTML(eta)}</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">Peers @ 100%</span>
|
||||
<span class="v mono">${escapeHTML(`${ps.at100}/${ps.connected}`)}</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">Finished</span>
|
||||
<span class="v">${escapeHTML(formatDate(t.doneDate))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${hasError && t.errorString ? `<div class="error-string">${escapeHTML(t.errorString)}</div>` : ""}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user