feat: 扫描整理并行化、WebSocket 实时进度与统计看板

- 移动阶段并行处理文件,按原子计数上报累计进度
- 扫描遍历阶段实时上报已发现文件数,界面不再卡在 0/0
- 新增 /api/scan/ws WebSocket 实时推送扫描状态;前端断线指数退避自动重连,并以低频轮询兜底
- 新增统计看板与媒体库筛选/排序,目录选择器路径不存在时回退到最近可用父目录
- 存放目录支持自定义并持久化,默认输出到 KMP已整理
This commit is contained in:
2025-05-08 21:16:20 +08:00
parent 36d7ecced8
commit f915a9b1ac
22 changed files with 1342 additions and 281 deletions
+52
View File
@@ -0,0 +1,52 @@
// 状态订阅发布:将 ScanService 的状态变更实时推送给订阅者(WebSocket 等)。
package service
import "sync"
// StatusListener 状态订阅通道(缓冲 1,保底容纳一条未消费状态)
type StatusListener chan ScanStatus
// StatusHub 管理状态订阅者;发布时若消费者来不及消费则丢弃旧消息,
// 保证订阅者拿到的始终是最新状态。
type StatusHub struct {
mu sync.RWMutex
subs map[StatusListener]struct{}
}
// NewStatusHub 创建状态订阅中心
func NewStatusHub() *StatusHub {
return &StatusHub{subs: make(map[StatusListener]struct{})}
}
// Subscribe 注册订阅,返回频道与取消函数。取消后频道被关闭,写方需停止发送。
func (h *StatusHub) Subscribe() (StatusListener, func()) {
h.mu.Lock()
defer h.mu.Unlock()
ch := make(StatusListener, 1)
h.subs[ch] = struct{}{}
return ch, func() {
h.mu.Lock()
if _, ok := h.subs[ch]; ok {
delete(h.subs, ch)
close(ch)
}
h.mu.Unlock()
}
}
// Publish 向所有订阅者推送最新状态(非阻塞;缓冲满时覆盖为最新状态)
func (h *StatusHub) Publish(st ScanStatus) {
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.subs {
select {
case ch <- st:
default:
select {
case <-ch:
default:
}
ch <- st
}
}
}
+178 -31
View File
@@ -30,32 +30,78 @@ const (
// 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"` // 运行日志
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
mu sync.Mutex
state ScanState
msg string
started time.Time
rootDir string
lines []string
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, state: ScanIdle}
return &ScanService{store: st, hub: NewStatusHub(), state: ScanIdle}
}
// Start 启动一次扫描整理;已在运行时返回错误。rootDir 为空时默认扫描当前运行目录。
func (s *ScanService) Start(rootDir string) error {
// 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()
@@ -66,14 +112,28 @@ func (s *ScanService) Start(rootDir string) error {
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()
go s.run(rootDir)
// 持久化存放目录,下次打开页面自动回填
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
}
@@ -85,14 +145,51 @@ func (s *ScanService) Status() ScanStatus {
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...),
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 解析扫描根目录:空值回退当前工作目录,并做绝对路径归一化
@@ -111,8 +208,11 @@ func resolveRootDir(dir string) string {
return abs
}
// maxLogLines 日志保留的最大行数,超出后丢弃最旧行,避免前端渲染卡顿
const maxLogLines = 500
// run 后台执行:捕获 stdout 日志流式写入 lines,结束后更新状态
func (s *ScanService) run(rootDir string) {
func (s *ScanService) run(rootDir, destDir string) {
r, w, err := os.Pipe()
if err != nil {
s.finish(ScanError, "创建日志管道失败: "+err.Error())
@@ -132,12 +232,16 @@ func (s *ScanService) run(rootDir string) {
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)
scanErr := s.runScan(rootDir, destDir)
os.Stdout = oldStdout
_ = w.Close()
@@ -156,6 +260,26 @@ func (s *ScanService) finish(state ScanState, msg string) {
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 颜色转义序列
@@ -169,15 +293,20 @@ func sanitizeLine(line string) string {
}
// runScan 执行完整整理流程:扫描 → 过滤 → 释放解码器 → 归类 → 移动
func (s *ScanService) runScan(rootDir string) error {
pickerDir := filepath.Join(rootDir, filepath.Base(rootDir)+"-picker")
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,
SkipNames: []string{store.DBFileName, probe.FFprobeExeName},
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)
@@ -196,13 +325,31 @@ func (s *ScanService) runScan(rootDir string) error {
}
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
}