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
parent a13aba1d9f
commit 91df7521ce
16 changed files with 530 additions and 72 deletions
+55 -1
View File
@@ -136,7 +136,7 @@ func TestCompletedTorrentSourceDoesNotFallbackToSavePath(t *testing.T) {
}
svc := NewDownloadService(zap.NewNop(), newOrganizerTestRepo(t), NewHub(zap.NewNop()), nil)
got := svc.completedTorrentSource(QBitTorrent{
got := svc.completedTorrentSource(t.Context(), QBitTorrent{
Hash: "done123",
Name: "Missing.Payload.S01",
SavePath: savePath,
@@ -190,6 +190,60 @@ func TestDownloadPollBaselinesAlreadyCompletedTorrents(t *testing.T) {
}
}
func TestDownloadPollCatchesUpRecentlyCompletedTorrents(t *testing.T) {
repos := newOrganizerTestRepo(t)
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
svc.processDownloadSnapshot(t.Context(), []QBitTorrent{
{Hash: "fresh-complete", Name: "Fresh Complete S01E01", Progress: 1, CompletionOn: time.Now().Add(-time.Hour).Unix()},
{Hash: "stale-complete", Name: "Stale Complete S01E01", Progress: 1, CompletionOn: time.Now().Add(-48 * time.Hour).Unix()},
{Hash: "no-timestamp", Name: "No Timestamp S01E01", Progress: 1},
}, nil)
// 只有补整理时间窗内完成的种子会被补整理;无 completion_on 的保守跳过。
if got := len(svc.organizeQueue); got != 1 {
t.Fatalf("first poll queued %d organize jobs, want 1 (recent completion only)", got)
}
}
func TestCompletedTorrentSourceUsesConfiguredMapping(t *testing.T) {
root := t.TempDir()
localRoot := filepath.Join(root, "localdl")
payload := filepath.Join(localRoot, "Show.S01")
if err := os.MkdirAll(payload, 0o755); err != nil {
t.Fatal(err)
}
repos := newOrganizerTestRepo(t)
if err := repos.Setting.Set(t.Context(), DownloadPathMappingsSettingKey, "/qb/downloads="+localRoot); err != nil {
t.Fatal(err)
}
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
got := svc.completedTorrentSource(t.Context(), QBitTorrent{ContentPath: "/qb/downloads/Show.S01"})
if got != payload {
t.Fatalf("completedTorrentSource = %q, want %q", got, payload)
}
}
func TestUserPathMappingsParsing(t *testing.T) {
repos := newOrganizerTestRepo(t)
raw := "# comment\n/a=/b\n/c => /d\n/e:/f\nbad-line\n"
if err := repos.Setting.Set(t.Context(), DownloadPathMappingsSettingKey, raw); err != nil {
t.Fatal(err)
}
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
got := svc.userPathMappings(t.Context())
want := map[string]string{"/a": "/b", "/c": "/d", "/e": "/f"}
if len(got) != len(want) {
t.Fatalf("userPathMappings = %v, want %v", got, want)
}
for k, v := range want {
if got[k] != v {
t.Fatalf("mapping %q = %q, want %q", k, got[k], v)
}
}
}
func TestPublicDownloadTitleUsesMagnetDisplayName(t *testing.T) {
got := publicDownloadTitle("magnet:?xt=urn:btih:abc&dn=%E6%B5%8B%E8%AF%95%E5%BD%B1%E7%89%87")
if got != "测试影片" {