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
+95 -48
View File
@@ -1,70 +1,117 @@
// kenaito-media-picker 主入口:启动 Web 服务(内嵌前端),媒体库扫描整理由页面点击触发。
package main
import (
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"kenaito-media-picker/internal/organize"
"kenaito-media-picker/internal/probe"
"kenaito-media-picker/internal/scanner"
"kenaito-media-picker/frontend"
"kenaito-media-picker/internal/api"
"kenaito-media-picker/internal/config"
"kenaito-media-picker/internal/store"
"github.com/gin-gonic/gin"
)
func main() {
fmt.Println("=== 警告:该程序会改变目录结构,将在5秒后开始执行")
time.Sleep(time.Second * 5)
config.LoadAppConfig()
cfg := config.GetAppConfig()
rootDir, err := os.Getwd()
// 打开数据库;失败时服务照常启动,仅媒体库查询不可用、扫描不记录信息
st, err := store.Open(cfg.Dsn)
if err != nil {
fmt.Println("=== 获取当前路径异常", err)
return
}
pickerDir := filepath.Join(rootDir, filepath.Base(rootDir)+"-picker")
fmt.Printf("=== 整理输出目录(白名单): %s \n", pickerDir)
// 1. 扫描并过滤(跳过输出目录、数据库与 ffprobe 相关文件)
res, err := scanner.Scan(scanner.Options{
RootDir: rootDir,
SkipDir: pickerDir,
SkipNames: []string{store.DBFileName, probe.FFprobeExeName},
})
if err != nil {
fmt.Println("=== 扫描失败:", err)
return
}
res.Filter()
// 2. 初始化媒体信息数据库(失败不影响整理,仅无法记录)
st, err := store.Open(filepath.Join(rootDir, store.DBFileName))
if err != nil {
fmt.Println("=== 初始化数据库失败(继续整理, 不记录媒体信息):", err)
fmt.Printf("=== 打开数据库失败(媒体库查询不可用): %v\n", err)
st = nil
}
if st != nil {
} else {
defer st.Close()
}
// 3. 释放内嵌 ffprobe(按当前系统);失败只跳过媒体整理,图片照常处理
ffprobePath := ""
if (len(res.Images) > 0 || len(res.Media) > 0) && probe.HasEmbeddedFFprobe() {
if err := probe.ReleaseFFprobe(); err != nil {
fmt.Println("=== 释放解码器失败, 视频/音频整理将跳过(图片照常处理):", err)
} else {
ffprobePath = probe.FFprobePath()
defer probe.RemoveFFprobe()
r := api.NewRouter(st)
// 请求日志输出到 stderr,避免占用被扫描流程捕获的 stdout
r.Use(gin.LoggerWithWriter(os.Stderr))
r.NoRoute(staticHandler())
addr := fmt.Sprintf(":%d", cfg.Port)
url := "http://localhost" + addr
fmt.Printf("=== Web 服务已启动: %s\n", url)
fmt.Printf("=== 数据库: %s\n", cfg.Dsn)
fmt.Printf("=== 在页面上点击【开始扫描】执行媒体整理(会移动文件,注意备份)\n")
if cfg.AutoOpen {
go func() {
time.Sleep(600 * time.Millisecond) // 等服务端口就绪再打开
openBrowser(url)
}()
}
if err := r.Run(addr); err != nil {
fmt.Printf("=== 服务启动失败: %v\n", err)
}
}
// openBrowser 调用系统默认浏览器打开指定 URL(Windows/macOS/Linux)
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("cmd", "/c", "start", "", url)
case "darwin":
cmd = exec.Command("open", url)
default:
cmd = exec.Command("xdg-open", url)
}
if err := cmd.Start(); err != nil {
fmt.Printf("=== 自动打开浏览器失败: %v\n", err)
}
}
// staticHandler 服务内嵌前端产物:命中静态资源则返回,否则回退 index.html(hash 路由)。
func staticHandler() gin.HandlerFunc {
distFS, err := fs.Sub(frontend.Dist, "dist")
if err != nil {
return func(c *gin.Context) {
c.String(http.StatusNotFound, "前端产物缺失,请先 npm run build 后重新编译")
}
}
// 4. 整理
org := organize.New(pickerDir, ffprobePath, st)
if len(res.Images) > 0 {
org.ProcessImages(res.Images)
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api") {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "接口不存在", "data": nil})
return
}
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if path == "" {
path = "index.html"
}
file, err := distFS.Open(path)
if err != nil {
file, err = distFS.Open("index.html")
}
if err != nil {
c.String(http.StatusNotFound, "页面不存在")
return
}
defer file.Close()
info, statErr := file.Stat()
if statErr != nil {
c.String(http.StatusInternalServerError, "读取前端资源失败")
return
}
if rs, ok := file.(io.ReadSeeker); ok {
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), rs)
return
}
body, err := io.ReadAll(file)
if err != nil {
c.String(http.StatusInternalServerError, "读取前端资源失败")
return
}
c.Data(http.StatusOK, mime.TypeByExtension(filepath.Ext(path)), body)
}
if len(res.Media) > 0 && ffprobePath != "" {
org.ProcessMedia(res.Media)
}
org.Flush()
fmt.Println("=== 整理完成")
}