adding scheduler slots
This commit is contained in:
@@ -18,7 +18,7 @@ Transmission Manager serves a fast, clean, dark-mode Single Page Application (SP
|
||||
- Filter torrents instantly by name.
|
||||
- Sort by **Status**, **Name**, **Progress**, **Ratio**, **Size**, **Speed**, or **Availability**.
|
||||
- **Interactive Controls**: Pause or resume individual torrents directly from the web interface.
|
||||
- **Smart Seed Scheduler**: Opt completed torrents in from the Scheduler tab, choose 1–100 managed seed slots, and rotate an idle slot to the least recently seeded waiting torrent after one hour without upload. A persistent cycle history shows activations and rotations.
|
||||
- **Smart Seed Scheduler**: Opt completed torrents in from the Scheduler tab, choose 1–100 active slots for them, and rotate an idle slot to the least recently seeded waiting torrent after one hour without upload. A persistent cycle history shows activations and rotations.
|
||||
- **Robust Transmission RPC Support**:
|
||||
- Automatically handles Transmission `409 Conflict` session ID (`X-Transmission-Session-Id`) handshakes.
|
||||
- Supports optional HTTP Basic Authentication.
|
||||
@@ -36,7 +36,7 @@ Transmission Manager is configured entirely via environment variables:
|
||||
| `TRANSMISSION_RPC_PASSWORD` | No | Basic Authentication password for Transmission RPC. |
|
||||
| `SCHEDULER_DB_PATH` | No | SQLite database path (default `./data/scheduler.db`). |
|
||||
|
||||
The scheduler runs in the server even when no browser is open. It manages only opted-in torrents. 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ This file is the canonical project context for AI agents working on Transmission
|
||||
|
||||
Transmission Manager is a lightweight local/LAN web app for viewing and managing torrents from a Transmission RPC daemon. The product goal is a fast, clean, dark-mode SPA that feels modern while remaining simple to build, deploy, and inspect.
|
||||
|
||||
The app is intentionally small: one Go binary serves both the API and embedded frontend assets. The frontend is vanilla JavaScript, HTML, and CSS, with no package manager, no compile step, and no JS framework. A server-side scheduler rotates opted-in completed torrents through a limited number of seed slots and records cycle history.
|
||||
The app is intentionally small: one Go binary serves both the API and embedded frontend assets. The frontend is vanilla JavaScript, HTML, and CSS, with no package manager, no compile step, and no JS framework. A server-side scheduler rotates opted-in completed torrents through a configurable number of active slots and records cycle history.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
@@ -79,7 +79,7 @@ Alternative-speed controls use the effective profile: alternative caps when `alt
|
||||
|
||||
The scheduler uses torrent hashes because Transmission numeric IDs can change after a daemon restart. It makes a lightweight `torrent-get` request for `hashString`, `name`, `percentDone`, `status`, `uploadedEver`, and `error`. It uses `torrent-start-now` for managed slots and `torrent-stop` for rotation; ordinary manual Resume keeps using `torrent-start`. Only opted-in, complete, error-free torrents are eligible. Manual actions on opted-in torrents are temporary; the next scheduler check restores the selected slot allocation.
|
||||
|
||||
The scheduler checks every 30 seconds independently of the browser. It defaults to one slot, allows 1–100, and only limits opted-in torrents. Each active slot tracks the last increase in `uploadedEver`; one hour without an increase makes it eligible to rotate to the least recently seeded waiting torrent. With no waiting torrent, the active one stays. A continuously uploading torrent may retain its slot indefinitely. SQLite retains settings, opt-ins, slot timestamps, and all cycle events across restarts; history is paged 50 events at a time.
|
||||
The scheduler checks every 30 seconds independently of the browser. Its active-slot setting defaults to one and allows 1–100. Only opted-in completed torrents consume these slots or receive scheduler start/stop actions; unopted torrents retain their Transmission state, including in-progress downloads and seeds. Each active slot tracks the last increase in `uploadedEver`; one hour without an increase makes it eligible to rotate to the least recently seeded waiting torrent. With no waiting torrent, the active one stays. A continuously uploading torrent may retain its slot indefinitely. SQLite retains settings, opt-ins, slot timestamps, and all cycle events across restarts; history is paged 50 events at a time.
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
|
||||
+28
-1
@@ -18,6 +18,7 @@ type fakeTransmission struct {
|
||||
mu sync.Mutex
|
||||
torrents map[string]schedulerTorrent
|
||||
failStart bool
|
||||
actions []string
|
||||
}
|
||||
|
||||
func newFakeTransmission(t *testing.T, torrents ...schedulerTorrent) (*Client, *fakeTransmission) {
|
||||
@@ -57,6 +58,7 @@ func newFakeTransmission(t *testing.T, torrents ...schedulerTorrent) (*Client, *
|
||||
response["result"] = "start failed"
|
||||
} else {
|
||||
for _, hash := range request.Arguments.IDs {
|
||||
fake.actions = append(fake.actions, request.Method+":"+hash)
|
||||
torrent := fake.torrents[hash]
|
||||
torrent.Status = 6
|
||||
fake.torrents[hash] = torrent
|
||||
@@ -64,6 +66,7 @@ func newFakeTransmission(t *testing.T, torrents ...schedulerTorrent) (*Client, *
|
||||
}
|
||||
case "torrent-stop":
|
||||
for _, hash := range request.Arguments.IDs {
|
||||
fake.actions = append(fake.actions, request.Method+":"+hash)
|
||||
torrent := fake.torrents[hash]
|
||||
torrent.Status = 0
|
||||
fake.torrents[hash] = torrent
|
||||
@@ -192,7 +195,9 @@ func TestSchedulerMultipleSlotsAndManualActions(t *testing.T) {
|
||||
a := schedulerTorrent{Hash: strings.Repeat("a", 40), Name: "A", PercentDone: 1}
|
||||
b := schedulerTorrent{Hash: strings.Repeat("b", 40), Name: "B", PercentDone: 1}
|
||||
c := schedulerTorrent{Hash: strings.Repeat("c", 40), Name: "C", PercentDone: 1}
|
||||
client, fake := newFakeTransmission(t, a, b, c)
|
||||
unmanagedDownload := schedulerTorrent{Hash: strings.Repeat("d", 40), Name: "Unmanaged download", PercentDone: .5, Status: 4}
|
||||
unmanagedSeed := schedulerTorrent{Hash: strings.Repeat("e", 40), Name: "Unmanaged seed", PercentDone: 1, Status: 6}
|
||||
client, fake := newFakeTransmission(t, a, b, c, unmanagedDownload, unmanagedSeed)
|
||||
scheduler := testScheduler(t, client)
|
||||
addOptIn(t, scheduler, a, b, c)
|
||||
if _, err := scheduler.db.Exec("UPDATE scheduler_settings SET max_active=2 WHERE id=1"); err != nil {
|
||||
@@ -229,6 +234,28 @@ func TestSchedulerMultipleSlotsAndManualActions(t *testing.T) {
|
||||
if got := activeHashes(t, scheduler); len(got) != 2 || got[0] != b.Hash || got[1] != c.Hash {
|
||||
t.Fatalf("expected one rotation with no same-tick reuse: %v", got)
|
||||
}
|
||||
if _, err := scheduler.db.Exec("UPDATE scheduler_settings SET max_active=1 WHERE id=1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scheduler.Tick(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := activeHashes(t, scheduler); len(got) != 1 {
|
||||
t.Fatalf("expected one opted-in active slot after reducing the limit: %v", got)
|
||||
}
|
||||
fake.mu.Lock()
|
||||
downloadStatus := fake.torrents[unmanagedDownload.Hash].Status
|
||||
seedStatus := fake.torrents[unmanagedSeed.Hash].Status
|
||||
actions := append([]string(nil), fake.actions...)
|
||||
fake.mu.Unlock()
|
||||
if downloadStatus != 4 || seedStatus != 6 {
|
||||
t.Fatalf("unopted torrents changed status: download=%d seed=%d", downloadStatus, seedStatus)
|
||||
}
|
||||
for _, action := range actions {
|
||||
if strings.Contains(action, unmanagedDownload.Hash) || strings.Contains(action, unmanagedSeed.Hash) {
|
||||
t.Fatalf("scheduler sent an action for an unopted torrent: %s", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerFailureDoesNotRecordActivation(t *testing.T) {
|
||||
|
||||
+3
-3
@@ -313,7 +313,7 @@ function renderScheduler() {
|
||||
$("#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} active · ${optedIn} opted in`);
|
||||
setText($("#scheduler-count"), `${active} / ${schedulerData.maxActive} active slots · ${optedIn} opted in`);
|
||||
}
|
||||
|
||||
function renderSchedulerHistory() {
|
||||
@@ -387,7 +387,7 @@ async function setSchedulerSlots(value) {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ maxActive: value }),
|
||||
});
|
||||
setText($("#scheduler-feedback"), "Seed slots saved.");
|
||||
setText($("#scheduler-feedback"), "Active slots saved.");
|
||||
schedulerError = "";
|
||||
await loadScheduler();
|
||||
} catch (err) {
|
||||
@@ -892,7 +892,7 @@ function wireEvents() {
|
||||
event.preventDefault();
|
||||
const value = Number($("#scheduler-slots").value);
|
||||
if (!Number.isInteger(value) || value < 1 || value > 100) {
|
||||
schedulerError = "Seed slots must be a whole number from 1 to 100.";
|
||||
schedulerError = "Active slots must be a whole number from 1 to 100.";
|
||||
renderScheduler();
|
||||
return;
|
||||
}
|
||||
|
||||
+2
-2
@@ -115,10 +115,10 @@
|
||||
<div class="scheduler-heading">
|
||||
<div>
|
||||
<h2>Seed scheduler</h2>
|
||||
<p>Completed torrents take turns when a scheduled seed has not uploaded for one hour.</p>
|
||||
<p>Opted-in completed torrents share the active slots and rotate after one hour without upload. Other torrents remain under Transmission's control.</p>
|
||||
</div>
|
||||
<form id="scheduler-settings" class="scheduler-settings">
|
||||
<label for="scheduler-slots">Seed slots</label>
|
||||
<label for="scheduler-slots">Active slots</label>
|
||||
<input id="scheduler-slots" type="number" min="1" max="100" value="1" required />
|
||||
<button type="submit" class="scheduler-button">Save</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user