Files
MeBox/internal/service/transfer.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

105 lines
3.6 KiB
Go

// Package service — file transfer strategies for the organizer.
//
// 整理媒体时支持四种转移方式:
//
// move 移动(同盘 rename,跨盘 copy+删除源)——会移除源文件
// copy 复制(保留源文件)
// hardlink 硬链接(同盘零额外占用,保留源文件;做种不受影响)
// symlink 软链接(保留源文件,指向源)
//
// 除 move 外,其余方式都保留源文件,因此 qBittorrent 等下载器仍能在原
// 路径找到数据继续做种上传。
package service
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// TransferMode 表示整理时文件的转移方式。
type TransferMode string
const (
// TransferMove 移动:同盘 rename,跨盘 copy+删除源。
TransferMove TransferMode = "move"
// TransferCopy 复制:保留源文件。
TransferCopy TransferMode = "copy"
// TransferHardlink 硬链接:同盘零额外占用并保留源文件,做种不受影响。
TransferHardlink TransferMode = "hardlink"
// TransferSymlink 软链接:保留源文件,目标指向源。
TransferSymlink TransferMode = "symlink"
)
// parseTransferMode 解析转移方式字符串,无法识别时回退为默认的移动。
func parseTransferMode(s string) TransferMode {
switch strings.ToLower(strings.TrimSpace(s)) {
case "copy", "复制":
return TransferCopy
case "hardlink", "hard", "link", "硬链接", "硬连接":
return TransferHardlink
case "symlink", "soft", "softlink", "软链接", "软连接", "符号链接":
return TransferSymlink
default:
return TransferMove
}
}
// keepsSource 报告该转移方式是否会保留源文件(用于做种)。
func (m TransferMode) keepsSource() bool {
return m == TransferCopy || m == TransferHardlink || m == TransferSymlink
}
// transferFile 按指定方式把 src 转移到 dst。
// dst 已存在时一律报错,绝不覆盖(防止不同 release 改名后互相覆盖)。
func transferFile(src, dst string, mode TransferMode) error {
if _, err := os.Stat(dst); err == nil {
return fmt.Errorf("destination already exists: %s", dst)
}
switch mode {
case TransferCopy:
return copyFile(src, dst)
case TransferHardlink:
if err := os.Link(src, dst); err != nil {
// Docker 部署里下载目录和媒体目录往往是两个独立的 bind mount,
// 即使在宿主机上同属一块盘,容器内 os.Link 也会因跨文件系统
// (EXDEV) 失败。此前直接报错导致 PT 下载完成后整理静默中断;
// 现在自动降级为复制(保留源文件继续做种,语义一致)。
if copyErr := copyFile(src, dst); copyErr == nil {
return nil
}
return fmt.Errorf("hardlink failed: %w; source and target must be on the same filesystem, choose copy if you want to duplicate data", err)
}
return nil
case TransferSymlink:
target := src
if abs, err := filepath.Abs(src); err == nil {
target = abs
}
return os.Symlink(target, dst)
default: // TransferMove
return moveFile(src, dst)
}
}
// copyFile 流式复制 src 到 dst(保留源文件)。O_EXCL 保证不覆盖已存在目标。
func copyFile(src, dst string) error {
in, err := os.Open(src) // #nosec G304 -- src is selected from configured media/download roots by the organizer.
if err != nil {
return err
}
defer in.Close()
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) // #nosec G304,G302 -- dst is organizer-generated; media files must remain readable by local players.
if err != nil {
return err
}
if _, werr := io.Copy(f, in); werr != nil {
_ = f.Close()
_ = os.Remove(dst)
return werr
}
return f.Close()
}