From 2e01f6c3c9d4affe7d0a5aa985ab6bd50c249e92 Mon Sep 17 00:00:00 2001 From: Odboy Date: Tue, 29 Jul 2025 21:18:35 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=B0=86=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E6=94=B9=E4=B8=BA=E7=8B=AC=E7=AB=8B=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E6=89=AB=E6=8F=8F=E5=AE=8C=E6=88=90=E5=90=8E?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application.properties | 2 - frontend/src/views/Scan.vue | 46 +++++++++- internal/api/router.go | 3 +- internal/api/scan_controller.go | 11 +++ internal/config/config.go | 6 +- internal/organize/organize.go | 16 ---- internal/service/scan.go | 144 +++++++++++++++++++++++++++++--- 7 files changed, 193 insertions(+), 35 deletions(-) diff --git a/application.properties b/application.properties index 74f2d74..712e05c 100644 --- a/application.properties +++ b/application.properties @@ -12,8 +12,6 @@ ffmpeg.path= organize.rotateImages=false # 探测/认类型/移动阶段的并发 worker 数(默认 4;0 表示用满全部 CPU 核数) organize.workers=8 -# 扫描完成后是否移动文件到分类目录(true=移动,默认;false=只归类不移动,文件留在原位) -organize.moveFiles=false # 色情图片识别(需本机安装 python 与 nudenet:pip install nudenet) # true=开启,判为色情的图片移入「图片_敏感_*」目录(明暗子目录照常);false=关闭(默认) # 模型:优先加载程序运行目录下 lib/nwfs/320n.onnx(推荐随程序分发,离线可用); diff --git a/frontend/src/views/Scan.vue b/frontend/src/views/Scan.vue index 5890081..dd8c465 100644 --- a/frontend/src/views/Scan.vue +++ b/frontend/src/views/Scan.vue @@ -60,7 +60,12 @@
- 扫描整理中,已运行 {{ status.elapsed }} 秒...({{ stageText }} {{ progressText }}) + + 预计剩余 {{ etaText }}
+
+ 移动文件到分类目录 + 根据扫描归类结果,将文件移动到「存放目录」下的分类子目录 +
+ 0 { @@ -218,12 +210,8 @@ func (o *Organizer) addToDir(f scanner.File, dir string) { // addClassified 归档一个文件并记进续传表:把"源路径 → 目标目录"写进 scan_log, // 这样下次重扫时这个文件直接复用目标目录,不再重新探测/识别。 -// 注意:moveFiles=false 时不写续传记录,因为文件没有真正移动。 func (o *Organizer) addClassified(f scanner.File, subDir string, size int64, headMd5, tailMd5 string) { o.add(f, subDir) - if !o.moveFiles { - return // 不移动文件时不写续传记录 - } if o.store == nil { return } @@ -241,11 +229,7 @@ func (o *Organizer) addClassified(f scanner.File, subDir string, size int64, hea // matchResume 判断一个文件上次是否已归类过,且内容没变。 // 命中返回上次的目标目录和 ok=true;内容变了/没记录则 ok=false(要重新探测)。 -// 注意:moveFiles=false 时禁用续传跳过,每次都完整探测。 func (o *Organizer) matchResume(f scanner.File) (store.ScanLog, bool) { - if !o.moveFiles { - return store.ScanLog{}, false // 不移动文件时禁用续传 - } if o.resume == nil { return store.ScanLog{}, false } diff --git a/internal/service/scan.go b/internal/service/scan.go index acb7e1d..6618dcd 100644 --- a/internal/service/scan.go +++ b/internal/service/scan.go @@ -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 }