diff --git a/main.go b/main.go index c541b51..93bc762 100644 --- a/main.go +++ b/main.go @@ -165,7 +165,7 @@ var torrentFields = []string{ type rpcRequest struct { Method string `json:"method"` - Arguments rpcArgs `json:"arguments"` + Arguments interface{} `json:"arguments"` Tag interface{} `json:"tag,omitempty"` } @@ -179,6 +179,16 @@ type rpcResponse struct { Args json.RawMessage `json:"arguments"` } +var sessionFields = []string{ + "alt-speed-enabled", "alt-speed-down", "alt-speed-up", + "speed-limit-down-enabled", "speed-limit-down", + "speed-limit-up-enabled", "speed-limit-up", "units", +} + +type sessionSetArgs struct { + AltSpeedEnabled bool `json:"alt-speed-enabled"` +} + func (c *Client) Torrents(ctx context.Context) (json.RawMessage, error) { reqBody, _ := json.Marshal(rpcRequest{ Method: "torrent-get", @@ -216,6 +226,44 @@ func (c *Client) Stats(ctx context.Context) (json.RawMessage, error) { return resp.Args, nil } +func (c *Client) Session(ctx context.Context) (json.RawMessage, error) { + reqBody, _ := json.Marshal(rpcRequest{ + Method: "session-get", + Arguments: rpcArgs{Fields: sessionFields}, + }) + data, err := c.rpc(ctx, reqBody) + if err != nil { + return nil, err + } + var resp rpcResponse + 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, nil +} + +func (c *Client) SetAlternativeSpeed(ctx context.Context, enabled bool) error { + reqBody, _ := json.Marshal(rpcRequest{ + Method: "session-set", + Arguments: sessionSetArgs{AltSpeedEnabled: enabled}, + }) + 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) TorrentAction(ctx context.Context, method string, ids []int) error { reqBody, _ := json.Marshal(rpcRequest{ Method: method, @@ -450,6 +498,38 @@ func handleTorrentAction(client *Client, setupErr error) http.HandlerFunc { } } +func handleAlternativeSpeed(client *Client, setupErr error) http.HandlerFunc { + return func(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 + } + if !requireTransmissionClient(w, client, setupErr) { + return + } + + var payload struct { + Enabled *bool `json:"enabled"` + } + decoder := json.NewDecoder(io.LimitReader(r.Body, 1024)) + if err := decoder.Decode(&payload); err != nil || payload.Enabled == nil { + writeError(w, http.StatusBadRequest, "request body must include a boolean enabled field") + return + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + writeError(w, http.StatusBadRequest, "request body must contain one JSON object") + return + } + if err := client.SetAlternativeSpeed(r.Context(), *payload.Enabled); err != nil { + log.Printf("alternative speed: %v", err) + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, json.RawMessage(`{"ok":true}`)) + } +} + func handleStaticFiles(fileServer http.Handler, client *Client, setupErr error) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && (r.URL.Path == "/" || r.URL.Path == "/index.html") { @@ -509,6 +589,24 @@ func main() { } writeJSON(w, args) }) + mux.HandleFunc("/api/session", func(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 !requireTransmissionClient(w, client, setupErr) { + return + } + args, err := client.Session(r.Context()) + if err != nil { + log.Printf("session: %v", err) + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, args) + }) + mux.HandleFunc("/api/session/alternative-speed", handleAlternativeSpeed(client, setupErr)) // Static files from embedded web/ directory webRoot, err := fs.Sub(webFS, "web") diff --git a/project-context.md b/project-context.md index 678e413..457a80c 100644 --- a/project-context.md +++ b/project-context.md @@ -22,11 +22,11 @@ The app is intentionally small: one Go binary serves both the API and embedded f ## 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. -- `web/index.html`: SPA shell. Defines the header, stats pills, filter input, sort buttons, list container, empty state, error state, footer, and script/style links. +- `web/index.html`: SPA shell. Defines the header, stats pills, alternative-speed controls, 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. -- `web/app.js`: frontend application logic. Owns state, polling, fetch helpers, formatting, torrent classification, sorting, keyed DOM reconciliation, pause/resume actions, and event wiring. -- `web/styles.css`: all styling. Owns dark theme variables, layout, cards, badges, progress bars, action buttons, empty/error states, and responsive rules. +- `web/app.js`: frontend application logic. Owns state, polling, fetch helpers, formatting, session-limit presentation and alternative-speed toggling, torrent classification, sorting, keyed DOM reconciliation, pause/resume actions, and event wiring. +- `web/styles.css`: all styling. Owns dark theme variables, layout, cards, badges, progress bars, action buttons, alternative-speed controls, empty/error states, and responsive rules. - `go.mod`: Go module declaration. - `Dockerfile`: multi-stage production build. - `README.md`: public project documentation, configuration guide, and deployment instructions. @@ -38,13 +38,15 @@ The backend uses `net/http` with a single `http.ServeMux`. It exposes: - `GET /api/torrents`: sends Transmission `torrent-get` with the required torrent fields and returns the RPC `arguments` JSON directly. - `GET /api/stats`: sends Transmission `session-stats` and returns the RPC `arguments` JSON directly. +- `GET /api/session`: sends Transmission `session-get` for alternative-speed state, normal and alternative speed limits, and speed-unit metadata; returns the RPC `arguments` JSON directly. +- `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`. - `/`: 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`. -API errors return JSON with an `error` string. Invalid torrent action methods should return `405`; invalid ids should return `400`; unknown action paths should return `404`; Transmission failures should return `502`. +API errors return JSON with an `error` string. Invalid torrent action methods should return `405`; invalid ids or malformed alternative-speed bodies should return `400`; unknown action paths should return `404`; Transmission failures should return `502`. ## Transmission RPC Model @@ -67,9 +69,11 @@ The UI definition of "peers at 100%" is an absolute count of `peers[]` entries w The UI availability value comes from Transmission's `availability` per-piece array. Render and sort it as a complete-copy estimate, treating `-1` entries as pieces available locally. +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. + ## 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 and stats 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. Important patterns: diff --git a/web/app.js b/web/app.js index 5a2872d..72b03fa 100644 --- a/web/app.js +++ b/web/app.js @@ -20,6 +20,7 @@ const TRANSMISSION_STATUSES = { const state = { torrents: [], stats: {}, + session: null, filter: "", sortKey: "status", refreshMs: DEFAULT_REFRESH_MS, @@ -31,6 +32,8 @@ const actionStates = new Map(); let syncInFlight = false; let syncQueued = false; let refreshTimer = null; +let speedControlPending = false; +let speedControlError = null; const $ = (sel) => document.querySelector(sel); @@ -63,6 +66,11 @@ async function loadStats() { state.stats = data || {}; } +async function loadSession() { + const data = await fetchJSON("/api/session"); + state.session = data && typeof data === "object" ? data : null; +} + async function sync() { if (syncInFlight) { syncQueued = true; @@ -72,8 +80,9 @@ async function sync() { syncInFlight = true; setSyncing(true); try { - await Promise.all([loadTorrents(), loadStats()]); + await Promise.all([loadTorrents(), loadStats(), loadSession()]); state.lastError = null; + speedControlError = null; } catch (err) { state.lastError = err.message || String(err); } finally { @@ -143,6 +152,28 @@ async function runTorrentAction(id, action) { } } +async function setAlternativeSpeed(enabled) { + if (speedControlPending || !state.session) return; + speedControlPending = true; + speedControlError = null; + renderHeader(); + + try { + await fetchJSON("/api/session/alternative-speed", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + await sync(); + speedControlPending = false; + renderHeader(); + } catch (err) { + speedControlError = err.message || String(err); + speedControlPending = false; + renderHeader(); + } +} + /* ---------- Formatting helpers ---------- */ function humanSize(bytes) { if (bytes == null || isNaN(bytes) || bytes < 0) return "—"; @@ -337,6 +368,64 @@ function renderHeader() { $("#stat-torrents").textContent = String(torrents.length); $("#stat-dl").textContent = humanSpeed(dl); $("#stat-ul").textContent = humanSpeed(ul); + renderSpeedControls(); +} + +function sessionValue(session, kebab, snake) { + if (!session) return null; + if (session[kebab] != null) return session[kebab]; + return snake ? session[snake] : null; +} + +function sessionSpeedBytes(session) { + const units = session && session.units; + const value = units && (units["speed-bytes"] ?? units.speed_bytes ?? units.speedBytes); + return Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : 1000; +} + +function activeSpeedCap(session, direction) { + const enabled = Boolean(sessionValue(session, "alt-speed-enabled", "alt_speed_enabled")); + const alt = direction === "down" + ? sessionValue(session, "alt-speed-down", "alt_speed_down") + : sessionValue(session, "alt-speed-up", "alt_speed_up"); + const normalEnabled = Boolean(sessionValue( + session, + direction === "down" ? "speed-limit-down-enabled" : "speed-limit-up-enabled", + direction === "down" ? "speed_limit_down_enabled" : "speed_limit_up_enabled", + )); + const normal = direction === "down" + ? sessionValue(session, "speed-limit-down", "speed_limit_down") + : sessionValue(session, "speed-limit-up", "speed_limit_up"); + const value = enabled ? alt : (normalEnabled ? normal : null); + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number * sessionSpeedBytes(session) : null; +} + +function renderSpeedControls() { + const toggle = $("#alt-speed-toggle"); + const toggleState = $("#alt-speed-state"); + const status = $("#speed-control-status"); + if (!toggle || !toggleState || !status) return; + + const session = state.session; + const enabled = Boolean(sessionValue(session, "alt-speed-enabled", "alt_speed_enabled")); + toggle.disabled = !session || speedControlPending; + toggle.setAttribute("aria-pressed", String(enabled)); + toggle.classList.toggle("is-saving", speedControlPending); + setText(toggleState, !session ? (state.lastError ? "Unavailable" : "Loading...") : (speedControlPending ? "Saving..." : (enabled ? "On" : "Off"))); + + const caps = [ + { id: "speed-cap-down", value: activeSpeedCap(session, "down") }, + { id: "speed-cap-up", value: activeSpeedCap(session, "up") }, + ]; + for (const cap of caps) { + const el = $("#" + cap.id); + if (!el) continue; + el.classList.toggle("hidden", cap.value == null); + const valueEl = el.querySelector("strong"); + if (valueEl && cap.value != null) setText(valueEl, humanSpeed(cap.value)); + } + setText(status, speedControlError || ""); } function initial(name) { @@ -611,6 +700,14 @@ function wireEvents() { }); } + const speedToggle = $("#alt-speed-toggle"); + if (speedToggle) { + speedToggle.addEventListener("click", () => { + const enabled = !Boolean(sessionValue(state.session, "alt-speed-enabled", "alt_speed_enabled")); + setAlternativeSpeed(enabled); + }); + } + $("#torrents").addEventListener("click", (e) => { const button = e.target.closest("[data-action-button]"); if (!button || button.disabled) return; diff --git a/web/index.html b/web/index.html index dcc29fc..5c59368 100644 --- a/web/index.html +++ b/web/index.html @@ -41,6 +41,18 @@ + +
+ +
+ + +
+ +
@@ -102,4 +114,4 @@ - \ No newline at end of file + diff --git a/web/styles.css b/web/styles.css index 5b192f2..e10ae65 100644 --- a/web/styles.css +++ b/web/styles.css @@ -140,6 +140,61 @@ button { font-family: inherit; } .stat-pill.download .stat-ico { color: var(--accent); } .stat-pill.upload .stat-ico { color: var(--ok); } +.speed-controls { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} +.speed-toggle { + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid var(--border-strong); + border-radius: 999px; + background: var(--bg-2); + color: var(--text-2); + cursor: pointer; + font-size: 11px; + font-weight: 700; + padding: 7px 11px; + white-space: nowrap; + transition: background .15s, border-color .15s, color .15s, opacity .15s; +} +.speed-toggle:hover:not(:disabled) { border-color: var(--border-hover); color: var(--text-0); } +.speed-toggle[aria-pressed="true"] { + background: var(--accent-soft); + border-color: rgba(0, 212, 255, 0.3); + color: var(--accent); +} +.speed-toggle:disabled { cursor: wait; opacity: 0.6; } +.speed-toggle-state { + color: var(--text-3); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.speed-toggle[aria-pressed="true"] .speed-toggle-state { color: var(--text-1); } +.speed-caps { display: inline-flex; align-items: center; gap: 5px; min-width: 0; } +.speed-cap { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-2); + color: var(--text-2); + font-size: 11px; + font-variant-numeric: tabular-nums; + padding: 6px 9px; + white-space: nowrap; +} +.speed-cap strong { color: var(--text-0); font-weight: 700; } +.speed-cap:first-child { color: var(--accent); } +.speed-cap:last-child { color: var(--ok); } +.speed-control-status { color: var(--red); font-size: 11px; max-width: 180px; } + /* ---------- Container / Toolbar ---------- */ .container { max-width: var(--maxw); @@ -470,6 +525,7 @@ button { font-family: inherit; } @media (max-width: 720px) { .header-inner { flex-wrap: wrap; padding: 12px 16px; gap: 12px; } .header-stats { width: 100%; } + .speed-controls { width: 100%; justify-content: flex-start; flex-wrap: wrap; } .stat-pill { padding: 5px 10px; font-size: 12px; } .container { padding: 16px 14px 36px; } .toolbar { gap: 8px; } @@ -484,6 +540,7 @@ button { font-family: inherit; } @media (max-width: 440px) { .brand-text h1 { font-size: 15px; } .stat-pill .stat-label { display: none; } + .speed-control-status { max-width: 100%; width: 100%; } .card-top { flex-wrap: wrap; } .card-body { flex-basis: calc(100% - 42px); } .card-actions { width: 100%; flex-direction: row; justify-content: space-between; }