adding scheduler slots
Build and Deploy (internal) / Build Transmission Manager Image (push) Successful in 2m20s
Build and Deploy (internal) / Deploy Transmission Manager (internal) (push) Successful in 3s

This commit is contained in:
2026-09-23 18:32:32 -03:00
parent c98af34202
commit 0d686847f2
5 changed files with 37 additions and 10 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>