534 lines
13 KiB
Go
534 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
//go:embed web
|
|
var webFS embed.FS
|
|
|
|
const (
|
|
defaultRPCURL = "http://192.168.20.22:3210/transmission/rpc"
|
|
envTransmissionURL = "TRANSMISSION_URL"
|
|
envTransmissionRPCUsername = "TRANSMISSION_RPC_USERNAME"
|
|
envTransmissionRPCPassword = "TRANSMISSION_RPC_PASSWORD"
|
|
sessionHeader = "X-Transmission-Session-Id"
|
|
)
|
|
|
|
// Client is a minimal Transmission RPC client with session-id handshake.
|
|
type Client struct {
|
|
http *http.Client
|
|
rpcURL string
|
|
username string
|
|
password string
|
|
mu sync.Mutex
|
|
sessionID string
|
|
}
|
|
|
|
func NewClient(rpcURL, username, password string) *Client {
|
|
return &Client{
|
|
http: &http.Client{Timeout: 15 * time.Second},
|
|
rpcURL: rpcURL,
|
|
username: username,
|
|
password: password,
|
|
}
|
|
}
|
|
|
|
func NewClientFromEnv() (*Client, error) {
|
|
rpcURL := configuredRPCURL()
|
|
if err := validateRPCURL(rpcURL); err != nil {
|
|
return nil, err
|
|
}
|
|
return NewClient(
|
|
rpcURL,
|
|
optionalEnv(envTransmissionRPCUsername),
|
|
optionalEnv(envTransmissionRPCPassword),
|
|
), nil
|
|
}
|
|
|
|
func configuredRPCURL() string {
|
|
rpcURL := strings.TrimSpace(os.Getenv(envTransmissionURL))
|
|
if rpcURL == "" {
|
|
return defaultRPCURL
|
|
}
|
|
return rpcURL
|
|
}
|
|
|
|
func optionalEnv(name string) string {
|
|
value := os.Getenv(name)
|
|
if strings.EqualFold(strings.TrimSpace(value), "null") {
|
|
return ""
|
|
}
|
|
return value
|
|
}
|
|
|
|
func validateRPCURL(raw string) error {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("%s is invalid: %w", envTransmissionURL, err)
|
|
}
|
|
if parsed.Scheme == "" || parsed.Host == "" {
|
|
return fmt.Errorf("%s must be an absolute http or https URL", envTransmissionURL)
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return fmt.Errorf("%s must use http or https", envTransmissionURL)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func redactedURL(raw string) string {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
if parsed.User != nil {
|
|
parsed.User = url.User("redacted")
|
|
}
|
|
return parsed.String()
|
|
}
|
|
|
|
// rpc sends a JSON-RPC request, handling the 409 session handshake with one retry.
|
|
func (c *Client) rpc(ctx context.Context, body []byte) ([]byte, error) {
|
|
doRequest := func(sessionID string) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.rpcURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.username != "" || c.password != "" {
|
|
req.SetBasicAuth(c.username, c.password)
|
|
}
|
|
if sessionID != "" {
|
|
req.Header.Set(sessionHeader, sessionID)
|
|
}
|
|
return c.http.Do(req)
|
|
}
|
|
|
|
c.mu.Lock()
|
|
currentSession := c.sessionID
|
|
c.mu.Unlock()
|
|
|
|
resp, err := doRequest(currentSession)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusConflict {
|
|
newSession := resp.Header.Get(sessionHeader)
|
|
// drain so the connection can be reused
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
if newSession == "" {
|
|
return nil, fmt.Errorf("409 received but no %s header present", sessionHeader)
|
|
}
|
|
c.mu.Lock()
|
|
c.sessionID = newSession
|
|
c.mu.Unlock()
|
|
// retry once with the fresh session id
|
|
resp, err = doRequest(newSession)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("rpc returned %d: %s", resp.StatusCode, string(data))
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// torrentFields requested from Transmission (per AGENTS.md).
|
|
var torrentFields = []string{
|
|
"id", "name", "totalSize", "downloadedEver", "uploadedEver", "uploadRatio",
|
|
"percentDone", "status", "eta", "error", "errorString", "doneDate", "isFinished",
|
|
"rateDownload", "rateUpload", "peersConnected", "peersGettingFromUs", "peersSendingToUs",
|
|
"peers",
|
|
}
|
|
|
|
type rpcRequest struct {
|
|
Method string `json:"method"`
|
|
Arguments rpcArgs `json:"arguments"`
|
|
Tag interface{} `json:"tag,omitempty"`
|
|
}
|
|
|
|
type rpcArgs struct {
|
|
Fields []string `json:"fields,omitempty"`
|
|
IDs []int `json:"ids,omitempty"`
|
|
}
|
|
|
|
type rpcResponse struct {
|
|
Result string `json:"result"`
|
|
Args json.RawMessage `json:"arguments"`
|
|
}
|
|
|
|
func (c *Client) Torrents(ctx context.Context) (json.RawMessage, error) {
|
|
reqBody, _ := json.Marshal(rpcRequest{
|
|
Method: "torrent-get",
|
|
Arguments: rpcArgs{Fields: torrentFields},
|
|
})
|
|
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) Stats(ctx context.Context) (json.RawMessage, error) {
|
|
reqBody, _ := json.Marshal(rpcRequest{
|
|
Method: "session-stats",
|
|
})
|
|
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) TorrentAction(ctx context.Context, method string, ids []int) error {
|
|
reqBody, _ := json.Marshal(rpcRequest{
|
|
Method: method,
|
|
Arguments: rpcArgs{IDs: ids},
|
|
})
|
|
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
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, data json.RawMessage) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(append(data, '\n'))
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
}
|
|
|
|
func writeConnectionWarningPage(w http.ResponseWriter, target string, err error) {
|
|
if target == "" {
|
|
target = "not configured"
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
_, _ = fmt.Fprintf(w, `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="color-scheme" content="dark">
|
|
<title>Transmission connection warning</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0a0a0f;
|
|
--panel: #12121c;
|
|
--panel-2: #181826;
|
|
--border: rgba(255, 255, 255, 0.1);
|
|
--text: #f5f6fa;
|
|
--muted: #8a8d9b;
|
|
--accent: #00d4ff;
|
|
--warn: #fbbf24;
|
|
--err: #f87171;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
min-height: 100vh;
|
|
margin: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 24px;
|
|
background: radial-gradient(900px 480px at 82%% -8%%, rgba(139, 92, 246, 0.07), transparent 70%%),
|
|
radial-gradient(820px 460px at 12%% -4%%, rgba(0, 212, 255, 0.06), transparent 70%%), var(--bg);
|
|
color: var(--text);
|
|
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
}
|
|
main {
|
|
width: min(720px, 100%%);
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 14px;
|
|
padding: 28px;
|
|
box-shadow: 0 18px 55px rgba(0, 0, 0, 0.35);
|
|
}
|
|
.eyebrow {
|
|
color: var(--warn);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
margin: 0 0 10px;
|
|
}
|
|
h1 {
|
|
margin: 0 0 10px;
|
|
color: var(--text);
|
|
font-size: clamp(26px, 5vw, 40px);
|
|
line-height: 1.05;
|
|
}
|
|
p {
|
|
color: var(--muted);
|
|
line-height: 1.55;
|
|
margin: 0 0 18px;
|
|
}
|
|
dl {
|
|
display: grid;
|
|
gap: 10px;
|
|
margin: 22px 0;
|
|
}
|
|
.row {
|
|
background: var(--panel-2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 12px 14px;
|
|
}
|
|
dt {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.1em;
|
|
margin-bottom: 6px;
|
|
text-transform: uppercase;
|
|
}
|
|
dd {
|
|
margin: 0;
|
|
overflow-wrap: anywhere;
|
|
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
|
font-size: 13px;
|
|
}
|
|
.error {
|
|
color: #fca5a5;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
margin-top: 20px;
|
|
}
|
|
a {
|
|
color: var(--bg);
|
|
background: var(--accent);
|
|
border-radius: 999px;
|
|
display: inline-flex;
|
|
padding: 10px 16px;
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
letter-spacing: 0.04em;
|
|
text-decoration: none;
|
|
text-transform: uppercase;
|
|
}
|
|
.hint {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
margin: 0;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<p class="eyebrow">Connection warning</p>
|
|
<h1>Transmission is not reachable</h1>
|
|
<p>The manager is running, but it could not set up a connection to the Transmission RPC endpoint.</p>
|
|
<dl>
|
|
<div class="row">
|
|
<dt>RPC target</dt>
|
|
<dd>%s</dd>
|
|
</div>
|
|
<div class="row">
|
|
<dt>Error</dt>
|
|
<dd class="error">%s</dd>
|
|
</div>
|
|
</dl>
|
|
<p>Check %s, %s, and %s, then refresh this page.</p>
|
|
<div class="actions">
|
|
<a href="/">Retry connection</a>
|
|
<p class="hint">The app will load once the RPC endpoint responds successfully.</p>
|
|
</div>
|
|
</main>
|
|
</body>
|
|
</html>`, html.EscapeString(target), html.EscapeString(err.Error()), envTransmissionURL, envTransmissionRPCUsername, envTransmissionRPCPassword)
|
|
}
|
|
|
|
func requireTransmissionClient(w http.ResponseWriter, client *Client, setupErr error) bool {
|
|
if setupErr == nil && client != nil {
|
|
return true
|
|
}
|
|
msg := "transmission client is not configured"
|
|
if setupErr != nil {
|
|
msg = setupErr.Error()
|
|
}
|
|
writeError(w, http.StatusBadGateway, msg)
|
|
return false
|
|
}
|
|
|
|
func handleTorrentAction(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
|
|
}
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/torrents/")
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
id, err := strconv.Atoi(parts[0])
|
|
if err != nil || id <= 0 {
|
|
writeError(w, http.StatusBadRequest, "invalid torrent id")
|
|
return
|
|
}
|
|
|
|
var method string
|
|
switch parts[1] {
|
|
case "pause":
|
|
method = "torrent-stop"
|
|
case "resume":
|
|
method = "torrent-start"
|
|
default:
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
|
|
if err := client.TorrentAction(r.Context(), method, []int{id}); err != nil {
|
|
log.Printf("torrent action %s id=%d: %v", parts[1], id, 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") {
|
|
target := redactedURL(configuredRPCURL())
|
|
if setupErr != nil {
|
|
writeConnectionWarningPage(w, target, setupErr)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
err := client.Check(ctx)
|
|
cancel()
|
|
if err != nil {
|
|
log.Printf("connection check: %v", err)
|
|
writeConnectionWarningPage(w, target, err)
|
|
return
|
|
}
|
|
}
|
|
fileServer.ServeHTTP(w, r)
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
client, setupErr := NewClientFromEnv()
|
|
if setupErr != nil {
|
|
log.Printf("transmission client setup: %v", setupErr)
|
|
} else {
|
|
log.Printf("transmission RPC target: %s", redactedURL(client.rpcURL))
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// API routes
|
|
mux.HandleFunc("/api/torrents", func(w http.ResponseWriter, r *http.Request) {
|
|
if !requireTransmissionClient(w, client, setupErr) {
|
|
return
|
|
}
|
|
args, err := client.Torrents(r.Context())
|
|
if err != nil {
|
|
log.Printf("torrents: %v", err)
|
|
writeError(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, args)
|
|
})
|
|
mux.HandleFunc("/api/torrents/", handleTorrentAction(client, setupErr))
|
|
|
|
mux.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) {
|
|
if !requireTransmissionClient(w, client, setupErr) {
|
|
return
|
|
}
|
|
args, err := client.Stats(r.Context())
|
|
if err != nil {
|
|
log.Printf("stats: %v", err)
|
|
writeError(w, http.StatusBadGateway, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, args)
|
|
})
|
|
|
|
// Static files from embedded web/ directory
|
|
webRoot, err := fs.Sub(webFS, "web")
|
|
if err != nil {
|
|
log.Fatalf("embed sub: %v", err)
|
|
}
|
|
fileServer := http.FileServer(http.FS(webRoot))
|
|
mux.Handle("/", handleStaticFiles(fileServer, client, setupErr))
|
|
|
|
addr := ":8080"
|
|
log.Printf("transmission-manager listening on %s", addr)
|
|
srv := &http.Server{
|
|
Addr: addr,
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
if err := srv.ListenAndServe(); err != nil {
|
|
log.Fatalf("server: %v", err)
|
|
}
|
|
}
|