1048 lines
36 KiB
JavaScript
1048 lines
36 KiB
JavaScript
"use strict";
|
|
|
|
/* ============================================================
|
|
Transmission Manager - frontend
|
|
Vanilla SPA. Auto-refreshes on a selectable interval with in-place updates.
|
|
============================================================ */
|
|
|
|
const DEFAULT_REFRESH_MS = 3000;
|
|
const REFRESH_INTERVALS = new Set([1000, 3000, 5000, 10000]);
|
|
const TRANSMISSION_STATUSES = {
|
|
0: "stopped",
|
|
1: "check-wait",
|
|
2: "checking",
|
|
3: "download-wait",
|
|
4: "downloading",
|
|
5: "seed-wait",
|
|
6: "seeding",
|
|
};
|
|
|
|
const state = {
|
|
torrents: [],
|
|
stats: {},
|
|
session: null,
|
|
filter: "",
|
|
scheduledOnly: false,
|
|
sortKey: "status",
|
|
refreshMs: DEFAULT_REFRESH_MS,
|
|
lastError: null,
|
|
};
|
|
|
|
const torrentCards = new Map();
|
|
const actionStates = new Map();
|
|
let syncInFlight = false;
|
|
let syncQueued = false;
|
|
let refreshTimer = null;
|
|
let speedControlPending = false;
|
|
let speedControlError = null;
|
|
let schedulerData = null;
|
|
let schedulerItems = new Map();
|
|
let schedulerError = "";
|
|
let schedulerLoadPromise = null;
|
|
let schedulerSettingsPending = false;
|
|
let schedulerHistoryLoading = false;
|
|
let schedulerHistoryLoaded = false;
|
|
let schedulerHistoryEvents = [];
|
|
let schedulerHistoryHasMore = false;
|
|
let schedulerHistoryLatestID = -1;
|
|
const schedulerActionPending = new Set();
|
|
const schedulerActionErrors = new Map();
|
|
const schedulerRows = new Map();
|
|
|
|
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 loadSession() {
|
|
const data = await fetchJSON("/api/session");
|
|
state.session = data && typeof data === "object" ? data : null;
|
|
}
|
|
|
|
async function sync() {
|
|
if (syncInFlight) {
|
|
syncQueued = true;
|
|
return;
|
|
}
|
|
|
|
syncInFlight = true;
|
|
setSyncing(true);
|
|
try {
|
|
await Promise.all([loadTorrents(), loadStats(), loadSession()]);
|
|
state.lastError = null;
|
|
speedControlError = 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);
|
|
updateRefreshMetadata(on);
|
|
}
|
|
|
|
function refreshLabel() {
|
|
return `${state.refreshMs / 1000}s`;
|
|
}
|
|
|
|
function updateRefreshMetadata(syncing = false) {
|
|
const label = refreshLabel();
|
|
const indicator = $("#refresh-indicator");
|
|
if (indicator) {
|
|
indicator.title = `Auto-refreshes every ${label}`;
|
|
indicator.setAttribute(
|
|
"aria-label",
|
|
syncing ? `Syncing; auto-refresh every ${label}` : `Auto-refresh active every ${label}`,
|
|
);
|
|
}
|
|
|
|
const hint = $("#error-hint");
|
|
if (hint) hint.textContent = `Retrying in ${label}...`;
|
|
}
|
|
|
|
function setRefreshInterval(ms) {
|
|
state.refreshMs = REFRESH_INTERVALS.has(ms) ? ms : DEFAULT_REFRESH_MS;
|
|
updateRefreshMetadata(syncInFlight);
|
|
|
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
refreshTimer = setInterval(sync, state.refreshMs);
|
|
}
|
|
|
|
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();
|
|
loadScheduler();
|
|
} catch (err) {
|
|
actionStates.set(id, {
|
|
pending: null,
|
|
error: err.message || String(err),
|
|
pendingText: "",
|
|
});
|
|
updateCardByID(id);
|
|
}
|
|
}
|
|
|
|
async function setAlternativeSpeed(enabled) {
|
|
if (speedControlPending || !state.session) return;
|
|
speedControlPending = true;
|
|
speedControlError = null;
|
|
renderHeader();
|
|
|
|
try {
|
|
await fetchJSON("/api/session/alternative-speed", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
await sync();
|
|
speedControlPending = false;
|
|
renderHeader();
|
|
} catch (err) {
|
|
speedControlError = err.message || String(err);
|
|
speedControlPending = false;
|
|
renderHeader();
|
|
}
|
|
}
|
|
|
|
/* ---------- 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 formatDateTime(unix) {
|
|
if (!unix) return "Never";
|
|
const date = new Date(unix * 1000);
|
|
return Number.isNaN(date.getTime()) ? "Never" : date.toLocaleString();
|
|
}
|
|
|
|
/* ---------- Scheduler ---------- */
|
|
function schedulerStatus(item) {
|
|
if (!item.available) return { label: "Unavailable", key: "" };
|
|
if (item.active) return item.status === 6
|
|
? { label: "Active", key: "active" }
|
|
: { label: item.status === 5 ? "Queued" : "Starting", key: "waiting" };
|
|
if (item.optedIn) return { label: "Waiting", key: "waiting" };
|
|
return { label: "Available", key: "" };
|
|
}
|
|
|
|
function schedulerItemFor(torrent) {
|
|
const hash = (torrent.hashString || "").toLowerCase();
|
|
return schedulerItems.get(hash);
|
|
}
|
|
|
|
function renderScheduleControls() {
|
|
const filter = $("#scheduled-filter");
|
|
filter.disabled = !schedulerData;
|
|
filter.setAttribute("aria-pressed", String(state.scheduledOnly));
|
|
const count = schedulerData ? state.torrents.filter((torrent) => schedulerItemFor(torrent)?.optedIn).length : 0;
|
|
setText(filter, schedulerData ? `Scheduled (${count})` : "Scheduled");
|
|
|
|
const status = $("#schedule-status");
|
|
const message = schedulerError || (schedulerData && schedulerData.error) || "";
|
|
setText(status, message ? `Scheduler: ${message}` : "");
|
|
status.classList.toggle("hidden", !message);
|
|
}
|
|
|
|
function createSchedulerRow() {
|
|
const row = document.createElement("div");
|
|
row.className = "scheduler-row";
|
|
row.innerHTML = `<div class="scheduler-row-main"><span class="scheduler-row-name"></span><span class="scheduler-row-meta"></span></div>
|
|
<div class="scheduler-row-controls"><span class="scheduler-state"></span><button type="button" class="scheduler-toggle"></button></div>`;
|
|
return row;
|
|
}
|
|
|
|
function renderScheduler() {
|
|
const error = $("#scheduler-error");
|
|
const message = schedulerError || (schedulerData && schedulerData.error) || "";
|
|
setText(error, message);
|
|
error.classList.toggle("hidden", !message);
|
|
if (!schedulerData) return;
|
|
|
|
const slots = $("#scheduler-slots");
|
|
if (document.activeElement !== slots && !schedulerSettingsPending) slots.value = String(schedulerData.maxActive);
|
|
$("#scheduler-settings").querySelector("button").disabled = schedulerSettingsPending;
|
|
|
|
const list = $("#scheduler-torrents");
|
|
const items = schedulerData.torrents || [];
|
|
const visible = new Set();
|
|
items.forEach((item, index) => {
|
|
visible.add(item.hashString);
|
|
let row = schedulerRows.get(item.hashString);
|
|
if (!row) {
|
|
row = createSchedulerRow();
|
|
schedulerRows.set(item.hashString, row);
|
|
}
|
|
row.dataset.hash = item.hashString;
|
|
const name = row.querySelector(".scheduler-row-name");
|
|
setText(name, item.name || item.hashString);
|
|
name.title = item.name || item.hashString;
|
|
setText(row.querySelector(".scheduler-row-meta"), item.optedIn
|
|
? `Last seeded: ${formatDateTime(item.lastSeededAt)} · Idle clock: ${formatDateTime(item.lastUploadAt)}`
|
|
: "Not scheduled");
|
|
const status = schedulerStatus(item);
|
|
const badge = row.querySelector(".scheduler-state");
|
|
badge.className = `scheduler-state ${status.key}`.trim();
|
|
setText(badge, status.label);
|
|
const button = row.querySelector(".scheduler-toggle");
|
|
button.disabled = schedulerActionPending.has(item.hashString);
|
|
button.setAttribute("aria-pressed", String(Boolean(item.optedIn)));
|
|
button.setAttribute("aria-label", `${item.optedIn ? "Remove" : "Add"} ${item.name || "torrent"} ${item.optedIn ? "from" : "to"} scheduler`);
|
|
setText(button, schedulerActionPending.has(item.hashString) ? "Saving..." : (item.optedIn ? "Opt out" : "Opt in"));
|
|
const current = list.children[index];
|
|
if (current !== row) list.insertBefore(row, current || null);
|
|
});
|
|
for (const [hash, row] of schedulerRows) {
|
|
if (!visible.has(hash)) {
|
|
row.remove();
|
|
schedulerRows.delete(hash);
|
|
}
|
|
}
|
|
$("#scheduler-empty").classList.toggle("hidden", items.length > 0);
|
|
const active = items.filter((item) => item.active).length;
|
|
const optedIn = items.filter((item) => item.optedIn).length;
|
|
setText($("#scheduler-count"), `${active} / ${schedulerData.maxActive} active slots · ${optedIn} opted in`);
|
|
}
|
|
|
|
function renderSchedulerHistory() {
|
|
const list = $("#scheduler-history");
|
|
list.replaceChildren();
|
|
for (const event of schedulerHistoryEvents) {
|
|
const row = document.createElement("li");
|
|
const description = document.createElement("span");
|
|
const names = document.createElement("strong");
|
|
const movement = event.fromName && event.toName
|
|
? `${event.fromName} → ${event.toName}`
|
|
: (event.toName ? `${event.toName} activated` : `${event.fromName || "Torrent"} paused`);
|
|
setText(names, movement);
|
|
description.append(names, document.createTextNode(` · ${event.reason}`));
|
|
const time = document.createElement("time");
|
|
time.dateTime = new Date(event.happenedAt * 1000).toISOString();
|
|
setText(time, formatDateTime(event.happenedAt));
|
|
row.append(description, time);
|
|
list.append(row);
|
|
}
|
|
$("#scheduler-history-empty").classList.toggle("hidden", schedulerHistoryEvents.length > 0);
|
|
$("#scheduler-history-more").classList.toggle("hidden", !schedulerHistoryHasMore);
|
|
$("#scheduler-history-more").disabled = schedulerHistoryLoading;
|
|
}
|
|
|
|
async function loadSchedulerHistory(reset = false) {
|
|
if (schedulerHistoryLoading) return;
|
|
schedulerHistoryLoading = true;
|
|
try {
|
|
const before = !reset && schedulerHistoryEvents.length
|
|
? `?before=${schedulerHistoryEvents[schedulerHistoryEvents.length - 1].id}` : "";
|
|
const result = await fetchJSON(`/api/scheduler/history${before}`);
|
|
schedulerHistoryEvents = reset ? (result.events || []) : schedulerHistoryEvents.concat(result.events || []);
|
|
schedulerHistoryHasMore = Boolean(result.hasMore);
|
|
schedulerHistoryLoaded = true;
|
|
schedulerHistoryLatestID = schedulerData ? schedulerData.latestEventId : schedulerHistoryLatestID;
|
|
schedulerError = "";
|
|
} catch (err) {
|
|
schedulerError = err.message || String(err);
|
|
renderScheduler();
|
|
} finally {
|
|
schedulerHistoryLoading = false;
|
|
renderSchedulerHistory();
|
|
}
|
|
}
|
|
|
|
async function loadScheduler() {
|
|
if (schedulerLoadPromise) return schedulerLoadPromise;
|
|
schedulerLoadPromise = (async () => {
|
|
try {
|
|
schedulerData = await fetchJSON("/api/scheduler");
|
|
schedulerItems = new Map((schedulerData.torrents || []).map((item) => [item.hashString.toLowerCase(), item]));
|
|
schedulerError = "";
|
|
renderScheduler();
|
|
if (location.hash === "#scheduler" && (!schedulerHistoryLoaded || schedulerData.latestEventId !== schedulerHistoryLatestID)) {
|
|
await loadSchedulerHistory(true);
|
|
}
|
|
} catch (err) {
|
|
schedulerError = err.message || String(err);
|
|
renderScheduler();
|
|
} finally {
|
|
renderScheduleControls();
|
|
renderList();
|
|
}
|
|
})();
|
|
try { await schedulerLoadPromise; } finally { schedulerLoadPromise = null; }
|
|
}
|
|
|
|
async function setSchedulerSlots(value) {
|
|
if (schedulerSettingsPending) return;
|
|
schedulerSettingsPending = true;
|
|
renderScheduler();
|
|
try {
|
|
await fetchJSON("/api/scheduler/settings", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ maxActive: value }),
|
|
});
|
|
setText($("#scheduler-feedback"), "Active slots saved.");
|
|
schedulerError = "";
|
|
await loadScheduler();
|
|
} catch (err) {
|
|
schedulerError = err.message || String(err);
|
|
} finally {
|
|
schedulerSettingsPending = false;
|
|
renderScheduler();
|
|
}
|
|
}
|
|
|
|
async function setSchedulerOptIn(hash, enabled) {
|
|
if (schedulerActionPending.has(hash)) return;
|
|
schedulerActionPending.add(hash);
|
|
schedulerActionErrors.delete(hash);
|
|
renderScheduler();
|
|
renderList();
|
|
try {
|
|
await fetchJSON(`/api/scheduler/torrents/${hash}/opt-in`, {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
schedulerError = "";
|
|
if (schedulerLoadPromise) await schedulerLoadPromise;
|
|
await loadScheduler();
|
|
} catch (err) {
|
|
schedulerError = err.message || String(err);
|
|
schedulerActionErrors.set(hash, schedulerError);
|
|
} finally {
|
|
schedulerActionPending.delete(hash);
|
|
renderScheduler();
|
|
renderScheduleControls();
|
|
renderList();
|
|
}
|
|
}
|
|
|
|
function activateTab(name) {
|
|
const selected = name === "scheduler" ? "scheduler" : "torrents";
|
|
for (const tabName of ["torrents", "scheduler"]) {
|
|
const tab = $(`#tab-${tabName}`);
|
|
const active = tabName === selected;
|
|
tab.classList.toggle("active", active);
|
|
tab.setAttribute("aria-selected", String(active));
|
|
tab.tabIndex = active ? 0 : -1;
|
|
$(`#panel-${tabName}`).hidden = !active;
|
|
}
|
|
if (selected === "scheduler") loadScheduler();
|
|
}
|
|
|
|
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 activitySpeed(t) {
|
|
return (t.rateDownload || 0) + (t.rateUpload || 0);
|
|
}
|
|
|
|
function availabilityValue(t) {
|
|
if (typeof t.availability === "number" && Number.isFinite(t.availability)) {
|
|
return t.availability;
|
|
}
|
|
|
|
const pieces = Array.isArray(t.availability) ? t.availability : [];
|
|
if (!pieces.length) return null;
|
|
|
|
const counts = [];
|
|
let min = Infinity;
|
|
for (const piece of pieces) {
|
|
const raw = Number(piece);
|
|
if (!Number.isFinite(raw)) continue;
|
|
const count = raw < 0 ? 1 : raw;
|
|
counts.push(count);
|
|
if (count < min) min = count;
|
|
}
|
|
|
|
if (!counts.length) return null;
|
|
|
|
let aboveMin = 0;
|
|
for (const count of counts) {
|
|
if (count > min) aboveMin += 1;
|
|
}
|
|
|
|
return min + (aboveMin / counts.length);
|
|
}
|
|
|
|
function availabilitySortValue(t) {
|
|
const value = availabilityValue(t);
|
|
return value == null ? -1 : value;
|
|
}
|
|
|
|
function humanAvailability(value) {
|
|
if (value == null || !Number.isFinite(value) || value < 0) return "—";
|
|
return `${value.toFixed(value < 10 ? 2 : 1)}x`;
|
|
}
|
|
|
|
function sortTorrents(list) {
|
|
const key = state.sortKey;
|
|
const sorted = list.slice();
|
|
const availabilityValues = key === "availability"
|
|
? new Map(sorted.map((t) => [t, availabilitySortValue(t)]))
|
|
: null;
|
|
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 "speed":
|
|
return (activitySpeed(b) - activitySpeed(a))
|
|
|| ((b.rateDownload || 0) - (a.rateDownload || 0))
|
|
|| ((b.rateUpload || 0) - (a.rateUpload || 0))
|
|
|| compareName(a, b);
|
|
case "availability":
|
|
return (availabilityValues.get(b) - availabilityValues.get(a))
|
|
|| ((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);
|
|
renderSpeedControls();
|
|
}
|
|
|
|
function sessionValue(session, kebab, snake) {
|
|
if (!session) return null;
|
|
if (session[kebab] != null) return session[kebab];
|
|
return snake ? session[snake] : null;
|
|
}
|
|
|
|
function sessionSpeedBytes(session) {
|
|
const units = session && session.units;
|
|
const value = units && (units["speed-bytes"] ?? units.speed_bytes ?? units.speedBytes);
|
|
return Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : 1000;
|
|
}
|
|
|
|
function activeSpeedCap(session, direction) {
|
|
const enabled = Boolean(sessionValue(session, "alt-speed-enabled", "alt_speed_enabled"));
|
|
const alt = direction === "down"
|
|
? sessionValue(session, "alt-speed-down", "alt_speed_down")
|
|
: sessionValue(session, "alt-speed-up", "alt_speed_up");
|
|
const normalEnabled = Boolean(sessionValue(
|
|
session,
|
|
direction === "down" ? "speed-limit-down-enabled" : "speed-limit-up-enabled",
|
|
direction === "down" ? "speed_limit_down_enabled" : "speed_limit_up_enabled",
|
|
));
|
|
const normal = direction === "down"
|
|
? sessionValue(session, "speed-limit-down", "speed_limit_down")
|
|
: sessionValue(session, "speed-limit-up", "speed_limit_up");
|
|
const value = enabled ? alt : (normalEnabled ? normal : null);
|
|
const number = Number(value);
|
|
return Number.isFinite(number) && number > 0 ? number * sessionSpeedBytes(session) : null;
|
|
}
|
|
|
|
function renderSpeedControls() {
|
|
const toggle = $("#alt-speed-toggle");
|
|
const toggleState = $("#alt-speed-state");
|
|
const status = $("#speed-control-status");
|
|
if (!toggle || !toggleState || !status) return;
|
|
|
|
const session = state.session;
|
|
const enabled = Boolean(sessionValue(session, "alt-speed-enabled", "alt_speed_enabled"));
|
|
toggle.disabled = !session || speedControlPending;
|
|
toggle.setAttribute("aria-pressed", String(enabled));
|
|
toggle.classList.toggle("is-saving", speedControlPending);
|
|
setText(toggleState, !session ? (state.lastError ? "Unavailable" : "Loading...") : (speedControlPending ? "Saving..." : (enabled ? "On" : "Off")));
|
|
|
|
const caps = [
|
|
{ id: "speed-cap-down", value: activeSpeedCap(session, "down") },
|
|
{ id: "speed-cap-up", value: activeSpeedCap(session, "up") },
|
|
];
|
|
for (const cap of caps) {
|
|
const el = $("#" + cap.id);
|
|
if (!el) continue;
|
|
el.classList.toggle("hidden", cap.value == null);
|
|
const valueEl = el.querySelector("strong");
|
|
if (valueEl && cap.value != null) setText(valueEl, humanSpeed(cap.value));
|
|
}
|
|
setText(status, speedControlError || "");
|
|
}
|
|
|
|
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 = `
|
|
<div class="card-top">
|
|
<div class="favicon" data-field="initial"></div>
|
|
<div class="card-body">
|
|
<div class="card-name" data-field="name"></div>
|
|
<div class="card-meta">
|
|
<span class="schedule-badge" data-field="schedule-badge"></span>
|
|
<span class="meta-item"><span class="meta-label">Size</span> <span data-field="size"></span></span>
|
|
<span class="meta-item"><span class="meta-label">Ratio</span> <span data-field="ratio"></span></span>
|
|
<span class="meta-item"><span class="meta-label">Peers</span> <span data-field="peers"></span></span>
|
|
<span class="meta-item done-meta hidden" data-field="done-meta-wrap"><span class="meta-label">Done</span> <span data-field="done-meta"></span></span>
|
|
</div>
|
|
</div>
|
|
<div class="card-actions">
|
|
<div class="card-status-col">
|
|
<span class="badge" data-field="badge"><span class="dotb"></span><span data-field="status-label"></span></span>
|
|
<span class="pct" data-field="pct"></span>
|
|
</div>
|
|
<button type="button" class="card-schedule-toggle hidden" data-schedule-button></button>
|
|
<button type="button" class="action-btn" data-action-button></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="progress-wrap">
|
|
<div class="progress-bar" data-field="progress"></div>
|
|
</div>
|
|
|
|
<div class="card-bottom">
|
|
<div class="stat-cell">
|
|
<span class="k">Downloaded</span>
|
|
<span class="v" data-field="downloaded"></span>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<span class="k">Uploaded</span>
|
|
<span class="v" data-field="uploaded"></span>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<span class="k">↓ Speed</span>
|
|
<span class="v" data-field="download-speed"></span>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<span class="k">↑ Speed</span>
|
|
<span class="v" data-field="upload-speed"></span>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<span class="k">ETA</span>
|
|
<span class="v" data-field="eta"></span>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<span class="k">Availability</span>
|
|
<span class="v mono" data-field="availability"></span>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<span class="k">Peers @ 100%</span>
|
|
<span class="v mono" data-field="peers-100"></span>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<span class="k">Finished</span>
|
|
<span class="v" data-field="finished"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="action-error hidden" data-field="action-error"></div>
|
|
<div class="error-string hidden" data-field="error-string"></div>
|
|
`;
|
|
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 availability = availabilityValue(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 scheduleItem = schedulerItemFor(t);
|
|
const scheduled = Boolean(scheduleItem && scheduleItem.optedIn);
|
|
const scheduleBadge = field(card, "schedule-badge");
|
|
scheduleBadge.classList.toggle("is-scheduled", scheduled);
|
|
setText(scheduleBadge, !schedulerData ? (schedulerError ? "Schedule unavailable" : "Schedule loading") : scheduled
|
|
? `Scheduled · ${schedulerStatus(scheduleItem).label}` : "Not scheduled");
|
|
scheduleBadge.title = !schedulerData
|
|
? (schedulerError ? "Scheduler state is unavailable" : "Loading scheduler state")
|
|
: (!scheduleItem ? "Torrent must be complete and error-free before scheduling" : "");
|
|
const scheduleButton = card.querySelector("[data-schedule-button]");
|
|
const schedulable = Boolean(scheduleItem && (scheduleItem.available || scheduled));
|
|
scheduleButton.classList.toggle("hidden", !schedulable);
|
|
scheduleButton.disabled = schedulerActionPending.has((t.hashString || "").toLowerCase());
|
|
scheduleButton.setAttribute("aria-pressed", String(scheduled));
|
|
scheduleButton.setAttribute("aria-label", `${scheduled ? "Remove" : "Add"} ${t.name || "torrent"} ${scheduled ? "from" : "to"} scheduler`);
|
|
setText(scheduleButton, scheduleButton.disabled ? "Saving..." : (scheduled ? "Unschedule" : "Schedule"));
|
|
|
|
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);
|
|
|
|
const availabilityEl = field(card, "availability");
|
|
setText(availabilityEl, humanAvailability(availability));
|
|
setValueClass(availabilityEl, availability == null ? "dim" : availability >= 1 ? "success" : "warn");
|
|
|
|
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) || schedulerActionErrors.get((t.hashString || "").toLowerCase()) || "";
|
|
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 || []);
|
|
if (state.scheduledOnly) torrents = torrents.filter((t) => schedulerItemFor(t)?.optedIn);
|
|
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));
|
|
setText($("#empty-title"), state.filter ? "No matching torrents" : (state.scheduledOnly ? "No scheduled torrents" : "No torrents"));
|
|
setText($("#empty-sub"), state.filter ? "Try a different filter." : (state.scheduledOnly
|
|
? "Schedule a completed torrent here to see it in this view."
|
|
: "Add torrents to your Transmission client to see them here."));
|
|
}
|
|
|
|
function renderAll() {
|
|
renderHeader();
|
|
renderScheduleControls();
|
|
renderList();
|
|
}
|
|
|
|
/* ---------- Events ---------- */
|
|
function wireEvents() {
|
|
document.querySelectorAll(".app-tab").forEach((tab) => {
|
|
tab.addEventListener("click", () => { location.hash = tab.id === "tab-scheduler" ? "scheduler" : "torrents"; });
|
|
tab.addEventListener("keydown", (event) => {
|
|
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
|
event.preventDefault();
|
|
const next = event.key === "Home" ? "torrents"
|
|
: event.key === "End" ? "scheduler"
|
|
: (tab.id === "tab-torrents" ? "scheduler" : "torrents");
|
|
location.hash = next;
|
|
$(`#tab-${next}`).focus();
|
|
});
|
|
});
|
|
window.addEventListener("hashchange", () => activateTab(location.hash.slice(1)));
|
|
|
|
$("#scheduler-settings").addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const value = Number($("#scheduler-slots").value);
|
|
if (!Number.isInteger(value) || value < 1 || value > 100) {
|
|
schedulerError = "Active slots must be a whole number from 1 to 100.";
|
|
renderScheduler();
|
|
return;
|
|
}
|
|
setSchedulerSlots(value);
|
|
});
|
|
$("#scheduler-torrents").addEventListener("click", (event) => {
|
|
const button = event.target.closest(".scheduler-toggle");
|
|
if (!button || button.disabled) return;
|
|
const hash = button.closest(".scheduler-row").dataset.hash;
|
|
const item = schedulerData && schedulerData.torrents.find((t) => t.hashString === hash);
|
|
if (item) setSchedulerOptIn(hash, !item.optedIn);
|
|
});
|
|
$("#scheduler-history-more").addEventListener("click", () => loadSchedulerHistory(false));
|
|
|
|
const filterInput = $("#filter");
|
|
filterInput.addEventListener("input", (e) => {
|
|
state.filter = e.target.value;
|
|
renderList();
|
|
});
|
|
$("#scheduled-filter").addEventListener("click", () => {
|
|
state.scheduledOnly = !state.scheduledOnly;
|
|
renderScheduleControls();
|
|
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();
|
|
});
|
|
});
|
|
|
|
const refreshSelect = $("#refresh-interval");
|
|
if (refreshSelect) {
|
|
refreshSelect.value = String(state.refreshMs);
|
|
refreshSelect.addEventListener("change", (e) => {
|
|
const next = Number(e.target.value);
|
|
if (!REFRESH_INTERVALS.has(next)) {
|
|
e.target.value = String(state.refreshMs);
|
|
return;
|
|
}
|
|
setRefreshInterval(next);
|
|
});
|
|
}
|
|
|
|
const speedToggle = $("#alt-speed-toggle");
|
|
if (speedToggle) {
|
|
speedToggle.addEventListener("click", () => {
|
|
const enabled = !Boolean(sessionValue(state.session, "alt-speed-enabled", "alt_speed_enabled"));
|
|
setAlternativeSpeed(enabled);
|
|
});
|
|
}
|
|
|
|
$("#torrents").addEventListener("click", (e) => {
|
|
const scheduleButton = e.target.closest("[data-schedule-button]");
|
|
if (scheduleButton && !scheduleButton.disabled) {
|
|
const card = scheduleButton.closest(".card");
|
|
const torrent = (state.torrents || []).find((t) => String(t.id) === card.dataset.id);
|
|
const item = torrent && schedulerItemFor(torrent);
|
|
if (item) setSchedulerOptIn(item.hashString, !item.optedIn);
|
|
return;
|
|
}
|
|
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();
|
|
loadScheduler();
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------- Init ---------- */
|
|
function boot() {
|
|
wireEvents();
|
|
activateTab(location.hash.slice(1));
|
|
setRefreshInterval(state.refreshMs);
|
|
setInterval(() => {
|
|
if (!document.hidden) loadScheduler();
|
|
}, 10000);
|
|
sync();
|
|
if (location.hash !== "#scheduler") loadScheduler();
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", boot);
|