- 移动阶段并行处理文件,按原子计数上报累计进度 - 扫描遍历阶段实时上报已发现文件数,界面不再卡在 0/0 - 新增 /api/scan/ws WebSocket 实时推送扫描状态;前端断线指数退避自动重连,并以低频轮询兜底 - 新增统计看板与媒体库筛选/排序,目录选择器路径不存在时回退到最近可用父目录 - 存放目录支持自定义并持久化,默认输出到 KMP已整理
356 lines
10 KiB
Go
356 lines
10 KiB
Go
// 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
|
|
}
|