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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user