changes
Some checks failed
Build and Deploy (internal) / Build Transmission Manager Image (push) Failing after 2m56s
Build and Deploy (internal) / Deploy Transmission Manager (internal) (push) Has been skipped

This commit is contained in:
2026-07-08 17:27:30 -03:00
parent 9981d0bc19
commit ec3e1ee6a3
7 changed files with 933 additions and 219 deletions

342
main.go
View File

@@ -6,10 +6,15 @@ import (
"embed"
"encoding/json"
"fmt"
"html"
"io"
"io/fs"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
)
@@ -18,31 +23,96 @@ import (
var webFS embed.FS
const (
rpcURL = "http://192.168.20.22:3210/transmission/rpc"
sessionHeader = "X-Transmission-Session-Id"
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() *Client {
func NewClient(rpcURL, username, password string) *Client {
return &Client{
http: &http.Client{Timeout: 15 * time.Second},
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, rpcURL, bytes.NewReader(body))
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)
}
@@ -103,6 +173,7 @@ type rpcRequest struct {
type rpcArgs struct {
Fields []string `json:"fields,omitempty"`
IDs []int `json:"ids,omitempty"`
}
type rpcResponse struct {
@@ -147,6 +218,30 @@ func (c *Client) Stats(ctx context.Context) (json.RawMessage, error) {
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'))
@@ -158,13 +253,242 @@ func writeError(w http.ResponseWriter, status int, msg string) {
_ = 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, `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Transmission connection warning</title>
<style>
:root {
--bg: #0a0a0f;
--panel: #12121c;
--panel-2: #181826;
--border: rgba(255, 255, 255, 0.1);
--text: #f5f6fa;
--muted: #8a8d9b;
--accent: #00d4ff;
--warn: #fbbf24;
--err: #f87171;
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
background: radial-gradient(900px 480px at 82%% -8%%, rgba(139, 92, 246, 0.07), transparent 70%%),
radial-gradient(820px 460px at 12%% -4%%, rgba(0, 212, 255, 0.06), transparent 70%%), var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
main {
width: min(720px, 100%%);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 28px;
box-shadow: 0 18px 55px rgba(0, 0, 0, 0.35);
}
.eyebrow {
color: var(--warn);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
margin: 0 0 10px;
}
h1 {
margin: 0 0 10px;
color: var(--text);
font-size: clamp(26px, 5vw, 40px);
line-height: 1.05;
}
p {
color: var(--muted);
line-height: 1.55;
margin: 0 0 18px;
}
dl {
display: grid;
gap: 10px;
margin: 22px 0;
}
.row {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
}
dt {
color: var(--muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
margin-bottom: 6px;
text-transform: uppercase;
}
dd {
margin: 0;
overflow-wrap: anywhere;
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
font-size: 13px;
}
.error {
color: #fca5a5;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 20px;
}
a {
color: var(--bg);
background: var(--accent);
border-radius: 999px;
display: inline-flex;
padding: 10px 16px;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.04em;
text-decoration: none;
text-transform: uppercase;
}
.hint {
color: var(--muted);
font-size: 12px;
margin: 0;
}
</style>
</head>
<body>
<main>
<p class="eyebrow">Connection warning</p>
<h1>Transmission is not reachable</h1>
<p>The manager is running, but it could not set up a connection to the Transmission RPC endpoint.</p>
<dl>
<div class="row">
<dt>RPC target</dt>
<dd>%s</dd>
</div>
<div class="row">
<dt>Error</dt>
<dd class="error">%s</dd>
</div>
</dl>
<p>Check %s, %s, and %s, then refresh this page.</p>
<div class="actions">
<a href="/">Retry connection</a>
<p class="hint">The app will load once the RPC endpoint responds successfully.</p>
</div>
</main>
</body>
</html>`, 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 := NewClient()
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)
@@ -173,8 +497,12 @@ func main() {
}
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)
@@ -190,7 +518,7 @@ func main() {
log.Fatalf("embed sub: %v", err)
}
fileServer := http.FileServer(http.FS(webRoot))
mux.Handle("/", fileServer)
mux.Handle("/", handleStaticFiles(fileServer, client, setupErr))
addr := ":8080"
log.Printf("transmission-manager listening on %s", addr)