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