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 ( 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 { return strings.TrimSpace(os.Getenv(envTransmissionURL)) } func optionalEnv(name string) string { value := os.Getenv(name) if strings.EqualFold(strings.TrimSpace(value), "null") { return "" } return value } func validateRPCURL(raw string) error { if raw == "" { return fmt.Errorf("%s environment variable must be set", envTransmissionURL) } 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", "availability", "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, ` Transmission connection warning

Connection warning

Transmission is not reachable

The manager is running, but it could not set up a connection to the Transmission RPC endpoint.

RPC target
%s
Error
%s

Check %s, %s, and %s, then refresh this page.

Retry connection

The app will load once the RPC endpoint responds successfully.

`, 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) } }