Files
MeBox/internal/service/download_clients.go
T
ShukeBta 3a2db6bdd9 feat: Emby 兼容层完整实现 + 多模块功能增强
## Emby/Jellyfin 兼容层 (emby_compat.go / emby.go)
- 新增 SystemInfoPublic、FindUser、Items、Item、LatestItems、ResumeItems
- 新增 SetFavorite、MarkPlayed、RecordProgress 用户播放状态同步
- 新增 itemPayload、mediaSource、mediaStreams 媒体信息组装
- 双前缀路由 /emby/* 和 / 根路径,兼容 Infuse/Yamby/Senplayer/Kodi
- 新增 Ping、SystemEndpoint、AuthByName 端点
- emby.go 扩展对应 handler 函数

## 站点适配器 (site_adapter.go / site.go)
- SiteConfig 扩展 UserAgent/Timeout/Extra/FlareSolverrURL 字段
- doRequest() GET 请求支持 FlareSolverr 代理绕过 Cloudflare/WAF
- MTeam api_key 认证改为 Authorization: Bearer 格式
- Search() 重构为 sync.WaitGroup 并发执行,提升多站搜索性能
- siteModelToConfig() 改为 SiteService 方法,按 BrowserEmulation 填充 FlareSolverrURL

## 图片代理 (image_proxy.go)
- 重构图片代理服务,支持更多来源和缓存策略

## 下载管理 (downloads.go / download_clients.go / qbittorrent.go)
- 下载任务增强:状态管理、进度追踪优化
- qBittorrent 客户端连接稳定性改进

## 刮削与数据库 (scraper.go / tmdb.go / repository.go)
- 刮削器增强 TMDB 集成,补全元数据字段
- repository 扩展查询方法

## 前端 (web/src/)
- HomePage: 首页布局重构,按媒体库分组展示,系列聚合优化
- DiscoverPage: 发现页增强,错误处理改进(API key 缺失/网络错误分离)
- PosterWallPage: 海报墙优化,系列聚合展示
- MediaCard: 媒体卡片优化
- PlayerPage: 播放器改进
- 新增 utils/groupSeries.ts: 系列聚合工具函数
- .gitignore: 添加 .tmp_* 临时文件排除规则
2026-05-26 16:09:13 +08:00

227 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"io"
"net/http"
"net/url"
"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: &http.Client{Timeout: 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) {
if err := validateClient(in); err != nil {
return nil, err
}
c := &model.DownloadClient{
Name: strings.TrimSpace(in.Name),
Type: in.Type,
Host: strings.TrimSpace(in.Host),
Username: in.Username,
Password: in.Password,
IsDefault: in.IsDefault,
Enabled: in.Enabled,
}
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) {
if err := validateClient(in); err != nil {
return nil, err
}
patch := map[string]any{
"name": strings.TrimSpace(in.Name),
"type": in.Type,
"host": strings.TrimSpace(in.Host),
"username": in.Username,
"is_default": in.IsDefault,
"enabled": in.Enabled,
}
// Only overwrite the password when the caller actually sent one.
if in.Password != "" {
patch["password"] = in.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")
}
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
}
return s.repo.DownloadClient.FindByID(ctx, id)
}
// Delete removes one client.
func (s *DownloadClientService) Delete(ctx context.Context, id string) error {
return s.repo.DownloadClient.Delete(ctx, id)
}
// 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 {
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":
host := strings.TrimRight(c.Host, "/")
body := url.Values{}
body.Set("username", c.Username)
body.Set("password", c.Password)
req, _ := http.NewRequestWithContext(
ctx, http.MethodPost,
host+"/api/v2/auth/login",
strings.NewReader(body.Encode()),
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// qBittorrent v4.6+ 默认开启 "host header validation" 并要求
// Referer 与 Host 同源,否则即使账户正确也会拒绝登录。
req.Header.Set("Referer", host)
req.Header.Set("Origin", host)
req.Header.Set("User-Agent", "MediaStationGo/0.1")
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden {
return errors.New("qbittorrent: 403 — 用户名/密码错误,或 WebUI 启用了 IP 封禁")
}
if resp.StatusCode >= 400 {
return fmt.Errorf("qbittorrent returned %d", resp.StatusCode)
}
// 即使返回 200,body 内容仍可能是 "Fails." 表示登录失败。
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
text := strings.TrimSpace(string(raw))
if text == "Fails." {
return errors.New("qbittorrent: 用户名/密码错误")
}
// 正确响应是 "Ok." — 但部分版本会重定向或返回空体,不强校验。
return nil
case "aria2", "transmission":
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.Host, nil)
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")
}
payload := fmt.Sprintf(
`{"jsonrpc":"2.0","id":"x","method":"aria2.getGlobalStat","params":["token:%s"]}`,
c.Password,
)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.Host,
strings.NewReader(payload))
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
}