Files
MeBox/internal/service/download_manager_svc.go
T
ShukeBta 585edeb984 fix: 资源占用/登录稳定性/QB整理入库/第三方播放404 综合修复
资源占用(Docker 部署 CPU/内存长期居高):
- 云盘探测预算改为按尝试扣减,杜绝队列满时对每个文件反复入队
  并刷出数万条 WARN(实测日志 41165 条)
- 探测队列满时给文件挂 30 分钟退避 + 告警限速为每分钟一条
- 扫描时每个文件的海报/背景图由同步下载(单张最长 20s)改为
  后台预取队列,云盘大库扫描不再串行拉图数小时
- PlaybackInfo 的云盘 ffprobe 探测改异步(原同步最长 8s,
  既拖慢起播又放大云盘流量),带单飞去重
- 访问日志跳过 /api/health 与静态资源;logging.level/format
  配置真正生效(此前是死配置)

登录稳定性(经常登录报错):
- refresh token 未及时落库期间,刷新请求可识别「待落库令牌」,
  不再把用户踢回登录页;轮换/登出后取消后台补写,防止旧令牌复活

QB 下载整理入库:
- 新增 download.path_mappings 设置:自定义下载器→本程序路径映射
  (每行 客户端路径=本地路径),并复用 compose 环境变量映射规则
- 应用重启后补整理最近 24h 内完成的种子(此前重启即永久漏掉)
- 下载客户端初始化失败仍注册并惰性重连(容器启动顺序免疫)
- 硬链接跨文件系统(EXDEV)自动降级为复制,保种语义不变

第三方播放器 404:
- 播放处理器不再把所有错误吞成 404:媒体不存在→404,
  云盘解析失败/STRM 关闭→502+原因
- 存库的云盘播放 URL 规范化为相对路径,免疫扫描时固化的旧 host
- 云盘媒体 SupportsDirectPlay=false,强制走带鉴权的 DirectStream
2026-06-12 13:19:42 +08:00

261 lines
7.0 KiB
Go

// Package service — 下载管理器,管理多个下载客户端适配器。
//
// DownloadManager 提供多客户端分发能力,支持运行时热插拔。
// 调用方通过 GetDefault() 或 GetClient(id) 获取适配器来执行下载操作。
package service
import (
"context"
"encoding/json"
"errors"
"sync"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// DownloadManager 管理多个下载客户端适配器实例。
type DownloadManager struct {
log *zap.Logger
repo *repository.Container
crypto *CryptoService
mu sync.RWMutex
clients map[string]DownloadAdapter // clientID -> adapter
configs map[string]DownloadClientConfig
}
// NewDownloadManager 创建新的下载管理器。
func NewDownloadManager(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *DownloadManager {
return &DownloadManager{
log: log,
repo: repo,
crypto: crypto,
clients: make(map[string]DownloadAdapter),
configs: make(map[string]DownloadClientConfig),
}
}
// LoadAll 从数据库加载所有已启用的客户端并初始化适配器。
func (m *DownloadManager) LoadAll(ctx context.Context) error {
dbClients, err := m.repo.DownloadClient.ListEnabled(ctx)
if err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
// 清空现有
m.clients = make(map[string]DownloadAdapter, len(dbClients))
m.configs = make(map[string]DownloadClientConfig, len(dbClients))
for _, dc := range dbClients {
cfg, err := m.buildConfig(&dc)
if err != nil {
m.log.Warn("failed to build config for download client",
zap.String("id", dc.ID),
zap.String("name", dc.Name),
zap.Error(err),
)
continue
}
adapter := AdapterFactory(dc.Type)
if adapter == nil {
m.log.Warn("unknown download client type",
zap.String("type", dc.Type),
zap.String("id", dc.ID),
)
continue
}
if initErr := adapter.Initialize(ctx, cfg); initErr != nil {
// 初始化失败通常是 Docker 启动顺序问题(qBittorrent 还没就绪)。
// 适配器内部支持按需重新登录(403/未登录时透明重试),所以
// 这里仍然注册适配器,等下载器上线后自动恢复;此前直接 continue
// 会让该客户端在应用重启前永久不可用,下载完成也无法整理入库。
m.log.Warn("download client init failed; registered for lazy reconnect",
zap.String("id", dc.ID),
zap.String("name", dc.Name),
zap.Error(initErr),
)
}
m.clients[dc.ID] = adapter
m.configs[dc.ID] = cfg
m.log.Info("download client registered",
zap.String("id", dc.ID),
zap.String("name", dc.Name),
zap.String("type", dc.Type),
)
}
return nil
}
// GetDefault 返回默认下载客户端适配器。
// 如果没有设置默认客户端,返回第一个可用的客户端。
func (m *DownloadManager) GetDefault() (string, DownloadAdapter, error) {
m.mu.RLock()
defer m.mu.RUnlock()
// 首先找默认的
defaultClient, err := m.repo.DownloadClient.FindDefault(context.Background())
if err != nil {
return "", nil, err
}
if defaultClient != nil {
if adapter, ok := m.clients[defaultClient.ID]; ok {
return defaultClient.ID, adapter, nil
}
}
// 返回第一个可用的
for id, adapter := range m.clients {
return id, adapter, nil
}
return "", nil, errors.New("no download client available")
}
// GetClient 返回指定 ID 的下载客户端适配器。
func (m *DownloadManager) GetClient(id string) (DownloadAdapter, error) {
m.mu.RLock()
defer m.mu.RUnlock()
adapter, ok := m.clients[id]
if !ok {
return nil, errors.New("download client not found or not initialized")
}
return adapter, nil
}
// AddClient 动态添加并初始化一个下载客户端。
func (m *DownloadManager) AddClient(ctx context.Context, dc *model.DownloadClient) error {
cfg, err := m.buildConfig(dc)
if err != nil {
return err
}
adapter := AdapterFactory(dc.Type)
if adapter == nil {
return errors.New("unknown download client type: " + dc.Type)
}
if err := adapter.Initialize(ctx, cfg); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
m.clients[dc.ID] = adapter
m.configs[dc.ID] = cfg
return nil
}
// RemoveClient 移除一个下载客户端(停止适配器,不删除数据库记录)。
func (m *DownloadManager) RemoveClient(id string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.clients, id)
delete(m.configs, id)
}
// UpdateClient 更新已有客户端的配置并重新初始化。
func (m *DownloadManager) UpdateClient(ctx context.Context, dc *model.DownloadClient) error {
m.RemoveClient(dc.ID)
return m.AddClient(ctx, dc)
}
// TestConnection 测试客户端连接。
func (m *DownloadManager) TestConnection(ctx context.Context, dc *model.DownloadClient) error {
cfg, err := m.buildConfig(dc)
if err != nil {
return err
}
adapter := AdapterFactory(dc.Type)
if adapter == nil {
return errors.New("unknown download client type: " + dc.Type)
}
return adapter.Initialize(ctx, cfg)
}
// ListAll 获取所有已加载客户端的种子列表。
func (m *DownloadManager) ListAll(ctx context.Context, filter string) (map[string][]TorrentInfo, error) {
m.mu.RLock()
ids := make([]string, 0, len(m.clients))
for id := range m.clients {
ids = append(ids, id)
}
adapters := make([]DownloadAdapter, 0, len(m.clients))
for _, id := range ids {
adapters = append(adapters, m.clients[id])
}
m.mu.RUnlock()
result := make(map[string][]TorrentInfo)
for i, id := range ids {
list, err := adapters[i].List(ctx, filter)
if err != nil {
m.log.Warn("failed to list torrents from client",
zap.String("id", id),
zap.Error(err),
)
continue
}
result[id] = list
}
return result, nil
}
// GetAdapterTypes 返回支持的下载客户端类型列表。
func (m *DownloadManager) GetAdapterTypes() []AdapterTypeInfo {
return []AdapterTypeInfo{
{Type: "qbittorrent", Name: "qBittorrent", Description: "qBittorrent WebUI API (v2)"},
{Type: "transmission", Name: "Transmission", Description: "Transmission RPC API"},
{Type: "aria2", Name: "Aria2", Description: "Aria2 JSON-RPC API"},
}
}
// AdapterTypeInfo 描述下载客户端类型信息。
type AdapterTypeInfo struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
}
// buildConfig 从数据库模型构建适配器配置。
func (m *DownloadManager) buildConfig(dc *model.DownloadClient) (DownloadClientConfig, error) {
password := dc.Password
if m.crypto != nil && password != "" {
password = m.crypto.Decrypt(password)
}
host, err := normalizeDownloadClientEndpoint(dc.Type, dc.Host)
if err != nil {
return DownloadClientConfig{}, err
}
cfg := DownloadClientConfig{
Host: host,
Username: dc.Username,
Password: password,
}
// 解析 Extra JSON 配置
if dc.Extra != "" {
extraStr := dc.Extra
if m.crypto != nil {
extraStr = m.crypto.Decrypt(extraStr)
}
var extra map[string]string
if err := json.Unmarshal([]byte(extraStr), &extra); err == nil {
cfg.Extra = extra
}
}
return cfg, nil
}