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
This commit is contained in:
ShukeBta
2026-06-12 04:31:34 +00:00
committed by Shuke
parent a13aba1d9f
commit 585edeb984
16 changed files with 530 additions and 72 deletions
+84 -2
View File
@@ -19,6 +19,7 @@ import (
"context"
"errors"
"math"
"os"
"net/url"
"path"
"path/filepath"
@@ -814,7 +815,15 @@ func (d *DownloadService) processDownloadSnapshot(ctx context.Context, live []QB
wasComplete, wasSeen := d.prevStates[stateKey]
switch {
case complete && (firstSnapshot || !wasSeen):
// 首次快照里已完成的种子:此前一律标记「已见过」并跳过整理,
// 导致「下载完成时应用恰好不在线/正在重启」的种子永远不会被
// 自动整理入库。现在对最近完成的种子补一次整理
// (onTorrentComplete 内部仍受 organize.auto 开关约束,且
// 整理对已存在的目标文件幂等跳过)。
d.prevStates[stateKey] = true
if recentlyCompletedTorrent(torrent, time.Now()) {
shouldQueue = true
}
case complete && !wasComplete:
shouldQueue = true
case complete:
@@ -915,6 +924,20 @@ func (d *DownloadService) markCompletedTorrentOrganizeDone(torrent QBitTorrent)
d.mu.Unlock()
}
// completedTorrentCatchupWindow 限定重启补整理只覆盖最近完成的种子,
// 防止每次启动都把全部历史种子重新过一遍整理流程。
const completedTorrentCatchupWindow = 24 * time.Hour
// recentlyCompletedTorrent 报告该种子是否在补整理时间窗内完成。
// qBittorrent 未提供 completion_on 时保守地返回 false。
func recentlyCompletedTorrent(torrent QBitTorrent, now time.Time) bool {
if torrent.CompletionOn <= 0 {
return false
}
completed := time.Unix(torrent.CompletionOn, 0)
return now.Sub(completed) <= completedTorrentCatchupWindow
}
func completedTorrentQueueKey(torrent QBitTorrent) string {
hash := strings.ToLower(strings.TrimSpace(torrent.Hash))
if hash != "" {
@@ -1010,7 +1033,7 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTor
d.log.Info("download completed, auto-organize disabled", zap.String("hash", torrent.Hash))
return
}
source := d.completedTorrentSource(torrent)
source := d.completedTorrentSource(ctx, torrent)
if source == "" {
d.log.Warn("download completed but payload path is not accessible",
zap.String("hash", torrent.Hash),
@@ -1045,13 +1068,23 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTor
zap.Int("errors", len(res.Errors)))
}
func (d *DownloadService) completedTorrentSource(torrent QBitTorrent) string {
// DownloadPathMappingsSettingKey 允许用户自定义「下载器路径 → 本程序路径」
// 映射,每行一条,格式 `客户端路径=本地路径`(也接受 `=>` 或单个 `:` 分隔)。
// qBittorrent 与本程序常在不同容器/主机里,对同一份数据看到的路径不同;
// 此前映射表是写死的三条猜测,对不上时整理静默失败。
const DownloadPathMappingsSettingKey = "download.path_mappings"
func (d *DownloadService) completedTorrentSource(ctx context.Context, torrent QBitTorrent) string {
// 常见路径映射:qBittorrent容器路径 -> MediaStationGo容器路径
mappings := map[string]string{
"/var/apps/qBittorrent/shares/qBittorrent/Download": "/downloads",
"/data/qBittorrent/downloads": "/downloads",
"/downloads/qBittorrent": "/downloads",
}
// 用户自定义映射优先(可覆盖内置猜测)。
for clientPrefix, localPrefix := range d.userPathMappings(ctx) {
mappings[clientPrefix] = localPrefix
}
for _, candidate := range []string{
torrent.ContentPath,
filepath.Join(torrent.SavePath, torrent.Name),
@@ -1064,6 +1097,55 @@ func (d *DownloadService) completedTorrentSource(torrent QBitTorrent) string {
if translated := translateClientPath(clean, mappings); translated != "" {
return translated
}
// 复用 compose 注入的 MEDIASTATION_DOWNLOAD_DIR/MEDIA_DIR 宿主机↔容器
// 映射(与媒体库路径换算同一套规则),覆盖「qB 跑在宿主机、
// 本程序在容器里」的最常见部署形态。
for _, mapped := range mappedPathCandidates(clean) {
if mapped == clean {
continue
}
if _, err := os.Stat(mapped); err == nil {
return mapped
}
}
}
return ""
}
// userPathMappings 解析用户配置的下载器路径映射。
func (d *DownloadService) userPathMappings(ctx context.Context) map[string]string {
out := map[string]string{}
if d == nil || d.repo == nil || d.repo.Setting == nil {
return out
}
raw, err := d.repo.Setting.Get(ctx, DownloadPathMappingsSettingKey)
if err != nil {
return out
}
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
var from, to string
switch {
case strings.Contains(line, "=>"):
parts := strings.SplitN(line, "=>", 2)
from, to = parts[0], parts[1]
case strings.Contains(line, "="):
parts := strings.SplitN(line, "=", 2)
from, to = parts[0], parts[1]
case strings.Count(line, ":") == 1:
parts := strings.SplitN(line, ":", 2)
from, to = parts[0], parts[1]
default:
continue
}
from = strings.TrimSpace(from)
to = strings.TrimSpace(to)
if from != "" && to != "" {
out[from] = to
}
}
return out
}