refactor: 将文件移动改为独立接口,扫描完成后手动触发

This commit is contained in:
2025-07-29 21:18:35 +08:00
parent 59b0206cf1
commit 2e01f6c3c9
7 changed files with 193 additions and 35 deletions
+134 -10
View File
@@ -24,6 +24,7 @@ import (
"time"
"kenaito-media-picker/internal/config"
"kenaito-media-picker/internal/mover"
"kenaito-media-picker/internal/nsfw"
"kenaito-media-picker/internal/organize"
"kenaito-media-picker/internal/probe"
@@ -234,6 +235,135 @@ func (s *ScanService) Cancel() error {
return nil
}
// Move 触发文件移动操作。
// 根据 scan_log 中记录的分类结果,将文件从源路径移动到目标目录。
// 只有在扫描完成(state == done)且当前没有任务运行时才能调用。
func (s *ScanService) Move() error {
s.mu.Lock()
// 正在跑就不能再开一个,直接拒绝
if s.state == ScanRunning {
s.mu.Unlock()
return errors.New("扫描整理正在运行中,请等待完成")
}
// 必须先完成一次扫描,才有 scan_log 记录
if s.state != ScanDone {
s.mu.Unlock()
return errors.New("请先完成扫描后再执行移动")
}
// 把状态改成"运行中",清空上一次的残留信息
s.state = ScanRunning
s.msg = ""
s.started = time.Now()
s.lines = nil
s.stage = stageMove
s.done = 0
s.total = 0
s.ctx, s.cancel = context.WithCancel(context.Background())
s.mu.Unlock()
go s.runMove(s.ctx)
return nil
}
// runMove 是移动文件的后台任务。
// 它从数据库读取 scan_log 记录,构建移动任务列表,然后并行执行文件移动。
func (s *ScanService) runMove(ctx context.Context) {
// 创建日志管道(和 run() 一样,截取 fmt.Println 输出)
r, w, err := os.Pipe()
if err != nil {
s.finish(ScanError, "创建日志管道失败: "+err.Error())
return
}
oldStdout := os.Stdout
os.Stdout = w
done := make(chan struct{})
go func() {
defer close(done)
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
line := sanitizeLine(sc.Text())
if line == "" {
continue
}
s.mu.Lock()
if len(s.lines) >= maxLogLines {
s.lines = append([]string{}, s.lines[len(s.lines)/2:]...)
}
s.lines = append(s.lines, line)
s.mu.Unlock()
}
}()
defer func() {
os.Stdout = oldStdout
_ = w.Close()
<-done
if rcv := recover(); rcv != nil {
s.finish(ScanError, fmt.Sprintf("移动文件内部错误: %v\n%s", rcv, debug.Stack()))
}
}()
// 从数据库读取 scan_log 记录
if s.store == nil {
s.finish(ScanError, "数据库不可用,无法执行移动")
return
}
logs, err := s.store.LoadScanLog()
if err != nil {
s.finish(ScanError, "加载续传记录失败: "+err.Error())
return
}
if len(logs) == 0 {
fmt.Println("=== 没有待移动的文件(scan_log 为空)")
s.finish(ScanDone, "没有待移动的文件")
return
}
// 构建移动任务列表
jobs := make([]mover.MoveJob, 0, len(logs))
for _, sl := range logs {
// 检查源文件是否还存在
if _, err := os.Stat(sl.SourcePath); err != nil {
fmt.Printf("=== 源文件不存在,跳过: %s\n", sl.SourcePath)
continue
}
jobs = append(jobs, mover.MoveJob{Source: sl.SourcePath, TargetDir: sl.TargetDir})
}
if len(jobs) == 0 {
fmt.Println("=== 所有源文件均已不存在,无需移动")
s.finish(ScanDone, "没有需要移动的文件(源文件已不存在)")
return
}
fmt.Printf("=== 开始移动文件,共 %d 个任务\n", len(jobs))
s.setProgress(stageMove, 0, len(jobs))
// 执行移动
mv := &mover.Mover{}
mv.MoveAll(jobs, func(doneCount int) {
s.setProgress(stageMove, doneCount, len(jobs))
if ctx.Err() != nil {
return
}
})
if ctx.Err() != nil {
s.finish(ScanCancelled, "移动已中止")
return
}
fmt.Println("=== 文件移动完成")
s.finish(ScanDone, fmt.Sprintf("文件移动完成,共移动 %d 个文件", len(jobs)))
}
// Status 生成一份"当前进度快照"返回给前端。
func (s *ScanService) Status() ScanStatus {
s.mu.Lock()
@@ -530,7 +660,6 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
org := organize.New(pickerDir, ffprobePath, ffmpegPath, s.store, ctx)
org.SetNSFWDetector(nsfwDet)
org.SetRotateImages(config.GetAppConfig().RotateImages)
org.SetMoveFiles(config.GetAppConfig().MoveFiles)
// 整个"整理"分两个阶段,界面上共用一条进度条:
// 归类阶段:图片、视频/音频分两批处理。进度 = 已处理文件数 / 全部待整理文件数
@@ -566,17 +695,12 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
return ctx.Err()
}
// 移动:把文件搬进各自的分类目录,并记录去向
if config.GetAppConfig().MoveFiles {
org.Flush()
} else {
// 不移动文件,但仍需写入媒体记录到数据库
org.FlushRecordsOnly()
fmt.Println("=== 配置 organize.moveFiles=false,跳过文件移动(媒体记录已保存)")
}
// 扫描完成后只写入媒体记录和续传记录,不移动文件。
// 文件移动由用户在页面上手动触发(POST /api/scan/move)。
org.FlushRecordsOnly()
// 把进度条顶满 100%,再打印"整理完成"
s.setProgress(stageMove, totalWork, totalWork)
fmt.Println("=== 整理完成")
fmt.Println("=== 扫描整理完成,请在页面点击「移动文件」按钮执行文件移动")
return nil
}