// 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"` // 本次扫描的根目录 Lines []string `json:"lines"` // 运行日志 } // ScanService 管理媒体库扫描整理任务(同一时间只允许一次) type ScanService struct { store *store.Store mu sync.Mutex state ScanState msg string started time.Time rootDir string lines []string } // NewScanService 创建扫描服务;store 可为 nil(数据库不可用时仅不记录媒体信息) func NewScanService(st *store.Store) *ScanService { return &ScanService{store: st, state: ScanIdle} } // Start 启动一次扫描整理;已在运行时返回错误。rootDir 为空时默认扫描当前运行目录。 func (s *ScanService) Start(rootDir 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) } s.state = ScanRunning s.msg = "" s.started = time.Now() s.rootDir = rootDir s.lines = nil s.mu.Unlock() go s.run(rootDir) 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()) } return ScanStatus{ State: s.state, Message: s.msg, Started: s.started.Format("2006-01-02 15:04:05"), Elapsed: elapsed, RootDir: s.rootDir, Lines: append([]string{}, s.lines...), } } // 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 } // run 后台执行:捕获 stdout 日志流式写入 lines,结束后更新状态 func (s *ScanService) run(rootDir 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() s.lines = append(s.lines, line) s.mu.Unlock() } }() scanErr := s.runScan(rootDir) 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() } // 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 string) error { pickerDir := filepath.Join(rootDir, filepath.Base(rootDir)+"-picker") fmt.Printf("=== 扫描根目录: %s \n", rootDir) fmt.Printf("=== 整理输出目录(白名单): %s \n", pickerDir) res, err := scanner.Scan(scanner.Options{ RootDir: rootDir, SkipDir: pickerDir, SkipNames: []string{store.DBFileName, probe.FFprobeExeName}, }) 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) if len(res.Images) > 0 { org.ProcessImages(res.Images) } if len(res.Media) > 0 && ffprobePath != "" { org.ProcessMedia(res.Media) } org.Flush() fmt.Println("=== 整理完成") return nil }