adding scheduled view in torrents tab
Build and Deploy (internal) / Build Transmission Manager Image (push) Successful in 3m22s
Build and Deploy (internal) / Deploy Transmission Manager (internal) (push) Successful in 3s

This commit is contained in:
2026-09-25 20:57:09 -03:00
parent 0d686847f2
commit 19c3ee2e26
5 changed files with 141 additions and 24 deletions
+2
View File
@@ -38,6 +38,8 @@ Transmission Manager is configured entirely via environment variables:
The scheduler runs in the server even when no browser is open. Active slots apply only to opted-in completed torrents. Torrents outside the opt-in set do not count toward those slots or have their Transmission state changed: an unopted download keeps downloading, and an unopted seed keeps seeding. A scheduled torrent that continues uploading can keep its slot indefinitely, so rotation does not guarantee a fixed maximum waiting time. Manual pause or resume of an opted-in torrent is temporary; the scheduler restores its chosen state on its next check.
In the Torrents tab, each card shows whether it is scheduled. Use the Scheduled filter to show only opted-in torrents, or the Schedule/Unschedule button on a completed torrent to change its opt-in without leaving the tab. The Scheduler tab remains available for active slot settings and cycle history.
---
## Quick Start
+2 -2
View File
@@ -24,7 +24,7 @@ The app is intentionally small: one Go binary serves both the API and embedded f
- `main.go`: backend entrypoint. Owns the HTTP server, embedded static file serving, environment-driven Transmission RPC client configuration, session-ID handshake, connection warning page, JSON helpers, and API routes.
- `scheduler.go`: SQLite persistence, background scheduling loop, Transmission seed-slot reconciliation, and scheduler API routes.
- `web/index.html`: SPA shell. Defines the header, stats pills, alternative-speed controls, filter input, sort buttons, list container, empty state, error state, footer, and script/style links.
- `web/index.html`: SPA shell. Defines the header, stats pills, alternative-speed controls, torrent search and Scheduled filter, sort buttons, list container, empty state, error state, footer, and script/style links.
- `web/icon.svg`: editable vector source for the app icon.
- `web/icon.png`: reusable raster app icon used by the SPA header and browser tab.
- `web/app.js`: frontend application logic. Owns state, polling, fetch helpers, formatting, session-limit presentation and alternative-speed toggling, torrent classification, sorting, keyed DOM reconciliation, pause/resume actions, and event wiring.
@@ -83,7 +83,7 @@ The scheduler checks every 30 seconds independently of the browser. Its active-s
## Frontend Architecture
The frontend is a single page with module-level state in `web/app.js`. It polls on a user-selectable interval, defaults to 3 seconds, and fetches torrents, stats, and session settings in parallel. The Torrents and Scheduler views are accessible tabs. The Scheduler view fetches its state every 10 seconds while visible and loads history when it changes.
The frontend is a single page with module-level state in `web/app.js`. It polls on a user-selectable interval, defaults to 3 seconds, and fetches torrents, stats, and session settings in parallel. The Torrents and Scheduler views are accessible tabs. Scheduler state is fetched every 10 seconds while the page is visible so the Torrents view can show opt-in badges, filter to scheduled torrents, and change opt-ins on cards. The Scheduler view loads history when it changes.
Important patterns:
+89 -19
View File
@@ -22,6 +22,7 @@ const state = {
stats: {},
session: null,
filter: "",
scheduledOnly: false,
sortKey: "status",
refreshMs: DEFAULT_REFRESH_MS,
lastError: null,
@@ -35,8 +36,9 @@ let refreshTimer = null;
let speedControlPending = false;
let speedControlError = null;
let schedulerData = null;
let schedulerItems = new Map();
let schedulerError = "";
let schedulerLoading = false;
let schedulerLoadPromise = null;
let schedulerSettingsPending = false;
let schedulerHistoryLoading = false;
let schedulerHistoryLoaded = false;
@@ -44,6 +46,7 @@ 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);
@@ -153,7 +156,7 @@ async function runTorrentAction(id, action) {
await fetchJSON(`/api/torrents/${id}/${action}`, { method: "POST" });
actionStates.delete(id);
await sync();
if (location.hash === "#scheduler") loadScheduler();
loadScheduler();
} catch (err) {
actionStates.set(id, {
pending: null,
@@ -256,6 +259,24 @@ function schedulerStatus(item) {
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";
@@ -361,21 +382,25 @@ async function loadSchedulerHistory(reset = false) {
}
async function loadScheduler() {
if (schedulerLoading) return;
schedulerLoading = true;
try {
schedulerData = await fetchJSON("/api/scheduler");
schedulerError = "";
renderScheduler();
if (!schedulerHistoryLoaded || schedulerData.latestEventId !== schedulerHistoryLatestID) {
await loadSchedulerHistory(true);
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();
}
} catch (err) {
schedulerError = err.message || String(err);
renderScheduler();
} finally {
schedulerLoading = false;
}
})();
try { await schedulerLoadPromise; } finally { schedulerLoadPromise = null; }
}
async function setSchedulerSlots(value) {
@@ -401,19 +426,25 @@ async function setSchedulerSlots(value) {
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();
}
}
@@ -647,6 +678,7 @@ function createCard() {
<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>
@@ -658,6 +690,7 @@ function createCard() {
<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>
@@ -761,6 +794,23 @@ function updateCard(card, t) {
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)}%`;
@@ -801,7 +851,7 @@ function updateCard(card, t) {
setText(button, actionState && actionState.pending ? actionState.pendingText : action.label);
const actionError = field(card, "action-error");
const actionErrorText = actionState && actionState.error ? actionState.error : "";
const actionErrorText = (actionState && actionState.error) || schedulerActionErrors.get((t.hashString || "").toLowerCase()) || "";
setText(actionError, actionErrorText);
actionError.classList.toggle("hidden", !actionErrorText);
}
@@ -860,15 +910,21 @@ function renderList() {
}
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();
}
@@ -912,6 +968,11 @@ function wireEvents() {
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", () => {
@@ -944,6 +1005,14 @@ function wireEvents() {
}
$("#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;
@@ -958,7 +1027,7 @@ function wireEvents() {
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
sync();
if (location.hash === "#scheduler") loadScheduler();
loadScheduler();
}
});
}
@@ -969,9 +1038,10 @@ function boot() {
activateTab(location.hash.slice(1));
setRefreshInterval(state.refreshMs);
setInterval(() => {
if (!document.hidden && location.hash === "#scheduler") loadScheduler();
if (!document.hidden) loadScheduler();
}, 10000);
sync();
if (location.hash !== "#scheduler") loadScheduler();
}
document.addEventListener("DOMContentLoaded", boot);
+4 -2
View File
@@ -75,6 +75,7 @@
<button class="sort-btn" data-sort="availability">Availability</button>
</div>
<div class="toolbar-actions">
<button type="button" id="scheduled-filter" class="scheduled-filter" aria-pressed="false" disabled>Scheduled</button>
<label class="interval-control" for="refresh-interval">
<span class="sr-only">Refresh interval</span>
<select id="refresh-interval" class="refresh-select" aria-label="Refresh interval">
@@ -90,6 +91,7 @@
</div>
</div>
</div>
<p id="schedule-status" class="schedule-status hidden" role="status"></p>
<section id="torrents" class="torrent-list" aria-live="polite"></section>
@@ -100,8 +102,8 @@
opacity="0.4" />
</svg>
</div>
<p class="empty-title">No torrents</p>
<p class="empty-sub">Add torrents to your Transmission client to see them here.</p>
<p class="empty-title" id="empty-title">No torrents</p>
<p class="empty-sub" id="empty-sub">Add torrents to your Transmission client to see them here.</p>
</div>
<div id="error-state" class="error-state hidden">
+44 -1
View File
@@ -290,6 +290,22 @@ button { font-family: inherit; }
gap: 10px;
margin-left: auto;
}
.scheduled-filter {
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--bg-2);
color: var(--text-1);
cursor: pointer;
font-size: 12px;
font-weight: 700;
padding: 8px 11px;
white-space: nowrap;
}
.scheduled-filter:hover:not(:disabled) { border-color: var(--border-hover); color: var(--text-0); }
.scheduled-filter[aria-pressed="true"] { border-color: rgba(0, 212, 255, .3); background: var(--accent-soft); color: var(--accent); }
.scheduled-filter:disabled { opacity: .5; cursor: wait; }
.scheduled-filter:focus-visible, .card-schedule-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.schedule-status { margin: -6px 0 16px; color: var(--err); font-size: 12px; }
.interval-control { display: inline-flex; align-items: center; }
.refresh-select {
color-scheme: dark;
@@ -385,6 +401,18 @@ button { font-family: inherit; }
font-size: 12px; color: var(--text-2);
font-variant-numeric: tabular-nums;
}
.schedule-badge {
display: inline-flex;
align-items: center;
border: 1px solid var(--border-strong);
border-radius: 999px;
color: var(--text-3);
font-size: 10px;
font-weight: 700;
padding: 1px 7px;
white-space: nowrap;
}
.schedule-badge.is-scheduled { border-color: rgba(139, 92, 246, .35); background: rgba(139, 92, 246, .12); color: var(--purple); }
.meta-item { display: inline-flex; align-items: center; gap: 5px; }
.meta-item .ico { font-size: 11px; opacity: 0.8; }
.meta-item.down .icon-color { color: var(--accent); }
@@ -402,6 +430,21 @@ button { font-family: inherit; }
align-items: center;
gap: 10px;
}
.card-schedule-toggle {
flex: 0 0 auto;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: var(--bg-3);
color: var(--text-1);
cursor: pointer;
font-size: 11px;
font-weight: 700;
padding: 6px 10px;
white-space: nowrap;
}
.card-schedule-toggle:hover:not(:disabled) { border-color: var(--border-hover); color: var(--text-0); }
.card-schedule-toggle[aria-pressed="true"] { border-color: rgba(139, 92, 246, .35); color: var(--purple); }
.card-schedule-toggle:disabled { opacity: .6; cursor: wait; }
.action-btn {
position: relative;
margin-left: 6px;
@@ -581,7 +624,7 @@ button { font-family: inherit; }
.speed-control-status { max-width: 100%; width: 100%; }
.card-top { flex-wrap: wrap; }
.card-body { flex-basis: calc(100% - 42px); }
.card-actions { width: 100%; flex-direction: row; justify-content: space-between; }
.card-actions { width: 100%; flex-direction: row; flex-wrap: wrap; justify-content: flex-end; }
.card-status-col { flex-direction: column-reverse; align-items: flex-start; gap: 6px; }
.scheduler-row { align-items: flex-start; }
.scheduler-row-controls { flex-direction: column; align-items: flex-end; gap: 6px; }