Files
transmission-manager/web/app.js

367 lines
11 KiB
JavaScript

"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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[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);