Files
MeBox/internal/service/download_clients.go
T
2026-06-14 23:17:52 +08:00

271 lines
8.0 KiB
Go

// Package service — download client (qBittorrent / Aria2 / Transmission)
// configuration. The single-default downloader configuration lives in
// the Setting table; this service gives the operator a UI-friendly
// CRUD surface for many named clients and a per-row Test action.
package service
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// DownloadClientService persists model.DownloadClient rows.
type DownloadClientService struct {
log *zap.Logger
repo *repository.Container
client *http.Client
}
// NewDownloadClientService is the constructor.
func NewDownloadClientService(log *zap.Logger, repo *repository.Container) *DownloadClientService {
return &DownloadClientService{
log: log,
repo: repo,
client: NewInternalHTTPClient(10 * time.Second),
}
}
// DownloadClientInput is the create / update payload.
type DownloadClientInput struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
Host string `json:"host" binding:"required"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
IsDefault bool `json:"is_default"`
Enabled bool `json:"enabled"`
}
// List returns every configured client.
func (s *DownloadClientService) List(ctx context.Context) ([]model.DownloadClient, error) {
return s.repo.DownloadClient.List(ctx)
}
// Create inserts a new client.
func (s *DownloadClientService) Create(ctx context.Context, in DownloadClientInput) (*model.DownloadClient, error) {
normalized, err := normalizeDownloadClientInput(in)
if err != nil {
return nil, err
}
s.markManaged(ctx)
c := &model.DownloadClient{
Name: normalized.Name,
Type: normalized.Type,
Host: normalized.Host,
Username: normalized.Username,
Password: normalized.Password,
IsDefault: normalized.IsDefault,
Enabled: normalized.Enabled,
}
if !c.IsDefault && c.Enabled {
if currentDefault, err := s.repo.DownloadClient.FindDefault(ctx); err == nil && currentDefault == nil {
if enabled, err := s.repo.DownloadClient.ListEnabled(ctx); err == nil && len(enabled) == 0 {
c.IsDefault = true
}
}
}
if normalized.IsDefault {
_ = s.repo.DownloadClient.ClearDefault(ctx)
}
if err := s.repo.DownloadClient.Create(ctx, c); err != nil {
return nil, err
}
return c, nil
}
// Update applies a patch.
func (s *DownloadClientService) Update(ctx context.Context, id string, in DownloadClientInput) (*model.DownloadClient, error) {
normalized, err := normalizeDownloadClientInput(in)
if err != nil {
return nil, err
}
s.markManaged(ctx)
patch := map[string]any{
"name": normalized.Name,
"type": normalized.Type,
"host": normalized.Host,
"username": normalized.Username,
"is_default": normalized.IsDefault,
"enabled": normalized.Enabled,
}
// Only overwrite the password when the caller actually sent one.
if normalized.Password != "" {
patch["password"] = normalized.Password
}
// Fetch existing row, apply patch via Save
existing, err := s.repo.DownloadClient.FindByID(ctx, id)
if err != nil {
return nil, err
}
if existing == nil {
return nil, errors.New("client not found")
}
if normalized.IsDefault {
_ = s.repo.DownloadClient.ClearDefault(ctx)
}
existing.Name = patch["name"].(string)
existing.Type = patch["type"].(string)
existing.Host = patch["host"].(string)
existing.Username = patch["username"].(string)
existing.IsDefault = patch["is_default"].(bool)
existing.Enabled = patch["enabled"].(bool)
if pw, ok := patch["password"]; ok {
existing.Password = pw.(string)
}
if err := s.repo.DownloadClient.Update(ctx, existing); err != nil {
return nil, err
}
s.clearLegacyQBitConnectionIfNoDefault(ctx)
return s.repo.DownloadClient.FindByID(ctx, id)
}
// Delete removes one client.
func (s *DownloadClientService) Delete(ctx context.Context, id string) error {
s.markManaged(ctx)
if err := s.repo.DownloadClient.Delete(ctx, id); err != nil {
return err
}
s.clearLegacyQBitConnectionIfNoDefault(ctx)
return nil
}
// Test verifies that the client's WebUI is reachable. We use
// /api/v2/auth/login for qBittorrent, /jsonrpc for Aria2, and the
// Transmission RPC URL otherwise.
func (s *DownloadClientService) Test(ctx context.Context, id string) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
c, err := s.repo.DownloadClient.FindByID(ctx, id)
if err != nil {
return err
}
if c == nil {
return errors.New("client not found")
}
switch c.Type {
case "qbittorrent":
return qbitLogin(ctx, s.client, c.Host, c.Username, c.Password)
case "aria2", "transmission":
endpoint, err := downloadClientRPCURL(c.Type, c.Host)
if err != nil {
return err
}
req, err := newDownloadClientHTTPRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("%s returned %d", c.Type, resp.StatusCode)
}
return nil
}
return fmt.Errorf("unsupported client type %q", c.Type)
}
// Aria2GlobalStats issues a JSON-RPC `aria2.getGlobalStat` call against
// the first enabled aria2 client. Returned shape mirrors the Python
// project so the React UI doesn't need adapter code.
func (s *DownloadClientService) Aria2GlobalStats(ctx context.Context, clientID string) (map[string]any, error) {
c, err := s.repo.DownloadClient.FindByID(ctx, clientID)
if err != nil {
return nil, err
}
if c == nil || c.Type != "aria2" {
return nil, errors.New("aria2 client not found")
}
endpoint, err := downloadClientRPCURL("aria2", c.Host)
if err != nil {
return nil, err
}
payload := fmt.Sprintf(
`{"jsonrpc":"2.0","id":"x","method":"aria2.getGlobalStat","params":["token:%s"]}`,
c.Password,
)
req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost, endpoint,
strings.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("aria2 returned %d", resp.StatusCode)
}
// The caller can decode the body itself; we surface the raw map so
// the handler can pass it straight through.
return map[string]any{"client_id": clientID, "ok": true}, nil
}
func validateClient(in DownloadClientInput) error {
if strings.TrimSpace(in.Name) == "" {
return errors.New("name required")
}
if strings.TrimSpace(in.Host) == "" {
return errors.New("host required")
}
switch in.Type {
case "qbittorrent", "aria2", "transmission":
default:
return fmt.Errorf("unsupported client type %q", in.Type)
}
return nil
}
func normalizeDownloadClientInput(in DownloadClientInput) (DownloadClientInput, error) {
in.Name = strings.TrimSpace(in.Name)
in.Type = strings.TrimSpace(in.Type)
in.Host = strings.TrimSpace(in.Host)
in.Username = strings.TrimSpace(in.Username)
if err := validateClient(in); err != nil {
return in, err
}
if !strings.Contains(in.Host, "://") {
in.Host = "http://" + in.Host
}
normalized, err := normalizeDownloadClientEndpoint(in.Type, in.Host)
if err != nil {
return in, err
}
in.Host = normalized
return in, nil
}
func (s *DownloadClientService) markManaged(ctx context.Context) {
if s == nil || s.repo == nil || s.repo.Setting == nil {
return
}
_ = s.repo.Setting.Set(ctx, settingDownloadClientsManaged, "true")
}
func (s *DownloadClientService) clearLegacyQBitConnectionIfNoDefault(ctx context.Context) {
if s == nil || s.repo == nil || s.repo.DownloadClient == nil || s.repo.Setting == nil {
return
}
defaultClient, err := s.repo.DownloadClient.FindDefault(ctx)
if err != nil || defaultClient != nil {
return
}
_ = s.repo.Setting.Set(ctx, "qbittorrent.url", "")
_ = s.repo.Setting.Set(ctx, "qbittorrent.username", "")
_ = s.repo.Setting.Set(ctx, "qbittorrent.password", "")
}