// Package service 提供 Web 服务端的后台任务编排(媒体库扫描整理)。 package service import ( "bufio" "errors" "fmt" "os" "path/filepath" "regexp" "strings" "sync" "time" "kenaito-media-picker/internal/organize" "kenaito-media-picker/internal/probe" "kenaito-media-picker/internal/scanner" "kenaito-media-picker/internal/store" ) // ScanState 扫描状态 type ScanState string const ( ScanIdle ScanState = "idle" ScanRunning ScanState = "running" ScanDone ScanState = "done" ScanError ScanState = "error" ) // ScanStatus 扫描进度与结果(供前端轮询) type ScanStatus struct { State ScanState `json:"state"` // idle/running/done/error Message string `json:"message"` // 简要说明 Started string `json:"started"` // 开始时间 Elapsed int64 `json:"elapsed"` // 已运行秒数 RootDir string `json:"rootDir"` // 本次扫描的根目录 DestDir string `json:"destDir"` // 上次使用的存放目录 Lines []string `json:"lines"` // 运行日志 Stage string `json:"stage"` // 当前阶段:scan/organize/move StageText string `json:"stageText"` // 阶段中文说明 Progress int `json:"progress"` // 总体进度百分比 0-100 Done int `json:"done"` // 已处理数 Total int `json:"total"` // 总数 Eta int64 `json:"eta"` // 预计剩余秒数(按当前阶段速率估算,0 表示无法估算) } // 应用级配置键 const ( ConfigKeyDestDir = "scan.destDir" ) // DefaultPickerDirName 默认存放目录名(未指定 destDir 时创建于扫描目录下) const DefaultPickerDirName = "KMP已整理" // ScanService 管理媒体库扫描整理任务(同一时间只允许一次) type ScanService struct { store *store.Store hub *StatusHub mu sync.Mutex state ScanState msg string started time.Time rootDir string destDir string lines []string stage string done int total int stageStart time.Time stageDone int lastPublish time.Time } // publishInterval 运行中状态推送的节流间隔;非运行状态(done/error)不受限,保证结束状态必达 const publishInterval = 100 * time.Millisecond // NewScanService 创建扫描服务;store 可为 nil(数据库不可用时仅不记录媒体信息) func NewScanService(st *store.Store) *ScanService { return &ScanService{store: st, hub: NewStatusHub(), state: ScanIdle} } // Subscribe 订阅扫描状态变更(供 WebSocket 等实时推送);取消后返回的频道被关闭 func (s *ScanService) Subscribe() (StatusListener, func()) { return s.hub.Subscribe() } // publish 将当前状态推送给所有订阅者。 // 运行中按 publishInterval 节流(约 10 次/秒),避免海量文件时 WebSocket 消息泛滥; // 非运行状态(done/error)无条件推送,确保结束状态不丢失。 func (s *ScanService) publish() { s.mu.Lock() if s.state == ScanRunning && time.Since(s.lastPublish) < publishInterval { s.mu.Unlock() return } s.lastPublish = time.Now() s.mu.Unlock() s.hub.Publish(s.Status()) } // Start 启动一次扫描整理;已在运行时返回错误。rootDir 为空时默认扫描当前运行目录, // destDir 为空时默认输出到扫描目录下的「KMP已整理」文件夹。 func (s *ScanService) Start(rootDir, destDir string) error { s.mu.Lock() if s.state == ScanRunning { s.mu.Unlock() return errors.New("扫描整理正在运行中,请等待完成") } rootDir = resolveRootDir(rootDir) if st, err := os.Stat(rootDir); err != nil || !st.IsDir() { s.mu.Unlock() return fmt.Errorf("扫描目录不存在或不可访问: %s", rootDir) } if destDir != "" { destDir = resolveRootDir(destDir) } s.state = ScanRunning s.msg = "" s.started = time.Now() s.rootDir = rootDir s.destDir = destDir s.lines = nil s.stage = "scan" s.done = 0 s.total = 0 s.mu.Unlock() // 持久化存放目录,下次打开页面自动回填 if s.store != nil { if err := s.store.SaveConfig(ConfigKeyDestDir, destDir); err != nil { fmt.Printf("=== 保存存放目录配置失败: %v\n", err) } } go s.run(rootDir, destDir) return nil } // Status 返回当前扫描状态 func (s *ScanService) Status() ScanStatus { s.mu.Lock() defer s.mu.Unlock() elapsed := int64(0) if !s.started.IsZero() { elapsed = int64(time.Since(s.started).Seconds()) } progress := 0 if s.total > 0 { progress = int(float64(s.done) / float64(s.total) * 100) if progress > 100 { progress = 100 } } st := ScanStatus{ State: s.state, Message: s.msg, Started: s.started.Format("2006-01-02 15:04:05"), Elapsed: elapsed, RootDir: s.rootDir, DestDir: s.destDir, Lines: append([]string{}, s.lines...), Stage: s.stage, Progress: progress, Done: s.done, Total: s.total, Eta: s.estimateEtaLocked(), } return st } // estimateEtaLocked 按当前阶段速率估算剩余秒数;需持有锁。返回 0 表示无法估算。 func (s *ScanService) estimateEtaLocked() int64 { if s.state != ScanRunning || s.total <= 0 || s.done <= 0 || s.done >= s.total { return 0 } if s.stageStart.IsZero() || s.done <= s.stageDone { return 0 } elapsedSec := time.Since(s.stageStart).Seconds() if elapsedSec <= 0 { return 0 } rate := float64(s.done-s.stageDone) / elapsedSec // 文件/秒 if rate <= 0 { return 0 } remain := float64(s.total-s.done) / rate if remain <= 0 { return 0 } return int64(remain + 0.5) } // resolveRootDir 解析扫描根目录:空值回退当前工作目录,并做绝对路径归一化 func resolveRootDir(dir string) string { if strings.TrimSpace(dir) == "" { wd, err := os.Getwd() if err == nil { return wd } return "." } abs, err := filepath.Abs(strings.TrimSpace(dir)) if err != nil { return strings.TrimSpace(dir) } return abs } // maxLogLines 日志保留的最大行数,超出后丢弃最旧行,避免前端渲染卡顿 const maxLogLines = 500 // run 后台执行:捕获 stdout 日志流式写入 lines,结束后更新状态 func (s *ScanService) run(rootDir, destDir string) { 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() } }() scanErr := s.runScan(rootDir, destDir) os.Stdout = oldStdout _ = w.Close() <-done if scanErr != nil { s.finish(ScanError, scanErr.Error()) return } s.finish(ScanDone, "扫描整理完成") } // finish 设置最终状态 func (s *ScanService) finish(state ScanState, msg string) { s.mu.Lock() s.state = state s.msg = msg s.mu.Unlock() s.publish() } // setProgress 更新阶段与进度(线程安全,供各阶段回调调用)。 // 阶段切换或进度回退(新阶段从头计数)时重置阶段起点,用于估算剩余时间。 func (s *ScanService) setProgress(stage string, done, total int) { s.mu.Lock() if stage != s.stage { s.stageStart = time.Now() s.stageDone = done } else if done < s.stageDone { // 阶段未变但进度回退(如开始新一轮计数),重置起点 s.stageStart = time.Now() s.stageDone = done } s.stage = stage s.done = done s.total = total s.mu.Unlock() s.publish() } // ansiRe 匹配 ANSI 颜色转义序列 var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`) // sanitizeLine 清理日志行:去除 ANSI 颜色码,进度条按 \r 分段只保留最后一段 func sanitizeLine(line string) string { line = ansiRe.ReplaceAllString(line, "") segs := strings.Split(line, "\r") return strings.TrimSpace(segs[len(segs)-1]) } // runScan 执行完整整理流程:扫描 → 过滤 → 释放解码器 → 归类 → 移动 func (s *ScanService) runScan(rootDir, destDir string) error { pickerDir := destDir if pickerDir == "" { pickerDir = filepath.Join(rootDir, DefaultPickerDirName) } fmt.Printf("=== 扫描根目录: %s \n", rootDir) fmt.Printf("=== 整理输出目录(白名单): %s \n", pickerDir) res, err := scanner.Scan(scanner.Options{ RootDir: rootDir, SkipDir: pickerDir, SkipDirNames: []string{DefaultPickerDirName}, SkipNames: []string{store.DBFileName, probe.FFprobeExeName}, OnProgress: func(done, total int) { s.setProgress("scan", done, total) }, }) if err != nil { return fmt.Errorf("扫描失败: %v", err) } res.Filter() // 释放内嵌 ffprobe;失败只跳过媒体整理,图片照常处理 ffprobePath := "" if (len(res.Images) > 0 || len(res.Media) > 0) && probe.HasEmbeddedFFprobe() { if err := probe.ReleaseFFprobe(); err != nil { fmt.Printf("=== 释放解码器失败, 视频/音频整理将跳过(图片照常处理): %v\n", err) } else { ffprobePath = probe.FFprobePath() defer probe.RemoveFFprobe() } } org := organize.New(pickerDir, ffprobePath, s.store) totalWork := len(res.Images) + len(res.Media) doneWork := 0 currentStage := "organize" org.SetProgress(func(done, total int) { // organize 阶段:doneWork 累计图片/媒体已处理数,加上本阶段内 done; // move 阶段:Flush 上报的 done 已是累计移动数,直接使用,避免叠加导致溢出。 if currentStage == "move" { s.setProgress("move", done, total) } else { s.setProgress(currentStage, doneWork+done, totalWork) } }) if len(res.Images) > 0 { org.ProcessImages(res.Images) doneWork += len(res.Images) } if len(res.Media) > 0 && ffprobePath != "" { org.ProcessMedia(res.Media) doneWork += len(res.Media) } // 移动阶段:进度为累计已移动文件数 currentStage = "move" org.Flush() currentStage = "done" s.setProgress("move", totalWork, totalWork) fmt.Println("=== 整理完成") return nil }