package upwatch import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "regexp" "strings" "time" ) // Report is what one app says about its own work right now. type Report struct { // UploadCounter is the app's own cumulative count of bytes it uploaded (qBittorrent `up_info_data`, Transmission // `current-stats.uploadedBytes`). It restarts at zero with the app; the watcher treats a drop as a restart. UploadCounter uint64 HasUploadCounter bool // Viewers is the number of playing sessions (Plex `/status/sessions` size). A viewer EXPLAINS upload of any size: // the per-session bandwidth was not measured in the spike (no claimed server with a remote player tonight), so // the rule is deliberately the conservative half — no viewer and a big upload is the Plex2Shell signature. Viewers int HasViewerCount bool // Transcodes is CPU work the app reports (Plex `/transcode/sessions` size). Any transcode explains the CPU. Transcodes int HasTranscodeCount bool // Limit is the app's current global upload limit in bytes/s (0 = none); HasLimit when the app has one to read. Limit int64 HasLimit bool } // Explainer asks one app about its own work. type Explainer interface { Stack() string Ask(ctx context.Context) (Report, error) } // Box is what an explainer needs outside HTTP — the mediawire seams (`docker exec printenv`, the Plex token read at // use). Production: mediawire.DockerBox. type Box interface { Env(container, name string) (string, error) Running(container string) bool } // httpDo is one request to an app's own container. Small and local on purpose: mediawire's helpers are methods of // its Wirer, which also writes; this package only reads (and sets one limit, qblimit.go). func httpDo(ctx context.Context, c *http.Client, method, u string, hdr map[string]string, body []byte, ctype string) (int, []byte, http.Header, error) { req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(body)) if err != nil { return 0, nil, nil, err } for k, v := range hdr { if strings.EqualFold(k, "Host") { req.Host = v continue } req.Header.Set(k, v) } if ctype != "" { req.Header.Set("Content-Type", ctype) } resp, err := c.Do(req) if err != nil { return 0, nil, nil, err } defer resp.Body.Close() b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) return resp.StatusCode, b, resp.Header, nil } // ── qBittorrent ────────────────────────────────────────────────────────────────────────────────────────────────── // QBittorrent asks qBittorrent's Web API. Measured on 9202 (qBittorrent 5.2.4, WebAPI 2.15.1): while seeding, the // container sent 1.03–1.07 × what `up_info_data` counted (protocol overhead); `transfer/uploadLimit` and // `app/preferences.up_limit` are the same value, persisted across a restart (`Session\GlobalUPSpeedLimit`, KiB/s). type QBittorrent struct { Box Box URL string // default http://qbittorrent:8080 (the container's name on the docker network, as mediawire calls it) HTTP *http.Client sess map[string]string // the session cookie, kept between minutes (a login a minute would fill qBittorrent's log) } func (q *QBittorrent) Stack() string { return "qbittorrent" } func (q *QBittorrent) base() string { if q.URL != "" { return q.URL } return "http://qbittorrent:8080" } func (q *QBittorrent) client() *http.Client { if q.HTTP != nil { return q.HTTP } return &http.Client{Timeout: 20 * time.Second} } var qbCookieRE = regexp.MustCompile(`((?:QBT_)?SID[A-Za-z0-9_]*)=([^;]+)`) // login returns the headers of an authenticated session. The password is the catalog's QBT_ADMIN_PASSWORD, read at // use; Referer must equal the base (qBittorrent's CSRF check — a mismatch is a 401, measured). func (q *QBittorrent) login(ctx context.Context) (map[string]string, error) { pw, err := q.Box.Env("qbittorrent", "QBT_ADMIN_PASSWORD") if err != nil { return nil, err } form := url.Values{"username": {"admin"}, "password": {pw}}.Encode() code, _, hd, err := httpDo(ctx, q.client(), "POST", q.base()+"/api/v2/auth/login", map[string]string{"Referer": q.base()}, []byte(form), "application/x-www-form-urlencoded") if err != nil { return nil, err } m := qbCookieRE.FindStringSubmatch(strings.Join(hd.Values("Set-Cookie"), "; ")) if (code != 200 && code != 204) || m == nil { // 5.x answers a good login 204 return nil, fmt.Errorf("qBittorrent login: HTTP %d", code) } return map[string]string{"Referer": q.base(), "Cookie": m[1] + "=" + m[2]}, nil } // session returns the kept session, logging in when there is none or when fresh is set. func (q *QBittorrent) session(ctx context.Context, fresh bool) (map[string]string, error) { if q.sess != nil && !fresh { return q.sess, nil } h, err := q.login(ctx) if err != nil { q.sess = nil return nil, err } q.sess = h return h, nil } func (q *QBittorrent) Ask(ctx context.Context) (Report, error) { h, err := q.session(ctx, false) if err != nil { return Report{}, err } code, b, _, err := httpDo(ctx, q.client(), "GET", q.base()+"/api/v2/transfer/info", h, nil, "") if err == nil && code == 403 { // the session expired (qBittorrent restarted, or its 1-hour timeout): once more if h, err = q.session(ctx, true); err != nil { return Report{}, err } code, b, _, err = httpDo(ctx, q.client(), "GET", q.base()+"/api/v2/transfer/info", h, nil, "") } if err != nil || code != 200 { return Report{}, fmt.Errorf("qBittorrent transfer/info: HTTP %d %v", code, err) } var ti struct { UpInfoData uint64 `json:"up_info_data"` } if err := json.Unmarshal(b, &ti); err != nil { return Report{}, err } r := Report{UploadCounter: ti.UpInfoData, HasUploadCounter: true} if lim, err := q.UploadLimit(ctx, h); err == nil { r.Limit, r.HasLimit = lim, true } return r, nil } // UploadLimit reads the global upload limit (bytes/s, 0 = none). h nil → logs in. func (q *QBittorrent) UploadLimit(ctx context.Context, h map[string]string) (int64, error) { if h == nil { var err error if h, err = q.session(ctx, true); err != nil { return 0, err } } code, b, _, err := httpDo(ctx, q.client(), "GET", q.base()+"/api/v2/transfer/uploadLimit", h, nil, "") if err != nil || code != 200 { return 0, fmt.Errorf("qBittorrent uploadLimit: HTTP %d %v", code, err) } var v int64 if _, err := fmt.Sscan(strings.TrimSpace(string(b)), &v); err != nil { return 0, fmt.Errorf("qBittorrent uploadLimit: %q", strings.TrimSpace(string(b))) } return v, nil } // SetUploadLimit sets the global upload limit (bytes/s) and returns the value qBittorrent stored (it keeps KiB/s, so // 2 500 000 reads back as 2 499 584 — measured). func (q *QBittorrent) SetUploadLimit(ctx context.Context, bytesPerSec int64) (int64, error) { h, err := q.session(ctx, true) if err != nil { return 0, err } form := url.Values{"limit": {fmt.Sprint(bytesPerSec)}}.Encode() code, _, _, err := httpDo(ctx, q.client(), "POST", q.base()+"/api/v2/transfer/setUploadLimit", h, []byte(form), "application/x-www-form-urlencoded") if err != nil || code != 200 { return 0, fmt.Errorf("qBittorrent setUploadLimit: HTTP %d %v", code, err) } return q.UploadLimit(ctx, h) } // ── Transmission ───────────────────────────────────────────────────────────────────────────────────────────────── // Transmission asks Transmission's RPC (4.1.3 on 9202): `session-stats.current-stats.uploadedBytes` matched the // container's sent bytes within 0.1–2 % while seeding (measured). type Transmission struct { Box Box URL string // default http://transmission:9091 HTTP *http.Client } func (t *Transmission) Stack() string { return "transmission" } func (t *Transmission) Ask(ctx context.Context) (Report, error) { pw, err := t.Box.Env("transmission", "PASS") if err != nil { return Report{}, err } base := t.URL if base == "" { base = "http://transmission:9091" } c := t.HTTP if c == nil { c = &http.Client{Timeout: 20 * time.Second} } h := map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("admin:"+pw)), "Host": "transmission:9091"} body := []byte(`{"method":"session-stats"}`) for i := 0; i < 2; i++ { code, b, hd, err := httpDo(ctx, c, "POST", base+"/transmission/rpc", h, body, "application/json") if err != nil { return Report{}, err } if code == 409 { h["X-Transmission-Session-Id"] = hd.Get("X-Transmission-Session-Id") continue } if code != 200 { return Report{}, fmt.Errorf("transmission session-stats: HTTP %d", code) } var st struct { Arguments struct { Current struct { UploadedBytes uint64 `json:"uploadedBytes"` } `json:"current-stats"` } `json:"arguments"` } if err := json.Unmarshal(b, &st); err != nil { return Report{}, err } return Report{UploadCounter: st.Arguments.Current.UploadedBytes, HasUploadCounter: true}, nil } return Report{}, fmt.Errorf("transmission session-stats: no session id") } // ── Plex ───────────────────────────────────────────────────────────────────────────────────────────────────────── // Plex asks Plex Media Server for its playing and transcoding sessions. Measured on 9202 (1.43.5, unclaimed): both // answer `{"MediaContainer":{"size":0}}` without a token from the docker network; a CLAIMED server needs its own // PlexOnlineToken, read from Preferences.xml at use exactly as mediawire does (`09` §3 decision 248). type Plex struct { Token func() string // mediawire.PlexToken(box); "" for an unclaimed server URL func(port int) (string, error) // mediawire.PlexURL (Plex refuses a call by NAME as non-local, R-1060) HTTP *http.Client } func (p *Plex) Stack() string { return "plex" } func (p *Plex) Ask(ctx context.Context) (Report, error) { base, err := p.URL(32400) if err != nil { return Report{}, err } c := p.HTTP if c == nil { c = &http.Client{Timeout: 20 * time.Second} } h := map[string]string{"Accept": "application/json"} if tok := p.Token(); tok != "" { h["X-Plex-Token"] = tok } size := func(path string) (int, error) { code, b, _, err := httpDo(ctx, c, "GET", base+path, h, nil, "") if err != nil || code != 200 { return 0, fmt.Errorf("plex %s: HTTP %d %v", path, code, err) } var mc struct { MediaContainer struct { Size int `json:"size"` } `json:"MediaContainer"` } if err := json.Unmarshal(b, &mc); err != nil { return 0, err } return mc.MediaContainer.Size, nil } v, err := size("/status/sessions") if err != nil { return Report{}, err } tr, err := size("/transcode/sessions") if err != nil { return Report{}, err } return Report{Viewers: v, HasViewerCount: true, Transcodes: tr, HasTranscodeCount: true}, nil }