Sync from /srv/compose/unified-media-manager

This commit is contained in:
Christopher Mayor
2026-04-24 10:45:19 -07:00
commit 7dbd00e537
132 changed files with 25394 additions and 0 deletions

View File

@@ -0,0 +1,285 @@
package download
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
type QBittorrentClient struct {
baseURL string
username string
password string
client *http.Client
mu sync.Mutex
sid *http.Cookie
}
func NewQBittorrentClient(baseURL, password string) *QBittorrentClient {
return &QBittorrentClient{
baseURL: strings.TrimRight(baseURL, "/"),
username: "admin",
password: password,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (q *QBittorrentClient) login(ctx context.Context) error {
q.mu.Lock()
defer q.mu.Unlock()
form := url.Values{}
form.Set("username", q.username)
form.Set("password", q.password)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
q.baseURL+"/api/v2/auth/login", strings.NewReader(form.Encode()))
if err != nil {
return fmt.Errorf("qbittorrent login request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := q.client.Do(req)
if err != nil {
return fmt.Errorf("qbittorrent login: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "Ok.") {
return fmt.Errorf("qbittorrent login failed: %s", string(body))
}
for _, cookie := range resp.Cookies() {
if cookie.Name == "SID" {
q.sid = cookie
return nil
}
}
return fmt.Errorf("qbittorrent login: no SID cookie in response")
}
func (q *QBittorrentClient) doAuthenticated(ctx context.Context, method, path string, body url.Values) (*http.Response, error) {
var bodyReader io.Reader
if body != nil {
bodyReader = strings.NewReader(body.Encode())
}
req, err := http.NewRequestWithContext(ctx, method, q.baseURL+path, bodyReader)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if q.sid != nil {
req.AddCookie(q.sid)
}
resp, err := q.client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusForbidden {
resp.Body.Close()
if err := q.login(ctx); err != nil {
return nil, fmt.Errorf("qbittorrent re-auth: %w", err)
}
req2, err := http.NewRequestWithContext(ctx, method, q.baseURL+path, bodyReader)
if err != nil {
return nil, err
}
if body != nil {
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
req2.AddCookie(q.sid)
return q.client.Do(req2)
}
return resp, nil
}
func (q *QBittorrentClient) Add(ctx context.Context, torrentURL string, category string) (string, error) {
if category == "" {
category = "umm"
}
form := url.Values{}
form.Set("urls", torrentURL)
form.Set("category", category)
resp, err := q.doAuthenticated(ctx, http.MethodPost, "/api/v2/torrents/add", form)
if err != nil {
return "", fmt.Errorf("qbittorrent add: %w", err)
}
resp.Body.Close()
resp, err = q.doAuthenticated(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/torrents/info?sort=added_on&reverse=true&category=%s", url.QueryEscape(category)), nil)
if err != nil {
return "", fmt.Errorf("qbittorrent add verify: %w", err)
}
defer resp.Body.Close()
var torrents []qbTorrentInfo
if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil {
return "", fmt.Errorf("qbittorrent add decode: %w", err)
}
for _, t := range torrents {
if t.MagnetURI == torrentURL || strings.Contains(t.Tracker, torrentURL) {
return t.Hash, nil
}
}
if len(torrents) > 0 {
return torrents[0].Hash, nil
}
return "", fmt.Errorf("qbittorrent add: torrent not found after adding")
}
func (q *QBittorrentClient) GetProgress(ctx context.Context, hash string) (*DownloadProgress, error) {
resp, err := q.doAuthenticated(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/torrents/info?hashes=%s", url.QueryEscape(hash)), nil)
if err != nil {
return nil, fmt.Errorf("qbittorrent progress: %w", err)
}
defer resp.Body.Close()
var torrents []qbTorrentInfo
if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil {
return nil, fmt.Errorf("qbittorrent progress decode: %w", err)
}
if len(torrents) == 0 {
return nil, fmt.Errorf("qbittorrent: torrent %s not found", hash)
}
t := torrents[0]
return &DownloadProgress{
ID: t.Hash,
Name: t.Name,
Status: qbStateToStatus(t.State),
Progress: t.Progress * 100,
Speed: t.DLSpeed,
ETA: t.ETA,
Size: t.Size,
}, nil
}
func (q *QBittorrentClient) GetCompleted(ctx context.Context) ([]CompletedDownload, error) {
resp, err := q.doAuthenticated(ctx, http.MethodGet,
"/api/v2/torrents/info?filter=completed", nil)
if err != nil {
return nil, fmt.Errorf("qbittorrent completed: %w", err)
}
defer resp.Body.Close()
var torrents []qbTorrentInfo
if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil {
return nil, fmt.Errorf("qbittorrent completed decode: %w", err)
}
var completed []CompletedDownload
for _, t := range torrents {
completed = append(completed, CompletedDownload{
ID: t.Hash,
Name: t.Name,
OutputPath: t.ContentPath,
Size: t.Size,
Status: qbStateToStatus(t.State),
})
}
return completed, nil
}
func (q *QBittorrentClient) Remove(ctx context.Context, hash string) error {
form := url.Values{}
form.Set("hashes", hash)
form.Set("deleteFiles", "true")
resp, err := q.doAuthenticated(ctx, http.MethodPost, "/api/v2/torrents/delete", form)
if err != nil {
return fmt.Errorf("qbittorrent remove: %w", err)
}
resp.Body.Close()
return nil
}
func (q *QBittorrentClient) Pause(ctx context.Context, hash string) error {
form := url.Values{}
form.Set("hashes", hash)
resp, err := q.doAuthenticated(ctx, http.MethodPost, "/api/v2/torrents/pause", form)
if err != nil {
return fmt.Errorf("qbittorrent pause: %w", err)
}
resp.Body.Close()
return nil
}
func (q *QBittorrentClient) Resume(ctx context.Context, hash string) error {
form := url.Values{}
form.Set("hashes", hash)
resp, err := q.doAuthenticated(ctx, http.MethodPost, "/api/v2/torrents/resume", form)
if err != nil {
return fmt.Errorf("qbittorrent resume: %w", err)
}
resp.Body.Close()
return nil
}
func qbStateToStatus(state string) string {
switch state {
case "downloading", "stalledDL", "forcedDL", "metaDL", "forcedMetaDL":
return "downloading"
case "uploading", "stalledUP", "forcedUP":
return "completed"
case "pausedDL", "pausedUP":
return "paused"
case "queuedDL", "queuedUP":
return "queued"
case "checkingDL", "checkingUP", "moving":
return "checking"
case "error", "missingFiles", "unknown":
return "error"
default:
return "unknown"
}
}
type qbTorrentInfo struct {
Hash string `json:"hash"`
Name string `json:"name"`
State string `json:"state"`
Progress float64 `json:"progress"`
DLSpeed int64 `json:"dlspeed"`
ETA int `json:"eta"`
Size int64 `json:"size"`
ContentPath string `json:"content_path"`
AddedOn int64 `json:"added_on"`
Tracker string `json:"tracker"`
MagnetURI string `json:"magnet_uri"`
}
var _ = strconv.Atoi
var _ io.Reader = nil