feat: 增加 Web 管理界面,扫描整理改为页面点击触发

- main.go 改为 Web 服务入口(Gin + 内嵌前端 dist + 自动打开浏览器)
- internal/service: 新增扫描整理后台任务,支持状态机与实时日志捕获
- internal/api: 新增统一响应、媒体列表/统计、扫描开始/状态接口
- internal/store: 追加 ListMedia/CountMedia/Stats 查询,MediaInfo 补 JSON tag
- internal/config: application.properties 配置加载(端口/数据库/自动开浏览器)
- frontend: Vue2 + ElementUI + Vite 工程,含扫描整理/统计看板/媒体库三页
- build.sh 整合前端构建,输出单一 kenaito-media-picker.exe
This commit is contained in:
2025-04-21 21:08:02 +08:00
parent 767b093b45
commit e053b378fb
29 changed files with 3684 additions and 104 deletions
+186
View File
@@ -0,0 +1,186 @@
// 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"` // 已运行秒数
Lines []string `json:"lines"` // 运行日志
}
// ScanService 管理媒体库扫描整理任务(同一时间只允许一次)
type ScanService struct {
store *store.Store
mu sync.Mutex
state ScanState
msg string
started time.Time
lines []string
}
// NewScanService 创建扫描服务;store 可为 nil(数据库不可用时仅不记录媒体信息)
func NewScanService(st *store.Store) *ScanService {
return &ScanService{store: st, state: ScanIdle}
}
// Start 启动一次扫描整理;已在运行时返回错误
func (s *ScanService) Start() error {
s.mu.Lock()
if s.state == ScanRunning {
s.mu.Unlock()
return errors.New("扫描整理正在运行中,请等待完成")
}
s.state = ScanRunning
s.msg = ""
s.started = time.Now()
s.lines = nil
s.mu.Unlock()
go s.run()
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,
Lines: append([]string{}, s.lines...),
}
}
// run 后台执行:捕获 stdout 日志流式写入 lines,结束后更新状态
func (s *ScanService) run() {
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()
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() error {
rootDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("获取当前路径异常: %v", err)
}
pickerDir := filepath.Join(rootDir, filepath.Base(rootDir)+"-picker")
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
}