adding scheduled view in torrents tab
This commit is contained in:
+89
-19
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user