Files
transmission-manager/web/app.js
T
ivanch 19c3ee2e26
Build and Deploy (internal) / Build Transmission Manager Image (push) Successful in 3m22s
Build and Deploy (internal) / Deploy Transmission Manager (internal) (push) Successful in 3s
adding scheduled view in torrents tab
2026-09-25 20:57:09 -03:00

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);