Sync from /srv/compose/unified-media-manager
This commit is contained in:
30
internal/download/client.go
Normal file
30
internal/download/client.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package download
|
||||
|
||||
import "context"
|
||||
|
||||
type DownloadProgress struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Progress float64 `json:"progress"`
|
||||
Speed int64 `json:"speed"`
|
||||
ETA int `json:"eta"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type CompletedDownload struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OutputPath string `json:"output_path"`
|
||||
Size int64 `json:"size"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type DownloadClient interface {
|
||||
Add(ctx context.Context, url string, category string) (string, error)
|
||||
GetProgress(ctx context.Context, id string) (*DownloadProgress, error)
|
||||
GetCompleted(ctx context.Context) ([]CompletedDownload, error)
|
||||
Remove(ctx context.Context, id string) error
|
||||
Pause(ctx context.Context, id string) error
|
||||
Resume(ctx context.Context, id string) error
|
||||
}
|
||||
285
internal/download/qbittorrent.go
Normal file
285
internal/download/qbittorrent.go
Normal 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
|
||||
230
internal/download/sabnzbd.go
Normal file
230
internal/download/sabnzbd.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SABnzbdClient struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewSABnzbdClient(baseURL, apiKey string) *SABnzbdClient {
|
||||
return &SABnzbdClient{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
apiKey: apiKey,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SABnzbdClient) Add(ctx context.Context, url string, category string) (string, error) {
|
||||
if category == "" {
|
||||
category = "umm"
|
||||
}
|
||||
apiURL := fmt.Sprintf("%s/api?mode=addurl&output=json&apikey=%s&name=%s&nzbname=umm&cat=%s",
|
||||
s.baseURL, s.apiKey, url, category)
|
||||
|
||||
resp, err := s.doRequest(ctx, apiURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sabnzbd add: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result sabAddResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("sabnzbd add decode: %w", err)
|
||||
}
|
||||
|
||||
if !result.Status {
|
||||
return "", fmt.Errorf("sabnzbd add failed: %s", result.Error)
|
||||
}
|
||||
|
||||
if len(result.NzoIDs) == 0 {
|
||||
return "", fmt.Errorf("sabnzbd add: no nzo_id returned")
|
||||
}
|
||||
|
||||
return result.NzoIDs[0], nil
|
||||
}
|
||||
|
||||
func (s *SABnzbdClient) GetProgress(ctx context.Context, id string) (*DownloadProgress, error) {
|
||||
apiURL := fmt.Sprintf("%s/api?mode=queue&output=json&apikey=%s", s.baseURL, s.apiKey)
|
||||
|
||||
resp, err := s.doRequest(ctx, apiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sabnzbd queue: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result sabQueueResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("sabnzbd queue decode: %w", err)
|
||||
}
|
||||
|
||||
for _, slot := range result.Queue.Slots {
|
||||
if slot.NzoID == id {
|
||||
sizeMB, _ := strconv.ParseFloat(slot.SizeTotal, 64)
|
||||
downloadedMB, _ := strconv.ParseFloat(slot.MB, 64)
|
||||
size := int64(sizeMB * 1024 * 1024)
|
||||
|
||||
progress := float64(0)
|
||||
if sizeMB > 0 {
|
||||
progress = (downloadedMB / sizeMB) * 100
|
||||
}
|
||||
|
||||
eta := parseTimeLeft(slot.TimeLeft)
|
||||
|
||||
return &DownloadProgress{
|
||||
ID: slot.NzoID,
|
||||
Name: slot.Filename,
|
||||
Status: slot.Status,
|
||||
Progress: progress,
|
||||
Speed: int64(slot.Speed * 1024),
|
||||
ETA: eta,
|
||||
Size: size,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("sabnzbd: item %s not found in queue", id)
|
||||
}
|
||||
|
||||
func (s *SABnzbdClient) GetCompleted(ctx context.Context) ([]CompletedDownload, error) {
|
||||
apiURL := fmt.Sprintf("%s/api?mode=history&output=json&apikey=%s", s.baseURL, s.apiKey)
|
||||
|
||||
resp, err := s.doRequest(ctx, apiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sabnzbd history: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result sabHistoryResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("sabnzbd history decode: %w", err)
|
||||
}
|
||||
|
||||
var completed []CompletedDownload
|
||||
for _, slot := range result.History.Slots {
|
||||
if strings.EqualFold(slot.Status, "Completed") {
|
||||
size, _ := strconv.ParseInt(slot.Size, 10, 64)
|
||||
completed = append(completed, CompletedDownload{
|
||||
ID: slot.NzoID,
|
||||
Name: slot.Name,
|
||||
OutputPath: slot.Storage,
|
||||
Size: size,
|
||||
Status: slot.Status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return completed, nil
|
||||
}
|
||||
|
||||
func (s *SABnzbdClient) Remove(ctx context.Context, id string) error {
|
||||
apiURL := fmt.Sprintf("%s/api?mode=queue&output=json&apikey=%s&name=delete&value=%s&del_files=1",
|
||||
s.baseURL, s.apiKey, id)
|
||||
|
||||
resp, err := s.doRequest(ctx, apiURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sabnzbd remove: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SABnzbdClient) Pause(ctx context.Context, id string) error {
|
||||
apiURL := fmt.Sprintf("%s/api?mode=queue&output=json&apikey=%s&name=pause&id=%s",
|
||||
s.baseURL, s.apiKey, id)
|
||||
|
||||
resp, err := s.doRequest(ctx, apiURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sabnzbd pause: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SABnzbdClient) Resume(ctx context.Context, id string) error {
|
||||
apiURL := fmt.Sprintf("%s/api?mode=queue&output=json&apikey=%s&name=resume&id=%s",
|
||||
s.baseURL, s.apiKey, id)
|
||||
|
||||
resp, err := s.doRequest(ctx, apiURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sabnzbd resume: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SABnzbdClient) doRequest(ctx context.Context, url string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.client.Do(req)
|
||||
}
|
||||
|
||||
func parseTimeLeft(timeLeft string) int {
|
||||
if timeLeft == "" {
|
||||
return 0
|
||||
}
|
||||
parts := strings.Split(timeLeft, ":")
|
||||
if len(parts) != 3 {
|
||||
return 0
|
||||
}
|
||||
hours, _ := strconv.Atoi(parts[0])
|
||||
minutes, _ := strconv.Atoi(parts[1])
|
||||
seconds, _ := strconv.Atoi(parts[2])
|
||||
return hours*3600 + minutes*60 + seconds
|
||||
}
|
||||
|
||||
type sabAddResponse struct {
|
||||
Status bool `json:"status"`
|
||||
NzoIDs []string `json:"nzo_ids"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type sabQueueResponse struct {
|
||||
Queue struct {
|
||||
Slots []sabQueueSlot `json:"slots"`
|
||||
} `json:"queue"`
|
||||
}
|
||||
|
||||
type sabQueueSlot struct {
|
||||
NzoID string `json:"nzo_id"`
|
||||
Filename string `json:"filename"`
|
||||
Status string `json:"status"`
|
||||
MB string `json:"mb"`
|
||||
SizeTotal string `json:"sizeleft"`
|
||||
TimeLeft string `json:"timeleft"`
|
||||
Speed float64 `json:"speed"`
|
||||
Percentage string `json:"percentage"`
|
||||
}
|
||||
|
||||
type sabHistoryResponse struct {
|
||||
History struct {
|
||||
Slots []sabHistorySlot `json:"slots"`
|
||||
} `json:"history"`
|
||||
}
|
||||
|
||||
type sabHistorySlot struct {
|
||||
NzoID string `json:"nzo_id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Storage string `json:"storage"`
|
||||
Size string `json:"size"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = io.EOF
|
||||
}
|
||||
Reference in New Issue
Block a user