Files
transmission-manager/main.go

206 lines
4.9 KiB
Go

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