mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-12 16:26:36 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user