213 lines
5.1 KiB
Go
213 lines
5.1 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|