367 lines
11 KiB
JavaScript
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) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
})[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);
|