package main import ( "bytes" "context" "embed" "encoding/json" "fmt" "io" "io/fs" "log" "net/http" "sync" "time" ) //go:embed web var webFS embed.FS const ( rpcURL = "http://192.168.20.22:3210/transmission/rpc" sessionHeader = "X-Transmission-Session-Id" ) // Client is a minimal Transmission RPC client with session-id handshake. type Client struct { http *http.Client mu sync.Mutex sessionID string } func NewClient() *Client { return &Client{ http: &http.Client{Timeout: 15 * time.Second}, } } // 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, rpcURL, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") 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"` } 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 } // sessionFields requested for stats. var sessionFields = []string{ "downloadSpeed", "uploadSpeed", "torrentCount", "activeTorrentCount", "pausedTorrentCount", "cumulativeDownloaded", "cumulativeUploaded", } func (c *Client) Stats(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 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 main() { client := NewClient() mux := http.NewServeMux() // API routes mux.HandleFunc("/api/torrents", func(w http.ResponseWriter, r *http.Request) { 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/stats", func(w http.ResponseWriter, r *http.Request) { 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("/", fileServer) 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) } }