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

This commit is contained in:
2026-09-23 17:32:39 -03:00
parent 44889338c2
commit c98af34202
12 changed files with 1430 additions and 8 deletions
+5
View File
@@ -12,3 +12,8 @@ Thumbs.db
# Docker
.docker/
# Scheduler database
data/
*.db-wal
*.db-shm
+2 -1
View File
@@ -7,7 +7,7 @@ Always read `project-context.md` before making code or documentation changes. If
Transmission Manager is a lightweight single-binary Go web app for managing and viewing torrents in a Transmission RPC client. It serves a dark-mode vanilla HTML/CSS/JS SPA and proxies Transmission RPC calls through a minimal Go backend.
## Implementation Guidelines
- Keep the backend stdlib-only. Do not add Gin, Fiber, Chi, Gorilla, or other Go web frameworks.
- Keep the backend stdlib-only except for the approved pure-Go SQLite driver used by the scheduler. Do not add Gin, Fiber, Chi, Gorilla, or other Go web frameworks.
- Keep the frontend dependency-free. Do not add React, Vue, Svelte, bundlers, package managers, or transpilers.
- Preserve the single-binary model: static frontend files should remain embedded via `embed`.
- Format display numbers in the frontend unless there is a clear backend reason.
@@ -25,6 +25,7 @@ Transmission Manager is a lightweight single-binary Go web app for managing and
4. Cache the session ID behind a mutex.
- "Peers at 100%" means count `peers[]` entries where `progress === 1.0`.
- `doneDate` is a Unix timestamp; `0` means unfinished.
- Scheduler opt-ins use `hashString` because numeric torrent IDs can change across daemon restarts. The scheduler uses `torrent-start-now` for its slots; manual Resume remains queue-respecting.
## Do Not
- Do not add authentication unless explicitly requested.
+12
View File
@@ -18,6 +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.
- **Robust Transmission RPC Support**:
- Automatically handles Transmission `409 Conflict` session ID (`X-Transmission-Session-Id`) handshakes.
- Supports optional HTTP Basic Authentication.
@@ -33,6 +34,9 @@ Transmission Manager is configured entirely via environment variables:
| `TRANSMISSION_URL` | **Yes** | Absolute HTTP or HTTPS RPC endpoint URL of your Transmission instance. |
| `TRANSMISSION_RPC_USERNAME` | No | Basic Authentication username for Transmission RPC. |
| `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.
---
@@ -46,6 +50,7 @@ Run the container directly:
docker run -d \
--name transmission-manager \
-p 8080:8080 \
-v transmission-manager-data:/app/data \
-e TRANSMISSION_URL=http://192.168.1.100:9091/transmission/rpc \
-e TRANSMISSION_RPC_USERNAME=admin \
-e TRANSMISSION_RPC_PASSWORD=secret \
@@ -63,11 +68,16 @@ services:
container_name: transmission-manager
ports:
- "8080:8080"
volumes:
- transmission-manager-data:/app/data
environment:
TRANSMISSION_URL: http://192.168.1.100:9091/transmission/rpc
TRANSMISSION_RPC_USERNAME: admin
TRANSMISSION_RPC_PASSWORD: secret
restart: unless-stopped
volumes:
transmission-manager-data:
```
Then start the application:
@@ -104,3 +114,5 @@ export TRANSMISSION_URL="http://localhost:9091/transmission/rpc"
```
By default, the server listens on port `8080`.
Keep the `data` directory (or the path set by `SCHEDULER_DB_PATH`) across restarts to preserve scheduler settings, opt-ins, and cycle history. The app creates the database directory on startup and exits with a clear error if it cannot open the database.
+16 -1
View File
@@ -1,3 +1,18 @@
module transmission-manager
go 1.24
go 1.24.0
require modernc.org/sqlite v1.40.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+49
View File
@@ -0,0 +1,49 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ=
modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+58 -1
View File
@@ -157,7 +157,7 @@ func (c *Client) rpc(ctx context.Context, body []byte) ([]byte, error) {
// torrentFields requested from Transmission (per AGENTS.md).
var torrentFields = []string{
"id", "name", "totalSize", "downloadedEver", "uploadedEver", "uploadRatio",
"id", "hashString", "name", "totalSize", "downloadedEver", "uploadedEver", "uploadRatio",
"percentDone", "status", "eta", "error", "errorString", "doneDate", "isFinished",
"rateDownload", "rateUpload", "peersConnected", "peersGettingFromUs", "peersSendingToUs",
"availability", "peers",
@@ -283,6 +283,51 @@ func (c *Client) TorrentAction(ctx context.Context, method string, ids []int) er
return nil
}
func (c *Client) SchedulerTorrents(ctx context.Context) ([]schedulerTorrent, error) {
reqBody, _ := json.Marshal(rpcRequest{
Method: "torrent-get",
Arguments: rpcArgs{Fields: []string{"hashString", "name", "percentDone", "status", "uploadedEver", "error"}},
})
data, err := c.rpc(ctx, reqBody)
if err != nil {
return nil, err
}
var resp struct {
Result string `json:"result"`
Args struct {
Torrents []schedulerTorrent `json:"torrents"`
} `json:"arguments"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
if resp.Result != "success" {
return nil, fmt.Errorf("rpc result: %s", resp.Result)
}
return resp.Args.Torrents, nil
}
func (c *Client) TorrentActionHash(ctx context.Context, method, hash string) error {
reqBody, _ := json.Marshal(rpcRequest{
Method: method,
Arguments: struct {
IDs []string `json:"ids"`
}{IDs: []string{hash}},
})
data, err := c.rpc(ctx, reqBody)
if err != nil {
return err
}
var resp rpcResponse
if err := json.Unmarshal(data, &resp); err != nil {
return err
}
if resp.Result != "success" {
return fmt.Errorf("rpc result: %s", resp.Result)
}
return nil
}
func (c *Client) Check(ctx context.Context) error {
_, err := c.Stats(ctx)
return err
@@ -559,6 +604,14 @@ func main() {
} else {
log.Printf("transmission RPC target: %s", redactedURL(client.rpcURL))
}
scheduler, err := NewScheduler(client, os.Getenv("SCHEDULER_DB_PATH"))
if err != nil {
log.Fatalf("scheduler database: %v", err)
}
defer scheduler.Close()
if client != nil {
go scheduler.Run(context.Background())
}
mux := http.NewServeMux()
@@ -607,6 +660,10 @@ func main() {
writeJSON(w, args)
})
mux.HandleFunc("/api/session/alternative-speed", handleAlternativeSpeed(client, setupErr))
mux.HandleFunc("/api/scheduler", scheduler.handleState)
mux.HandleFunc("/api/scheduler/settings", scheduler.handleSettings)
mux.HandleFunc("/api/scheduler/history", scheduler.handleHistory)
mux.HandleFunc("/api/scheduler/torrents/", scheduler.handleOptIn)
// Static files from embedded web/ directory
webRoot, err := fs.Sub(webFS, "web")
+15 -5
View File
@@ -6,22 +6,24 @@ 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.
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.
## Technology Stack
- Go module: `transmission-manager`
- Go version: `1.24`
- Backend dependencies: standard library only
- Backend dependencies: standard library plus the approved pure-Go `modernc.org/sqlite` driver for scheduler persistence
- Frontend dependencies: none
- Static assets: embedded with Go `embed`
- Runtime port: `8080`
- Docker build: `golang:1.24-alpine` builder, `alpine:latest` runtime
- Runtime RPC configuration: `TRANSMISSION_URL`, `TRANSMISSION_RPC_USERNAME`, and `TRANSMISSION_RPC_PASSWORD`
- Scheduler database: `SCHEDULER_DB_PATH` (default `./data/scheduler.db`); mount `/app/data` in Docker for persistence
## Repository Organization
- `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/icon.svg`: editable vector source for the app icon.
- `web/icon.png`: reusable raster app icon used by the SPA header and browser tab.
@@ -42,6 +44,10 @@ The backend uses `net/http` with a single `http.ServeMux`. It exposes:
- `POST /api/session/alternative-speed`: validates an `{"enabled": boolean}` body and sends Transmission `session-set` to toggle alternative speeds.
- `POST /api/torrents/{id}/pause`: validates a positive numeric torrent id and sends `torrent-stop`.
- `POST /api/torrents/{id}/resume`: validates a positive numeric torrent id and sends queue-respecting `torrent-start`.
- `GET /api/scheduler`: returns slot settings, completed torrent opt-ins, active/waiting states, and scheduler errors.
- `POST /api/scheduler/settings`: accepts `{"maxActive": 1..100}`.
- `POST /api/scheduler/torrents/{hash}/opt-in`: accepts `{"enabled": boolean}`; enabling requires a complete, error-free torrent.
- `GET /api/scheduler/history?before=<id>`: returns up to 50 cycle events and a `hasMore` flag.
- `/`: checks the Transmission connection and serves a warning page with the connection error when setup fails; otherwise serves embedded files from `web/`.
Transmission RPC calls must reuse the existing `Client.rpc` path so session-ID handling is consistent. The session ID is cached in `Client.sessionID` and guarded by `sync.Mutex`.
@@ -57,7 +63,7 @@ Transmission usually rejects the first request or an expired session with HTTP `
Torrent list requests should include:
```text
id, name, totalSize, downloadedEver, uploadedEver, uploadRatio,
id, hashString, name, totalSize, downloadedEver, uploadedEver, uploadRatio,
percentDone, status, eta, error, errorString, doneDate, isFinished,
rateDownload, rateUpload, peersConnected, peersGettingFromUs, peersSendingToUs,
availability, peers
@@ -71,9 +77,13 @@ The UI availability value comes from Transmission's `availability` per-piece arr
Alternative-speed controls use the effective profile: alternative caps when `alt-speed-enabled` is true, otherwise enabled normal caps. Positive caps are displayed in the header; zero or negative values are treated as unrestricted.
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.
## 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 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.
Important patterns:
@@ -106,7 +116,7 @@ Current sort keys:
## Implementation Rules
- Do not add external Go dependencies.
- Do not add external Go dependencies beyond the approved pure-Go SQLite driver.
- Do not add frontend dependencies, bundlers, or transpilation.
- Do not add auth unless explicitly requested.
- Do not add CI/CD, GitHub Actions, or repository setup.
+612
View File
@@ -0,0 +1,612 @@
package main
import (
"context"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
const schedulerInterval = 30 * time.Second
const schedulerIdleLimit = time.Hour
type schedulerTorrent struct {
Hash string `json:"hashString"`
Name string `json:"name"`
PercentDone float64 `json:"percentDone"`
Status int `json:"status"`
UploadedEver int64 `json:"uploadedEver"`
Error int `json:"error"`
}
func (t schedulerTorrent) eligible() bool { return t.PercentDone >= 1 && t.Error == 0 && t.Hash != "" }
type schedulerRow struct {
Hash string
Name string
LastSeeded int64
SlotStarted int64
LastUpload int64
UploadedEver int64
Active bool
}
type Scheduler struct {
client *Client
db *sql.DB
mu sync.Mutex
now func() time.Time
lastError string
retryAt map[string]int64
}
func NewScheduler(client *Client, path string) (*Scheduler, error) {
if path == "" {
path = "data/scheduler.db"
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
for _, query := range []string{
"PRAGMA busy_timeout = 5000",
"PRAGMA journal_mode = WAL",
`CREATE TABLE IF NOT EXISTS scheduler_settings (
id INTEGER PRIMARY KEY CHECK (id = 1), max_active INTEGER NOT NULL CHECK (max_active BETWEEN 1 AND 100)
)`,
"INSERT OR IGNORE INTO scheduler_settings (id, max_active) VALUES (1, 1)",
`CREATE TABLE IF NOT EXISTS scheduler_torrents (
hash TEXT PRIMARY KEY, name TEXT NOT NULL, last_seeded_at INTEGER NOT NULL DEFAULT 0,
slot_started_at INTEGER NOT NULL DEFAULT 0, last_upload_at INTEGER NOT NULL DEFAULT 0,
uploaded_ever INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS scheduler_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, happened_at INTEGER NOT NULL,
from_hash TEXT NOT NULL, from_name TEXT NOT NULL, to_hash TEXT NOT NULL,
to_name TEXT NOT NULL, reason TEXT NOT NULL
)`,
} {
if _, err := db.Exec(query); err != nil {
db.Close()
return nil, err
}
}
return &Scheduler{client: client, db: db, now: time.Now, retryAt: make(map[string]int64)}, nil
}
func (s *Scheduler) Close() error { return s.db.Close() }
func (s *Scheduler) Run(ctx context.Context) {
check := func() {
checkCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
if err := s.Tick(checkCtx); err != nil {
log.Printf("scheduler: %v", err)
}
}
check()
ticker := time.NewTicker(schedulerInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
check()
}
}
}
func (s *Scheduler) rows(ctx context.Context) ([]*schedulerRow, error) {
r, err := s.db.QueryContext(ctx, `SELECT hash, name, last_seeded_at, slot_started_at,
last_upload_at, uploaded_ever, active FROM scheduler_torrents ORDER BY hash`)
if err != nil {
return nil, err
}
defer r.Close()
var result []*schedulerRow
for r.Next() {
row := new(schedulerRow)
if err := r.Scan(&row.Hash, &row.Name, &row.LastSeeded, &row.SlotStarted,
&row.LastUpload, &row.UploadedEver, &row.Active); err != nil {
return nil, err
}
result = append(result, row)
}
return result, r.Err()
}
func (s *Scheduler) saveRow(ctx context.Context, row *schedulerRow) error {
_, err := s.db.ExecContext(ctx, `UPDATE scheduler_torrents SET name=?, last_seeded_at=?,
slot_started_at=?, last_upload_at=?, uploaded_ever=?, active=? WHERE hash=?`,
row.Name, row.LastSeeded, row.SlotStarted, row.LastUpload, row.UploadedEver, row.Active, row.Hash)
return err
}
func (s *Scheduler) event(ctx context.Context, at int64, from, to *schedulerRow, reason string) error {
fromHash, fromName, toHash, toName := "", "", "", ""
if from != nil {
fromHash, fromName = from.Hash, from.Name
}
if to != nil {
toHash, toName = to.Hash, to.Name
}
_, err := s.db.ExecContext(ctx, `INSERT INTO scheduler_events
(happened_at, from_hash, from_name, to_hash, to_name, reason) VALUES (?, ?, ?, ?, ?, ?)`,
at, fromHash, fromName, toHash, toName, reason)
return err
}
func nextWaiting(rows []*schedulerRow, live map[string]schedulerTorrent, exclude map[string]bool) *schedulerRow {
var waiting []*schedulerRow
for _, row := range rows {
if t, ok := live[row.Hash]; ok && t.eligible() && !row.Active && !exclude[row.Hash] {
waiting = append(waiting, row)
}
}
sort.Slice(waiting, func(i, j int) bool {
if waiting[i].LastSeeded != waiting[j].LastSeeded {
return waiting[i].LastSeeded < waiting[j].LastSeeded
}
return waiting[i].Hash < waiting[j].Hash
})
if len(waiting) == 0 {
return nil
}
return waiting[0]
}
func (s *Scheduler) activate(ctx context.Context, row *schedulerRow, t schedulerTorrent, at int64, from *schedulerRow, reason string) error {
if t.Status != 6 {
if err := s.client.TorrentActionHash(ctx, "torrent-start-now", row.Hash); err != nil {
return fmt.Errorf("start %s: %w", row.Name, err)
}
}
row.Active = true
row.LastSeeded = at
row.SlotStarted = at
row.LastUpload = at
row.UploadedEver = t.UploadedEver
if err := s.saveRow(ctx, row); err != nil {
return err
}
return s.event(ctx, at, from, row, reason)
}
func (s *Scheduler) deactivate(ctx context.Context, row *schedulerRow, t schedulerTorrent) error {
if t.Status == 6 || t.Status == 5 {
if err := s.client.TorrentActionHash(ctx, "torrent-stop", row.Hash); err != nil {
return fmt.Errorf("stop %s: %w", row.Name, err)
}
}
row.Active = false
row.SlotStarted = 0
delete(s.retryAt, row.Hash)
return s.saveRow(ctx, row)
}
func (s *Scheduler) Tick(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
err := s.tickLocked(ctx)
if err != nil {
s.lastError = err.Error()
} else {
s.lastError = ""
}
return err
}
func (s *Scheduler) tickLocked(ctx context.Context) error {
if s.client == nil {
return nil
}
torrents, err := s.client.SchedulerTorrents(ctx)
if err != nil {
return err
}
live := make(map[string]schedulerTorrent, len(torrents))
for _, t := range torrents {
live[strings.ToLower(t.Hash)] = t
}
rows, err := s.rows(ctx)
if err != nil {
return err
}
var maxActive int
if err := s.db.QueryRowContext(ctx, "SELECT max_active FROM scheduler_settings WHERE id=1").Scan(&maxActive); err != nil {
return err
}
at := s.now().Unix()
active := make([]*schedulerRow, 0)
for _, row := range rows {
t, exists := live[row.Hash]
if !exists || !t.eligible() {
if row.Active {
row.Active = false
row.SlotStarted = 0
if err := s.saveRow(ctx, row); err != nil {
return err
}
}
continue
}
row.Name = t.Name
if row.Active {
if row.SlotStarted == 0 {
row.SlotStarted, row.LastUpload = at, at
}
if t.UploadedEver > row.UploadedEver {
row.LastUpload = at
}
row.UploadedEver = t.UploadedEver
active = append(active, row)
} else {
row.UploadedEver = t.UploadedEver
}
if err := s.saveRow(ctx, row); err != nil {
return err
}
}
// Keep the longest-held slots when the configured limit is lowered.
sort.Slice(active, func(i, j int) bool {
if active[i].SlotStarted != active[j].SlotStarted {
return active[i].SlotStarted < active[j].SlotStarted
}
return active[i].Hash < active[j].Hash
})
for len(active) > maxActive {
row := active[len(active)-1]
if err := s.deactivate(ctx, row, live[row.Hash]); err != nil {
return err
}
if err := s.event(ctx, at, row, nil, "slot limit"); err != nil {
return err
}
active = active[:len(active)-1]
}
// Adopt already seeding opted-in torrents if a slot is open. Stop extras.
for _, row := range rows {
t, ok := live[row.Hash]
if !ok || !t.eligible() || row.Active || t.Status != 6 {
continue
}
if len(active) < maxActive {
if err := s.activate(ctx, row, t, at, nil, "adopted"); err != nil {
return err
}
active = append(active, row)
} else if err := s.deactivate(ctx, row, t); err != nil {
return err
}
}
rotatedOut := make(map[string]bool)
for _, row := range active {
t := live[row.Hash]
if t.Status == 6 {
delete(s.retryAt, row.Hash)
}
if at-row.LastUpload >= int64(schedulerIdleLimit/time.Second) {
next := nextWaiting(rows, live, rotatedOut)
if next != nil {
if err := s.deactivate(ctx, row, t); err != nil {
return err
}
rotatedOut[row.Hash] = true
if err := s.activate(ctx, next, live[next.Hash], at, row, "idle for 1 hour"); err != nil {
return err
}
continue
}
}
if t.Status != 6 && at >= s.retryAt[row.Hash] {
if err := s.client.TorrentActionHash(ctx, "torrent-start-now", row.Hash); err != nil {
return fmt.Errorf("restore %s: %w", row.Name, err)
}
s.retryAt[row.Hash] = at + 5*60
}
}
count := 0
for _, row := range rows {
if row.Active {
count++
}
}
for count < maxActive {
next := nextWaiting(rows, live, rotatedOut)
if next == nil {
break
}
if err := s.activate(ctx, next, live[next.Hash], at, nil, "slot filled"); err != nil {
return err
}
count++
}
return nil
}
type schedulerItem struct {
Hash string `json:"hashString"`
Name string `json:"name"`
OptedIn bool `json:"optedIn"`
Active bool `json:"active"`
Status int `json:"status"`
Available bool `json:"available"`
LastSeeded int64 `json:"lastSeededAt"`
LastUpload int64 `json:"lastUploadAt"`
}
func (s *Scheduler) handleState(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if s.client == nil {
writeError(w, http.StatusBadGateway, "transmission client is not configured")
return
}
torrents, err := s.client.SchedulerTorrents(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.mu.Lock()
defer s.mu.Unlock()
rows, err := s.rows(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
byHash := make(map[string]*schedulerRow, len(rows))
for _, row := range rows {
byHash[row.Hash] = row
}
items := make([]schedulerItem, 0)
seen := make(map[string]bool)
for _, t := range torrents {
hash := strings.ToLower(t.Hash)
if !t.eligible() && byHash[hash] == nil {
continue
}
row := byHash[hash]
item := schedulerItem{Hash: hash, Name: t.Name, Status: t.Status, Available: t.eligible()}
if row != nil {
item.OptedIn, item.Active = true, row.Active
item.LastSeeded, item.LastUpload = row.LastSeeded, row.LastUpload
}
items = append(items, item)
seen[hash] = true
}
for _, row := range rows {
if !seen[row.Hash] {
items = append(items, schedulerItem{Hash: row.Hash, Name: row.Name, OptedIn: true,
LastSeeded: row.LastSeeded, LastUpload: row.LastUpload})
}
}
sort.Slice(items, func(i, j int) bool { return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name) })
var maxActive int
var latest int64
if err := s.db.QueryRowContext(r.Context(), "SELECT max_active FROM scheduler_settings WHERE id=1").Scan(&maxActive); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.db.QueryRowContext(r.Context(), "SELECT COALESCE(MAX(id),0) FROM scheduler_events").Scan(&latest); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
MaxActive int `json:"maxActive"`
Torrents []schedulerItem `json:"torrents"`
LatestEventID int64 `json:"latestEventId"`
Error string `json:"error"`
}{maxActive, items, latest, s.lastError})
}
func decodeSchedulerBody(r *http.Request, dest any) error {
decoder := json.NewDecoder(io.LimitReader(r.Body, 1024))
decoder.DisallowUnknownFields()
if err := decoder.Decode(dest); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return fmt.Errorf("request body must contain one JSON object")
}
return nil
}
func (s *Scheduler) handleSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var body struct {
MaxActive *int `json:"maxActive"`
}
if err := decodeSchedulerBody(r, &body); err != nil || body.MaxActive == nil || *body.MaxActive < 1 || *body.MaxActive > 100 {
writeError(w, http.StatusBadRequest, "maxActive must be an integer from 1 to 100")
return
}
s.mu.Lock()
_, err := s.db.ExecContext(r.Context(), "UPDATE scheduler_settings SET max_active=? WHERE id=1", *body.MaxActive)
s.mu.Unlock()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.Tick(r.Context()); err != nil {
log.Printf("scheduler settings: %v", err)
}
writeJSON(w, json.RawMessage(`{"ok":true}`))
}
func (s *Scheduler) handleOptIn(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/scheduler/torrents/")
parts := strings.Split(path, "/")
if len(parts) != 2 || parts[1] != "opt-in" {
writeError(w, http.StatusNotFound, "not found")
return
}
hash := strings.ToLower(parts[0])
if len(hash) != 40 {
writeError(w, http.StatusBadRequest, "invalid torrent hash")
return
}
if _, err := hex.DecodeString(hash); err != nil {
writeError(w, http.StatusBadRequest, "invalid torrent hash")
return
}
var body struct {
Enabled *bool `json:"enabled"`
}
if err := decodeSchedulerBody(r, &body); err != nil || body.Enabled == nil {
writeError(w, http.StatusBadRequest, "enabled must be a boolean")
return
}
if s.client == nil {
writeError(w, http.StatusBadGateway, "transmission client is not configured")
return
}
if *body.Enabled {
torrents, err := s.client.SchedulerTorrents(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
var selected *schedulerTorrent
for i := range torrents {
if strings.EqualFold(torrents[i].Hash, hash) {
selected = &torrents[i]
break
}
}
if selected == nil {
writeError(w, http.StatusNotFound, "torrent not found")
return
}
if !selected.eligible() {
writeError(w, http.StatusConflict, "torrent must be complete and error-free")
return
}
s.mu.Lock()
_, err = s.db.ExecContext(r.Context(), `INSERT INTO scheduler_torrents (hash, name, uploaded_ever)
VALUES (?, ?, ?) ON CONFLICT(hash) DO UPDATE SET name=excluded.name`, hash, selected.Name, selected.UploadedEver)
s.mu.Unlock()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
} else {
s.mu.Lock()
var active bool
err := s.db.QueryRowContext(r.Context(), "SELECT active FROM scheduler_torrents WHERE hash=?", hash).Scan(&active)
if err == sql.ErrNoRows {
s.mu.Unlock()
writeError(w, http.StatusNotFound, "torrent is not opted in")
return
}
if err == nil && active {
err = s.client.TorrentActionHash(r.Context(), "torrent-stop", hash)
}
if err == nil {
_, err = s.db.ExecContext(r.Context(), "DELETE FROM scheduler_torrents WHERE hash=?", hash)
}
s.mu.Unlock()
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
}
if err := s.Tick(r.Context()); err != nil {
log.Printf("scheduler opt-in: %v", err)
}
writeJSON(w, json.RawMessage(`{"ok":true}`))
}
type schedulerEvent struct {
ID int64 `json:"id"`
HappenedAt int64 `json:"happenedAt"`
FromHash string `json:"fromHash"`
FromName string `json:"fromName"`
ToHash string `json:"toHash"`
ToName string `json:"toName"`
Reason string `json:"reason"`
}
func (s *Scheduler) handleHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
before := int64(0)
if raw := r.URL.Query().Get("before"); raw != "" {
var err error
before, err = strconv.ParseInt(raw, 10, 64)
if err != nil || before <= 0 {
writeError(w, http.StatusBadRequest, "before must be a positive event id")
return
}
}
query := `SELECT id, happened_at, from_hash, from_name, to_hash, to_name, reason
FROM scheduler_events WHERE (?=0 OR id<?) ORDER BY id DESC LIMIT 51`
s.mu.Lock()
defer s.mu.Unlock()
rows, err := s.db.QueryContext(r.Context(), query, before, before)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
events := make([]schedulerEvent, 0)
for rows.Next() {
var event schedulerEvent
if err := rows.Scan(&event.ID, &event.HappenedAt, &event.FromHash, &event.FromName,
&event.ToHash, &event.ToName, &event.Reason); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
events = append(events, event)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
hasMore := len(events) > 50
if hasMore {
events = events[:50]
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
Events []schedulerEvent `json:"events"`
HasMore bool `json:"hasMore"`
}{events, hasMore})
}
+345
View File
@@ -0,0 +1,345 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
)
type fakeTransmission struct {
mu sync.Mutex
torrents map[string]schedulerTorrent
failStart bool
}
func newFakeTransmission(t *testing.T, torrents ...schedulerTorrent) (*Client, *fakeTransmission) {
t.Helper()
fake := &fakeTransmission{torrents: make(map[string]schedulerTorrent)}
for _, torrent := range torrents {
fake.torrents[torrent.Hash] = torrent
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(sessionHeader) != "test-session" {
w.Header().Set(sessionHeader, "test-session")
w.WriteHeader(http.StatusConflict)
return
}
var request struct {
Method string `json:"method"`
Arguments struct {
IDs []string `json:"ids"`
} `json:"arguments"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode RPC: %v", err)
return
}
fake.mu.Lock()
defer fake.mu.Unlock()
response := map[string]any{"result": "success", "arguments": map[string]any{}}
switch request.Method {
case "torrent-get":
var result []schedulerTorrent
for _, torrent := range fake.torrents {
result = append(result, torrent)
}
response["arguments"] = map[string]any{"torrents": result}
case "torrent-start-now":
if fake.failStart {
response["result"] = "start failed"
} else {
for _, hash := range request.Arguments.IDs {
torrent := fake.torrents[hash]
torrent.Status = 6
fake.torrents[hash] = torrent
}
}
case "torrent-stop":
for _, hash := range request.Arguments.IDs {
torrent := fake.torrents[hash]
torrent.Status = 0
fake.torrents[hash] = torrent
}
default:
t.Errorf("unexpected RPC method %s", request.Method)
}
_ = json.NewEncoder(w).Encode(response)
}))
t.Cleanup(server.Close)
return NewClient(server.URL, "", ""), fake
}
func testScheduler(t *testing.T, client *Client) *Scheduler {
t.Helper()
scheduler, err := NewScheduler(client, filepath.Join(t.TempDir(), "scheduler.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = scheduler.Close() })
return scheduler
}
func addOptIn(t *testing.T, scheduler *Scheduler, torrents ...schedulerTorrent) {
t.Helper()
for _, torrent := range torrents {
if _, err := scheduler.db.Exec(`INSERT INTO scheduler_torrents (hash, name) VALUES (?, ?)`, torrent.Hash, torrent.Name); err != nil {
t.Fatal(err)
}
}
}
func activeHashes(t *testing.T, scheduler *Scheduler) []string {
t.Helper()
rows, err := scheduler.rows(context.Background())
if err != nil {
t.Fatal(err)
}
var active []string
for _, row := range rows {
if row.Active {
active = append(active, row.Hash)
}
}
return active
}
func eventCount(t *testing.T, scheduler *Scheduler) int {
t.Helper()
var count int
if err := scheduler.db.QueryRow("SELECT COUNT(*) FROM scheduler_events").Scan(&count); err != nil {
t.Fatal(err)
}
return count
}
func TestSchedulerIdleRotationAndPersistence(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)
path := filepath.Join(t.TempDir(), "scheduler.db")
scheduler, err := NewScheduler(client, path)
if err != nil {
t.Fatal(err)
}
addOptIn(t, scheduler, a, b, c)
now := time.Unix(1_700_000_000, 0)
scheduler.now = func() time.Time { return now }
if err := scheduler.Tick(context.Background()); err != nil {
t.Fatal(err)
}
if got := activeHashes(t, scheduler); len(got) != 1 || got[0] != a.Hash {
t.Fatalf("initial active: %v", got)
}
now = now.Add(59 * time.Minute)
if err := scheduler.Tick(context.Background()); err != nil {
t.Fatal(err)
}
fake.mu.Lock()
uploading := fake.torrents[a.Hash]
uploading.UploadedEver = 10
fake.torrents[a.Hash] = uploading
fake.mu.Unlock()
if err := scheduler.Tick(context.Background()); err != nil {
t.Fatal(err)
}
now = now.Add(59 * time.Minute)
if err := scheduler.Tick(context.Background()); err != nil {
t.Fatal(err)
}
if got := activeHashes(t, scheduler); len(got) != 1 || got[0] != a.Hash {
t.Fatalf("upload should delay rotation: %v", got)
}
now = now.Add(time.Minute)
if err := scheduler.Tick(context.Background()); err != nil {
t.Fatal(err)
}
if got := activeHashes(t, scheduler); len(got) != 1 || got[0] != b.Hash {
t.Fatalf("expected oldest waiting torrent: %v", got)
}
if got := eventCount(t, scheduler); got != 2 {
t.Fatalf("expected activation and rotation, got %d events", got)
}
if err := scheduler.Close(); err != nil {
t.Fatal(err)
}
reopened, err := NewScheduler(client, path)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
reopened.now = func() time.Time { return now }
if err := reopened.Tick(context.Background()); err != nil {
t.Fatal(err)
}
if got := activeHashes(t, reopened); len(got) != 1 || got[0] != b.Hash {
t.Fatalf("restart lost slot state: %v", got)
}
if got := eventCount(t, reopened); got != 2 {
t.Fatalf("restart lost history: %d", got)
}
}
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)
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 {
t.Fatal(err)
}
now := time.Unix(1_700_000_000, 0)
scheduler.now = func() time.Time { return now }
if err := scheduler.Tick(context.Background()); err != nil {
t.Fatal(err)
}
if got := activeHashes(t, scheduler); len(got) != 2 || got[0] != a.Hash || got[1] != b.Hash {
t.Fatalf("expected two slots: %v", got)
}
fake.mu.Lock()
paused := fake.torrents[a.Hash]
paused.Status = 0
fake.torrents[a.Hash] = paused
resumed := fake.torrents[c.Hash]
resumed.Status = 6
fake.torrents[c.Hash] = resumed
fake.mu.Unlock()
if err := scheduler.Tick(context.Background()); err != nil {
t.Fatal(err)
}
fake.mu.Lock()
if fake.torrents[a.Hash].Status != 6 || fake.torrents[c.Hash].Status != 0 {
t.Fatalf("manual actions were not reconciled: A=%d C=%d", fake.torrents[a.Hash].Status, fake.torrents[c.Hash].Status)
}
fake.mu.Unlock()
now = now.Add(time.Hour)
if err := scheduler.Tick(context.Background()); err != nil {
t.Fatal(err)
}
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)
}
}
func TestSchedulerFailureDoesNotRecordActivation(t *testing.T) {
a := schedulerTorrent{Hash: strings.Repeat("a", 40), Name: "A", PercentDone: 1}
client, fake := newFakeTransmission(t, a)
scheduler := testScheduler(t, client)
addOptIn(t, scheduler, a)
fake.failStart = true
if err := scheduler.Tick(context.Background()); err == nil {
t.Fatal("expected RPC start failure")
}
if got := eventCount(t, scheduler); got != 0 {
t.Fatalf("failed activation recorded %d events", got)
}
fake.failStart = false
if err := scheduler.Tick(context.Background()); err != nil {
t.Fatal(err)
}
if got := eventCount(t, scheduler); got != 1 {
t.Fatalf("successful activation recorded %d events", got)
}
}
func TestSchedulerKeepsOnlyTorrentWhenIdle(t *testing.T) {
a := schedulerTorrent{Hash: strings.Repeat("a", 40), Name: "A", PercentDone: 1}
client, _ := newFakeTransmission(t, a)
scheduler := testScheduler(t, client)
addOptIn(t, scheduler, a)
now := time.Unix(1_700_000_000, 0)
scheduler.now = func() time.Time { return now }
if err := scheduler.Tick(context.Background()); err != nil {
t.Fatal(err)
}
now = now.Add(2 * time.Hour)
if err := scheduler.Tick(context.Background()); err != nil {
t.Fatal(err)
}
if got := activeHashes(t, scheduler); len(got) != 1 || got[0] != a.Hash {
t.Fatalf("only torrent should keep its slot: %v", got)
}
if got := eventCount(t, scheduler); got != 1 {
t.Fatalf("idle slot without a candidate should not rotate: %d events", got)
}
}
func TestSchedulerHTTPValidationAndHistory(t *testing.T) {
a := schedulerTorrent{Hash: strings.Repeat("a", 40), Name: "A", PercentDone: .5}
client, fake := newFakeTransmission(t, a)
scheduler := testScheduler(t, client)
request := func(handler http.HandlerFunc, method, path, body string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
handler(recorder, httptest.NewRequest(method, path, bytes.NewBufferString(body)))
return recorder
}
if got := request(scheduler.handleSettings, "POST", "/api/scheduler/settings", `{"maxActive":101}`).Code; got != 400 {
t.Fatalf("settings validation returned %d", got)
}
if got := request(scheduler.handleOptIn, "POST", "/api/scheduler/torrents/"+a.Hash+"/opt-in", `{"enabled":true}`).Code; got != 409 {
t.Fatalf("incomplete opt-in returned %d", got)
}
if got := request(scheduler.handleSettings, "POST", "/api/scheduler/settings", `{"maxActive":2}`).Code; got != 200 {
t.Fatalf("valid settings returned %d", got)
}
fake.mu.Lock()
completed := fake.torrents[a.Hash]
completed.PercentDone = 1
fake.torrents[a.Hash] = completed
fake.mu.Unlock()
if got := request(scheduler.handleOptIn, "POST", "/api/scheduler/torrents/"+a.Hash+"/opt-in", `{"enabled":true}`).Code; got != 200 {
t.Fatalf("valid opt-in returned %d", got)
}
if got := activeHashes(t, scheduler); len(got) != 1 || got[0] != a.Hash {
t.Fatalf("opt-in did not fill a slot: %v", got)
}
if got := request(scheduler.handleOptIn, "POST", "/api/scheduler/torrents/"+a.Hash+"/opt-in", `{"enabled":false}`).Code; got != 200 {
t.Fatalf("opt-out returned %d", got)
}
if got := activeHashes(t, scheduler); len(got) != 0 {
t.Fatalf("opt-out did not release its slot: %v", got)
}
if _, err := scheduler.db.Exec("DELETE FROM scheduler_events"); err != nil {
t.Fatal(err)
}
for i := 0; i < 51; i++ {
if _, err := scheduler.db.Exec(`INSERT INTO scheduler_events
(happened_at, from_hash, from_name, to_hash, to_name, reason) VALUES (1,'','','a','A','test')`); err != nil {
t.Fatal(err)
}
}
response := request(scheduler.handleHistory, "GET", "/api/scheduler/history", "")
if response.Code != 200 {
t.Fatalf("history returned %d: %s", response.Code, response.Body.String())
}
var page struct {
Events []schedulerEvent `json:"events"`
HasMore bool `json:"hasMore"`
}
if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if len(page.Events) != 50 || !page.HasMore {
t.Fatalf("bad first history page: %d events, more=%v", len(page.Events), page.HasMore)
}
response = request(scheduler.handleHistory, "GET", "/api/scheduler/history?before="+strconv.FormatInt(page.Events[49].ID, 10), "")
if response.Code != 200 {
t.Fatalf("history cursor returned %d", response.Code)
}
if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if len(page.Events) != 1 || page.HasMore {
t.Fatalf("bad final history page: %d events, more=%v", len(page.Events), page.HasMore)
}
}
+240
View File
@@ -34,6 +34,17 @@ let syncQueued = false;
let refreshTimer = null;
let speedControlPending = false;
let speedControlError = null;
let schedulerData = null;
let schedulerError = "";
let schedulerLoading = false;
let schedulerSettingsPending = false;
let schedulerHistoryLoading = false;
let schedulerHistoryLoaded = false;
let schedulerHistoryEvents = [];
let schedulerHistoryHasMore = false;
let schedulerHistoryLatestID = -1;
const schedulerActionPending = new Set();
const schedulerRows = new Map();
const $ = (sel) => document.querySelector(sel);
@@ -142,6 +153,7 @@ async function runTorrentAction(id, action) {
await fetchJSON(`/api/torrents/${id}/${action}`, { method: "POST" });
actionStates.delete(id);
await sync();
if (location.hash === "#scheduler") loadScheduler();
} catch (err) {
actionStates.set(id, {
pending: null,
@@ -228,6 +240,196 @@ 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 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} active · ${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 (schedulerLoading) return;
schedulerLoading = true;
try {
schedulerData = await fetchJSON("/api/scheduler");
schedulerError = "";
renderScheduler();
if (!schedulerHistoryLoaded || schedulerData.latestEventId !== schedulerHistoryLatestID) {
await loadSchedulerHistory(true);
}
} catch (err) {
schedulerError = err.message || String(err);
renderScheduler();
} finally {
schedulerLoading = false;
}
}
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"), "Seed 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);
renderScheduler();
try {
await fetchJSON(`/api/scheduler/torrents/${hash}/opt-in`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
schedulerError = "";
await loadScheduler();
} catch (err) {
schedulerError = err.message || String(err);
} finally {
schedulerActionPending.delete(hash);
renderScheduler();
}
}
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;
@@ -672,6 +874,39 @@ function renderAll() {
/* ---------- 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 = "Seed 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;
@@ -723,6 +958,7 @@ function wireEvents() {
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
sync();
if (location.hash === "#scheduler") loadScheduler();
}
});
}
@@ -730,7 +966,11 @@ function wireEvents() {
/* ---------- Init ---------- */
function boot() {
wireEvents();
activateTab(location.hash.slice(1));
setRefreshInterval(state.refreshMs);
setInterval(() => {
if (!document.hidden && location.hash === "#scheduler") loadScheduler();
}, 10000);
sync();
}
+35
View File
@@ -57,6 +57,12 @@
</header>
<main class="container">
<div class="app-tabs" role="tablist" aria-label="Views">
<button type="button" id="tab-torrents" class="app-tab active" role="tab" aria-selected="true" aria-controls="panel-torrents" tabindex="0">Torrents</button>
<button type="button" id="tab-scheduler" class="app-tab" role="tab" aria-selected="false" aria-controls="panel-scheduler" tabindex="-1">Scheduler</button>
</div>
<section id="panel-torrents" role="tabpanel" aria-labelledby="tab-torrents">
<div class="toolbar">
<input type="search" id="filter" class="filter-input" placeholder="Filter torrents…" autocomplete="off" />
<div class="sort-group" role="group" aria-label="Sort torrents">
@@ -103,6 +109,35 @@
<p class="error-sub" id="error-msg">Could not reach the Transmission daemon.</p>
<p class="error-hint" id="error-hint">Retrying in 3s…</p>
</div>
</section>
<section id="panel-scheduler" role="tabpanel" aria-labelledby="tab-scheduler" hidden>
<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>
</div>
<form id="scheduler-settings" class="scheduler-settings">
<label for="scheduler-slots">Seed slots</label>
<input id="scheduler-slots" type="number" min="1" max="100" value="1" required />
<button type="submit" class="scheduler-button">Save</button>
</form>
</div>
<p id="scheduler-error" class="scheduler-error hidden" role="alert"></p>
<p id="scheduler-feedback" class="scheduler-feedback" role="status"></p>
<div class="scheduler-section-heading">
<h3>Completed torrents</h3>
<span id="scheduler-count"></span>
</div>
<div id="scheduler-torrents" class="scheduler-torrents"></div>
<p id="scheduler-empty" class="scheduler-empty hidden">No completed torrents are available for scheduling.</p>
<div class="scheduler-section-heading scheduler-history-heading">
<h3>Cycle history</h3>
</div>
<ol id="scheduler-history" class="scheduler-history"></ol>
<p id="scheduler-history-empty" class="scheduler-empty hidden">No cycles recorded yet.</p>
<button type="button" id="scheduler-history-more" class="scheduler-button secondary hidden">Load more</button>
</section>
</main>
<footer class="app-footer">
+41
View File
@@ -204,6 +204,44 @@ button { font-family: inherit; }
flex: 1;
}
.app-tabs { display: flex; gap: 5px; margin-bottom: 22px; border-bottom: 1px solid var(--border-strong); }
.app-tab { padding: 10px 18px 12px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--text-2); cursor: pointer; font-size: 14px; font-weight: 700; }
.app-tab:hover { color: var(--text-0); }
.app-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.app-tab:focus-visible, .scheduler-button:focus-visible, .scheduler-toggle:focus-visible, .scheduler-settings input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.scheduler-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; flex-wrap: wrap; margin-bottom: 20px; }
.scheduler-heading h2 { margin: 0 0 6px; color: var(--text-0); font-size: 23px; }
.scheduler-heading p { margin: 0; max-width: 620px; color: var(--text-2); font-size: 13px; line-height: 1.5; }
.scheduler-settings { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.scheduler-settings label { color: var(--text-1); font-size: 12px; font-weight: 700; }
.scheduler-settings input { width: 68px; padding: 7px 8px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--bg-2); color: var(--text-0); font-size: 13px; }
.scheduler-button { border: 1px solid rgba(0, 212, 255, 0.28); border-radius: var(--radius-sm); padding: 8px 12px; background: var(--accent-soft); color: var(--accent); cursor: pointer; font-size: 12px; font-weight: 700; }
.scheduler-button:hover:not(:disabled) { background: rgba(0, 212, 255, 0.2); }
.scheduler-button.secondary { margin-top: 12px; border-color: var(--border-strong); background: var(--bg-2); color: var(--text-1); }
.scheduler-button:disabled, .scheduler-toggle:disabled { opacity: .5; cursor: wait; }
.scheduler-error { padding: 10px 12px; border: 1px solid rgba(248, 113, 113, .35); border-radius: var(--radius-sm); background: rgba(248, 113, 113, .08); color: var(--err); font-size: 13px; }
.scheduler-feedback { min-height: 18px; margin: 0 0 10px; color: var(--text-2); font-size: 12px; }
.scheduler-section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 16px 0 10px; }
.scheduler-section-heading h3 { margin: 0; color: var(--text-0); font-size: 15px; }
.scheduler-section-heading span, .scheduler-empty { color: var(--text-2); font-size: 12px; }
.scheduler-history-heading { margin-top: 30px; }
.scheduler-torrents { display: flex; flex-direction: column; gap: 8px; }
.scheduler-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-2); }
.scheduler-row-main { min-width: 0; }
.scheduler-row-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-0); font-size: 13px; font-weight: 650; }
.scheduler-row-meta { display: block; margin-top: 4px; color: var(--text-2); font-size: 11px; }
.scheduler-row-controls { display: flex; align-items: center; gap: 10px; flex: none; }
.scheduler-state { padding: 4px 7px; border-radius: 999px; background: var(--bg-3); color: var(--text-2); font-size: 11px; white-space: nowrap; }
.scheduler-state.active { background: rgba(52, 211, 153, .12); color: var(--ok); }
.scheduler-state.waiting { background: rgba(251, 191, 36, .1); color: var(--warn); }
.scheduler-toggle { min-width: 78px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 7px 9px; background: var(--bg-3); color: var(--text-1); cursor: pointer; font-size: 11px; font-weight: 700; }
.scheduler-toggle[aria-pressed="true"] { border-color: rgba(0, 212, 255, .3); background: var(--accent-soft); color: var(--accent); }
.scheduler-history { display: flex; flex-direction: column; gap: 7px; padding: 0; margin: 0; list-style: none; }
.scheduler-history li { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-2); font-size: 12px; }
.scheduler-history strong { color: var(--text-0); font-weight: 650; }
.scheduler-history time { flex: none; color: var(--text-2); }
.toolbar {
display: flex;
align-items: center;
@@ -545,4 +583,7 @@ button { font-family: inherit; }
.card-body { flex-basis: calc(100% - 42px); }
.card-actions { width: 100%; flex-direction: row; justify-content: space-between; }
.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; }
.scheduler-history li { flex-direction: column; gap: 4px; }
}