refactor: 全面重构代码并加白话注释,移动全局并行,去掉去向/重复记录

- 重构后端与前端,全程大白话注释(不懂程序也能看懂)
- 移动阶段改为全局并行(MoveAll,工人数=CPU核心数)
- 去掉记录每个文件搬去哪了(删除去向回填,解决写库卡顿)
- 去掉重复文件标记与统计:重复判定改为 MD5+路径都相同才算,重复则原地忽略不移动;MD5 相同但路径不同仍照常搬
- 媒体库去掉归类目录列,看板去掉重复/归类分布统计
This commit is contained in:
2025-05-11 21:05:35 +08:00
parent f915a9b1ac
commit 8979c744a8
26 changed files with 1044 additions and 683 deletions
-26
View File
@@ -31,16 +31,6 @@
<div class="stat-value">{{ fmtSize(stats.minSize) }}</div> <div class="stat-value">{{ fmtSize(stats.minSize) }}</div>
<div class="stat-sub">体积最小的文件</div> <div class="stat-sub">体积最小的文件</div>
</div> </div>
<div class="stat-card">
<div class="stat-label">重复文件</div>
<div class="stat-value warning">{{ stats.duplicate }}</div>
<div class="stat-sub">额外副本数</div>
</div>
<div class="stat-card">
<div class="stat-label">重复占用</div>
<div class="stat-value warning">{{ fmtSize(stats.dupSize) }}</div>
<div class="stat-sub">可释放空间(约)</div>
</div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">总时长</div> <div class="stat-label">总时长</div>
<div class="stat-value">{{ fmtDuration(stats.durationTotal) }}</div> <div class="stat-value">{{ fmtDuration(stats.durationTotal) }}</div>
@@ -127,16 +117,6 @@
<div v-if="!stats.createdCounts || stats.createdCounts.length === 0" class="empty-tip">暂无入库记录</div> <div v-if="!stats.createdCounts || stats.createdCounts.length === 0" class="empty-tip">暂无入库记录</div>
</div> </div>
<div class="panel top-panel">
<div class="panel-title">归类目录分布</div>
<div v-for="item in (stats.categoryCounts || [])" :key="item.key" class="dist-row">
<span class="dist-label">{{ item.key }}</span>
<el-progress :percentage="percent(item.count, categoryTotal)" :stroke-width="14" color="#fa8c16"/>
<span class="dist-count">{{ item.count }}</span>
</div>
<div v-if="!stats.categoryCounts || stats.categoryCounts.length === 0" class="empty-tip">暂无归类记录(扫描整理后显示)</div>
</div>
<div class="panel top-panel"> <div class="panel top-panel">
<div class="panel-title">扩展名分布</div> <div class="panel-title">扩展名分布</div>
<div v-for="item in stats.extCounts" :key="item.key" class="dist-row"> <div v-for="item in stats.extCounts" :key="item.key" class="dist-row">
@@ -197,8 +177,6 @@ export default {
resCount: [], resCount: [],
codecCount: [], codecCount: [],
formatCount: [], formatCount: [],
duplicate: 0,
dupSize: 0,
topLarge: [], topLarge: [],
durationTotal: 0, durationTotal: 0,
durationAvg: 0, durationAvg: 0,
@@ -207,7 +185,6 @@ export default {
minSize: 0, minSize: 0,
durationBins: [], durationBins: [],
createdCounts: [], createdCounts: [],
categoryCounts: [],
extCounts: [], extCounts: [],
mimeCounts: [], mimeCounts: [],
audioCodecCount: [] audioCodecCount: []
@@ -233,9 +210,6 @@ export default {
createdTotal() { createdTotal() {
return this.sumCount(this.stats.createdCounts) return this.sumCount(this.stats.createdCounts)
}, },
categoryTotal() {
return this.sumCount(this.stats.categoryCounts)
},
extTotal() { extTotal() {
return this.sumCount(this.stats.extCounts) return this.sumCount(this.stats.extCounts)
}, },
-6
View File
@@ -81,12 +81,6 @@
<el-table-column prop="fileSize" label="大小" width="100" align="right" sortable="custom"> <el-table-column prop="fileSize" label="大小" width="100" align="right" sortable="custom">
<template slot-scope="scope">{{ scope.row.sizeText }}</template> <template slot-scope="scope">{{ scope.row.sizeText }}</template>
</el-table-column> </el-table-column>
<el-table-column label="归类目录" min-width="180" show-overflow-tooltip>
<template slot-scope="scope">
<span class="muted" v-if="!scope.row.destDir">-</span>
<span v-else>{{ scope.row.destDir }}</span>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="入库时间" width="200" align="center" sortable="custom"> <el-table-column prop="createdAt" label="入库时间" width="200" align="center" sortable="custom">
<template slot-scope="scope"> <template slot-scope="scope">
<span class="muted" v-if="!scope.row.createdAt">-</span> <span class="muted" v-if="!scope.row.createdAt">-</span>
+108 -51
View File
@@ -160,53 +160,74 @@
<script> <script>
import request from '../utils/request' import request from '../utils/request'
/**
* 扫描整理页面。
*
* 页面干四件事:
* 1. 开始扫描:填好扫描目录和存放目录,点按钮发给后端
* 2. 实时看进度:通过 WebSocket 专线接收后端主动推来的进度(实时),
* 页面刷新/断线后会自动重连;万一 WebSocket 不通,就退回"每 3 秒问一次"的低频轮询,
* 保证进度永远不会卡住不动
* 3. 看运行日志:后端干活时打印的日志会推到这里逐行显示
* 4. 选目录:弹窗一层层翻服务器上的文件夹,挑一个目录
*/
export default { export default {
name: 'Scan', name: 'Scan',
data() { data() {
return { return {
starting: false, // ---------- 开始扫描 ----------
scanDir: localStorage.getItem('scan-dir') || '', starting: false, // 点完按钮后、等后端回话前的"转圈"
destDir: localStorage.getItem('scan-dest-dir') || '', scanDir: localStorage.getItem('scan-dir') || '', // 要扫描的目录(记住上次填的)
destDir: localStorage.getItem('scan-dest-dir') || '', // 整理后文件放哪(记住上次填的)
// ---------- 扫描进度与状态 ----------
status: { status: {
state: 'idle', state: 'idle', // 任务状态:idle 空闲 / running 运行中 / done 完成 / error 出错
message: '', message: '', // 一句话说明(出错原因等)
started: '', started: '', // 开始时间
elapsed: 0, elapsed: 0, // 已运行秒数
rootDir: '', rootDir: '', // 扫描根目录
destDir: '', destDir: '', // 存放目录
lines: [], lines: [], // 运行日志
initialized: false, initialized: false, // 是否已经拿到过第一份状态(用于回填存放目录)
stage: '', stage: '', // 当前阶段:scan 扫描 / organize 归类 / move 移动
progress: 0, progress: 0, // 总进度百分比
done: 0, done: 0, // 已完成数
total: 0 total: 0 // 总数
}, },
ws: null,
wsAlive: false, // ---------- 实时推送(WebSocket)与兜底轮询 ----------
usingPolling: false, ws: null, // 当前 WebSocket 连接
wsClosed: false, wsAlive: false, // 专线是不是通着
wsReconnectTimer: null, usingPolling: false, // 是不是退回了轮询模式(浏览器不支持 WebSocket 时)
wsReconnectDelay: 1000, wsClosed: false, // 页面准备关闭,不再重连(销毁时置 true)
pollTimer: null, wsReconnectTimer: null, // 重连的倒计时
dirPickerVisible: false, wsReconnectDelay: 1000, // 重连间隔(失败会翻倍,最多 5 秒)
pickerTarget: 'scan', pollTimer: null, // 兜底轮询的计时器
dirLoading: false,
// ---------- 目录选择弹窗 ----------
dirPickerVisible: false, // 弹窗是否显示
pickerTarget: 'scan', // 当前选的是扫描目录还是存放目录
dirLoading: false, // 弹窗里正在翻目录
fsData: { fsData: {
current: '', current: '', // 当前所在目录
parent: '', parent: '', // 上一级
drives: [], drives: [], // Windows 盘符
dirs: [] dirs: [] // 当前目录下的子目录
} }
} }
}, },
mounted() { mounted() {
// 进页面先问一次当前状态(可能上次扫描到一半),然后连上实时专线
this.fetchStatus() this.fetchStatus()
this.connectWs() this.connectWs()
}, },
destroyed() { destroyed() {
// 页面关了:断开专线、停掉计时器,免得后台还在偷偷干活
this.closeWs() this.closeWs()
}, },
computed: { computed: {
// 当前阶段的文字(给用户看的)
stageText() { stageText() {
return { return {
scan: '扫描文件', scan: '扫描文件',
@@ -214,20 +235,21 @@ export default {
move: '移动文件' move: '移动文件'
}[this.status.stage] || '' }[this.status.stage] || ''
}, },
// 总数未知(遍历目录树阶段)时只显示已发现数,避免卡在 0/0 // 进度文字:总数已知就显示 "已完成/总数";
// 总数还不知道(后端还在翻目录)就只显示"已发现 N 个文件",避免卡在 0/0
progressText() { progressText() {
const {total, done} = this.status const {total, done} = this.status
if (total > 0) return done + '/' + total if (total > 0) return done + '/' + total
if (done > 0) return '已发现 ' + done + ' 个文件' if (done > 0) return '已发现 ' + done + ' 个文件'
return '准备中…' return '准备中…'
}, },
// 后端按当前阶段速率估算的剩余时间;0 表示无法估算 // 剩余时间(后端按当前速度估的;0 表示还估不出来就不显示)
etaText() { etaText() {
const eta = this.status.eta const eta = this.status.eta
if (!eta || eta <= 0) return '' if (!eta || eta <= 0) return ''
return this.fmtDuration(Math.round(eta)) return this.fmtDuration(Math.round(eta))
}, },
// 实时推送连接状态文案 // 实时专线的连接状态文案
connText() { connText() {
if (this.wsAlive) return {icon: 'el-icon-link', text: '实时推送已连接'} if (this.wsAlive) return {icon: 'el-icon-link', text: '实时推送已连接'}
if (this.usingPolling) return {icon: 'el-icon-refresh', text: '轮询模式'} if (this.usingPolling) return {icon: 'el-icon-refresh', text: '轮询模式'}
@@ -235,37 +257,48 @@ export default {
} }
}, },
methods: { methods: {
// 统一处理状态更新(HTTP 响应与 WebSocket 消息共用) // ==================== 状态更新 ====================
// 统一处理一份状态快照(WebSocket 推来的、HTTP 问来的都用这个函数)。
// 它负责:存下状态、回填上次的存放目录、日志有新增就滚到底、任务结束弹个提示。
applyStatus(data) { applyStatus(data) {
if (!data) return if (!data) return
const wasRunning = this.status.state === 'running' const wasRunning = this.status.state === 'running' // 之前是不是在跑
const isRunning = data.state === 'running' const isRunning = data.state === 'running' // 现在是不是在跑
const prevLineCount = (this.status.lines || []).length const prevLineCount = (this.status.lines || []).length
// 第一次拿到状态时,把后端记的"上次存放目录"回填进来
if (!this.status.initialized && data.destDir) { if (!this.status.initialized && data.destDir) {
this.destDir = data.destDir this.destDir = data.destDir
} }
this.status = data this.status = data
this.status.initialized = true this.status.initialized = true
// 日志有新行就滚到最下面(否则来回乱滚)
if (isRunning && data.lines && data.lines.length !== prevLineCount) { if (isRunning && data.lines && data.lines.length !== prevLineCount) {
this.scrollBottom() this.scrollBottom()
} }
// 从"运行中"变到"结束"(完成或出错)时,弹个提示
if (wasRunning && !isRunning) { if (wasRunning && !isRunning) {
this.$message.success(data.message || '扫描完成') this.$message.success(data.message || '扫描完成')
} }
}, },
// 用 HTTP 问一次当前状态(连上专线后、以及兜底轮询时用)
async fetchStatus() { async fetchStatus() {
try { try {
const res = await request.get('/api/scan/status') const res = await request.get('/api/scan/status')
this.applyStatus(res.data) this.applyStatus(res.data)
} catch (e) { } catch (e) {
// 网络异常:看门狗/轮询会继续重试,WS 模式忽略(等待 WS 重连) // 网络暂时不通:轮询会继续重试,WebSocket 模式则等专线自动重连
} }
}, },
// WebSocket 连接:页面刷新后自动重连,失败指数退避重试(上限 5 秒)。
// 同时维持一个低频 HTTP 轮询作为看门狗:WS 正常时保证兜底刷新, // ==================== 实时推送(WebSocket) ====================
// WS 不可用/断开时也能低频更新,界面永不冻结。
// 建立专线。页面刷新后会自动重新连上;连失败会退避重试(1 秒 → 2 秒 → 4 秒 → 封顶 5 秒)。
// 同时开一个低频轮询当"看门狗":就算专线悄悄断了,也能兜底刷新,界面永远不会卡住。
connectWs() { connectWs() {
if (this.ws || this.wsClosed) return if (this.ws || this.wsClosed) return
// 浏览器太老不支持 WebSocket:直接退回 1 秒轮询
if (!window.WebSocket) { if (!window.WebSocket) {
this.usingPolling = true this.usingPolling = true
this.wsAlive = false this.wsAlive = false
@@ -276,6 +309,7 @@ export default {
const ws = new WebSocket(proto + location.host + '/api/scan/ws') const ws = new WebSocket(proto + location.host + '/api/scan/ws')
this.ws = ws this.ws = ws
ws.onopen = () => { ws.onopen = () => {
// 连上了:重连间隔归零,先问一次当前状态,再开低频轮询兜底
this.wsAlive = true this.wsAlive = true
this.usingPolling = false this.usingPolling = false
this.wsReconnectDelay = 1000 this.wsReconnectDelay = 1000
@@ -283,25 +317,31 @@ export default {
this.startPoll(3000) this.startPoll(3000)
} }
ws.onmessage = (e) => { ws.onmessage = (e) => {
// 后端推来一份状态:解析后更新页面
try { try {
this.applyStatus(JSON.parse(e.data)) this.applyStatus(JSON.parse(e.data))
} catch (err) { } catch (err) {
// 忽略无法解析的消息 // 内容坏了就忽略,等下一份
} }
} }
ws.onerror = () => { ws.onerror = () => {
// 出错就关掉连接,onclose 里会自动重连
ws.close() ws.close()
} }
ws.onclose = () => { ws.onclose = () => {
// 断了:标记掉线;页面要关的话就不再连了
this.ws = null this.ws = null
this.wsAlive = false this.wsAlive = false
if (this.wsClosed) return if (this.wsClosed) return
// 隔一小会儿重连(间隔逐次翻倍,封顶 5 秒)
this.wsReconnectTimer = setTimeout(() => { this.wsReconnectTimer = setTimeout(() => {
this.wsReconnectDelay = Math.min(this.wsReconnectDelay * 2, 5000) this.wsReconnectDelay = Math.min(this.wsReconnectDelay * 2, 5000)
this.connectWs() this.connectWs()
}, this.wsReconnectDelay) }, this.wsReconnectDelay)
} }
}, },
// 页面销毁时调用:断开专线、停掉所有计时器
closeWs() { closeWs() {
this.wsClosed = true this.wsClosed = true
this.wsAlive = false this.wsAlive = false
@@ -315,7 +355,9 @@ export default {
} }
this.stopPoll() this.stopPoll()
}, },
// 周期轮询:WS 正常时每 3 秒兜底,无 WS 环境时每 1 秒
// 开一个周期轮询:专线正常时每 3 秒兜底刷新一次;
// 没有 WebSocket 的浏览器退回 1 秒一次
startPoll(interval) { startPoll(interval) {
this.stopPoll() this.stopPoll()
this.pollTimer = setInterval(() => this.fetchStatus(), interval || 3000) this.pollTimer = setInterval(() => this.fetchStatus(), interval || 3000)
@@ -326,37 +368,37 @@ export default {
this.pollTimer = null this.pollTimer = null
} }
}, },
// 秒 → "X 分 Y 秒" / "X 小时 Y 分"
fmtDuration(sec) { // ==================== 开始扫描 ====================
if (!sec) return ''
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const s = sec % 60
if (h > 0) return h + ' 小时 ' + m + ' 分'
if (m > 0) return m + ' 分 ' + s + ' 秒'
return s + ' 秒'
},
async handleStart() { async handleStart() {
this.starting = true this.starting = true
try { try {
// 记住这次填的目录,下次打开页面自动填上
localStorage.setItem('scan-dir', this.scanDir) localStorage.setItem('scan-dir', this.scanDir)
localStorage.setItem('scan-dest-dir', this.destDir) localStorage.setItem('scan-dest-dir', this.destDir)
const res = await request.post('/api/scan/start', { const res = await request.post('/api/scan/start', {
dir: this.scanDir.trim(), dir: this.scanDir.trim(),
destDir: this.destDir.trim() destDir: this.destDir.trim()
}) })
// 后端立刻回了当前状态(已进入运行中),直接展示
this.applyStatus(res.data) this.applyStatus(res.data)
this.$message.success('已开始扫描整理') this.$message.success('已开始扫描整理')
} finally { } finally {
this.starting = false this.starting = false
} }
}, },
// ==================== 目录选择弹窗 ====================
// 打开弹窗;target 决定这次选的是"扫描目录"还是"存放目录"
openDirPicker(target) { openDirPicker(target) {
this.pickerTarget = target || 'scan' this.pickerTarget = target || 'scan'
this.dirPickerVisible = true this.dirPickerVisible = true
const initial = target === 'dest' ? this.destDir.trim() : this.scanDir.trim() const initial = target === 'dest' ? this.destDir.trim() : this.scanDir.trim()
this.loadFs(initial) this.loadFs(initial)
}, },
// 向后端要某个目录下的子目录列表
async loadFs(dir) { async loadFs(dir) {
this.dirLoading = true this.dirLoading = true
try { try {
@@ -368,6 +410,7 @@ export default {
this.dirLoading = false this.dirLoading = false
} }
}, },
// 点"选中此目录":把选中的目录填到对应的输入框里
confirmDir() { confirmDir() {
if (!this.fsData.current) return if (!this.fsData.current) return
if (this.pickerTarget === 'dest') { if (this.pickerTarget === 'dest') {
@@ -379,6 +422,20 @@ export default {
} }
this.dirPickerVisible = false this.dirPickerVisible = false
}, },
// ==================== 小工具 ====================
// 秒 → "X 小时 Y 分" / "X 分 Y 秒" / "X 秒"
fmtDuration(sec) {
if (!sec) return ''
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const s = sec % 60
if (h > 0) return h + ' 小时 ' + m + ' 分'
if (m > 0) return m + ' 分 ' + s + ' 秒'
return s + ' 秒'
},
// 让日志框滚到最下面(等页面渲染完再滚)
scrollBottom() { scrollBottom() {
this.$nextTick(() => { this.$nextTick(() => {
const box = this.$refs.logBox const box = this.$refs.logBox
+23 -13
View File
@@ -1,4 +1,5 @@
// Package api 提供服务器文件系统浏览接口,供前端目录选择器使用(只列出目录,不列文件)。 // 文件目录浏览接口:给页面上"选择目录"用的,只列目录、不列文件。
// 前端那个"选择目录"弹窗就是一层层调这个接口往下翻文件夹的。
package api package api
import ( import (
@@ -12,33 +13,38 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// FsController 服务器文件系统浏览控制器 // FsController 目录浏览的"窗口"。
type FsController struct{} type FsController struct{}
// NewFsController 创建文件系统浏览控制器 // NewFsController 创建目录浏览控制器。
func NewFsController() *FsController { func NewFsController() *FsController {
return &FsController{} return &FsController{}
} }
// FsListResult 目录浏览结果 // FsListResult 一次目录浏览的结果:
// - current:当前在哪个目录(盘符层时为空)
// - parent:上一级目录(在根/盘符层时等于 current)
// - drives:Windows 的盘符列表(非 Windows 为空)
// - dirs:当前目录下的子目录(不含文件)
type FsListResult struct { type FsListResult struct {
Current string `json:"current"` // 当前目录绝对路径;盘符层为空 Current string `json:"current"`
Parent string `json:"parent"` // 父目录;根/盘符层时等于 Current Parent string `json:"parent"`
Drives []string `json:"drives"` // Windows 盘符列表(非 Windows 为空) Drives []string `json:"drives"`
Dirs []FsDir `json:"dirs"` // 子目录列表(不含文件) Dirs []FsDir `json:"dirs"`
} }
// FsDir 子目录项 // FsDir 子目录项。
type FsDir struct { type FsDir struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
} }
// List GET /api/fs/list?dir= 列出指定目录的子目录(不含文件); // List 处理 GET /api/fs/list?dir=xxx:列出指定目录下的子目录。
// dir 为空时返回 Windows 盘符列表或 Unix 根目录。 // 没传目录时,Windows 返回盘符列表,其他系统返回根目录。
func (c *FsController) List(ctx *gin.Context) { func (c *FsController) List(ctx *gin.Context) {
dir := strings.TrimSpace(ctx.Query("dir")) dir := strings.TrimSpace(ctx.Query("dir"))
// 没指定目录:Windows 先让用户选盘符
if dir == "" { if dir == "" {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
Success(ctx, FsListResult{Drives: listLogicalDrives()}) Success(ctx, FsListResult{Drives: listLogicalDrives()})
@@ -52,7 +58,8 @@ func (c *FsController) List(ctx *gin.Context) {
Error(ctx, http.StatusBadRequest, "路径解析失败: "+err.Error()) Error(ctx, http.StatusBadRequest, "路径解析失败: "+err.Error())
return return
} }
// 目录不存在/不可访问时向上回退到最近的已存在父目录,避免阻塞目录选择器 // 目录不存在/打不开时,往上找最近的"确实存在"的父目录,
// 这样用户输入了不存在的地方也不会卡死,而是回到最近的可用目录。
for { for {
st, statErr := os.Stat(abs) st, statErr := os.Stat(abs)
if statErr == nil && st.IsDir() { if statErr == nil && st.IsDir() {
@@ -60,7 +67,7 @@ func (c *FsController) List(ctx *gin.Context) {
} }
parent := filepath.Dir(abs) parent := filepath.Dir(abs)
if parent == abs { if parent == abs {
// 已到根仍不存在:回退到盘符层 // 已经退到根还是不存在:直接返回盘符层
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
Success(ctx, FsListResult{Drives: listLogicalDrives()}) Success(ctx, FsListResult{Drives: listLogicalDrives()})
return return
@@ -77,10 +84,12 @@ func (c *FsController) List(ctx *gin.Context) {
return return
} }
// 上一级目录;在根目录时等于自己
parent := filepath.Dir(abs) parent := filepath.Dir(abs)
if parent == abs { if parent == abs {
parent = abs parent = abs
} }
// 只收集子目录(跳过文件,这里只需要目录)
dirs := make([]FsDir, 0, len(entries)) dirs := make([]FsDir, 0, len(entries))
for _, e := range entries { for _, e := range entries {
if !e.IsDir() { if !e.IsDir() {
@@ -88,6 +97,7 @@ func (c *FsController) List(ctx *gin.Context) {
} }
dirs = append(dirs, FsDir{Name: e.Name(), Path: filepath.Join(abs, e.Name())}) dirs = append(dirs, FsDir{Name: e.Name(), Path: filepath.Join(abs, e.Name())})
} }
// 按名字排序,翻起来不凌乱
sort.Slice(dirs, func(i, j int) bool { sort.Slice(dirs, func(i, j int) bool {
return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name)
}) })
+3 -1
View File
@@ -4,13 +4,15 @@ package api
import "golang.org/x/sys/windows" import "golang.org/x/sys/windows"
// listLogicalDrives 返回 Windows 所有逻辑盘符(如 C:\、D:\) // listLogicalDrives 列出 Windows 所有可用的盘符(如 C:\、D:\)。
// 只有 Windows 编译时才需要这个函数(见文件顶部的 //go:build windows)。
func listLogicalDrives() []string { func listLogicalDrives() []string {
bits, err := windows.GetLogicalDrives() bits, err := windows.GetLogicalDrives()
if err != nil { if err != nil {
return nil return nil
} }
drives := make([]string, 0, 26) drives := make([]string, 0, 26)
// 26 个字母逐个检查,看哪个盘存在
for i := 0; i < 26; i++ { for i := 0; i < 26; i++ {
if bits&(1<<uint(i)) != 0 { if bits&(1<<uint(i)) != 0 {
drives = append(drives, string(rune('A'+i))+":\\") drives = append(drives, string(rune('A'+i))+":\\")
+19 -12
View File
@@ -1,3 +1,6 @@
// 媒体库的两个接口:查列表、查统计。
// 「媒体库」页面查列表(带筛选和分页),「统计看板」页面查统计。
// 这两个接口只读数据库,不干别的活。
package api package api
import ( import (
@@ -11,17 +14,17 @@ import (
"kenaito-media-picker/internal/store" "kenaito-media-picker/internal/store"
) )
// MediaController 媒体库接口 // MediaController 媒体库相关接口的"窗口"。
type MediaController struct { type MediaController struct {
store *store.Store store *store.Store // 数据库(只读)
} }
// NewMediaController 创建媒体控制器,注入数据访问层 // NewMediaController 创建媒体库控制器,注入数据库。
func NewMediaController(st *store.Store) *MediaController { func NewMediaController(st *store.Store) *MediaController {
return &MediaController{store: st} return &MediaController{store: st}
} }
// mediaListReq 列表查询请求 // mediaListReq 媒体库列表的查询条件(前端「媒体库」页面传过来的)。
type mediaListReq struct { type mediaListReq struct {
Page int `json:"page"` Page int `json:"page"`
PageSize int `json:"pageSize"` PageSize int `json:"pageSize"`
@@ -35,16 +38,17 @@ type mediaListReq struct {
SortOrder string `json:"sortOrder"` SortOrder string `json:"sortOrder"`
} }
// mediaView 列表展示视图:补充方向/分辨率/可读大小等派生字段 // mediaView 列表里展示的一条记录:在数据库记录的基础上,
// 再补几个"现算"的字段(方向、分辨率档位、人类可读的体积和时长),方便页面直接显示。
type mediaView struct { type mediaView struct {
store.MediaInfo store.MediaInfo
Orientation string `json:"orientation"` // 横屏/竖屏/等比 Orientation string `json:"orientation"` // 横屏/竖屏/等比
Resolution string `json:"resolution"` // 分辨率档位 Resolution string `json:"resolution"` // 分辨率档位(如 1080P)
SizeText string `json:"sizeText"` // 人类可读体积 SizeText string `json:"sizeText"` // 人类可读体积(如 1.5 MB)
DurationText string `json:"durationText"` // 人类可读时长 DurationText string `json:"durationText"` // 人类可读时长(如 05:30)
} }
// List POST /api/media/list 分页查询媒体库 // List 处理 POST /api/media/list:按条件分页查媒体库列表。
func (c *MediaController) List(ctx *gin.Context) { func (c *MediaController) List(ctx *gin.Context) {
var req mediaListReq var req mediaListReq
if err := ctx.ShouldBindJSON(&req); err != nil { if err := ctx.ShouldBindJSON(&req); err != nil {
@@ -67,6 +71,7 @@ func (c *MediaController) List(ctx *gin.Context) {
SortOrder: req.SortOrder, SortOrder: req.SortOrder,
} }
// 查这一页的数据,同时查总条数(分页条要用)
list, err := c.store.ListMedia(q) list, err := c.store.ListMedia(q)
if err != nil { if err != nil {
Error(ctx, http.StatusInternalServerError, "查询媒体列表失败:"+err.Error()) Error(ctx, http.StatusInternalServerError, "查询媒体列表失败:"+err.Error())
@@ -78,6 +83,7 @@ func (c *MediaController) List(ctx *gin.Context) {
return return
} }
// 每条记录补上"现算"的展示字段再返回
views := make([]interface{}, 0, len(list)) views := make([]interface{}, 0, len(list))
for _, m := range list { for _, m := range list {
views = append(views, toMediaView(m)) views = append(views, toMediaView(m))
@@ -85,7 +91,7 @@ func (c *MediaController) List(ctx *gin.Context) {
Success(ctx, map[string]interface{}{"list": views, "total": total}) Success(ctx, map[string]interface{}{"list": views, "total": total})
} }
// Stats POST /api/media/stats 媒体库统计看板 // Stats 处理 POST /api/media/stats:统计看板要用的全部数据。
func (c *MediaController) Stats(ctx *gin.Context) { func (c *MediaController) Stats(ctx *gin.Context) {
st, err := c.store.Stats() st, err := c.store.Stats()
if err != nil { if err != nil {
@@ -95,9 +101,10 @@ func (c *MediaController) Stats(ctx *gin.Context) {
Success(ctx, st) Success(ctx, st)
} }
// toMediaView 补充派生字段 // toMediaView 给一条数据库记录补上展示用的派生字段。
func toMediaView(m store.MediaInfo) mediaView { func toMediaView(m store.MediaInfo) mediaView {
v := mediaView{MediaInfo: m} v := mediaView{MediaInfo: m}
// 宽高有效才算方向和分辨率档位
if m.Width > 0 && m.Height > 0 { if m.Width > 0 && m.Height > 0 {
v.Orientation = classify.OrientationOf(m.Width, m.Height).String() v.Orientation = classify.OrientationOf(m.Width, m.Height).String()
v.Resolution = classify.ResLabel(m.Width, m.Height) v.Resolution = classify.ResLabel(m.Width, m.Height)
@@ -111,7 +118,7 @@ func toMediaView(m store.MediaInfo) mediaView {
return v return v
} }
// formatDuration 秒 → 时:分:秒 // formatDuration 把秒数转成人类可读的时:分:秒。
func formatDuration(sec float64) string { func formatDuration(sec float64) string {
s := int(sec) s := int(sec)
h := s / 3600 h := s / 3600
+12 -5
View File
@@ -1,4 +1,8 @@
// Package api 提供 Web 管理接口(Gin),遵循统一响应与分页约定。 // Package api 是程序的"门面"——所有网页请求都先到这里,再转给背后的服务。
//
// 大白话理解:这是前台接待处。页面上的每个按钮、每次查询,
// 都会变成一个"请求"发到这里;这里分发给对应的办事窗口(controller),
// 办完再用统一的格式回话。
package api package api
import ( import (
@@ -7,14 +11,17 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// Response 统一响应结构 // Response 所有接口的统一回话格式:
// - code:成功 200,出错 400/500
// - message:一句话说明(成功就是 success,出错就是原因)
// - data:真正要返回的数据
type Response struct { type Response struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Data interface{} `json:"data"` Data interface{} `json:"data"`
} }
// Success 成功响应 // Success 返回成功回话(code=200)。
func Success(c *gin.Context, data interface{}) { func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{ c.JSON(http.StatusOK, Response{
Code: http.StatusOK, Code: http.StatusOK,
@@ -23,7 +30,7 @@ func Success(c *gin.Context, data interface{}) {
}) })
} }
// Error 失败响应(仅允许 400 / 500) // Error 返回失败回话。statusCode 只允许 400(参数不对)/ 500(服务器出错)。
func Error(c *gin.Context, statusCode int, message string) { func Error(c *gin.Context, statusCode int, message string) {
c.JSON(statusCode, Response{ c.JSON(statusCode, Response{
Code: statusCode, Code: statusCode,
@@ -32,7 +39,7 @@ func Error(c *gin.Context, statusCode int, message string) {
}) })
} }
// PageResult 分页响应体 // PageResult 分页列表的统一格式:list 是这一页的数据,total 是总条数。
type PageResult struct { type PageResult struct {
List []interface{} `json:"list"` List []interface{} `json:"list"`
Total int64 `json:"total"` Total int64 `json:"total"`
+12 -2
View File
@@ -1,3 +1,9 @@
// Package api 是程序的"门面",所有网页请求都先进这里。
//
// 这里的 router.go 是"指路牌",负责把不同的网址指给不同的窗口(controller):
// - /api/scan/... → 扫描整理
// - /api/media/... → 媒体库与统计看板
// - /api/fs/... → 文件目录浏览(页面上"选择目录"用)
package api package api
import ( import (
@@ -9,27 +15,31 @@ import (
"kenaito-media-picker/internal/store" "kenaito-media-picker/internal/store"
) )
// NewRouter 组装 Gin 路由:健康检查 + API 路由组 // NewRouter 把"网址 → 窗口"的路由都登记好,返回给主程序去跑。
func NewRouter(st *store.Store) *gin.Engine { func NewRouter(st *store.Store) *gin.Engine {
r := gin.New() r := gin.New()
r.Use(gin.Recovery()) r.Use(gin.Recovery())
// 健康检查 // 健康检查:别的程序或人想知道"服务还活着吗",就访问这个
r.GET("/health/check", func(c *gin.Context) { r.GET("/health/check", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
}) })
// 所有 /api/ 开头的接口都归到这里
apiGroup := r.Group("/api") apiGroup := r.Group("/api")
{ {
// 媒体库 + 统计看板
mc := NewMediaController(st) mc := NewMediaController(st)
apiGroup.POST("/media/list", mc.List) apiGroup.POST("/media/list", mc.List)
apiGroup.POST("/media/stats", mc.Stats) apiGroup.POST("/media/stats", mc.Stats)
// 扫描整理(开始 / 查进度 / 实时进度)
sc := NewScanController(service.NewScanService(st)) sc := NewScanController(service.NewScanService(st))
apiGroup.POST("/scan/start", sc.Start) apiGroup.POST("/scan/start", sc.Start)
apiGroup.GET("/scan/status", sc.Status) apiGroup.GET("/scan/status", sc.Status)
apiGroup.GET("/scan/ws", sc.Ws) apiGroup.GET("/scan/ws", sc.Ws)
// 文件目录浏览(页面上"选择目录")
fc := NewFsController() fc := NewFsController()
apiGroup.GET("/fs/list", fc.List) apiGroup.GET("/fs/list", fc.List)
} }
+11 -6
View File
@@ -1,3 +1,5 @@
// 扫描整理的三个接口:开始扫描、查进度、实时进度(WebSocket)。
// 它们不干活,只负责"接话、转达",真正的活由 service 里的 ScanService 干。
package api package api
import ( import (
@@ -8,23 +10,26 @@ import (
"kenaito-media-picker/internal/service" "kenaito-media-picker/internal/service"
) )
// ScanController 扫描整理接口 // ScanController 扫描相关接口的"窗口"。
type ScanController struct { type ScanController struct {
service *service.ScanService service *service.ScanService // 背后干活的管家
} }
// NewScanController 创建扫描控制器 // NewScanController 创建扫描控制器,并注入干活的服务。
func NewScanController(svc *service.ScanService) *ScanController { func NewScanController(svc *service.ScanService) *ScanController {
return &ScanController{service: svc} return &ScanController{service: svc}
} }
// scanStartReq 扫描启动请求;dir 为空时使用服务当前运行目录,destDir 为空时默认输出到扫描目录下的「KMP已整理」 // scanStartReq 点击"开始扫描"时前端传过来的参数:
// - dir:要扫描哪个目录(留空就扫程序当前所在目录)
// - destDir:整理好的文件放哪(留空默认放扫描目录下的「KMP已整理」)
type scanStartReq struct { type scanStartReq struct {
Dir string `json:"dir"` Dir string `json:"dir"`
DestDir string `json:"destDir"` DestDir string `json:"destDir"`
} }
// Start POST /api/scan/start 触发一次扫描整理 // Start 处理 POST /api/scan/start:触发一次扫描整理。
// 成功后立刻返回当前进度快照,好让页面马上进入"扫描中"状态。
func (c *ScanController) Start(ctx *gin.Context) { func (c *ScanController) Start(ctx *gin.Context) {
var req scanStartReq var req scanStartReq
if err := ctx.ShouldBindJSON(&req); err != nil { if err := ctx.ShouldBindJSON(&req); err != nil {
@@ -38,7 +43,7 @@ func (c *ScanController) Start(ctx *gin.Context) {
Success(ctx, c.service.Status()) Success(ctx, c.service.Status())
} }
// Status GET /api/scan/status 查询扫描状态与日志 // Status 处理 GET /api/scan/status:查询当前扫描状态与日志(轮询兜底用的)。
func (c *ScanController) Status(ctx *gin.Context) { func (c *ScanController) Status(ctx *gin.Context) {
Success(ctx, c.service.Status()) Success(ctx, c.service.Status())
} }
+27 -13
View File
@@ -1,4 +1,8 @@
// 扫描状态 WebSocket 实时推送端点。 // 扫描状态实时推送:用 WebSocket 把进度不停推给前端页面。
//
// 大白话理解:网页和服务器之间开一条"专线",服务器一有动静(进度变了、
// 日志多了、干完了)就主动通过这条线喊一声,页面立刻更新。
// 以前是页面每隔一秒"问一次",现在是服务器"主动喊",实时又不浪费。
package api package api
import ( import (
@@ -10,51 +14,58 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
// wsUpgrader WebSocket 升级器 // wsUpgrader 负责把普通的网页请求"升级"成 WebSocket 专线。
// 同一个管理页面访问,所以允许任意来源(也方便局域网内访问)。
var wsUpgrader = websocket.Upgrader{ var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 1024,
WriteBufferSize: 4096, WriteBufferSize: 4096,
// 同源管理页,允许任意来源,便于局域网访问
CheckOrigin: func(r *http.Request) bool { return true }, CheckOrigin: func(r *http.Request) bool { return true },
} }
// 心跳相关的三个时间:
const ( const (
wsWriteWait = 10 * time.Second wsWriteWait = 10 * time.Second // 写消息的最长等待时间
wsPongWait = 60 * time.Second wsPongWait = 60 * time.Second // 等客户端"应答"的最长时间,超了就当它掉了
wsPingPeriod = (wsPongWait * 9) / 10 wsPingPeriod = (wsPongWait * 9) / 10 // 每隔多久主动发一次"心跳"
) )
// Ws GET /api/scan/ws 建立 WebSocket 连接,持续推送扫描状态。 // Ws 处理 GET /api/scan/ws:建立专线后,不停把扫描状态推给前端。
// 连接建立后立即推送当前状态快照;断开后前端可自动重连(服务端不做重连保证)。 //
// 连接一建立,先立刻把当前状态推一份(快照),之后一有更新就推。
// 前端页面刷新、网络断了之后会自己重新连上来,这里只管服务端这一端。
func (c *ScanController) Ws(ctx *gin.Context) { func (c *ScanController) Ws(ctx *gin.Context) {
// 升级成 WebSocket 专线
conn, err := wsUpgrader.Upgrade(ctx.Writer, ctx.Request, nil) conn, err := wsUpgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil { if err != nil {
return return
} }
// 订阅进度更新:之后有状态就送到 ch 这个管道里
ch, cancel := c.service.Subscribe() ch, cancel := c.service.Subscribe()
defer func() { defer func() {
cancel() cancel()
conn.Close() conn.Close()
}() }()
// 读协程:仅用于感知客户端断开/失活(配合 pong 心跳),不处理业务消息 // 读消息的协程只是用来"感知"客户端还在不在(配合心跳应答),不处理实际内容
conn.SetReadLimit(1024) conn.SetReadLimit(1024)
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait)) _ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
conn.SetPongHandler(func(string) error { conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(wsPongWait)) return conn.SetReadDeadline(time.Now().Add(wsPongWait))
}) })
// 写协程:先推送快照,再转发订阅更新,并周期性 ping 保活 // 写消息的协程:先推一份当前状态快照,再不停转发订阅到的更新,
// 空闲时定期发心跳(ping),防止专线被路由器等中间设备掐断。
writeDone := make(chan struct{}) writeDone := make(chan struct{})
go func() { go func() {
defer close(writeDone) defer close(writeDone)
write := func(msgType int, data []byte) bool { write := func(msgType int, data []byte) bool {
if err := conn.WriteMessage(msgType, data); err != nil { if err := conn.WriteMessage(msgType, data); err != nil {
conn.Close() // 写失败即断开,让读协程退出 conn.Close() // 写失败说明专线断了,把连接关掉,让读协程收工
return false return false
} }
return true return true
} }
// 先推快照
if b, err := json.Marshal(c.service.Status()); err == nil { if b, err := json.Marshal(c.service.Status()); err == nil {
if !write(websocket.TextMessage, b) { if !write(websocket.TextMessage, b) {
return return
@@ -66,7 +77,7 @@ func (c *ScanController) Ws(ctx *gin.Context) {
select { select {
case st, ok := <-ch: case st, ok := <-ch:
if !ok { if !ok {
return return // 订阅被取消了(连接要关了)
} }
b, err := json.Marshal(st) b, err := json.Marshal(st)
if err != nil { if err != nil {
@@ -76,6 +87,7 @@ func (c *ScanController) Ws(ctx *gin.Context) {
return return
} }
case <-ticker.C: case <-ticker.C:
// 定期发心跳
if !write(websocket.PingMessage, nil) { if !write(websocket.PingMessage, nil) {
return return
} }
@@ -83,11 +95,13 @@ func (c *ScanController) Ws(ctx *gin.Context) {
} }
}() }()
// 主线程一直等着读:客户端断开或失活时 ReadMessage 会出错,这里就收工
for { for {
if _, _, err := conn.ReadMessage(); err != nil { if _, _, err := conn.ReadMessage(); err != nil {
break break
} }
} }
cancel() // 先关闭订阅频道,让写协程退出,避免 <-writeDone 永久阻塞 // 先关掉订阅,让写协程退出,避免白等
cancel()
<-writeDone <-writeDone
} }
+12 -6
View File
@@ -2,12 +2,17 @@ package classify
import "fmt" import "fmt"
// neutralMargin 判定明暗主导的最小分差:两分差幅小于该值视为中性图 // neutralMargin 判定"中性图"的最小分差:
// 如果亮色占比和暗色占比相差不到这个值,就认为这张图不亮不暗,归为「mid」中性。
const neutralMargin = 0.15 const neutralMargin = 0.15
// BrightnessName 判定图片明暗等级,返回如 "dark_70" / "bright_40" / "mid"。 // BrightnessName 根据暗色/亮色像素占比,给图片定"明暗等级",返回类似:
// 以主导分差幅判定亮暗:lightScore 高于 darkScore neutralMargin 以上归 bright, // - "dark_70" → 暗色像素占 65%~75% 的暗图
// 反之归 dark,两者接近视为中性 mid(不再强制二选一)。 // - "bright_40" → 亮色像素占 35%~45% 的亮图
// - "mid" → 明暗差不多,不好说谁多,归中性
//
// 判断规则(大白话):谁占得多、而且多出明显一截(超过 neutralMargin),
// 就按谁命名,数字是占比取整到 10% 一档。
func BrightnessName(darkScore, lightScore float64) string { func BrightnessName(darkScore, lightScore float64) string {
switch { switch {
case lightScore > darkScore+neutralMargin: case lightScore > darkScore+neutralMargin:
@@ -19,8 +24,9 @@ func BrightnessName(darkScore, lightScore float64) string {
} }
} }
// brightnessTier 将占比分档到最接近的 10%,表示命中区间 [N-5%, N+5%),最小 10、最大 100。 // brightnessTier 把占比(0~1)取整到"最接近的 10%"作为档位数字,范围 10~100。
// 例如 0.30~0.39 归 "30",0.96 以上归 "100",避免占比不足却挂高档名。 // 例:0.30~0.39 → "30";0.96 以上 → "100"。
// 这样文件夹名里的数字不会出现"明明很少却挂个大数字"的情况。
func brightnessTier(score float64) int { func brightnessTier(score float64) int {
tier := int(score*100+5) / 10 * 10 tier := int(score*100+5) / 10 * 10
if tier < 10 { if tier < 10 {
+54 -15
View File
@@ -1,4 +1,15 @@
// Package classify 提供纯函数的分辨率归类规则,不依赖任何状态。 // Package classify 负责给媒体文件定"分辨率档位",决定它该进哪个分类文件夹。
//
// 大白话理解,就是定分类命名规则:
// - 画面是横的还是竖的?(横屏 / 竖屏 / 正方形)
// - 清晰度算哪个档?(720P / 1080P / 4K…;不标准的按长边大小给个约数,如 5K)
// - 图片还要看是暗调还是亮调(dark / mid / light)
//
// 最终拼出一个文件夹名,比如:
// - 「图片_横屏_1080P-light」 一张横向、1080P、偏亮的图片
// - 「视频_竖屏_4k」 一个竖向、4K 的视频
//
// 这个包全是"纯函数"——不依赖任何状态,给什么算什么,好理解也好测。
package classify package classify
import ( import (
@@ -6,16 +17,18 @@ import (
"strings" "strings"
) )
// Orientation 画面方向 // ============================ 画面方向 ============================
// Orientation 画面方向。
type Orientation int type Orientation int
const ( const (
OrientationSquare Orientation = iota OrientationSquare Orientation = iota // 正方形(宽 = 高)
OrientationHorizontal OrientationHorizontal // 横屏(宽 > 高)
OrientationVertical OrientationVertical // 竖屏(宽 < 高)
) )
// OrientationOf 由宽高判定画面方向 // OrientationOf 根据宽高判断画面是横是竖还是正方。
func OrientationOf(width, height int) Orientation { func OrientationOf(width, height int) Orientation {
switch { switch {
case width > height: case width > height:
@@ -27,6 +40,7 @@ func OrientationOf(width, height int) Orientation {
} }
} }
// String 把方向转成中文名,方便拼进文件夹名和前端展示。
func (o Orientation) String() string { func (o Orientation) String() string {
switch o { switch o {
case OrientationHorizontal: case OrientationHorizontal:
@@ -38,8 +52,11 @@ func (o Orientation) String() string {
} }
} }
// ImageDir 返回图片归类子目录名,如 "图片_横屏_1080P-dark_70" / "图片_横屏_1080P-mid" / "图片_GIF"。 // ============================ 归类目录名 ============================
// darkScore/lightScore 用于明暗分类(GIF 不做明暗分类,忽略这两项)。
// ImageDir 返回一张图片该进的文件夹名,比如:
// - 「图片_GIF」:GIF 动图单独放(不做明暗分类)
// - 「图片_横屏_1080P-light」:普通图片 = 方向 + 清晰度 + 明暗
func ImageDir(ext string, width, height int, darkScore, lightScore float64) string { func ImageDir(ext string, width, height int, darkScore, lightScore float64) string {
if strings.EqualFold(ext, ".gif") { if strings.EqualFold(ext, ".gif") {
return "图片_GIF" return "图片_GIF"
@@ -48,19 +65,25 @@ func ImageDir(ext string, width, height int, darkScore, lightScore float64) stri
return dir + "-" + BrightnessName(darkScore, lightScore) return dir + "-" + BrightnessName(darkScore, lightScore)
} }
// VideoDir 返回视频归类子目录名,如 "视频_竖屏_4k" / "视频_横屏_GIF" // VideoDir 返回一个视频该进的文件夹名,比如「视频_竖屏_4k」「视频_横屏_GIF」。
func VideoDir(ext string, width, height int) string { func VideoDir(ext string, width, height int) string {
return resolutionDir("视频", ext, width, height) return resolutionDir("视频", ext, width, height)
} }
// resolutionDir 拼出"类型 + 方向 + 清晰度"这段目录名。
// 步骤:先看宽高是横是竖;再匹配标准清晰度(720P/1080P…),
// 匹配不上就按长边大小给个约数(普通 / 5K / 原画质)。
func resolutionDir(kind, ext string, width, height int) string { func resolutionDir(kind, ext string, width, height int) string {
ori := OrientationOf(width, height).String() ori := OrientationOf(width, height).String()
// GIF 特殊:不管多清楚,都归「_GIF」
if strings.EqualFold(ext, ".gif") { if strings.EqualFold(ext, ".gif") {
return kind + "_" + ori + "_GIF" return kind + "_" + ori + "_GIF"
} }
// 先看是不是标准清晰度(精确匹配宽高)
if suffix := matchStandard(width, height); suffix != "" { if suffix := matchStandard(width, height); suffix != "" {
return kind + "_" + ori + "_" + suffix return kind + "_" + ori + "_" + suffix
} }
// 不是标准清晰度:按长边给约数。图片叫"原图",视频叫"原画质"(超出档位时用)
overflow := "原图" overflow := "原图"
if kind == "视频" { if kind == "视频" {
overflow = "原画质" overflow = "原画质"
@@ -68,7 +91,7 @@ func resolutionDir(kind, ext string, width, height int) string {
return kind + "_" + ori + "_" + levelName(longEdge(width, height), overflow) return kind + "_" + ori + "_" + levelName(longEdge(width, height), overflow)
} }
// longEdge 返回长边像素 // longEdge 返回宽和高中较长的那条边(像素)。
func longEdge(w, h int) int { func longEdge(w, h int) int {
if w > h { if w > h {
return w return w
@@ -76,8 +99,16 @@ func longEdge(w, h int) int {
return h return h
} }
// ResLabel 返回分辨率档位标签(标准后缀 / 1K~19K / 普通 / 原图),无有效尺寸时返回空串。 // ============================ 分辨率档位(给前端筛选/统计用) ============================
// 供媒体库统计与筛选使用,图片/视频统一以"原图"作为超档标签。
// ResLabel 把一个宽高翻译成"分辨率档位标签",例如:
// - 1920×1080 → "1080P"(标准档直接叫标准名)
// - 2560×1440 → "2D5KP"
// - 1024×768 → "普通"
// - 5120×2880 → "5K"(约数)
// - 20000×10000 → "原图"(超出档位上限)
//
// 图片、视频统一用"原图"作为超出档位的标签。宽高无效时返回空串。
func ResLabel(width, height int) string { func ResLabel(width, height int) string {
if width <= 0 || height <= 0 { if width <= 0 || height <= 0 {
return "" return ""
@@ -88,24 +119,32 @@ func ResLabel(width, height int) string {
return levelName(longEdge(width, height), "原图") return levelName(longEdge(width, height), "原图")
} }
// ResRange 解析分辨率档位标签,返回 (长边下限 minLong, 长边上限 maxLong(不含), 精确宽 exactW, 精确高 exactH)。 // ResRange 把"分辨率档位标签"翻译回具体的尺寸范围,给数据库查 SQL 用。
// minLong<0 表示未知标签;maxLong 为 -1 表示无上限;exactW/exactH 非零时表示标准分辨率精确匹配。 // 返回四样东西:
// 供数据层构造 SQL 过滤条件,规则与归类逻辑保持一致。 // - minLong:长边至少多少像素(-1 表示这个标签不认得)
// - maxLong:长边小于多少像素(-1 表示没有上限)
// - exactW / exactH:标准分辨率的精确宽高(非零表示要精确匹配,不用管范围)
//
// 规则和归类逻辑必须保持一致,否则筛选出来的和实际分类会对不上。
func ResRange(label string) (minLong, maxLong, exactW, exactH int) { func ResRange(label string) (minLong, maxLong, exactW, exactH int) {
if label == "" { if label == "" {
return -1, -1, 0, 0 return -1, -1, 0, 0
} }
// 标准档:精确匹配宽高
for _, r := range standardResolutions { for _, r := range standardResolutions {
if r.suffix == label { if r.suffix == label {
return 0, 0, r.width, r.height return 0, 0, r.width, r.height
} }
} }
// 「普通」:长边不到 1 档(<1024px)
if label == "普通" { if label == "普通" {
return 0, bucketStep, 0, 0 return 0, bucketStep, 0, 0
} }
// 「原图」:长边超出最大档(≥20480px),没有上限
if label == "原图" { if label == "原图" {
return (maxLevel + 1) * bucketStep, -1, 0, 0 return (maxLevel + 1) * bucketStep, -1, 0, 0
} }
// 「NK」档(如 5K):长边在第 N 档到第 N+1 档之间
if len(label) > 1 && label[len(label)-1] == 'K' { if len(label) > 1 && label[len(label)-1] == 'K' {
var level int var level int
if _, err := fmt.Sscanf(label, "%dK", &level); err == nil && level >= 1 && level <= maxLevel { if _, err := fmt.Sscanf(label, "%dK", &level); err == nil && level >= 1 && level <= maxLevel {
+18 -9
View File
@@ -2,16 +2,19 @@ package classify
import "fmt" import "fmt"
// standardResolution 标准分辨率规则:宽高精确匹配 // standardResolution 一条"标准分辨率"规则:宽高精确对上就算这个档位。
// 比如 1920×1080 就叫"1080P",而不是按长边算成"1K"。
type standardResolution struct { type standardResolution struct {
width int width int
height int height int
suffix string suffix string // 这个档位叫什么名(也用作文件夹名 / 筛选标签)
} }
// 标准分辨率表:都是一些大家熟悉的清晰度档位。
// (1D5KP、2DKP、2D5KP 这些是介于常见档位之间的中间值,能对上就精确分类,对不上就按约数。)
var standardResolutions = []standardResolution{ var standardResolutions = []standardResolution{
{1280, 720, "720P"}, // 720p 标准高清 {1280, 720, "720P"}, // 720p 标准高清
{1600, 900, "1D5KP"}, // 介于 HD 与 FHD 之间 {1600, 900, "1D5KP"}, // 介于 720P 与 1080P 之间
{1920, 1080, "1080P"}, // 1080p 全高清 {1920, 1080, "1080P"}, // 1080p 全高清
{2048, 1080, "2DKP"}, // 影院 2K {2048, 1080, "2DKP"}, // 影院 2K
{2560, 1440, "2D5KP"}, // 2.5K {2560, 1440, "2D5KP"}, // 2.5K
@@ -19,17 +22,20 @@ var standardResolutions = []standardResolution{
{3440, 1440, "UltraWide3KP"}, // 带鱼屏 {3440, 1440, "UltraWide3KP"}, // 带鱼屏
{5120, 2160, "UltraWide4KP"}, // 带鱼屏 {5120, 2160, "UltraWide4KP"}, // 带鱼屏
{3840, 2160, "4KP"}, // 主流 4K {3840, 2160, "4KP"}, // 主流 4K
{4096, 2160, "4KHP"}, // DCI 4K {4096, 2160, "4KHP"}, // DCI 影院 4K
{7680, 4320, "8KP"}, // 8K {7680, 4320, "8KP"}, // 8K
} }
// 非标分辨率分档参数 // 非标准分辨率的"约数"分档参数:
// - 长边不足 1024px 的,叫「普通」;
// - 每满 1024px 一档,叫 1K、2K … 19K;
// - 超过 19K 的,叫「原图」(视频叫「原画质」)。
const ( const (
bucketStep = 1024 // 每 1024px 一档 bucketStep = 1024 // 每 1024 像素算一档
maxLevel = 19 // 1K ~ 19K 共 19 档(普通另算 1 档,合计 20 档) maxLevel = 19 // 最高算到 19K
) )
// matchStandard 精确匹配标准分辨率,命中返回后缀,否则返回空串 // matchStandard 精确匹配标准分辨率:宽高都对上就返回档位名,否则返回空串。
func matchStandard(width, height int) string { func matchStandard(width, height int) string {
for _, r := range standardResolutions { for _, r := range standardResolutions {
if r.width == width && r.height == height { if r.width == width && r.height == height {
@@ -39,7 +45,10 @@ func matchStandard(width, height int) string {
return "" return ""
} }
// levelName 按长边分档:普通(<1024) / 1K~19K(每1024一档) / overflow(≥20480) // levelName 按长边分档:
// - 不到一档(<1024px):「普通」
// - 1K~19K:按 1024px 一档,几档就叫几 K
// - 超过 19K:用调用方给的兜底名(图片「原图」/ 视频「原画质」)
func levelName(longEdge int, overflow string) string { func levelName(longEdge int, overflow string) string {
if longEdge < bucketStep { if longEdge < bucketStep {
return "普通" return "普通"
+17 -9
View File
@@ -1,33 +1,41 @@
// Package config 提供 Web 服务的基础配置加载(Spring Boot 风格 properties 文件)。 // Package config 负责读程序的配置文件(application.properties)。
//
// 大白话理解:程序启动时先看看配置文件里写了啥,没写就用默认值。
// 配置文件就在程序旁边,叫 application.properties,用记事本就能改。
package config package config
import ( import (
"github.com/magiconair/properties" "github.com/magiconair/properties"
) )
// Config 服务配置 // Config 程序配置:
// - Port:网页服务用哪个端口(默认 8080)
// - Dsn:数据库文件叫什么(默认 media-info.db,放在程序目录下)
// - AutoOpen:启动后要不要自动打开浏览器
type Config struct { type Config struct {
Port int // HTTP 监听端口 Port int
Dsn string // 数据库路径(相对路径相对服务启动目录) Dsn string
AutoOpen bool // 启动后自动打开浏览器 AutoOpen bool
} }
// appConfig 当前生效配置(供 api 层读取) // appConfig 当前生效的配置(程序运行期间一直不变)。
var appConfig Config var appConfig Config
// GetAppConfig 返回当前生效配置 // GetAppConfig 返回当前生效的配置。
func GetAppConfig() Config { func GetAppConfig() Config {
return appConfig return appConfig
} }
// LoadAppConfig 从 application.properties 读取配置;文件缺失时使用默认值 // LoadAppConfig 从 application.properties 读配置;文件不存在就用默认值。
func LoadAppConfig() { func LoadAppConfig() {
// 先给一套默认值
appConfig = Config{Port: 8080, Dsn: "media-info.db", AutoOpen: true} appConfig = Config{Port: 8080, Dsn: "media-info.db", AutoOpen: true}
p, err := properties.LoadFile("application.properties", properties.UTF8) p, err := properties.LoadFile("application.properties", properties.UTF8)
if err != nil { if err != nil {
return return // 没有配置文件,就用默认值
} }
// 配置文件里写了就覆盖默认值
if port := p.GetInt("server.port", 0); port > 0 { if port := p.GetInt("server.port", 0); port > 0 {
appConfig.Port = port appConfig.Port = port
} }
+19 -6
View File
@@ -1,4 +1,12 @@
// Package digest 提供文件 MD5 与指纹采样计算,供媒体入库时复用。 // Package digest 负责给文件算"身份证号"(MD5),以及一种偷懒的指纹算法。
//
// 大白话理解:
// - MD5:把整个文件的内容算出一串固定长度的数字,内容一样 MD5 就一样,
// 所以可以用它来判断"两个文件是不是一模一样"。
// - 指纹:大文件(几 GB 的视频)算全量 MD5 很慢。于是只读文件"开头 256KB
// 和结尾 256KB"各算一个 MD5,加上文件大小,凑成一个"指纹"。
// 指纹一样基本就说明是同一个文件——先用它去数据库碰碰运气,
// 能对上就直接复用已有的 MD5,省掉全量计算。
package digest package digest
import ( import (
@@ -9,13 +17,14 @@ import (
) )
const ( const (
// FingerprintThreshold 文件大于该字节数才使用指纹复用,避免全量MD5 // FingerprintThreshold 文件大于这个字节数(1MB)才值得用指纹偷懒;
// 小文件全量算 MD5 也没多慢,没必要麻烦。
FingerprintThreshold = 1 << 20 FingerprintThreshold = 1 << 20
fingerprintHeadSize = 256 << 10 // 文件头采样256KB fingerprintHeadSize = 256 << 10 // 文件开头采样 256KB
fingerprintTailSize = 256 << 10 // 文件尾采样256KB fingerprintTailSize = 256 << 10 // 文件结尾采样 256KB
) )
// CalcFileMd5 计算文件MD5(分块读取,适合大文件) // CalcFileMd5 计算整个文件的 MD5。分块读取,大文件也能吃下不占太多内存。
func CalcFileMd5(filePath string) (string, error) { func CalcFileMd5(filePath string) (string, error) {
file, err := os.Open(filePath) file, err := os.Open(filePath)
if err != nil { if err != nil {
@@ -29,18 +38,22 @@ func CalcFileMd5(filePath string) (string, error) {
return hex.EncodeToString(h.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil
} }
// CalcFileFingerprint 计算文件头部与尾部采样MD5(只读取很少字节,用于快速识别相同文件) // CalcFileFingerprint 计算文件的"指纹":开头 256KB 的 MD5 + 结尾 256KB 的 MD5。
// 只读很少一点字节,用来快速判断"是不是和之前见过的文件一样"。
// 注意:文件大小必须大于开头+结尾采样的大小(即大于 512KB),否则读不满,会报错。
func CalcFileFingerprint(filePath string, size int64) (headMd5, tailMd5 string, err error) { func CalcFileFingerprint(filePath string, size int64) (headMd5, tailMd5 string, err error) {
file, err := os.Open(filePath) file, err := os.Open(filePath)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
defer file.Close() defer file.Close()
// 开头 256KB
h := md5.New() h := md5.New()
if _, err := io.CopyN(h, file, fingerprintHeadSize); err != nil { if _, err := io.CopyN(h, file, fingerprintHeadSize); err != nil {
return "", "", err return "", "", err
} }
headMd5 = hex.EncodeToString(h.Sum(nil)) headMd5 = hex.EncodeToString(h.Sum(nil))
// 跳到"结尾往前 256KB"的位置,再读 256KB
h = md5.New() h = md5.New()
if _, err := file.Seek(size-fingerprintTailSize, io.SeekStart); err != nil { if _, err := file.Seek(size-fingerprintTailSize, io.SeekStart); err != nil {
return "", "", err return "", "", err
+59 -28
View File
@@ -1,4 +1,11 @@
// Package mover 负责文件的移动,跨文件系统时自动回退为复制后删除。 // Package mover 负责"搬文件"这一步:把一批文件从老地方移到新目录。
//
// 大白话理解:
// - 同一个磁盘里移动 = 改个目录记录而已,瞬间完成(改名操作);
// - 跨磁盘移动 = 系统不让"改名",只能老老实实把内容复制过去再删掉原来的。
// 这个包会自动判断,跨盘时偷偷用"复制+删除"顶上,调用方感觉不到区别。
//
// 搬文件比较慢(尤其跨盘复制),所以用几个工人并行搬,能快不少。
package mover package mover
import ( import (
@@ -14,49 +21,64 @@ import (
uuid "github.com/satori/go.uuid" uuid "github.com/satori/go.uuid"
) )
// Mover 文件移动器 // MoveJob 一次"搬文件"的任务:把 Source 这个文件搬进 TargetDir 目录。
type MoveJob struct {
Source string // 文件现在在哪
TargetDir string // 要搬进哪个目录
}
// Mover 文件移动器。目前不需要记录任何状态,所以是个空壳结构体。
type Mover struct{} type Mover struct{}
// workerCount 并行移动的 worker 数量:不超过 CPU 核数,限制在 1~4 之间。 // workerCount 并行搬文件的工人数:直接用满 CPU 核心数。
// 移动/复制是磁盘 I/O 密集型操作,过高的并行度反而会降低吞吐(尤其机械硬盘)。 // 你的机器够强、平时又没别的活,那就让它全速跑,这就是空闲时的极限值。
// (如果搬文件的同时机器还在干别的活、被拖慢了,再手动把这个数调低即可。)
func workerCount() int { func workerCount() int {
n := runtime.NumCPU() n := runtime.NumCPU()
if n < 1 { if n < 1 {
return 1 return 1
} }
if n > 4 {
return 4
}
return n return n
} }
// MoveToDir 将一批文件并行移动到目标目录(目标文件名使用 uuid 前缀防止同名覆盖)。 // MoveAll 把一批"搬文件任务"全局并行搬完——不管任务分散在几个目录,
// 返回移动成功的 源路径 → 完整目标文件路径 映射,供记录去向使用。 // 所有文件同时开搬(最多 8 个工人),这才是真正的并行。
// onProgress 在每移动一个文件后调用(done 为累计成功移动数,并发安全、单调递增)。 //
func (m *Mover) MoveToDir(filePaths []string, targetDir string, onProgress func(done int)) map[string]string { // 目标文件名会加一长串随机前缀(uuid),这样即使两个文件同名也不会互相覆盖。
// 返回"移动成功的 源路径 → 新位置完整路径"的对照表,方便调用方记录每个文件去哪了。
// onProgress 每搬完一个文件调一次,参数是"已累计搬完多少个"(只会涨不会退,并发安全)。
func (m *Mover) MoveAll(jobs []MoveJob, onProgress func(done int)) map[string]string {
moved := make(map[string]string) moved := make(map[string]string)
if len(filePaths) == 0 { if len(jobs) == 0 {
return moved return moved
} }
if err := os.MkdirAll(targetDir, 0755); err != nil { // 先把所有目标目录建好(去重,每个目录只需建一次;MkdirAll 建已存在的目录也没关系)
fmt.Printf("=== 创建目录失败: %s, 错误: %v\n", targetDir, err) created := make(map[string]bool)
return moved for _, j := range jobs {
if created[j.TargetDir] {
continue
}
created[j.TargetDir] = true
if err := os.MkdirAll(j.TargetDir, 0755); err != nil {
fmt.Printf("=== 创建目录失败: %s, 错误: %v\n", j.TargetDir, err)
}
} }
results := make([]string, len(filePaths)) // 全局并行搬:results[i] 存 jobs[i] 搬完之后的新路径(失败为空串)
results := make([]string, len(jobs))
workers := workerCount() workers := workerCount()
if workers > len(filePaths) { if workers > len(jobs) {
workers = len(filePaths) workers = len(jobs)
} }
var wg sync.WaitGroup var wg sync.WaitGroup
var doneCount int32 var doneCount int32
jobs := make(chan int) ch := make(chan int)
for w := 0; w < workers; w++ { for w := 0; w < workers; w++ {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for i := range jobs { for i := range ch {
if target, ok := moveOne(filePaths[i], targetDir); ok { if target, ok := moveOne(jobs[i].Source, jobs[i].TargetDir); ok {
results[i] = target results[i] = target
} }
if onProgress != nil { if onProgress != nil {
@@ -65,28 +87,34 @@ func (m *Mover) MoveToDir(filePaths []string, targetDir string, onProgress func(
} }
}() }()
} }
for i := range filePaths { for i := range jobs {
jobs <- i ch <- i
} }
close(jobs) close(ch)
wg.Wait() wg.Wait()
// 把搬成功的整理成 源路径 → 新路径 的对照表
for i, target := range results { for i, target := range results {
if target != "" { if target != "" {
moved[filePaths[i]] = target moved[jobs[i].Source] = target
} }
} }
return moved return moved
} }
// moveOne 移动单个文件;跨文件系统时 fallback 为 copy+delete。 // moveOne 移动单个文件;跨磁盘搬不动时,自动降级为"复制 + 删除源文件"。
// 成功时返回完整目标文件路径;部分成功(复制成功删除源失败)同样返回目标路径。 // 成功返回新位置的完整路径;连复制都失败返回 false。
// 注意一种特殊情况:复制成功了但删源文件失败(比如源文件被占用),
// 这时源和目标会同时存在,但依然算"移动成功",返回新路径。
func moveOne(src, targetDir string) (string, bool) { func moveOne(src, targetDir string) (string, bool) {
// 新文件名 = 随机前缀 + 原文件名,防止重名覆盖
fileName := strings.ReplaceAll(uuid.NewV4().String(), "-", "") + "_" + filepath.Base(src) fileName := strings.ReplaceAll(uuid.NewV4().String(), "-", "") + "_" + filepath.Base(src)
target := filepath.Join(targetDir, fileName) target := filepath.Join(targetDir, fileName)
// 先在同一个磁盘里直接改名(秒完成)
if err := os.Rename(src, target); err == nil { if err := os.Rename(src, target); err == nil {
return target, true return target, true
} }
// 改名失败(大概率是跨磁盘)→ 复制 + 删除
if err := copyFile(src, target); err != nil { if err := copyFile(src, target); err != nil {
fmt.Printf("=== 复制文件失败, 源: %s, 目标: %s, 错误: %v\n", src, target, err) fmt.Printf("=== 复制文件失败, 源: %s, 目标: %s, 错误: %v\n", src, target, err)
return "", false return "", false
@@ -97,7 +125,8 @@ func moveOne(src, targetDir string) (string, bool) {
return target, true return target, true
} }
// copyFile 逐块复制文件;失败时清理已写入的目标文件,避免残留半截文件 // copyFile 把一个文件的内容完整复制到另一个位置。
// 复制到一半失败时,会把已经写出来的半截文件删掉,不给磁盘留垃圾。
func copyFile(src, dst string) error { func copyFile(src, dst string) error {
in, err := os.Open(src) in, err := os.Open(src)
if err != nil { if err != nil {
@@ -109,11 +138,13 @@ func copyFile(src, dst string) error {
if err != nil { if err != nil {
return err return err
} }
// 复制途中出错:关掉并删掉半截文件
if _, err := io.Copy(out, in); err != nil { if _, err := io.Copy(out, in); err != nil {
out.Close() out.Close()
_ = os.Remove(dst) _ = os.Remove(dst)
return err return err
} }
// 写完收尾出错:同样删掉
if err := out.Close(); err != nil { if err := out.Close(); err != nil {
_ = os.Remove(dst) _ = os.Remove(dst)
return err return err
+163 -135
View File
@@ -1,4 +1,13 @@
// Package organize 编排整理流程:探测 → 归类 → 收集 bucket → 一次性移动并记录去向。 // Package organize 是"整理"这一步的总指挥,负责把一批文件分门别类、搬到该去的地方。
//
// 整理一辆快递车的过程可以这样理解:
// 1. 探测(并行):每个文件先做"体检"——是图片还是视频?多大?什么分辨率?会不会是重复的?
// 2. 归类(串行):根据体检结果,决定这个文件该进哪个分类目录,或者直接判为重复/异常/忽略
// 3. 移动(并行):把每个分类目录攒齐的文件,批量搬进对应的文件夹,并把"搬去哪了"记进数据库
//
// 为什么探测要并行、归类要串行?
// - 探测很慢(读文件、算 MD5、调 ffprobe),多个一起干能明显提速;
// - 归类要看"前面有没有见过同样的文件"(去重判断),必须按顺序一个个来,否则会乱。
package organize package organize
import ( import (
@@ -18,32 +27,44 @@ import (
"kenaito-media-picker/internal/store" "kenaito-media-picker/internal/store"
) )
// 特殊归类目录名 // ============================ 阶段与目录命名 ============================
// 整理内部的两个阶段名,上报进度时用,供上层(service)区分显示。
const ( const (
dirImagePSD = "图片_PSD" PhaseImages = "images" // 正在处理图片
dirImageErr = "图片_读取异常" PhaseMedia = "media" // 正在处理视频/音频
dirImageIgnored = "图片_已忽略" PhaseMove = "move" // 正在移动文件
dirVideoErr = "视频_读取异常"
dirVideoIgnored = "视频_已忽略"
dirAudio = "音频"
dirDuplicate = "重复文件"
) )
// Organizer 整理器:按目标目录收集文件,处理完成后统一建目录并移动 // 特殊归类目录的名字(都是大白话,用户一眼能看懂)。
const (
dirImagePSD = "图片_PSD" // 没做任何处理,原样收走的 PSD 设计稿
dirImageErr = "图片_读取异常" // 打不开、读不出信息的图片
dirImageIgnored = "图片_已忽略" // 不支持的图片格式,原样收走但不做处理
dirVideoErr = "视频_读取异常" // 打不开、读不出信息的视频
dirVideoIgnored = "视频_已忽略" // 不支持的视频格式,原样收走
dirAudio = "音频" // 音频统一放这一个文件夹
)
// ============================ 整理器主体 ============================
// Organizer 整理器。它的工作方式:
// - 先把文件按"目标目录"分堆攒起来(buckets),
// - 攒完后统一创建目录、批量搬文件。
//
// 这样设计是为了避免"边分类边搬"导致目录反复开关、文件搬来搬去,效率更高。
type Organizer struct { type Organizer struct {
pickerDir string pickerDir string // 整理后的文件都放到这个根目录下
ffprobePath string ffprobePath string // ffprobe 程序路径;为空表示没有可用解码器
store *store.Store store *store.Store // 数据库;为 nil 表示不记录媒体信息
mover *mover.Mover mover *mover.Mover // 负责实际搬文件的家伙
buckets map[string][]string // 目标目录 → 文件路径 buckets map[string][]string // 目标目录 → 里面该放哪些文件(只记源路径)
order []string // 目标目录的创建顺序(保证输出稳定) order []string // 目标目录的先后顺序(保证输出稳定、不来回跳)
seenMd5 map[string]bool // 本次扫描已入库的 MD5(同一扫描内去重) onProgress func(phase string, done, total int)
onProgress func(done, total int) pending []*store.MediaInfo // 待写入数据库的媒体记录(攒一批再批量写)
pending []*store.MediaInfo // 待批量入库的媒体记录
pendingDup []*store.MediaInfo // 待批量入库的重复文件记录
} }
// New 创建整理器;store 可为 nil(数据库初始化失败时仅不记录信息) // New 创建整理器。store 可以为 nil——数据库打不开时,整理照常进行,只是不记入库信息。
func New(pickerDir, ffprobePath string, st *store.Store) *Organizer { func New(pickerDir, ffprobePath string, st *store.Store) *Organizer {
return &Organizer{ return &Organizer{
pickerDir: pickerDir, pickerDir: pickerDir,
@@ -51,37 +72,40 @@ func New(pickerDir, ffprobePath string, st *store.Store) *Organizer {
store: st, store: st,
mover: &mover.Mover{}, mover: &mover.Mover{},
buckets: make(map[string][]string), buckets: make(map[string][]string),
seenMd5: make(map[string]bool),
} }
} }
// SetProgress 设置进度回调(done/total 为已处理文件数与总数) // SetProgress 设置进度回调。回调参数:
func (o *Organizer) SetProgress(fn func(done, total int)) { // - phase:当前在干哪一步(PhaseImages / PhaseMedia / PhaseMove)
// - done / total:这一步已完成数和总数
func (o *Organizer) SetProgress(fn func(phase string, done, total int)) {
o.onProgress = fn o.onProgress = fn
} }
// report 上报进度 // report 上报进度。total 为 0 表示这一步没活干,就不用报了。
func (o *Organizer) report(done, total int) { func (o *Organizer) report(phase string, done, total int) {
if o.onProgress != nil && total > 0 { if o.onProgress != nil && total > 0 {
o.onProgress(done, total) o.onProgress(phase, done, total)
} }
} }
// workerCount 并行探测的 worker 数量:不超过 CPU 核数,且限制在 1~8 之间 // ============================ 并行小工具 ============================
// workerCount 并行探测的工人数:直接用满 CPU 核心数(空闲时的极限值)。
// 多核机器并行探测能明显提速;如果机器同时还在跑别的重活,再手动调低即可。
func workerCount() int { func workerCount() int {
n := runtime.NumCPU() n := runtime.NumCPU()
if n < 1 { if n < 1 {
return 1 return 1
} }
if n > 8 {
return 8
}
return n return n
} }
// runParallel 用固定 worker 数并行处理切片,按原顺序收集结果。 // runParallel 用固定数量的工人并行处理一批东西,结果按原来的顺序放回。
// onProgress 在每次完成一个任务后调用(done 为已完成任务数,并发安全)。 //
// 返回 results[i] 对应 items[i] 的处理结果。 // 通俗理解:一条流水线有 N 个工人,每个工人从传送带上拿一个活干完,
// 干完把结果按编号放回架子上(results[i] 对应 items[i] 的结果)。
// onProgress 每干完一个活就报一次数(已完成几个),线程安全、只会涨不会退。
func runParallel[T any, R any](items []T, fn func(T) R, onProgress func(done int)) []R { func runParallel[T any, R any](items []T, fn func(T) R, onProgress func(done int)) []R {
results := make([]R, len(items)) results := make([]R, len(items))
if len(items) == 0 { if len(items) == 0 {
@@ -114,7 +138,9 @@ func runParallel[T any, R any](items []T, fn func(T) R, onProgress func(done int
return results return results
} }
// add 将文件归入某个子目录 // ============================ 分类与入库小工具 ============================
// add 把一个文件记到某个目标目录的名下(先攒着,最后统一搬)。
func (o *Organizer) add(f scanner.File, subDir string) { func (o *Organizer) add(f scanner.File, subDir string) {
dir := filepath.Join(o.pickerDir, subDir) dir := filepath.Join(o.pickerDir, subDir)
if _, ok := o.buckets[dir]; !ok { if _, ok := o.buckets[dir]; !ok {
@@ -123,8 +149,11 @@ func (o *Organizer) add(f scanner.File, subDir string) {
o.buckets[dir] = append(o.buckets[dir], f.Path) o.buckets[dir] = append(o.buckets[dir], f.Path)
} }
// calcFileInfo 读取文件大小并计算 MD5(大文件优先用指纹复用已入库的 MD5,跳过全量读取)。 // calcFileInfo 读取文件大小并算 MD5(文件的"身份证号")。
// store 为 nil 时直接返回空值(无需去重/入库)。 //
// 大文件算全量 MD5 很费时间,所以先用"指纹"(文件大小 + 头尾各 256KB 的 MD5)
// 去数据库里找找看有没有同款——有的话直接借它的 MD5 用,省得再全量算一遍。
// store 为 nil 时直接返回空,因为没有数据库可查,也不需要去重/入库。
func (o *Organizer) calcFileInfo(f scanner.File) (md5Str, headMd5, tailMd5 string, size int64) { func (o *Organizer) calcFileInfo(f scanner.File) (md5Str, headMd5, tailMd5 string, size int64) {
if o.store == nil { if o.store == nil {
return "", "", "", 0 return "", "", "", 0
@@ -140,7 +169,7 @@ func (o *Organizer) calcFileInfo(f scanner.File) (md5Str, headMd5, tailMd5 strin
if head, tail, ferr := digest.CalcFileFingerprint(f.Path, size); ferr == nil { if head, tail, ferr := digest.CalcFileFingerprint(f.Path, size); ferr == nil {
headMd5, tailMd5 = head, tail headMd5, tailMd5 = head, tail
if existing, ok := o.store.FindMediaByFingerprint(size, head, tail); ok && existing.FileMd5 != "" { if existing, ok := o.store.FindMediaByFingerprint(size, head, tail); ok && existing.FileMd5 != "" {
md5Str = existing.FileMd5 // 指纹命中,复用MD5,跳过全量读取 md5Str = existing.FileMd5 // 指纹命中,复用已入库的 MD5,跳过全量计算
} }
} }
} }
@@ -153,17 +182,17 @@ func (o *Organizer) calcFileInfo(f scanner.File) (md5Str, headMd5, tailMd5 strin
return md5Str, headMd5, tailMd5, size return md5Str, headMd5, tailMd5, size
} }
// isDuplicate 判断文件是否重复: // isDuplicate 判断一个文件是不是"同一个文件又出现了一次"。
// 1. 本次扫描已入库的 MD5 → 重复(同一扫描内去重) //
// 2. 库中已有记录且其副本在磁盘上仍存在(DestPath)→ 重复 // 判据:数据库里已经有一条记录,**MD5 相同、源路径也相同**。
// 若库中副本已被移动/改名/删除,说明是同一文件被重新扫描,不算重复。 // 只有这种情况才认为是"同一个文件被重复扫到",直接忽略:不动它、不记录、不搬走。
func (o *Organizer) isDuplicate(md5Str string) bool { //
// 特别注意:MD5 一样但**路径不一样**,那是另一个文件(碰巧内容一样),
// 不算重复,照样要搬进媒体库。
func (o *Organizer) isDuplicate(md5Str, path string) bool {
if md5Str == "" { if md5Str == "" {
return false return false
} }
if o.seenMd5[md5Str] {
return true
}
if o.store == nil { if o.store == nil {
return false return false
} }
@@ -171,22 +200,10 @@ func (o *Organizer) isDuplicate(md5Str string) bool {
if !ok { if !ok {
return false return false
} }
if existing.DestPath != "" { return existing.FilePath == path
if _, err := os.Stat(existing.DestPath); err == nil {
return true
}
}
return false
} }
// markSeen 记录本次扫描已入库的 MD5(去重用) // record 把一份媒体信息记到"待入库"缓冲里(先攒着,最后统一批量写库)。
func (o *Organizer) markSeen(md5Str string) {
if md5Str != "" {
o.seenMd5[md5Str] = true
}
}
// record 收集媒体信息到待入库缓冲(不立即写库,ProcessImages/ProcessMedia 结束统一批量提交)
func (o *Organizer) record(f scanner.File, mediaType string, res *probe.ProbeResult, md5Str, headMd5, tailMd5 string, size int64) { func (o *Organizer) record(f scanner.File, mediaType string, res *probe.ProbeResult, md5Str, headMd5, tailMd5 string, size int64) {
if o.store == nil || md5Str == "" { if o.store == nil || md5Str == "" {
return return
@@ -194,20 +211,14 @@ func (o *Organizer) record(f scanner.File, mediaType string, res *probe.ProbeRes
o.pending = append(o.pending, buildMediaInfo(f, mediaType, res, md5Str, headMd5, tailMd5, size)) o.pending = append(o.pending, buildMediaInfo(f, mediaType, res, md5Str, headMd5, tailMd5, size))
} }
// recordDuplicate 收集重复文件信息到待入库缓冲 // buildMediaInfo 把探测结果整理成一条要入库的记录。
func (o *Organizer) recordDuplicate(f scanner.File, mediaType string, res *probe.ProbeResult, md5Str, headMd5, tailMd5 string, size int64) {
if o.store == nil || md5Str == "" {
return
}
o.pendingDup = append(o.pendingDup, buildMediaInfo(f, mediaType, res, md5Str, headMd5, tailMd5, size))
}
// buildMediaInfo 构造入库记录
func buildMediaInfo(f scanner.File, mediaType string, res *probe.ProbeResult, md5Str, headMd5, tailMd5 string, size int64) *store.MediaInfo { func buildMediaInfo(f scanner.File, mediaType string, res *probe.ProbeResult, md5Str, headMd5, tailMd5 string, size int64) *store.MediaInfo {
// 视频记视频编码,音频记音频编码
codec := res.VideoCodec codec := res.VideoCodec
if mediaType == "audio" { if mediaType == "audio" {
codec = res.AudioCodec codec = res.AudioCodec
} }
// 格式名:ffprobe 没读出来就用扩展名顶替(去掉前面的点)
format := res.Format format := res.Format
if format == "" && f.Ext != "" { if format == "" && f.Ext != "" {
format = strings.TrimPrefix(f.Ext, ".") format = strings.TrimPrefix(f.Ext, ".")
@@ -231,7 +242,7 @@ func buildMediaInfo(f scanner.File, mediaType string, res *probe.ProbeResult, md
} }
} }
// flushRecords 将缓冲的媒体/重复记录批量提交入库 // flushRecords 把缓冲的媒体记录批量写进数据库(一次事务,快得多)。
func (o *Organizer) flushRecords() { func (o *Organizer) flushRecords() {
if o.store == nil { if o.store == nil {
return return
@@ -242,47 +253,51 @@ func (o *Organizer) flushRecords() {
} }
o.pending = nil o.pending = nil
} }
if len(o.pendingDup) > 0 {
if err := o.store.InsertDuplicateBatch(o.pendingDup); err != nil {
fmt.Printf("=== 批量保存重复文件信息失败: %v\n", err)
}
o.pendingDup = nil
}
} }
// imageProbeResult 图片探测结果(并行阶段产出,串行阶段归类) // ============================ 图片整理 ============================
// imageProbeResult 是"给一张图片做完体检"的结论,体检在并行阶段完成,
// 后面的归类(判断重复、分目录)在串行阶段根据这个结论来定。
type imageProbeResult struct { type imageProbeResult struct {
psd bool // PSD 特殊归类 // 下面几个是"体检结论",最多只有一个为 true:
unsupported bool // 非白名单且无 ffprobe 兜底,忽略 psd bool // 是 PSD 设计稿,不做处理原样收走
probeErr bool // 探测失败,归入异常 unsupported bool // 不支持的格式,归入"已忽略"
res *probe.ProbeResult probeErr bool // 体检失败(打不开/读不出),归入"读取异常"
md5Str string res *probe.ProbeResult // 体检出的尺寸等信息(体检成功时才有)
headMd5 string md5Str string // 文件 MD5
tailMd5 string headMd5 string // 文件头采样 MD5(大文件指纹)
size int64 tailMd5 string // 文件尾采样 MD5
darkScore float64 size int64 // 文件大小
lightScore float64 darkScore float64 // 暗色像素占比(用于亮度分类)
lightScore float64 // 亮色像素占比
} }
// probeImage 并行探测一张图片:探测尺寸 + MD5 + 明暗分析(不含重复判断/归类/入库)。 // probeImage 给一张图片做体检(并行阶段执行):探测尺寸 + 算 MD5 + 分析明暗。
// 注意:不访问 o.seenMd5 等共享可变状态,仅读 store(指纹复用),并行安全。 // 注意:这里不能碰 o.buckets 之类的共享可变状态,因为多个工人同时跑;
// 只读数据库(查指纹),这是安全的。
func (o *Organizer) probeImage(f scanner.File) imageProbeResult { func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
r := imageProbeResult{} r := imageProbeResult{}
// PSD 不体检,直接标记"原样收走"
if strings.EqualFold(f.Ext, ".psd") { if strings.EqualFold(f.Ext, ".psd") {
r.psd = true r.psd = true
return r return r
} }
// 格式不认识,又没有 ffprobe 兜底 → 标记"忽略"
if !probe.IsSupportImage(f.Ext) && o.ffprobePath == "" { if !probe.IsSupportImage(f.Ext) && o.ffprobePath == "" {
r.unsupported = true r.unsupported = true
return r return r
} }
// 探测尺寸
res, err := probe.ProbeImage(f.Path, o.ffprobePath) res, err := probe.ProbeImage(f.Path, o.ffprobePath)
if err != nil { if err != nil {
r.probeErr = true r.probeErr = true
return r return r
} }
r.res = res r.res = res
// 算 MD5(顺便拿指纹,可能省掉全量计算)
r.md5Str, r.headMd5, r.tailMd5, r.size = o.calcFileInfo(f) r.md5Str, r.headMd5, r.tailMd5, r.size = o.calcFileInfo(f)
// 分析明暗(GIF 不做,动画图逐帧分析没意义)
if !strings.EqualFold(f.Ext, ".gif") { if !strings.EqualFold(f.Ext, ".gif") {
dark, light, berr := probe.AnalyzeBrightness(f.Path) dark, light, berr := probe.AnalyzeBrightness(f.Path)
if berr != nil { if berr != nil {
@@ -294,34 +309,37 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
return r return r
} }
// ProcessImages 处理全部图片:并行探测(尺寸/MD5/明暗)→ 串行去重归类 // ProcessImages 处理全部图片:先并行体检,再按顺序一个个归类。
func (o *Organizer) ProcessImages(images []scanner.File) { func (o *Organizer) ProcessImages(images []scanner.File) {
// 统计结果,最后打一行总结给用户看
success, fail, ignore, dup := 0, 0, 0, 0 success, fail, ignore, dup := 0, 0, 0, 0
total := len(images) total := len(images)
results := runParallel(images, o.probeImage, func(done int) { results := runParallel(images, o.probeImage, func(done int) {
o.report(done, total) o.report(PhaseImages, done, total)
}) })
for i, r := range results { for i, r := range results {
f := images[i] f := images[i]
switch { switch {
case r.psd: case r.psd:
// PSD 设计稿:不做处理,原样收进「图片_PSD」
o.add(f, dirImagePSD) o.add(f, dirImagePSD)
success++ success++
case r.unsupported: case r.unsupported:
// 不支持的格式:原样收进「图片_已忽略」
o.add(f, dirImageIgnored) o.add(f, dirImageIgnored)
ignore++ ignore++
case r.probeErr: case r.probeErr:
// 体检失败:收进「图片_读取异常」
o.add(f, dirImageErr) o.add(f, dirImageErr)
fail++ fail++
fmt.Printf("=== 异常图片: %s \n", f.Path) fmt.Printf("=== 异常图片: %s \n", f.Path)
case o.isDuplicate(r.md5Str): case o.isDuplicate(r.md5Str, f.Path):
o.recordDuplicate(f, "image", r.res, r.md5Str, r.headMd5, r.tailMd5, r.size) // 同一个文件又出现一次:直接忽略,不动它、不记录
o.add(f, dirDuplicate)
dup++ dup++
fmt.Printf("=== 重复图片: %s \n", f.Path) fmt.Printf("=== 重复图片(同路径), 忽略: %s \n", f.Path)
default: default:
// 正常图片:按分辨率/方向/明暗归入对应的分类目录
o.record(f, "image", r.res, r.md5Str, r.headMd5, r.tailMd5, r.size) o.record(f, "image", r.res, r.md5Str, r.headMd5, r.tailMd5, r.size)
o.markSeen(r.md5Str)
o.add(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore)) o.add(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore))
success++ success++
} }
@@ -329,22 +347,26 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
fmt.Printf("=== 图片处理完毕,成功: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", success, fail, ignore, dup) fmt.Printf("=== 图片处理完毕,成功: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", success, fail, ignore, dup)
} }
// mediaProbeResult 媒体探测结果(并行阶段产出,串行阶段归类) // ============================ 视频/音频整理 ============================
// mediaProbeResult 是"给一个视频/音频做完体检"的结论(并行阶段产出)。
type mediaProbeResult struct { type mediaProbeResult struct {
res *probe.ProbeResult res *probe.ProbeResult // 体检结果(ffprobe 读出的信息)
err error err error // 体检失败的原因
md5 string md5 string // 文件 MD5
head string head string // 文件头采样 MD5
tail string tail string // 文件尾采样 MD5
size int64 size int64 // 文件大小
} }
// probeMedia 并行探测一个媒体文件(ffprobe + MD5)。不访问共享可变状态。 // probeMedia 给一个媒体文件做体检(并行阶段执行):调 ffprobe 读信息 + 算 MD5。
// 不访问任何共享可变状态,多个工人可以同时跑。
func (o *Organizer) probeMedia(f scanner.File) mediaProbeResult { func (o *Organizer) probeMedia(f scanner.File) mediaProbeResult {
res, err := probe.ProbeMediaFile(o.ffprobePath, f.Path) res, err := probe.ProbeMediaFile(o.ffprobePath, f.Path)
if err != nil { if err != nil {
return mediaProbeResult{err: err} return mediaProbeResult{err: err}
} }
// 既没有视频流也没有音频流:说明根本不是媒体,不用算 MD5 了
if !res.HasVideo && !res.HasAudio { if !res.HasVideo && !res.HasAudio {
return mediaProbeResult{res: res} return mediaProbeResult{res: res}
} }
@@ -352,18 +374,23 @@ func (o *Organizer) probeMedia(f scanner.File) mediaProbeResult {
return mediaProbeResult{res: res, md5: md5, head: head, tail: tail, size: size} return mediaProbeResult{res: res, md5: md5, head: head, tail: tail, size: size}
} }
// ProcessMedia 处理媒体候选:并行 ffprobe 判定 → 串行去重归类 // ProcessMedia 处理全部媒体候选:先并行体检,再按顺序一个个归类。
//
// 特别说明:文件虽然进了"媒体候选"名单,但很多其实是"不知道是什么"的文件
// (比如扩展名被改过、或者 MIME 没认出来)。ffprobe 一探便知真假——
// 探出有视频/音频流的就是真媒体,探不出来的就看它的 MIME 是不是明确标注了媒体类型。
func (o *Organizer) ProcessMedia(media []scanner.File) { func (o *Organizer) ProcessMedia(media []scanner.File) {
video, audio, fail, ignore, dup := 0, 0, 0, 0, 0 video, audio, fail, ignore, dup := 0, 0, 0, 0, 0
total := len(media) total := len(media)
results := runParallel(media, o.probeMedia, func(done int) { results := runParallel(media, o.probeMedia, func(done int) {
o.report(done, total) o.report(PhaseMedia, done, total)
}) })
for i, r := range results { for i, r := range results {
f := media[i] f := media[i]
// ---------- 体检失败 ----------
if r.err != nil { if r.err != nil {
// 仅 MIME 明确为视频/音频的文件才移入异常/忽略目录; // 只有 MIME 明确说是视频/音频的才收进"异常/忽略"目录;
// octet-stream/空 MIME 且 ffprobe 无法确认的原地忽略,不移动 // 完全没头绪的文件(MIME 为空或 octet-stream)原地不管,不搬走
if strings.HasPrefix(f.Mime, "video/") { if strings.HasPrefix(f.Mime, "video/") {
fail++ fail++
o.add(f, dirVideoErr) o.add(f, dirVideoErr)
@@ -377,33 +404,33 @@ func (o *Organizer) ProcessMedia(media []scanner.File) {
} }
continue continue
} }
// ---------- 体检成功:看它到底是什么 ----------
switch { switch {
case r.res.HasVideo: case r.res.HasVideo:
if o.isDuplicate(r.md5) { // 有视频流 → 视频
o.recordDuplicate(f, "video", r.res, r.md5, r.head, r.tail, r.size) if o.isDuplicate(r.md5, f.Path) {
// 同一个文件又出现一次:直接忽略,不动它、不记录
dup++ dup++
o.add(f, dirDuplicate) fmt.Printf("=== 重复媒体(同路径), 忽略: %s \n", f.Path)
fmt.Printf("=== 重复媒体: %s \n", f.Path)
continue continue
} }
video++ video++
o.record(f, "video", r.res, r.md5, r.head, r.tail, r.size) o.record(f, "video", r.res, r.md5, r.head, r.tail, r.size)
o.markSeen(r.md5)
o.add(f, classify.VideoDir(f.Ext, r.res.Width, r.res.Height)) o.add(f, classify.VideoDir(f.Ext, r.res.Width, r.res.Height))
case r.res.HasAudio: case r.res.HasAudio:
if o.isDuplicate(r.md5) { // 有音频流 → 音频
o.recordDuplicate(f, "audio", r.res, r.md5, r.head, r.tail, r.size) if o.isDuplicate(r.md5, f.Path) {
// 同一个文件又出现一次:直接忽略,不动它、不记录
dup++ dup++
o.add(f, dirDuplicate) fmt.Printf("=== 重复媒体(同路径), 忽略: %s \n", f.Path)
fmt.Printf("=== 重复媒体: %s \n", f.Path)
continue continue
} }
audio++ audio++
o.record(f, "audio", r.res, r.md5, r.head, r.tail, r.size) o.record(f, "audio", r.res, r.md5, r.head, r.tail, r.size)
o.markSeen(r.md5)
o.add(f, dirAudio) o.add(f, dirAudio)
default: default:
// ffprobe 无音视频流:MIME 明确为媒体的归入忽略目录,其余原地不动 // 没有视频流也没有音频流 → 假媒体。
// MIME 明确标注是媒体的,收进「已忽略」;否则原地不动。
if scanner.IsExplicitMediaMime(f.Mime) { if scanner.IsExplicitMediaMime(f.Mime) {
ignore++ ignore++
o.add(f, dirVideoIgnored) o.add(f, dirVideoIgnored)
@@ -416,25 +443,26 @@ func (o *Organizer) ProcessMedia(media []scanner.File) {
fmt.Printf("=== 媒体处理完毕,视频: %d, 音频: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", video, audio, fail, ignore, dup) fmt.Printf("=== 媒体处理完毕,视频: %d, 音频: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", video, audio, fail, ignore, dup)
} }
// Flush 先将缓冲的媒体记录批量入库,再将收集到的文件移动到对应目录并回填归类去向。 // ============================ 移动(最后一步) ============================
// 移动期间按累计完成数上报进度。
// Flush 收尾两步:
// 1. 把之前攒的媒体记录一次性写进数据库;
// 2. 把每个分类目录攒的文件**全局并行**搬过去(所有目录的文件一起搬)。
// 不需要记录每个文件搬去哪了——文件就在分类目录里,一眼能看到。
func (o *Organizer) Flush() { func (o *Organizer) Flush() {
o.flushRecords() o.flushRecords()
total := 0
// 把 buckets 里所有文件拼成一张"搬运清单"
jobs := make([]mover.MoveJob, 0)
for _, dir := range o.order { for _, dir := range o.order {
total += len(o.buckets[dir]) for _, src := range o.buckets[dir] {
} jobs = append(jobs, mover.MoveJob{Source: src, TargetDir: dir})
movedAll := 0
for _, dir := range o.order {
onProgress := func(done int) {
o.report(movedAll+done, total)
}
moved := o.mover.MoveToDir(o.buckets[dir], dir, onProgress)
movedAll += len(o.buckets[dir])
for src, dest := range moved {
if err := o.store.UpdateMediaDest(src, dir, dest); err != nil {
fmt.Printf("=== 更新媒体去向失败: %s, 错误: %v\n", src, err)
}
} }
} }
// 全局并行搬完:不管文件分散在几个目录,所有文件同时开搬。
// onProgress 收到的是全局累计已搬数,正好和总数对得上。
o.mover.MoveAll(jobs, func(done int) {
o.report(PhaseMove, done, len(jobs))
})
} }
+27 -14
View File
@@ -14,18 +14,23 @@ import (
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
) )
// 明暗分析相关的三个参数:
const ( const (
// brightnessDecodeLimit 明暗分析允许解码的最大像素数,超出时跳过分析(按 mid 归类),避免超大图 OOM // brightnessDecodeLimit 允许分析的最大像素数。超过就跳过分析(按中性归类),
// 防止超大图把内存吃爆。
brightnessDecodeLimit = 40_000_000 brightnessDecodeLimit = 40_000_000
// thumbnailMaxEdge 明暗分析时大图缩放后的最长边像素,缩小到该尺寸内再统计 // thumbnailMaxEdge 大图缩放后的最长边像素。先缩小再统计,省内存省时间。
thumbnailMaxEdge = 256 thumbnailMaxEdge = 256
// thumbnailScaleThreshold 像素数超过该值才缩放为缩略图再统计 // thumbnailScaleThreshold 像素数超过这个值才缩放成缩略图再统计。
thumbnailScaleThreshold = 250_000 thumbnailScaleThreshold = 250_000
) )
// AnalyzeBrightness 打开并解码图片,分析明暗程度。 // AnalyzeBrightness 打开一张图片,分析它偏暗还是偏亮。
// darkScore: 暗色像素(亮度 < 80)占比;lightScore: 亮色像素(亮度 > 175)占比。 // - darkScore:暗色像素(亮度 < 80)占多少比例
// 超大图(超过 brightnessDecodeLimit 像素)直接跳过分析返回 mid;大图先缩放为缩略图再统计,降低内存与耗时。 // - lightScore:亮色像素(亮度 > 175)占多少比例
//
// 超大图直接跳过(返回两个 0,调用方会按"中性"归类);
// 普通大图先缩小成缩略图再统计,既快又不失真(整体明暗分布基本保留)。
func AnalyzeBrightness(filePath string) (darkScore float64, lightScore float64, err error) { func AnalyzeBrightness(filePath string) (darkScore float64, lightScore float64, err error) {
f, err := os.Open(filePath) f, err := os.Open(filePath)
if err != nil { if err != nil {
@@ -33,13 +38,15 @@ func AnalyzeBrightness(filePath string) (darkScore float64, lightScore float64,
} }
defer f.Close() defer f.Close()
// 先只读配置看看多大,太大就不分析了
cfg, _, err := image.DecodeConfig(f) cfg, _, err := image.DecodeConfig(f)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
} }
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > brightnessDecodeLimit { if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > brightnessDecodeLimit {
return 0, 0, nil // 超大图跳过,由调用方按 mid 归类 return 0, 0, nil // 超大图,跳过
} }
// 回到文件开头,重新完整解码
if _, err := f.Seek(0, 0); err != nil { if _, err := f.Seek(0, 0); err != nil {
return 0, 0, err return 0, 0, err
} }
@@ -48,6 +55,7 @@ func AnalyzeBrightness(filePath string) (darkScore float64, lightScore float64,
return 0, 0, err return 0, 0, err
} }
// 大图先缩小成缩略图
if img.Bounds().Dx()*img.Bounds().Dy() > thumbnailScaleThreshold { if img.Bounds().Dx()*img.Bounds().Dy() > thumbnailScaleThreshold {
img = scaleToThumbnail(img, thumbnailMaxEdge) img = scaleToThumbnail(img, thumbnailMaxEdge)
} }
@@ -55,8 +63,8 @@ func AnalyzeBrightness(filePath string) (darkScore float64, lightScore float64,
return dark, light, nil return dark, light, nil
} }
// scaleToThumbnail 将大图等比缩放到最长边不超过 maxEdge 的缩略图(纯内存,不落盘)。 // scaleToThumbnail 把大图等比缩放到最长边不超过 maxEdge 的缩略图(只占内存,不写盘)。
// 缩放是像素平均,整体亮度分布基本保留,用于粗粒度明暗分类足够准确。 // 缩小时是像素平均,整体明暗分布基本保留,用来粗分亮暗足够了。
func scaleToThumbnail(img image.Image, maxEdge int) image.Image { func scaleToThumbnail(img image.Image, maxEdge int) image.Image {
srcBounds := img.Bounds() srcBounds := img.Bounds()
srcW, srcH := srcBounds.Dx(), srcBounds.Dy() srcW, srcH := srcBounds.Dx(), srcBounds.Dy()
@@ -76,9 +84,11 @@ func scaleToThumbnail(img image.Image, maxEdge int) image.Image {
return dst return dst
} }
// analyzeBrightness 统计暗色/亮色像素占比(ITU-R BT.601 亮度公式)。 // analyzeBrightness 数一数暗色/亮色像素各占多少比例。
// 完全透明的像素(alpha=0)不计入统计。 // 用 ITU-R BT.601 的亮度公式算每个像素的"亮度值"。
// 对常见内存布局(NRGBA/RGBA/Gray)直接访问像素数组,避免 image.At 接口逐像素开销。 // 完全透明的像素(alpha=0)不算数。
// 为了快,常见的内存格式(NRGBA/RGBA/Gray)直接翻底层像素数组,
// 不常见的才用慢一点的通用方式。
func analyzeBrightness(img image.Image) (darkScore float64, lightScore float64) { func analyzeBrightness(img image.Image) (darkScore float64, lightScore float64) {
bounds := img.Bounds() bounds := img.Bounds()
totalPixels := bounds.Dx() * bounds.Dy() totalPixels := bounds.Dx() * bounds.Dy()
@@ -108,7 +118,8 @@ func analyzeBrightness(img image.Image) (darkScore float64, lightScore float64)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
offset := m.PixOffset(bounds.Min.X, y) offset := m.PixOffset(bounds.Min.X, y)
for x := bounds.Min.X; x < bounds.Max.X; x++ { for x := bounds.Min.X; x < bounds.Max.X; x++ {
// 预乘 alpha:若完全不透明则按直接值,否则近似取非预乘分量 // RGBA 是"预乘 alpha",完全不透明就用原值,
// 半透明的按比例还原出真实颜色分量再算亮度
a := pix[offset+3] a := pix[offset+3]
if a == 0 { if a == 0 {
offset += 4 offset += 4
@@ -126,6 +137,7 @@ func analyzeBrightness(img image.Image) (darkScore float64, lightScore float64)
} }
} }
case *image.Gray: case *image.Gray:
// 灰度图:像素本身就是亮度值
pix := m.Pix pix := m.Pix
for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
offset := m.PixOffset(bounds.Min.X, y) offset := m.PixOffset(bounds.Min.X, y)
@@ -141,6 +153,7 @@ func analyzeBrightness(img image.Image) (darkScore float64, lightScore float64)
} }
} }
default: default:
// 其他格式:用通用的读法
for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ { for x := bounds.Min.X; x < bounds.Max.X; x++ {
r, g, b, a := img.At(x, y).RGBA() r, g, b, a := img.At(x, y).RGBA()
@@ -159,7 +172,7 @@ func analyzeBrightness(img image.Image) (darkScore float64, lightScore float64)
return float64(darkCount) / float64(sampleCount), float64(lightCount) / float64(sampleCount) return float64(darkCount) / float64(sampleCount), float64(lightCount) / float64(sampleCount)
} }
// countBrightness 按 BT.601 亮度公式累计暗/亮像素计数 // countBrightness 按亮度公式(BT.601)判断一个像素偏暗还是偏亮,并计数。
func countBrightness(r, g, b float64, darkCount, lightCount *int64) { func countBrightness(r, g, b float64, darkCount, lightCount *int64) {
brightness := 0.299*r + 0.587*g + 0.114*b brightness := 0.299*r + 0.587*g + 0.114*b
if brightness < 80 { if brightness < 80 {
+7 -5
View File
@@ -5,20 +5,22 @@ import (
"os" "os"
) )
// FFprobeExeName 释放到当前目录的解码器文件名 // FFprobeExeName 释放出来的解码器文件名。程序启动时扫到它要跳过,
// 免得把自己人(ffprobe)当成媒体文件收走了。
const FFprobeExeName = "ffprobe.exe" const FFprobeExeName = "ffprobe.exe"
// FFprobePath 返回当前目录下的 ffprobe 可执行路径(Windows/Unix 均可直接执行) // FFprobePath 返回当前目录下 ffprobe 的完整路径。
func FFprobePath() string { func FFprobePath() string {
return "." + string(os.PathSeparator) + FFprobeExeName return "." + string(os.PathSeparator) + FFprobeExeName
} }
// HasEmbeddedFFprobe 当前平台是否内嵌了 ffprobe // HasEmbeddedFFprobe 当前编译出的程序里有没有内嵌 ffprobe。
// 内嵌的版本只在对应系统里编译时带进去,所以要先问一句有没有。
func HasEmbeddedFFprobe() bool { func HasEmbeddedFFprobe() bool {
return len(embeddedFFprobe) > 0 return len(embeddedFFprobe) > 0
} }
// ReleaseFFprobe 释放内嵌的 ffprobe 到当前目录(按当前系统自动选择对应版本) // ReleaseFFprobe 把内嵌的 ffprobe 释放(写)到当前目录,才能当程序调用它。
func ReleaseFFprobe() error { func ReleaseFFprobe() error {
if len(embeddedFFprobe) == 0 { if len(embeddedFFprobe) == 0 {
return fmt.Errorf("当前系统没有内嵌的ffprobe") return fmt.Errorf("当前系统没有内嵌的ffprobe")
@@ -26,7 +28,7 @@ func ReleaseFFprobe() error {
return os.WriteFile(FFprobeExeName, embeddedFFprobe, 0777) return os.WriteFile(FFprobeExeName, embeddedFFprobe, 0777)
} }
// RemoveFFprobe 删除当前目录下释放的 ffprobe // RemoveFFprobe 整理完把临时释放的 ffprobe 删掉,不留在目录里。
func RemoveFFprobe() { func RemoveFFprobe() {
_ = os.Remove(FFprobeExeName) _ = os.Remove(FFprobeExeName)
} }
+18 -10
View File
@@ -8,7 +8,8 @@ import (
"strings" "strings"
) )
// supportImageTypes 支持的标准图片扩展名(大小写不敏感) // supportImageTypes 认识的图片扩展名(大小写不敏感)。
// 这些格式可以自己读出尺寸;列表之外的(如 tiff)在 ffprobe 可用时兜底探测。
var supportImageTypes = []string{ var supportImageTypes = []string{
".bmp", ".bmp",
".gif", ".gif",
@@ -21,7 +22,7 @@ var supportImageTypes = []string{
".jfif", ".jfif",
} }
// IsSupportImage 判断扩展名是否属于支持的图片格式 // IsSupportImage 判断扩展名是不是认识的图片格式。
func IsSupportImage(ext string) bool { func IsSupportImage(ext string) bool {
for _, t := range supportImageTypes { for _, t := range supportImageTypes {
if strings.EqualFold(t, ext) { if strings.EqualFold(t, ext) {
@@ -31,14 +32,17 @@ func IsSupportImage(ext string) bool {
return false return false
} }
// ProbeImage 统一探测图片尺寸: // ProbeImage 探测一张图片的尺寸(宽高)。
// 1. 特殊格式(webp/bmp/psd/psb)走专用解码器,失败后用 ffprobe 兜底
// 2. 其余白名单格式走 Go 标准库 image.DecodeConfig
// 3. 标准库失败时用 ffprobe 兜底(ffmpeg 支持的图片格式更广,如 tiff/svg 等)
// //
// ffprobePath 为空表示不启用 ffprobe 兜底;非白名单扩展名(如 tiff)在 ffprobe 可用时兜底探测。 // 流程(从快到慢、从准到兜底):
// 1. 特殊格式(WebP/BMP/PSD)用专门写的读取函数,它们标准库不一定认识;
// 2. 其余认识的格式用 Go 标准库读(最轻量);
// 3. 上面都失败、或者格式不常见时,用 ffprobe 兜底(它认识的图片格式更多)。
//
// ffprobePath 为空表示不启用兜底;遇到不认识的格式就报"不支持"。
func ProbeImage(filePath, ffprobePath string) (*ProbeResult, error) { func ProbeImage(filePath, ffprobePath string) (*ProbeResult, error) {
ext := strings.ToLower(filepath.Ext(filePath)) ext := strings.ToLower(filepath.Ext(filePath))
// 先试试专门的读取函数
switch ext { switch ext {
case ".webp": case ".webp":
if res, err := probeWebp(filePath); err == nil { if res, err := probeWebp(filePath); err == nil {
@@ -53,34 +57,38 @@ func ProbeImage(filePath, ffprobePath string) (*ProbeResult, error) {
return res, nil return res, nil
} }
} }
// 不在认识列表里的格式:只能用 ffprobe 碰运气
if !IsSupportImage(ext) { if !IsSupportImage(ext) {
// 非白名单扩展名(tiff/svg 等):ffprobe 可用时兜底探测,否则视为不支持
if res, err := probeByFFprobe(ffprobePath, filePath); err == nil { if res, err := probeByFFprobe(ffprobePath, filePath); err == nil {
return res, nil return res, nil
} }
return nil, fmt.Errorf("不支持的图片格式: %s", ext) return nil, fmt.Errorf("不支持的图片格式: %s", ext)
} }
// 用标准库读宽高
if w, h, err := decodeConfig(filePath); err == nil { if w, h, err := decodeConfig(filePath); err == nil {
return &ProbeResult{Width: w, Height: h}, nil return &ProbeResult{Width: w, Height: h}, nil
} }
// 标准库读不出 → 用 ffprobe 兜底
if res, err := probeByFFprobe(ffprobePath, filePath); err == nil { if res, err := probeByFFprobe(ffprobePath, filePath); err == nil {
return res, nil return res, nil
} }
return nil, fmt.Errorf("无法读取图片尺寸: %s", filePath) return nil, fmt.Errorf("无法读取图片尺寸: %s", filePath)
} }
// probeByFFprobe 用 ffprobe 兜底探测图片尺寸;ffprobePath 为空或探测失败时返回错误 // probeByFFprobe 用 ffprobe 兜底读图片尺寸。
// ffprobePath 为空或探测失败时返回错误。
func probeByFFprobe(ffprobePath, filePath string) (*ProbeResult, error) { func probeByFFprobe(ffprobePath, filePath string) (*ProbeResult, error) {
if ffprobePath == "" { if ffprobePath == "" {
return nil, fmt.Errorf("未启用 ffprobe 兜底") return nil, fmt.Errorf("未启用 ffprobe 兜底")
} }
// 图片在 ffprobe 眼里会被当成"只有视频流"的视频
if res, err := ProbeMediaFile(ffprobePath, filePath); err == nil && res.HasVideo { if res, err := ProbeMediaFile(ffprobePath, filePath); err == nil && res.HasVideo {
return res, nil return res, nil
} }
return nil, fmt.Errorf("ffprobe 兜底失败: %s", filePath) return nil, fmt.Errorf("ffprobe 兜底失败: %s", filePath)
} }
// decodeConfig 用 Go 标准库读取图片宽高 // decodeConfig 用 Go 标准库读图片宽高(只解码头部配置,不加载整张图,很快)。
func decodeConfig(filePath string) (int, int, error) { func decodeConfig(filePath string) (int, int, error) {
f, err := os.Open(filePath) f, err := os.Open(filePath)
if err != nil { if err != nil {
+32 -14
View File
@@ -1,4 +1,10 @@
// Package probe 负责媒体信息探测:图片尺寸读取、ffprobe 视频/音频解析与内嵌解码器释放。 // Package probe 负责给媒体文件"体检":
// - 图片:读出它的宽高(尺寸),特殊格式(WebP/BMP/PSD)有专门的读法
// - 视频/音频:调 ffprobe 程序读出编码、时长、宽高等信息
// - 明暗分析:图片是偏暗还是偏亮(给图片分类用)
//
// 大白话理解:这一步负责搞清楚"这个文件到底是什么样的",好让后面决定把它归到哪。
// 体检结果统一放进 ProbeResult 这一个结构里,图片、视频、音频都往里填。
package probe package probe
import ( import (
@@ -10,44 +16,51 @@ import (
"time" "time"
) )
// ProbeResult 媒体探测结果(图片/视频/音频通用) // ProbeResult 一份"体检报告",图片/视频/音频通用。
type ProbeResult struct { type ProbeResult struct {
HasVideo bool HasVideo bool // 有没有视频流
HasAudio bool HasAudio bool // 有没有音频流
Width int Width int // 宽(像素)
Height int Height int // 高(像素)
Duration float64 Duration float64 // 时长(秒)
VideoCodec string VideoCodec string // 视频编码(如 h264)
AudioCodec string AudioCodec string // 音频编码(如 aac)
Format string Format string // 容器格式(如 mp4)
RawJSON string RawJSON string // ffprobe 读出的原始信息,存下来留底
} }
// streamInfo 是 ffprobe 输出里"一条流"的 JSON 结构(视频流/音频流都会列出来)。
type streamInfo struct { type streamInfo struct {
Index int `json:"index"` Index int `json:"index"`
CodecType string `json:"codec_type"` CodecType string `json:"codec_type"` // video / audio
CodecName string `json:"codec_name"` CodecName string `json:"codec_name"`
Width int `json:"width"` Width int `json:"width"`
Height int `json:"height"` Height int `json:"height"`
} }
// formatInfo 是 ffprobe 输出里"容器格式"部分的 JSON 结构。
type formatInfo struct { type formatInfo struct {
Duration string `json:"duration"` Duration string `json:"duration"`
Format string `json:"format_name"` Format string `json:"format_name"`
} }
// probeOutput 是 ffprobe 完整输出的 JSON 结构。
type probeOutput struct { type probeOutput struct {
Streams []streamInfo `json:"streams"` Streams []streamInfo `json:"streams"`
Format formatInfo `json:"format"` Format formatInfo `json:"format"`
} }
// ffprobeTimeout ffprobe 探测超时时间,超时视为探测失败(避免损坏文件卡死程序) // ffprobeTimeout 调 ffprobe 的超时时间。超过就当探测失败,
// 免得遇到损坏文件时卡住整个程序。
const ffprobeTimeout = 30 * time.Second const ffprobeTimeout = 30 * time.Second
// ProbeMediaFile 用 ffprobe 探测媒体文件,一次调用同时获取视频/音频流与时长 // ProbeMediaFile 调 ffprobe 探测一个媒体文件,一次调用就把
// 视频流/音频流/时长/格式全部拿到。
func ProbeMediaFile(ffprobePath, filePath string) (*ProbeResult, error) { func ProbeMediaFile(ffprobePath, filePath string) (*ProbeResult, error) {
// 带超时的执行环境:文件坏了也不至于永远卡住
ctx, cancel := context.WithTimeout(context.Background(), ffprobeTimeout) ctx, cancel := context.WithTimeout(context.Background(), ffprobeTimeout)
defer cancel() defer cancel()
// 用 ffprobe 读信息,指定输出 JSON 格式
cmd := exec.CommandContext(ctx, ffprobePath, "-v", "error", cmd := exec.CommandContext(ctx, ffprobePath, "-v", "error",
"-show_entries", "stream=index,codec_type,codec_name,width,height:format=duration,format_name", "-show_entries", "stream=index,codec_type,codec_name,width,height:format=duration,format_name",
"-of", "json", filePath) "-of", "json", filePath)
@@ -55,6 +68,7 @@ func ProbeMediaFile(ffprobePath, filePath string) (*ProbeResult, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("ffprobe failed: %w", err) return nil, fmt.Errorf("ffprobe failed: %w", err)
} }
// 把 JSON 输出翻译成结构体
var out probeOutput var out probeOutput
if err := json.Unmarshal(output, &out); err != nil { if err := json.Unmarshal(output, &out); err != nil {
return nil, fmt.Errorf("parse ffprobe output: %w", err) return nil, fmt.Errorf("parse ffprobe output: %w", err)
@@ -63,9 +77,13 @@ func ProbeMediaFile(ffprobePath, filePath string) (*ProbeResult, error) {
Format: out.Format.Format, Format: out.Format.Format,
RawJSON: string(output), RawJSON: string(output),
} }
// 时长(秒)
if d, err := strconv.ParseFloat(out.Format.Duration, 64); err == nil { if d, err := strconv.ParseFloat(out.Format.Duration, 64); err == nil {
res.Duration = d res.Duration = d
} }
// 把每条流的信息填进报告:
// 视频流 → 记下"有视频",补上编码和宽高;
// 音频流 → 记下"有音频",补上编码。
for _, s := range out.Streams { for _, s := range out.Streams {
switch s.CodecType { switch s.CodecType {
case "video": case "video":
+49 -30
View File
@@ -1,4 +1,9 @@
// Package scanner 负责递归扫描文件、读取 MIME 并按类型过滤。 // Package scanner 负责"扫描"这一步:把一个目录(包括所有子目录)翻个底朝天,
// 找出里面所有文件,认出每个文件的"身份"(MIME 类型),再大致分成图片、视频/音频候选。
//
// 大白话理解:
// - Scan:走进一个文件夹,一层层翻下去,把每个文件登记在案,并判断"这是个啥文件"
// - Filter:把登记好的文件分成两堆——一堆像图片,一堆可能是视频/音频
package scanner package scanner
import ( import (
@@ -13,46 +18,52 @@ import (
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
) )
// File 扫描到的文件信息 // File 扫描到的一个文件。
type File struct { type File struct {
Path string // 完整路径 Path string // 文件的完整路径(从哪找到的)
Name string // 文件名(含扩展名) Name string // 文件名(含扩展名)
Ext string // 扩展名(含点) Ext string // 扩展名(带点,如 .jpg)
Mime string // MIME 类型 Mime string // 文件类型(MIME 类型,如 image/jpeg、video/mp4)
} }
// Result 过滤结果 // Result 扫描 + 过滤后的结果。
type Result struct { type Result struct {
Files []File // 全部文件 Files []File // 全部文件(还没分堆时)
Images []File // 图片文件 Images []File // 图片文件
Media []File // 待 ffprobe 确认的媒体候选 Media []File // 可能是视频/音频的文件(还需要 ffprobe 进一步确认)
} }
// Options 扫描选项 // Options 扫描的参数。
type Options struct { type Options struct {
RootDir string // 扫描根目录 RootDir string // 从哪个目录开始扫
SkipDir string // 白名单输出目录,整目录跳过 SkipDir string // 这个目录整个跳过(整理输出目录,不能把自己整理的成果又扫一遍)
SkipDirNames []string // 按目录名跳过(如 KMP已整理),任意位置命中即跳过 SkipDirNames []string // 名字叫这些的目录跳过(比如任意位置的「KMP已整理」)
SkipNames []string // 按文件名跳过(数据库、ffprobe 等) SkipNames []string // 名字叫这些的文件跳过(数据库文件、ffprobe 程序等)
OnProgress func(done, total int) OnProgress func(done, total int) // 每处理完一些文件就报一次进度
} }
// Scan 递归扫描目录并读取每个文件的 MIME 信息 // Scan 从根目录开始递归扫描,找出所有文件并识别每个文件的 MIME 类型。
//
// 分两个阶段:
// 1. 走目录树(遍历):把文件一个个找出来登记。这个阶段还不知道总数,
// 所以每发现一些文件就报一次"已发现 N 个"(总数先报 0,前端会特殊显示)。
// 2. 认身份(MIME):逐个读取文件内容判断类型。这时总数已确定,进度就是"已认完 N/总数"。
func Scan(opts Options) (*Result, error) { func Scan(opts Options) (*Result, error) {
res := &Result{} res := &Result{}
// 遍历阶段尚不知道总文件数,先按「已发现文件数」实时上报(total=0), // ---------- 第一阶段:走目录树,登记文件 ----------
// 并节流上报频率,避免目录很大时界面长时间无动静或推送过密。 // 节流上报:最多每 0.1 秒报一次,免得目录特别大时推送消息刷爆。
walkCount := 0 walkCount := 0
lastReport := time.Now() lastReport := time.Now()
err := filepath.Walk(opts.RootDir, func(currentPath string, info os.FileInfo, err error) error { err := filepath.Walk(opts.RootDir, func(currentPath string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
// 单个路径访问失败(权限不足/损坏)不中止整体扫描,仅告警后跳过该子树 // 某个文件夹打不开(没权限/坏了)不影响大局,警告一声跳过它继续扫
fmt.Printf("=== 扫描跳过(无法访问): %s, 错误: %v\n", currentPath, err) fmt.Printf("=== 扫描跳过(无法访问): %s, 错误: %v\n", currentPath, err)
return nil return nil
} }
// 是目录:检查要不要跳过
if info.IsDir() { if info.IsDir() {
if opts.SkipDir != "" && currentPath == opts.SkipDir { if opts.SkipDir != "" && currentPath == opts.SkipDir {
return filepath.SkipDir return filepath.SkipDir // 整个目录连同子目录一起跳过
} }
for _, n := range opts.SkipDirNames { for _, n := range opts.SkipDirNames {
if info.Name() == n { if info.Name() == n {
@@ -61,15 +72,17 @@ func Scan(opts Options) (*Result, error) {
} }
return nil return nil
} }
// 是文件:名字在跳过名单里的,直接忽略
for _, n := range opts.SkipNames { for _, n := range opts.SkipNames {
if info.Name() == n { if info.Name() == n {
return nil return nil
} }
} }
// 空文件不可能是媒体,跳过,免得白费力气去认它
if info.Size() == 0 { if info.Size() == 0 {
// 空文件不可能是媒体,直接跳过,避免无谓的 MIME/ffprobe 探测
return nil return nil
} }
// 登记这个文件(MIME 还没认,留到第二阶段)
res.Files = append(res.Files, File{ res.Files = append(res.Files, File{
Path: currentPath, Path: currentPath,
Name: info.Name(), Name: info.Name(),
@@ -86,9 +99,10 @@ func Scan(opts Options) (*Result, error) {
return nil, err return nil, err
} }
// ---------- 第二阶段:逐个文件认身份(MIME) ----------
total := len(res.Files) total := len(res.Files)
fmt.Printf("=== 文件总数: %d \n", total) fmt.Printf("=== 文件总数: %d \n", total)
// 遍历结束、开始 MIME 探测前,上报一次完整总数 // 开认之前,先把总数报给前端(之前是 0,现在补上)
if opts.OnProgress != nil { if opts.OnProgress != nil {
opts.OnProgress(total, total) opts.OnProgress(total, total)
} }
@@ -101,22 +115,24 @@ func Scan(opts Options) (*Result, error) {
return res, nil return res, nil
} }
// Filter 将文件分为图片与媒体候选 // Filter 把扫描结果分堆:图片一堆,可能是视频/音频的一堆,其余不管。
func (r *Result) Filter() { func (r *Result) Filter() {
for _, f := range r.Files { for _, f := range r.Files {
// 是图片(MIME 说图片,或扩展名是认识的图片格式,或 PSD)
if strings.Contains(f.Mime, "image/") || probe.IsSupportImage(f.Ext) || strings.EqualFold(f.Ext, ".psd") { if strings.Contains(f.Mime, "image/") || probe.IsSupportImage(f.Ext) || strings.EqualFold(f.Ext, ".psd") {
r.Images = append(r.Images, f) r.Images = append(r.Images, f)
continue continue
} }
// 可能是视频/音频:先放进候选堆,后面用 ffprobe 验证真伪
if isPotentialMedia(f.Mime) { if isPotentialMedia(f.Mime) {
r.Media = append(r.Media, f) r.Media = append(r.Media, f)
continue continue
} }
// 明显的非媒体文件(代码/文档/压缩包等)直接不处理 // 一眼就看出不是媒体的(文档、压缩包、代码等):直接不管
} }
} }
// detectMime 读取文件的 MIME 类型 // detectMime 读取文件内容,判断它是什么类型的文件。认不出来返回空串。
func detectMime(filePath string) string { func detectMime(filePath string) string {
mt, err := mimetype.DetectFile(filePath) mt, err := mimetype.DetectFile(filePath)
if err != nil { if err != nil {
@@ -125,22 +141,25 @@ func detectMime(filePath string) string {
return mt.String() return mt.String()
} }
// IsExplicitMediaMime 判断 MIME 是否明确属于视频/音频(区别于 octet-stream/空 等无法确定的情况) // IsExplicitMediaMime 判断 MIME 是否明确说了自己是视频/音频
// (区别于 application/octet-stream、空值这种"说不清是啥"的情况)。
func IsExplicitMediaMime(fileMime string) bool { func IsExplicitMediaMime(fileMime string) bool {
return strings.HasPrefix(fileMime, "video/") || strings.HasPrefix(fileMime, "audio/") return strings.HasPrefix(fileMime, "video/") || strings.HasPrefix(fileMime, "audio/")
} }
// isPotentialMedia 判断是否可能是媒体文件(交由 ffprobe 进一步确认) // isPotentialMedia 判断一个文件"有可能是视频/音频",值得交给 ffprobe 验证一下。
// 说不上类型的、或者类型就是视频/音频的,都算候选;明确是文档/代码/压缩包的不算。
func isPotentialMedia(fileMime string) bool { func isPotentialMedia(fileMime string) bool {
if fileMime == "" || fileMime == "application/octet-stream" { if fileMime == "" || fileMime == "application/octet-stream" {
return true return true // 说不清是啥,验一下最稳妥
} }
if strings.HasPrefix(fileMime, "text/") { if strings.HasPrefix(fileMime, "text/") {
return false return false // 文本,肯定不是
} }
if strings.HasPrefix(fileMime, "video/") || strings.HasPrefix(fileMime, "audio/") || strings.HasPrefix(fileMime, "image/") { if strings.HasPrefix(fileMime, "video/") || strings.HasPrefix(fileMime, "audio/") || strings.HasPrefix(fileMime, "image/") {
return true return true // 明确是媒体
} }
// 这些 MIME 一眼就看得出不是媒体,不用浪费 ffprobe 的时间
switch fileMime { switch fileMime {
case "application/zip", "application/gzip", "application/x-gzip", "application/pdf", case "application/zip", "application/gzip", "application/x-gzip", "application/pdf",
"application/json", "application/xml", "application/x-httpd-php", "application/x-sh", "application/json", "application/xml", "application/x-httpd-php", "application/x-sh",
@@ -153,5 +172,5 @@ func isPotentialMedia(fileMime string) bool {
"application/msword", "application/vnd.ms-excel", "application/vnd.ms-powerpoint": "application/msword", "application/vnd.ms-excel", "application/vnd.ms-powerpoint":
return false return false
} }
return true return true // 其他说不清的类型,保险起见验一下
} }
+19 -9
View File
@@ -1,24 +1,31 @@
// 状态订阅发布:将 ScanService 的状态变更实时推送给订阅者(WebSocket 等)。 // 状态订阅/发布中心:把 ScanService 的状态变更实时"广播"给所有订阅者。
//
// 大白话理解:这是个广播站。后台干活时进度一变,就通过广播站喊一嗓子;
// 谁订阅了(比如 WebSocket 连接),谁就收到。没有人听的时候也不影响干活。
//
// 还有个贴心小设计:每个订阅者面前的信箱最多存一条消息。
// 如果进度变得太快、接收方来不及处理,就只留最新一条——反正看进度的人
// 关心的是"现在到哪了",而不是"中间每一刻到哪了"。
package service package service
import "sync" import "sync"
// StatusListener 状态订阅通道(缓冲 1,保底容纳一条未消费状态) // StatusListener 订阅者的"信箱"(一个缓冲 1 的管道)。
type StatusListener chan ScanStatus type StatusListener chan ScanStatus
// StatusHub 管理状态订阅者;发布时若消费者来不及消费则丢弃旧消息, // StatusHub 广播站:管理所有订阅者,负责把状态推给每个人。
// 保证订阅者拿到的始终是最新状态。
type StatusHub struct { type StatusHub struct {
mu sync.RWMutex mu sync.RWMutex // 保护订阅者名单
subs map[StatusListener]struct{} subs map[StatusListener]struct{} // 当前所有订阅者
} }
// NewStatusHub 创建状态订阅中心 // NewStatusHub 建一个广播站。
func NewStatusHub() *StatusHub { func NewStatusHub() *StatusHub {
return &StatusHub{subs: make(map[StatusListener]struct{})} return &StatusHub{subs: make(map[StatusListener]struct{})}
} }
// Subscribe 注册订阅,返回频道与取消函数。取消后频道被关闭,写方需停止发送。 // Subscribe 让调用方订阅进度。返回它的信箱,和一个"退订"函数。
// 退订后信箱会被关闭(写方要自己注意别再往里面塞)。
func (h *StatusHub) Subscribe() (StatusListener, func()) { func (h *StatusHub) Subscribe() (StatusListener, func()) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
@@ -34,7 +41,9 @@ func (h *StatusHub) Subscribe() (StatusListener, func()) {
} }
} }
// Publish 向所有订阅者推送最新状态(非阻塞;缓冲满时覆盖为最新状态) // Publish 把最新状态推给所有订阅者。
// 谁的信箱满了(来不及消费),就把里面的旧消息挤掉、换上最新的一条。
// 整个过程不阻塞,广播站永远不会因为某个订阅者太慢而卡住。
func (h *StatusHub) Publish(st ScanStatus) { func (h *StatusHub) Publish(st ScanStatus) {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
@@ -42,6 +51,7 @@ func (h *StatusHub) Publish(st ScanStatus) {
select { select {
case ch <- st: case ch <- st:
default: default:
// 信箱满了:清掉旧消息,放最新的进去
select { select {
case <-ch: case <-ch:
default: default:
+178 -85
View File
@@ -1,4 +1,12 @@
// Package service 提供 Web 服务端的后台任务编排(媒体库扫描整理)。 // Package service 是"扫描整理"这个后台任务的管家。
//
// 你可以把它理解成一家快递分拣中心的调度室:
// - 页面上点「开始扫描」,调度室就把整批活接下来,开一个后台线程慢慢干;
// - 干到哪一步(扫描 / 归类 / 移动)、干了多少个、还要多久,调度室都记下来;
// - 前端页面通过 WebSocket 实时看到这些进度,也能随时问一句"现在啥情况"。
//
// 这个包不自己干活,真正的"扫描、整理、移动"分别由 scanner、organize、mover
// 三个包完成,这里只负责:发号施令、记录进度、把日志发给前端看。
package service package service
import ( import (
@@ -18,9 +26,16 @@ import (
"kenaito-media-picker/internal/store" "kenaito-media-picker/internal/store"
) )
// ScanState 扫描状态 // ============================ 扫描状态 ============================
// ScanState 表示一次扫描整理任务当前处于什么状态。
type ScanState string type ScanState string
// 一次任务的四种状态:
// - idle 空闲:目前没有任务在跑
// - running 运行中:任务正在后台干活
// - done 完成:顺利干完了
// - error 出错:干活过程中出了岔子
const ( const (
ScanIdle ScanState = "idle" ScanIdle ScanState = "idle"
ScanRunning ScanState = "running" ScanRunning ScanState = "running"
@@ -28,66 +43,93 @@ const (
ScanError ScanState = "error" ScanError ScanState = "error"
) )
// ScanStatus 扫描进度与结果(供前端轮询) // ScanStatus 是发给前端的"当前进度快照",页面轮询 / 实时推送用的都是它。
type ScanStatus struct { type ScanStatus struct {
State ScanState `json:"state"` // idle/running/done/error State ScanState `json:"state"` // 任务状态:idle/running/done/error
Message string `json:"message"` // 简要说明 Message string `json:"message"` // 一句话说明,比如出错原因
Started string `json:"started"` // 开始时间 Started string `json:"started"` // 任务开始时间
Elapsed int64 `json:"elapsed"` // 已运行秒数 Elapsed int64 `json:"elapsed"` // 已经运行了多少秒
RootDir string `json:"rootDir"` // 本次扫描的根目录 RootDir string `json:"rootDir"` // 本次扫描的是哪个目录
DestDir string `json:"destDir"` // 上次使用的存放目录 DestDir string `json:"destDir"` // 整理后的文件放到哪个目录(上次用过的,下次自动填上)
Lines []string `json:"lines"` // 运行日志 Lines []string `json:"lines"` // 运行日志,逐行显示给用户看
Stage string `json:"stage"` // 当前阶段:scan/organize/move Stage string `json:"stage"` // 当前干到哪一步:scan/organize/move
StageText string `json:"stageText"` // 阶段中文说明 StageText string `json:"stageText"` // 当前步骤的中文名
Progress int `json:"progress"` // 总体进度百分比 0-100 Progress int `json:"progress"` // 总进度百分比 0~100
Done int `json:"done"` // 已处理数 Done int `json:"done"` // 这一步已经处理了多少个文件
Total int `json:"total"` // 总数 Total int `json:"total"` // 这一步一共要处理多少个文件
Eta int64 `json:"eta"` // 预计剩余秒数(按当前阶段速率估算,0 表示无法估算) Eta int64 `json:"eta"` // 预计还要多少秒(0 表示算不出来)
} }
// 应用级配置键 // ============================ 应用级常量 ============================
const (
ConfigKeyDestDir = "scan.destDir"
)
// DefaultPickerDirName 默认存放目录名(未指定 destDir 时创建于扫描目录下) // 应用级配置键。目前只存了一个:上次使用的存放目录,方便下次打开页面自动回填。
const ConfigKeyDestDir = "scan.destDir"
// DefaultPickerDirName 用户没指定存放目录时,默认在扫描目录下新建这个文件夹装整理好的文件。
const DefaultPickerDirName = "KMP已整理" const DefaultPickerDirName = "KMP已整理"
// ScanService 管理媒体库扫描整理任务(同一时间只允许一次) // 三个工作阶段的名字。前端根据这个名字显示"正在扫描/正在整理/正在移动"。
type ScanService struct { const (
store *store.Store stageScan = "scan"
hub *StatusHub stageOrganize = "organize"
mu sync.Mutex stageMove = "move"
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)不受限,保证结束状态必达 // publishInterval 进度推送的节流间隔。
// 扫描大量文件时进度变化非常频繁,如果每次都推给前端,WebSocket 会被刷爆。
// 这里限制为每 0.1 秒最多推一次(约每秒 10 次),既实时又不至于泛滥。
// 注意:任务结束(done/error)不受这个限制,必须立刻推出去,否则用户看不到结果。
const publishInterval = 100 * time.Millisecond const publishInterval = 100 * time.Millisecond
// NewScanService 创建扫描服务;store 可为 nil(数据库不可用时仅不记录媒体信息) // maxLogLines 日志最多保留的行数。日志太长就丢掉最旧的一半,防止占用太多内存、拖慢页面。
const maxLogLines = 500
// ============================ 扫描服务 ============================
// ScanService 是扫描整理任务的管家,负责:
// - 开始任务(同一时间只允许一个任务在跑)
// - 记录任务进度、当前阶段、运行日志
// - 通过 StatusHub 把进度实时推给订阅者(前端 WebSocket)
//
// 用锁(mu)保护这些共享数据,因为后台干活线程和前端查询线程会同时读写。
type ScanService struct {
store *store.Store // 数据库;为 nil 表示数据库打不开,此时扫描照跑,只是不记录媒体信息
hub *StatusHub // 订阅中心:把进度推给所有订阅者
mu sync.Mutex // 下面这些字段都会被多线程读写,所以用这把锁保护
state ScanState // 当前任务状态
msg string // 附加说明(一般存出错原因)
started time.Time // 任务开始时间
rootDir string // 本次扫描的根目录
destDir string // 整理后文件的存放目录
lines []string // 运行日志
stage string // 当前阶段:scan/organize/move
done int // 当前阶段已完成数
total int // 当前阶段总数
// 估算剩余时间用:记住当前阶段开始的时间点和当时已完成的数,
// 这样就能算出"每秒干几个",进而估算"还剩多少秒"。
stageStart time.Time
stageDone int
lastPublish time.Time // 上次推送进度的时间,用于节流
}
// NewScanService 创建扫描服务。
// 注意 store 可以传 nil——数据库打不开时服务照常能用,只是不记录媒体信息。
func NewScanService(st *store.Store) *ScanService { func NewScanService(st *store.Store) *ScanService {
return &ScanService{store: st, hub: NewStatusHub(), state: ScanIdle} return &ScanService{store: st, hub: NewStatusHub(), state: ScanIdle}
} }
// Subscribe 订阅扫描状态变更(供 WebSocket 等实时推送);取消后返回的频道被关闭 // Subscribe 让调用方(通常是 WebSocket 处理器)订阅进度更新。
// 返回值第一个是"收进度"的管道,第二个是取消订阅的函数(取消后管道会被关闭)。
func (s *ScanService) Subscribe() (StatusListener, func()) { func (s *ScanService) Subscribe() (StatusListener, func()) {
return s.hub.Subscribe() return s.hub.Subscribe()
} }
// publish 将当前状态推送给所有订阅者。 // publish 把当前进度推给所有订阅者。
// 运行中按 publishInterval 节流(约 10 次/秒),避免海量文件时 WebSocket 消息泛滥; // 运行中按 publishInterval 节流;任务结束时无条件推送,保证"干完了"这个消息一定送达。
// 非运行状态(done/error)无条件推送,确保结束状态不丢失。
func (s *ScanService) publish() { func (s *ScanService) publish() {
s.mu.Lock() s.mu.Lock()
if s.state == ScanRunning && time.Since(s.lastPublish) < publishInterval { if s.state == ScanRunning && time.Since(s.lastPublish) < publishInterval {
@@ -99,15 +141,24 @@ func (s *ScanService) publish() {
s.hub.Publish(s.Status()) s.hub.Publish(s.Status())
} }
// Start 启动一次扫描整理;已在运行时返回错误。rootDir 为空时默认扫描当前运行目录, // Start 开始一次扫描整理任务。
// destDir 为空时默认输出到扫描目录下的「KMP已整理」文件夹。 // - rootDir:要扫描哪个目录。留空就用程序当前所在的目录。
// - destDir:整理好的文件放哪。留空就默认放到扫描目录下的「KMP已整理」。
//
// 任务在后台 goroutine 里跑,这个函数只负责"接下活"就立即返回,不阻塞页面。
func (s *ScanService) Start(rootDir, destDir string) error { func (s *ScanService) Start(rootDir, destDir string) error {
s.mu.Lock() s.mu.Lock()
// 正在跑就不能再开一个,直接拒绝
if s.state == ScanRunning { if s.state == ScanRunning {
s.mu.Unlock() s.mu.Unlock()
return errors.New("扫描整理正在运行中,请等待完成") return errors.New("扫描整理正在运行中,请等待完成")
} }
// 把目录变成标准绝对路径;留空就取当前工作目录
rootDir = resolveRootDir(rootDir) rootDir = resolveRootDir(rootDir)
// 先确认要扫描的目录真的存在,不然白忙活
if st, err := os.Stat(rootDir); err != nil || !st.IsDir() { if st, err := os.Stat(rootDir); err != nil || !st.IsDir() {
s.mu.Unlock() s.mu.Unlock()
return fmt.Errorf("扫描目录不存在或不可访问: %s", rootDir) return fmt.Errorf("扫描目录不存在或不可访问: %s", rootDir)
@@ -115,36 +166,43 @@ func (s *ScanService) Start(rootDir, destDir string) error {
if destDir != "" { if destDir != "" {
destDir = resolveRootDir(destDir) destDir = resolveRootDir(destDir)
} }
// 把状态改成"运行中",清空上一次的残留信息
s.state = ScanRunning s.state = ScanRunning
s.msg = "" s.msg = ""
s.started = time.Now() s.started = time.Now()
s.rootDir = rootDir s.rootDir = rootDir
s.destDir = destDir s.destDir = destDir
s.lines = nil s.lines = nil
s.stage = "scan" s.stage = stageScan
s.done = 0 s.done = 0
s.total = 0 s.total = 0
s.mu.Unlock() s.mu.Unlock()
// 持久化存放目录,下次打开页面自动回填 // 把存放目录记到数据库里,下次打开页面自动填上,省得用户重敲
if s.store != nil { if s.store != nil {
if err := s.store.SaveConfig(ConfigKeyDestDir, destDir); err != nil { if err := s.store.SaveConfig(ConfigKeyDestDir, destDir); err != nil {
fmt.Printf("=== 保存存放目录配置失败: %v\n", err) fmt.Printf("=== 保存存放目录配置失败: %v\n", err)
} }
} }
// 开个后台线程干活,这个函数立刻返回
go s.run(rootDir, destDir) go s.run(rootDir, destDir)
return nil return nil
} }
// Status 返回当前扫描状态 // Status 生成一份"当前进度快照"返回给前端。
func (s *ScanService) Status() ScanStatus { func (s *ScanService) Status() ScanStatus {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
// 算已经跑了多少秒
elapsed := int64(0) elapsed := int64(0)
if !s.started.IsZero() { if !s.started.IsZero() {
elapsed = int64(time.Since(s.started).Seconds()) elapsed = int64(time.Since(s.started).Seconds())
} }
// 算百分比:已完成 / 总数 × 100,最多 100
progress := 0 progress := 0
if s.total > 0 { if s.total > 0 {
progress = int(float64(s.done) / float64(s.total) * 100) progress = int(float64(s.done) / float64(s.total) * 100)
@@ -152,13 +210,15 @@ func (s *ScanService) Status() ScanStatus {
progress = 100 progress = 100
} }
} }
st := ScanStatus{
return ScanStatus{
State: s.state, State: s.state,
Message: s.msg, Message: s.msg,
Started: s.started.Format("2006-01-02 15:04:05"), Started: s.started.Format("2006-01-02 15:04:05"),
Elapsed: elapsed, Elapsed: elapsed,
RootDir: s.rootDir, RootDir: s.rootDir,
DestDir: s.destDir, DestDir: s.destDir,
// 复制一份日志再返回,避免前端拿到后修改到我们内部的数据
Lines: append([]string{}, s.lines...), Lines: append([]string{}, s.lines...),
Stage: s.stage, Stage: s.stage,
Progress: progress, Progress: progress,
@@ -166,10 +226,12 @@ func (s *ScanService) Status() ScanStatus {
Total: s.total, Total: s.total,
Eta: s.estimateEtaLocked(), Eta: s.estimateEtaLocked(),
} }
return st
} }
// estimateEtaLocked 按当前阶段速率估算剩余秒数;需持有锁。返回 0 表示无法估算。 // estimateEtaLocked 估算"还剩多少秒",规则是:记住这个阶段开始的时间,
// 看这段时间干了多少个文件,算出速度,再用剩下的数量除以速度。
// 返回 0 表示还估不出来(刚开跑、或者数量太小)。
// 注意:调用前必须已经持有锁。
func (s *ScanService) estimateEtaLocked() int64 { func (s *ScanService) estimateEtaLocked() int64 {
if s.state != ScanRunning || s.total <= 0 || s.done <= 0 || s.done >= s.total { if s.state != ScanRunning || s.total <= 0 || s.done <= 0 || s.done >= s.total {
return 0 return 0
@@ -181,7 +243,7 @@ func (s *ScanService) estimateEtaLocked() int64 {
if elapsedSec <= 0 { if elapsedSec <= 0 {
return 0 return 0
} }
rate := float64(s.done-s.stageDone) / elapsedSec // 文件/秒 rate := float64(s.done-s.stageDone) / elapsedSec // 每秒处理多少个文件
if rate <= 0 { if rate <= 0 {
return 0 return 0
} }
@@ -192,7 +254,7 @@ func (s *ScanService) estimateEtaLocked() int64 {
return int64(remain + 0.5) return int64(remain + 0.5)
} }
// resolveRootDir 解析扫描根目录:空值回退当前工作目录,并做绝对路径归一化 // resolveRootDir 把用户填的目录整理成标准绝对路径;留空就取程序当前所在目录。
func resolveRootDir(dir string) string { func resolveRootDir(dir string) string {
if strings.TrimSpace(dir) == "" { if strings.TrimSpace(dir) == "" {
wd, err := os.Getwd() wd, err := os.Getwd()
@@ -208,11 +270,15 @@ func resolveRootDir(dir string) string {
return abs return abs
} }
// maxLogLines 日志保留的最大行数,超出后丢弃最旧行,避免前端渲染卡顿 // ============================ 后台任务本体 ============================
const maxLogLines = 500
// run 后台执行:捕获 stdout 日志流式写入 lines,结束后更新状态 // run 是后台干活线程。除了调用 runScan 真正干活外,它还要做一件特殊的事:
// 把程序打印到屏幕上的日志"截胡"下来,存进 s.lines,好让前端能看到实时日志。
//
// 做法:把标准输出 os.Stdout 临时换成一根管道(pipe)的写端,
// 另起一个 goroutine 从管道的读端一行行读日志。干完活再把 Stdout 换回去。
func (s *ScanService) run(rootDir, destDir string) { func (s *ScanService) run(rootDir, destDir string) {
// 创建一根管道,准备截取日志
r, w, err := os.Pipe() r, w, err := os.Pipe()
if err != nil { if err != nil {
s.finish(ScanError, "创建日志管道失败: "+err.Error()) s.finish(ScanError, "创建日志管道失败: "+err.Error())
@@ -221,11 +287,12 @@ func (s *ScanService) run(rootDir, destDir string) {
oldStdout := os.Stdout oldStdout := os.Stdout
os.Stdout = w os.Stdout = w
// 专门读日志的 goroutine:读到一行就存进 s.lines
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
defer close(done) defer close(done)
sc := bufio.NewScanner(r) sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 1024*1024), 1024*1024) sc.Buffer(make([]byte, 1024*1024), 1024*1024) // 允许很长的日志行
for sc.Scan() { for sc.Scan() {
line := sanitizeLine(sc.Text()) line := sanitizeLine(sc.Text())
if line == "" { if line == "" {
@@ -233,7 +300,7 @@ func (s *ScanService) run(rootDir, destDir string) {
} }
s.mu.Lock() s.mu.Lock()
if len(s.lines) >= maxLogLines { if len(s.lines) >= maxLogLines {
// 丢弃最旧一半,保留最近日志,控制内存与前端渲染规模 // 日志太多就丢最旧一半,只留最近的部分
s.lines = append([]string{}, s.lines[len(s.lines)/2:]...) s.lines = append([]string{}, s.lines[len(s.lines)/2:]...)
} }
s.lines = append(s.lines, line) s.lines = append(s.lines, line)
@@ -241,8 +308,10 @@ func (s *ScanService) run(rootDir, destDir string) {
} }
}() }()
// 真正开始干活。期间程序里所有 fmt.Println/Printf 都会进上面的管道
scanErr := s.runScan(rootDir, destDir) scanErr := s.runScan(rootDir, destDir)
// 干完了:把 Stdout 换回去,关上管道,等读日志的 goroutine 把剩下的读完
os.Stdout = oldStdout os.Stdout = oldStdout
_ = w.Close() _ = w.Close()
<-done <-done
@@ -254,7 +323,7 @@ func (s *ScanService) run(rootDir, destDir string) {
s.finish(ScanDone, "扫描整理完成") s.finish(ScanDone, "扫描整理完成")
} }
// finish 设置最终状态 // finish 把任务状态改成最终状态(完成或出错),并立刻推给前端。
func (s *ScanService) finish(state ScanState, msg string) { func (s *ScanService) finish(state ScanState, msg string) {
s.mu.Lock() s.mu.Lock()
s.state = state s.state = state
@@ -263,15 +332,16 @@ func (s *ScanService) finish(state ScanState, msg string) {
s.publish() s.publish()
} }
// setProgress 更新阶段与进度(线程安全,供各阶段回调调用)。 // setProgress 更新当前阶段与进度,并推给前端。
// 阶段切换或进度回退(新阶段从头计数)时重置阶段起点,用于估算剩余时间。 // 它还负责记录"阶段开始点",用于估算剩余时间:
// - 阶段换了(比如从归类切到移动)→ 重新记起点
// - 阶段没换但进度倒退了(说明开始新一轮计数)→ 也重新记起点
func (s *ScanService) setProgress(stage string, done, total int) { func (s *ScanService) setProgress(stage string, done, total int) {
s.mu.Lock() s.mu.Lock()
if stage != s.stage { if stage != s.stage {
s.stageStart = time.Now() s.stageStart = time.Now()
s.stageDone = done s.stageDone = done
} else if done < s.stageDone { } else if done < s.stageDone {
// 阶段未变但进度回退(如开始新一轮计数),重置起点
s.stageStart = time.Now() s.stageStart = time.Now()
s.stageDone = done s.stageDone = done
} }
@@ -282,18 +352,25 @@ func (s *ScanService) setProgress(stage string, done, total int) {
s.publish() s.publish()
} }
// ansiRe 匹配 ANSI 颜色转义序列 // ansiRe 用来去掉日志里的 ANSI 颜色码(那些 [31m 之类的转义序列)。
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`) var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// sanitizeLine 清理日志行:去除 ANSI 颜色码,进度条按 \r 分段只保留最后一段 // sanitizeLine 清理一行日志:
// - 去掉颜色码
// - 有些进度条用 \r(回车)原地刷新,这里按 \r 切开只留最后一段
func sanitizeLine(line string) string { func sanitizeLine(line string) string {
line = ansiRe.ReplaceAllString(line, "") line = ansiRe.ReplaceAllString(line, "")
segs := strings.Split(line, "\r") segs := strings.Split(line, "\r")
return strings.TrimSpace(segs[len(segs)-1]) return strings.TrimSpace(segs[len(segs)-1])
} }
// runScan 执行完整整理流程:扫描 → 过滤 → 释放解码器 → 归类 → 移动 // runScan 执行完整的整理流程,一共四步:
// 1. 扫描:把目录里的文件全部找出来,识别每个文件的类型
// 2. 过滤:把"值得处理"的文件分成图片、视频/音频两拨
// 3. 准备解码器:把内嵌的 ffprobe 程序释放到当前目录(用它读视频/音频信息)
// 4. 整理:给每个文件探测信息 → 判断是否重复 → 分到分类目录 → 搬进去
func (s *ScanService) runScan(rootDir, destDir string) error { func (s *ScanService) runScan(rootDir, destDir string) error {
// 确定整理后文件的存放目录:用户没填就默认放在扫描目录下的「KMP已整理」
pickerDir := destDir pickerDir := destDir
if pickerDir == "" { if pickerDir == "" {
pickerDir = filepath.Join(rootDir, DefaultPickerDirName) pickerDir = filepath.Join(rootDir, DefaultPickerDirName)
@@ -301,19 +378,27 @@ func (s *ScanService) runScan(rootDir, destDir string) error {
fmt.Printf("=== 扫描根目录: %s \n", rootDir) fmt.Printf("=== 扫描根目录: %s \n", rootDir)
fmt.Printf("=== 整理输出目录(白名单): %s \n", pickerDir) fmt.Printf("=== 整理输出目录(白名单): %s \n", pickerDir)
// ---------- 第一步:扫描 ----------
// 扫描时跳过这些目录/文件:整理输出目录本身、数据库文件、ffprobe 程序,
// 不然它们会被当成"媒体文件"被自己整理进去,那可就乱套了。
res, err := scanner.Scan(scanner.Options{ res, err := scanner.Scan(scanner.Options{
RootDir: rootDir, RootDir: rootDir,
SkipDir: pickerDir, SkipDir: pickerDir,
SkipDirNames: []string{DefaultPickerDirName}, SkipDirNames: []string{DefaultPickerDirName},
SkipNames: []string{store.DBFileName, probe.FFprobeExeName}, SkipNames: []string{store.DBFileName, probe.FFprobeExeName},
OnProgress: func(done, total int) { s.setProgress("scan", done, total) }, OnProgress: func(done, total int) { s.setProgress(stageScan, done, total) },
}) })
if err != nil { if err != nil {
return fmt.Errorf("扫描失败: %v", err) return fmt.Errorf("扫描失败: %v", err)
} }
// ---------- 第二步:过滤 ----------
// 把扫描到的文件分成"图片"和"视频/音频候选"两拨
res.Filter() res.Filter()
// 释放内嵌 ffprobe;失败只跳过媒体整理,图片照常处理 // ---------- 第三步:准备解码器 ----------
// 读取视频/音频信息需要 ffprobe。程序里内嵌了一份,用之前先释放到当前目录。
// 释放失败就跳过视频/音频整理,图片照常处理。
ffprobePath := "" ffprobePath := ""
if (len(res.Images) > 0 || len(res.Media) > 0) && probe.HasEmbeddedFFprobe() { if (len(res.Images) > 0 || len(res.Media) > 0) && probe.HasEmbeddedFFprobe() {
if err := probe.ReleaseFFprobe(); err != nil { if err := probe.ReleaseFFprobe(); err != nil {
@@ -324,32 +409,40 @@ func (s *ScanService) runScan(rootDir, destDir string) error {
} }
} }
// ---------- 第四步:整理 ----------
org := organize.New(pickerDir, ffprobePath, s.store) 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; imageCount := len(res.Images)
// move 阶段:Flush 上报的 done 已是累计移动数,直接使用,避免叠加导致溢出。 totalWork := imageCount + len(res.Media)
if currentStage == "move" { org.SetProgress(func(phase string, done, total int) {
s.setProgress("move", done, total) switch phase {
} else { case organize.PhaseImages:
s.setProgress(currentStage, doneWork+done, totalWork) // 图片阶段:媒体还没开始,全局进度 = 本阶段进度
s.setProgress(stageOrganize, done, totalWork)
case organize.PhaseMedia:
// 媒体阶段:全局进度 = 图片数量 + 本阶段进度
s.setProgress(stageOrganize, imageCount+done, totalWork)
case organize.PhaseMove:
// 移动阶段:进度单独统计(已移动数 / 待移动数)
s.setProgress(stageMove, done, total)
} }
}) })
if len(res.Images) > 0 { if len(res.Images) > 0 {
org.ProcessImages(res.Images) org.ProcessImages(res.Images)
doneWork += len(res.Images)
} }
if len(res.Media) > 0 && ffprobePath != "" { if len(res.Media) > 0 && ffprobePath != "" {
org.ProcessMedia(res.Media) org.ProcessMedia(res.Media)
doneWork += len(res.Media)
} }
// 移动阶段:进度为累计已移动文件数
currentStage = "move" // 移动:把文件搬进各自的分类目录,并记录去向
org.Flush() org.Flush()
currentStage = "done"
s.setProgress("move", totalWork, totalWork) // 把进度条顶满 100%,再打印"整理完成"
s.setProgress(stageMove, totalWork, totalWork)
fmt.Println("=== 整理完成") fmt.Println("=== 整理完成")
return nil return nil
} }
+118 -152
View File
@@ -1,10 +1,15 @@
// Package store 提供媒体信息的 SQLite 持久化(xorm + 纯 Go 驱动,免 CGO)。 // Package store 负责把所有媒体信息存进数据库(SQLite),并提供查询。
//
// 大白话理解:这是"媒体库"的账本。扫描整理时把每个文件的来龙去脉记下来
// (叫什么、多大、什么类型),网页上「媒体库」和「统计看板」两个页面
// 就是从这本账本里查数据、算统计的。不记录文件搬去哪了——文件就在分类目录里。
//
// 技术细节:用的是 xorm + 纯 Go 的 SQLite 驱动(不用装任何 C 语言编译环境)。
package store package store
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"path/filepath"
"sort" "sort"
"time" "time"
@@ -14,61 +19,60 @@ import (
"xorm.io/xorm" "xorm.io/xorm"
) )
// DBFileName 数据库文件名(存放于被整理目录的根目录,扫描时跳过) // DBFileName 数据库文件名。数据库放在被整理目录的根目录下,扫描时会跳过它,
// 免得把"账本"自己当成媒体文件收走了。
const DBFileName = "media-info.db" const DBFileName = "media-info.db"
// CategoryDuplicate 重复文件的归类目录名(入库记录用于统计重复数量,媒体统计中排除) // init 把纯 Go 的 SQLite 驱动注册成 xorm 认识的 "sqlite3" 名字,
const CategoryDuplicate = "重复文件" // 这样就不用依赖 CGO(C 语言编译器)了。
// init 将纯Go的sqlite驱动注册为 xorm 认识的 sqlite3 驱动名(避免CGO依赖)
func init() { func init() {
sql.Register("sqlite3", &sqlite.Driver{}) sql.Register("sqlite3", &sqlite.Driver{})
} }
// MediaInfo 媒体文件信息记录,按字段拆分存放,便于查询 // MediaInfo 一条媒体文件的记录:文件本身的信息——路径、名字、大小、类型、宽高、编码、格式等。
type MediaInfo struct { type MediaInfo struct {
Id int64 `xorm:"pk autoincr 'id'" json:"id"` Id int64 `xorm:"pk autoincr 'id'" json:"id"`
FileMd5 string `xorm:"char(32) index 'file_md5'" json:"fileMd5"` // 文件MD5,唯一标识 FileMd5 string `xorm:"char(32) index 'file_md5'" json:"fileMd5"` // 文件 MD5,内容相同则相同,用来判断是不是同一个文件
FilePath string `xorm:"text 'file_path'" json:"filePath"` // 扫描时的源路径 FilePath string `xorm:"text 'file_path'" json:"filePath"` // 扫描时的源路径(在哪发现的)
FileName string `xorm:"varchar(255) 'file_name'" json:"fileName"` // 文件名(含扩展名) FileName string `xorm:"varchar(255) 'file_name'" json:"fileName"` // 文件名(含扩展名)
FileExt string `xorm:"varchar(32) 'file_ext'" json:"fileExt"` // 扩展名 FileExt string `xorm:"varchar(32) 'file_ext'" json:"fileExt"` // 扩展名(如 .jpg)
FileSize int64 `xorm:"bigint 'file_size'" json:"fileSize"` // 文件大小(字节) FileSize int64 `xorm:"bigint 'file_size'" json:"fileSize"` // 文件大小(字节)
MimeType string `xorm:"varchar(128) 'mime_type'" json:"mimeType"` // MIME类型 MimeType string `xorm:"varchar(128) 'mime_type'" json:"mimeType"` // MIME 类型(如 image/jpeg)
MediaType string `xorm:"varchar(16) index 'media_type'" json:"mediaType"` // image/video/audio MediaType string `xorm:"varchar(16) index 'media_type'" json:"mediaType"` // 大类型:image / video / audio
Width int `xorm:"int index 'width'" json:"width"` // 宽(像素) Width int `xorm:"int index 'width'" json:"width"` // 宽(像素)
Height int `xorm:"int index 'height'" json:"height"` // 高(像素) Height int `xorm:"int index 'height'" json:"height"` // 高(像素)
Duration float64 `xorm:"real 'duration'" json:"duration"` // 时长(秒) Duration float64 `xorm:"real 'duration'" json:"duration"` // 时长(秒)
Codec string `xorm:"varchar(64) 'codec'" json:"codec"` // 视频编码,如h264/hevc Codec string `xorm:"varchar(64) 'codec'" json:"codec"` // 编码,如 h264 / hevc
Format string `xorm:"varchar(64) 'format'" json:"format"` // 容器格式,如 mp4 / mkv Format string `xorm:"varchar(64) 'format'" json:"format"` // 容器格式,如 mp4 / mkv
RawInfo string `xorm:"text 'raw_info'" json:"rawInfo"` // ffprobe原始JSON(完整信息兜底) RawInfo string `xorm:"text 'raw_info'" json:"rawInfo"` // ffprobe 读出的原始信息(JSON),留底备用
HeadMd5 string `xorm:"char(32) index('idx_fp') 'head_md5'" json:"headMd5"` // 文件头256KB的MD5(指纹加速) HeadMd5 string `xorm:"char(32) index('idx_fp') 'head_md5'" json:"headMd5"` // 文件头 256KB 的 MD5(指纹用)
TailMd5 string `xorm:"char(32) index('idx_fp') 'tail_md5'" json:"tailMd5"` // 文件尾256KB的MD5(指纹加速) TailMd5 string `xorm:"char(32) index('idx_fp') 'tail_md5'" json:"tailMd5"` // 文件尾 256KB 的 MD5(指纹用)
Category string `xorm:"varchar(64) index 'category'" json:"category"` // 归类的目录名(去向)
DestDir string `xorm:"text 'dest_dir'" json:"destDir"` // 移动后的目录路径(去向)
DestPath string `xorm:"text 'dest_path'" json:"destPath"` // 移动后的完整文件路径(判断库中副本是否仍存在)
CreatedAt time.Time `xorm:"created 'created_at'" json:"createdAt"` // 首次入库时间 CreatedAt time.Time `xorm:"created 'created_at'" json:"createdAt"` // 首次入库时间
UpdatedAt time.Time `xorm:"updated 'updated_at'" json:"updatedAt"` // 最近更新时间 UpdatedAt time.Time `xorm:"updated 'updated_at'" json:"updatedAt"` // 最近更新时间
} }
// Store 封装 SQLite 存取,按实例持有连接,不再依赖全局状态 // Store 数据库访问的封装。整个程序共用一个实例,通过它读写数据库。
type Store struct { type Store struct {
engine *xorm.Engine engine *xorm.Engine // xorm 的数据库引擎(连接)
} }
// AppConfig 应用级键值配置(如上次使用的存放目录),持久化到数据库便于复用 // AppConfig 应用级键值配置表。目前只存一条:上次使用的存放目录。
// 存到数据库里,下次打开页面能自动回填,不用用户重新输。
type AppConfig struct { type AppConfig struct {
Key string `xorm:"pk varchar(64) 'key'"` Key string `xorm:"pk varchar(64) 'key'"` // 配置项名字
Value string `xorm:"text 'value'"` Value string `xorm:"text 'value'"` // 配置值
} }
// Open 打开(或创建)数据库并同步表结构 // Open 打开(或第一次时创建)数据库,并保证表结构齐全。
func Open(dbPath string) (*Store, error) { func Open(dbPath string) (*Store, error) {
e, err := xorm.NewEngine("sqlite3", dbPath) e, err := xorm.NewEngine("sqlite3", dbPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
e.ShowSQL(false) e.ShowSQL(false)
e.SetMaxOpenConns(1) // sqlite同一时刻只允许一个写连接 // SQLite 同一时刻只允许一个写连接,这里限制成 1 个,避免并发写冲突
e.SetMaxOpenConns(1)
// 自动创建/补全两张表(媒体记录表、配置表)
if err := e.Sync(new(MediaInfo), new(AppConfig)); err != nil { if err := e.Sync(new(MediaInfo), new(AppConfig)); err != nil {
_ = e.Close() _ = e.Close()
return nil, err return nil, err
@@ -76,7 +80,7 @@ func Open(dbPath string) (*Store, error) {
return &Store{engine: e}, nil return &Store{engine: e}, nil
} }
// Close 关闭数据库连接 // Close 关闭数据库连接。
func (s *Store) Close() error { func (s *Store) Close() error {
if s == nil || s.engine == nil { if s == nil || s.engine == nil {
return nil return nil
@@ -86,7 +90,9 @@ func (s *Store) Close() error {
return err return err
} }
// SaveConfig 保存/更新应用级配置项 // ============================ 应用配置 ============================
// SaveConfig 保存/更新一条应用配置(有就更新,没有就插入)。
func (s *Store) SaveConfig(key, value string) error { func (s *Store) SaveConfig(key, value string) error {
if s == nil || s.engine == nil || key == "" { if s == nil || s.engine == nil || key == "" {
return nil return nil
@@ -104,7 +110,7 @@ func (s *Store) SaveConfig(key, value string) error {
return err return err
} }
// GetConfig 读取应用级配置项;不存在时返回空串 // GetConfig 读取一条应用配置;不存在返回空串。
func (s *Store) GetConfig(key string) string { func (s *Store) GetConfig(key string) string {
if s == nil || s.engine == nil || key == "" { if s == nil || s.engine == nil || key == "" {
return "" return ""
@@ -117,7 +123,9 @@ func (s *Store) GetConfig(key string) string {
return cfg.Value return cfg.Value
} }
// SaveMedia 按MD5去重保存/更新媒体信息 // ============================ 媒体记录的新增/更新 ============================
// SaveMedia 保存/更新一条媒体记录:按 MD5 找,有就更新,没有就新增。
func (s *Store) SaveMedia(info *MediaInfo) error { func (s *Store) SaveMedia(info *MediaInfo) error {
if s == nil || s.engine == nil { if s == nil || s.engine == nil {
return nil return nil
@@ -136,16 +144,8 @@ func (s *Store) SaveMedia(info *MediaInfo) error {
return err return err
} }
// InsertDuplicate 记录重复文件(与已入库内容 MD5 相同,独立入库便于统计重复数量) // SaveMediaBatch 在一个事务里批量保存/更新媒体记录。
func (s *Store) InsertDuplicate(info *MediaInfo) error { // 一次扫描可能有几千个文件,一个个提交事务很慢,攒一批一次写入快得多。
if s == nil || s.engine == nil {
return nil
}
_, err := s.engine.Insert(info)
return err
}
// SaveMediaBatch 在一个事务内批量按MD5去重保存/更新,避免大量文件逐个提交事务的 fsync 开销
func (s *Store) SaveMediaBatch(infos []*MediaInfo) error { func (s *Store) SaveMediaBatch(infos []*MediaInfo) error {
if s == nil || s.engine == nil || len(infos) == 0 { if s == nil || s.engine == nil || len(infos) == 0 {
return nil return nil
@@ -176,26 +176,10 @@ func (s *Store) SaveMediaBatch(infos []*MediaInfo) error {
return sess.Commit() return sess.Commit()
} }
// InsertDuplicateBatch 在一个事务内批量插入重复文件记录 // ============================ 媒体记录的查询 ============================
func (s *Store) InsertDuplicateBatch(infos []*MediaInfo) error {
if s == nil || s.engine == nil || len(infos) == 0 {
return nil
}
sess := s.engine.NewSession()
defer sess.Close()
if err := sess.Begin(); err != nil {
return err
}
for _, info := range infos {
if _, err := sess.Insert(info); err != nil {
_ = sess.Rollback()
return err
}
}
return sess.Commit()
}
// FindMediaByFingerprint 按 (文件大小 + 头256KB + 尾256KB) 查找已入库记录,避免对大文件重复计算全量MD5 // FindMediaByFingerprint 按"文件大小 + 头 256KB + 尾 256KB"这三个指纹找记录。
// 用途:大文件先用指纹去库里碰碰运气,对上了直接借它的 MD5,省得全量算一遍。
func (s *Store) FindMediaByFingerprint(size int64, headMd5, tailMd5 string) (*MediaInfo, bool) { func (s *Store) FindMediaByFingerprint(size int64, headMd5, tailMd5 string) (*MediaInfo, bool) {
if s == nil || s.engine == nil || headMd5 == "" || tailMd5 == "" { if s == nil || s.engine == nil || headMd5 == "" || tailMd5 == "" {
return nil, false return nil, false
@@ -208,7 +192,7 @@ func (s *Store) FindMediaByFingerprint(size int64, headMd5, tailMd5 string) (*Me
return info, true return info, true
} }
// FindMediaByMd5 按文件MD5查找已入库记录(用于识别重复文件) // FindMediaByMd5 按文件 MD5 找记录(用于判断"同一个文件是不是又出现了一次")。
func (s *Store) FindMediaByMd5(md5 string) (*MediaInfo, bool) { func (s *Store) FindMediaByMd5(md5 string) (*MediaInfo, bool) {
if s == nil || s.engine == nil || md5 == "" { if s == nil || s.engine == nil || md5 == "" {
return nil, false return nil, false
@@ -221,34 +205,23 @@ func (s *Store) FindMediaByMd5(md5 string) (*MediaInfo, bool) {
return info, true return info, true
} }
// UpdateMediaDest 按源路径回填文件的归类目录与去向(destPath 为移动后的完整文件路径) // ============================ 媒体库列表查询 ============================
func (s *Store) UpdateMediaDest(filePath, destDir, destPath string) error {
if s == nil || s.engine == nil {
return nil
}
_, err := s.engine.Where("file_path = ?", filePath).Update(&MediaInfo{
Category: filepath.Base(destDir),
DestDir: destDir,
DestPath: destPath,
})
return err
}
// MediaQuery 媒体库列表查询条件;空值表示不过滤 // MediaQuery 媒体库列表的查询条件。空值表示"这一项不过滤"。
type MediaQuery struct { type MediaQuery struct {
MediaType string // image / video / audio MediaType string // 类型:image / video / audio
Orientation string // horizontal / vertical / square Orientation string // 方向:horizontal / vertical / square
Keyword string // 文件名模糊搜索 Keyword string // 文件名模糊搜索
Codec string Codec string // 编码
Format string Format string // 容器格式
Resolution string // 分辨率档位标签(标准后缀 / NK / 普通 / 原图) Resolution string // 分辨率档位(如 1080P / 5K / 普通 / 原图)
Page int // 从 1 开始 Page int // 第几页(从 1 开始)
PageSize int PageSize int // 每页几条
SortField string // id / fileSize / width / height / duration / createdAt SortField string // 按哪个字段排序:id / fileSize / width / duration / createdAt
SortOrder string // asc / desc SortOrder string // asc 升序 / desc 降序
} }
// ListMedia 按条件查询媒体列表(含分页排序) // ListMedia 按条件查媒体列表(带分页和排序)。
func (s *Store) ListMedia(q MediaQuery) ([]MediaInfo, error) { func (s *Store) ListMedia(q MediaQuery) ([]MediaInfo, error) {
if s == nil || s.engine == nil { if s == nil || s.engine == nil {
return nil, nil return nil, nil
@@ -258,7 +231,7 @@ func (s *Store) ListMedia(q MediaQuery) ([]MediaInfo, error) {
if order := sortColumn(q.SortField, q.SortOrder); order != "" { if order := sortColumn(q.SortField, q.SortOrder); order != "" {
sess.OrderBy(order) sess.OrderBy(order)
} else { } else {
sess.Desc("id") sess.Desc("id") // 默认按入库顺序倒序,最新的排最前
} }
if q.Page > 0 { if q.Page > 0 {
pageSize := q.PageSize pageSize := q.PageSize
@@ -272,7 +245,7 @@ func (s *Store) ListMedia(q MediaQuery) ([]MediaInfo, error) {
return list, err return list, err
} }
// CountMedia 统计满足条件的记录数(分页用) // CountMedia 统计满足条件的记录数(给分页算"总共多少页"用)。
func (s *Store) CountMedia(q MediaQuery) (int64, error) { func (s *Store) CountMedia(q MediaQuery) (int64, error) {
if s == nil || s.engine == nil { if s == nil || s.engine == nil {
return 0, nil return 0, nil
@@ -282,10 +255,9 @@ func (s *Store) CountMedia(q MediaQuery) (int64, error) {
return sess.Count(new(MediaInfo)) return sess.Count(new(MediaInfo))
} }
// newQuerySession 创建带查询条件的会话(默认排除重复文件分类记录) // newQuerySession 建一个带查询条件的会话。
func (s *Store) newQuerySession(q MediaQuery) *xorm.Session { func (s *Store) newQuerySession(q MediaQuery) *xorm.Session {
sess := s.engine.NewSession() sess := s.engine.NewSession()
sess.Where("COALESCE(category,'') <> ?", CategoryDuplicate)
cond, args := buildMediaCond(q) cond, args := buildMediaCond(q)
if cond != "" { if cond != "" {
sess.Where(cond, args...) sess.Where(cond, args...)
@@ -293,10 +265,11 @@ func (s *Store) newQuerySession(q MediaQuery) *xorm.Session {
return sess return sess
} }
// buildMediaCond 根据查询条件构造 where 子句;返回 nil 表示无条件 // buildMediaCond 根据查询条件拼 SQL 的 where 部分;一个条件都没有返回空。
func buildMediaCond(q MediaQuery) (string, []interface{}) { func buildMediaCond(q MediaQuery) (string, []interface{}) {
var cond string var cond string
var args []interface{} var args []interface{}
// appendCond 是拼装小工具:把"片段 + 参数"用 AND 串起来
appendCond := func(fragment string, vals ...interface{}) { appendCond := func(fragment string, vals ...interface{}) {
if cond != "" { if cond != "" {
cond += " AND " cond += " AND "
@@ -307,16 +280,17 @@ func buildMediaCond(q MediaQuery) (string, []interface{}) {
if q.MediaType != "" { if q.MediaType != "" {
appendCond("media_type = ?", q.MediaType) appendCond("media_type = ?", q.MediaType)
} }
// 方向筛选:其实就看宽高谁大谁小
switch q.Orientation { switch q.Orientation {
case "horizontal": case "horizontal":
appendCond("width > ? AND width > height", 0) appendCond("width > ? AND width > height", 0) // 横屏:宽 > 高
case "vertical": case "vertical":
appendCond("width > ? AND width < height", 0) appendCond("width > ? AND width < height", 0) // 竖屏:宽 < 高
case "square": case "square":
appendCond("width > ? AND width = height", 0) appendCond("width > ? AND width = height", 0) // 等比:宽 = 高
} }
if q.Keyword != "" { if q.Keyword != "" {
appendCond("file_name LIKE ?", "%"+q.Keyword+"%") appendCond("file_name LIKE ?", "%"+q.Keyword+"%") // 文件名模糊匹配
} }
if q.Codec != "" { if q.Codec != "" {
appendCond("codec = ?", q.Codec) appendCond("codec = ?", q.Codec)
@@ -324,13 +298,17 @@ func buildMediaCond(q MediaQuery) (string, []interface{}) {
if q.Format != "" { if q.Format != "" {
appendCond("format = ?", q.Format) appendCond("format = ?", q.Format)
} }
// 分辨率筛选:把"档位标签"翻译成尺寸范围再查
if q.Resolution != "" { if q.Resolution != "" {
if minLong, maxLong, exactW, exactH := classify.ResRange(q.Resolution); minLong >= 0 { if minLong, maxLong, exactW, exactH := classify.ResRange(q.Resolution); minLong >= 0 {
if exactW > 0 && exactH > 0 { if exactW > 0 && exactH > 0 {
// 标准档:宽高精确匹配
appendCond("(width = ? AND height = ?)", exactW, exactH) appendCond("(width = ? AND height = ?)", exactW, exactH)
} else if maxLong >= 0 { } else if maxLong >= 0 {
// 约数档:长边在一个区间内
appendCond("(max(width, height) >= ? AND max(width, height) < ?)", minLong, maxLong) appendCond("(max(width, height) >= ? AND max(width, height) < ?)", minLong, maxLong)
} else { } else {
// 超出档位:长边不小于某值
appendCond("max(width, height) >= ?", minLong) appendCond("max(width, height) >= ?", minLong)
} }
} }
@@ -341,7 +319,8 @@ func buildMediaCond(q MediaQuery) (string, []interface{}) {
return cond, args return cond, args
} }
// sortColumn 将排序字段白名单映射为数据库列名,避免 SQL 注入;返回 "" 表示不排序 // sortColumn 把排序字段翻译成数据库列名。
// 只认白名单里的字段,防止用户传个奇怪字段名来搞 SQL 注入。返回 "" 表示不排序。
func sortColumn(field, order string) string { func sortColumn(field, order string) string {
columns := map[string]string{ columns := map[string]string{
"id": "id", "id": "id",
@@ -361,95 +340,96 @@ func sortColumn(field, order string) string {
return col + " ASC" return col + " ASC"
} }
// MediaStats 媒体库统计结果 // ============================ 统计看板 ============================
// MediaStats 统计看板要展示的全部数据。前端「统计看板」页面就是照这个结构展示的。
type MediaStats struct { type MediaStats struct {
TotalFiles int64 `json:"totalFiles"` TotalFiles int64 `json:"totalFiles"` // 媒体总数
TotalSize int64 `json:"totalSize"` TotalSize int64 `json:"totalSize"` // 总体积(字节)
TypeCounts []KV `json:"typeCounts"` // image/video/audio 数量与体积 TypeCounts []KV `json:"typeCounts"` // 按类型(图片/视频/音频)的数量与体积
OrientCount []KV `json:"orientCount"` // horizontal/vertical/square 数量 OrientCount []KV `json:"orientCount"` // 按方向(横/竖/正方)的数量
ResCount []KV `json:"resCount"` // 分辨率档位数量 ResCount []KV `json:"resCount"` // 按分辨率档位的数量
CodecCount []KV `json:"codecCount"` // 编码分布(视频) CodecCount []KV `json:"codecCount"` // 视频编码分布
FormatCount []KV `json:"formatCount"` // 容器格式分布 FormatCount []KV `json:"formatCount"` // 容器格式分布
Duplicate int64 `json:"duplicate"` // 重复文件数(同一MD5出现多次的去重后计数)
DupSize int64 `json:"dupSize"` // 重复文件占用的总字节
TopLarge []KV `json:"topLarge"` // 体积最大的 10 个文件 TopLarge []KV `json:"topLarge"` // 体积最大的 10 个文件
DurationTotal int64 `json:"durationTotal"` // 视频/音频总时长(秒) DurationTotal int64 `json:"durationTotal"` // 视频/音频总时长(秒)
DurationAvg int64 `json:"durationAvg"` // 视频/音频平均时长(秒) DurationAvg int64 `json:"durationAvg"` // 平均时长(秒)
AvgSize int64 `json:"avgSize"` // 平均文件体积(字节) AvgSize int64 `json:"avgSize"` // 平均文件体积(字节)
MaxSize int64 `json:"maxSize"` // 最大文件体积(字节) MaxSize int64 `json:"maxSize"` // 最大文件体积(字节)
MinSize int64 `json:"minSize"` // 最小文件体积(字节) MinSize int64 `json:"minSize"` // 最小文件体积(字节)
DurationBins []KV `json:"durationBins"` // 时长区间分布(视频/音频) DurationBins []KV `json:"durationBins"` // 时长区间分布(如 1-5分钟)
CreatedCounts []KV `json:"createdCounts"` // 入库趋势(按日,近 30 天) CreatedCounts []KV `json:"createdCounts"` // 入库趋势(近 30 天,按天)
CategoryCounts []KV `json:"categoryCounts"` // 归类目录分布
ExtCounts []KV `json:"extCounts"` // 扩展名分布 ExtCounts []KV `json:"extCounts"` // 扩展名分布
MimeCounts []KV `json:"mimeCounts"` // MIME 类型分布 MimeCounts []KV `json:"mimeCounts"` // MIME 类型分布
AudioCodecCount []KV `json:"audioCodecCount"` // 音频编码分布 AudioCodecCount []KV `json:"audioCodecCount"` // 音频编码分布
} }
// KV 统计键值对 // KV 统计里的一种"键值对":某一类是什么(key)、有多少个(count)、占多少字节(size)。
type KV struct { type KV struct {
Key string `json:"key"` Key string `json:"key"`
Count int64 `json:"count"` Count int64 `json:"count"`
Size int64 `json:"size"` Size int64 `json:"size"`
} }
// Stats 汇总媒体库统计信息 // Stats 汇总全部统计数据。用了一堆 SQL 查询,把各种分布都算出来。
func (s *Store) Stats() (*MediaStats, error) { func (s *Store) Stats() (*MediaStats, error) {
if s == nil || s.engine == nil { if s == nil || s.engine == nil {
return &MediaStats{}, nil return &MediaStats{}, nil
} }
st := &MediaStats{ st := &MediaStats{
ResCount: []KV{}, // 避免空库时返回 null,前端 .slice 直接可用 // 空库时这些列表默认为空数组(而不是 null),前端直接能用
ResCount: []KV{},
DurationBins: []KV{}, DurationBins: []KV{},
CreatedCounts: []KV{}, CreatedCounts: []KV{},
CategoryCounts: []KV{},
ExtCounts: []KV{}, ExtCounts: []KV{},
MimeCounts: []KV{}, MimeCounts: []KV{},
AudioCodecCount: []KV{}, AudioCodecCount: []KV{},
} }
// 总数和总体积
var totalCnt struct { var totalCnt struct {
C int64 `xorm:"c"` C int64 `xorm:"c"`
} }
if _, err := s.engine.SQL("SELECT COUNT(*) AS c FROM media_info WHERE COALESCE(category,'') <> '重复文件'").Get(&totalCnt); err == nil { if _, err := s.engine.SQL("SELECT COUNT(*) AS c FROM media_info").Get(&totalCnt); err == nil {
st.TotalFiles = totalCnt.C st.TotalFiles = totalCnt.C
} }
var sizeTotal struct { var sizeTotal struct {
Size int64 `xorm:"size"` Size int64 `xorm:"size"`
} }
if _, err := s.engine.SQL("SELECT COALESCE(SUM(file_size),0) AS size FROM media_info WHERE COALESCE(category,'') <> '重复文件'").Get(&sizeTotal); err == nil { if _, err := s.engine.SQL("SELECT COALESCE(SUM(file_size),0) AS size FROM media_info").Get(&sizeTotal); err == nil {
st.TotalSize = sizeTotal.Size st.TotalSize = sizeTotal.Size
} }
// 类型统计(数量 + 体积) // 类型统计(数量 + 体积)
typeRows := make([]KV, 0) typeRows := make([]KV, 0)
if err := s.engine.SQL("SELECT media_type AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE COALESCE(category,'') <> '重复文件' GROUP BY media_type ORDER BY count DESC").Find(&typeRows); err == nil { if err := s.engine.SQL("SELECT media_type AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info GROUP BY media_type ORDER BY count DESC").Find(&typeRows); err == nil {
st.TypeCounts = typeRows st.TypeCounts = typeRows
} }
// 方向统计(仅含有效尺寸的图片/视频) // 方向统计(只有宽高都有效的才算)
orientRows := make([]KV, 0) orientRows := make([]KV, 0)
if err := s.engine.SQL("SELECT CASE WHEN width>height THEN 'horizontal' WHEN width<height THEN 'vertical' ELSE 'square' END AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE width>0 AND height>0 AND COALESCE(category,'') <> '重复文件' GROUP BY 1 ORDER BY count DESC").Find(&orientRows); err == nil { if err := s.engine.SQL("SELECT CASE WHEN width>height THEN 'horizontal' WHEN width<height THEN 'vertical' ELSE 'square' END AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE width>0 AND height>0 GROUP BY 1 ORDER BY count DESC").Find(&orientRows); err == nil {
st.OrientCount = orientRows st.OrientCount = orientRows
} }
// 编码 / 格式 / 分辨率 / 大文件统计 // 编码 / 格式 / 大文件 TOP10
codecRows := make([]KV, 0) codecRows := make([]KV, 0)
if err := s.engine.SQL("SELECT COALESCE(NULLIF(codec,''),'未知') AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE media_type='video' AND codec <> '' AND COALESCE(category,'') <> '重复文件' GROUP BY codec ORDER BY count DESC LIMIT 10").Find(&codecRows); err == nil { if err := s.engine.SQL("SELECT COALESCE(NULLIF(codec,''),'未知') AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE media_type='video' AND codec <> '' GROUP BY codec ORDER BY count DESC LIMIT 10").Find(&codecRows); err == nil {
st.CodecCount = codecRows st.CodecCount = codecRows
} }
formatRows := make([]KV, 0) formatRows := make([]KV, 0)
if err := s.engine.SQL("SELECT COALESCE(NULLIF(format,''), LOWER(ltrim(file_ext,'.'))) AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE (format <> '' OR file_ext <> '') AND COALESCE(category,'') <> '重复文件' GROUP BY key ORDER BY count DESC LIMIT 10").Find(&formatRows); err == nil { if err := s.engine.SQL("SELECT COALESCE(NULLIF(format,''), LOWER(ltrim(file_ext,'.'))) AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE (format <> '' OR file_ext <> '') GROUP BY key ORDER BY count DESC LIMIT 10").Find(&formatRows); err == nil {
st.FormatCount = formatRows st.FormatCount = formatRows
} }
largeRows := make([]KV, 0) largeRows := make([]KV, 0)
if err := s.engine.SQL("SELECT file_name AS key, 1 AS count, file_size AS size FROM media_info WHERE COALESCE(category,'') <> '重复文件' ORDER BY file_size DESC LIMIT 10").Find(&largeRows); err == nil { if err := s.engine.SQL("SELECT file_name AS key, 1 AS count, file_size AS size FROM media_info ORDER BY file_size DESC LIMIT 10").Find(&largeRows); err == nil {
st.TopLarge = largeRows st.TopLarge = largeRows
} }
// 分辨率分布(Go 侧计算,复用归类规则) // 分辨率分布:这里不用 SQL 分组,而是把每个文件的大小取出来,
// 用和归类时同一套规则(classify.ResLabel)算档位再计数,保证和分类结果完全一致。
resMap := make(map[string]int64) resMap := make(map[string]int64)
rows, err := s.engine.SQL("SELECT width, height FROM media_info WHERE width>0 AND height>0 AND COALESCE(category,'') <> '重复文件'").Query() rows, err := s.engine.SQL("SELECT width, height FROM media_info WHERE width>0 AND height>0").Query()
if err != nil { if err != nil {
return st, err return st, err
} }
@@ -470,18 +450,18 @@ func (s *Store) Stats() (*MediaStats, error) {
} }
sortKVs(st.ResCount) sortKVs(st.ResCount)
// 时长统计(视频/音频) // 时长统计(视频/音频):总时长 + 平均时长
var dur struct { var dur struct {
Total float64 `xorm:"total"` Total float64 `xorm:"total"`
Avg float64 `xorm:"avg"` Avg float64 `xorm:"avg"`
} }
if _, err := s.engine.SQL("SELECT COALESCE(SUM(duration),0) AS total, COALESCE(AVG(CASE WHEN duration>0 THEN duration END),0) AS avg FROM media_info WHERE duration>0 AND COALESCE(category,'') <> '重复文件'").Get(&dur); err == nil { if _, err := s.engine.SQL("SELECT COALESCE(SUM(duration),0) AS total, COALESCE(AVG(CASE WHEN duration>0 THEN duration END),0) AS avg FROM media_info WHERE duration>0").Get(&dur); err == nil {
st.DurationTotal = int64(dur.Total) st.DurationTotal = int64(dur.Total)
st.DurationAvg = int64(dur.Avg) st.DurationAvg = int64(dur.Avg)
} }
// 时长区间分布 // 时长区间分布:<1分钟 / 1-5分钟 / 5-30分钟 / 30分钟-1小时 / >1小时
durRows := make([]KV, 0) durRows := make([]KV, 0)
if err := s.engine.SQL("SELECT CASE WHEN duration<60 THEN '<1分钟' WHEN duration<300 THEN '1-5分钟' WHEN duration<1800 THEN '5-30分钟' WHEN duration<3600 THEN '30分钟-1小时' ELSE '>1小时' END AS key, COUNT(*) AS count, COALESCE(SUM(duration),0) AS size FROM media_info WHERE duration>0 AND COALESCE(category,'') <> '重复文件' GROUP BY key ORDER BY count DESC").Find(&durRows); err == nil { if err := s.engine.SQL("SELECT CASE WHEN duration<60 THEN '<1分钟' WHEN duration<300 THEN '1-5分钟' WHEN duration<1800 THEN '5-30分钟' WHEN duration<3600 THEN '30分钟-1小时' ELSE '>1小时' END AS key, COUNT(*) AS count, COALESCE(SUM(duration),0) AS size FROM media_info WHERE duration>0 GROUP BY key ORDER BY count DESC").Find(&durRows); err == nil {
st.DurationBins = durRows st.DurationBins = durRows
} }
// 平均/最大/最小文件体积 // 平均/最大/最小文件体积
@@ -490,52 +470,38 @@ func (s *Store) Stats() (*MediaStats, error) {
Max int64 `xorm:"max"` Max int64 `xorm:"max"`
Min int64 `xorm:"min"` Min int64 `xorm:"min"`
} }
if _, err := s.engine.SQL("SELECT COALESCE(AVG(file_size),0) AS avg, COALESCE(MAX(file_size),0) AS max, COALESCE(MIN(file_size),0) AS min FROM media_info WHERE COALESCE(category,'') <> '重复文件'").Get(&sizeStat); err == nil { if _, err := s.engine.SQL("SELECT COALESCE(AVG(file_size),0) AS avg, COALESCE(MAX(file_size),0) AS max, COALESCE(MIN(file_size),0) AS min FROM media_info").Get(&sizeStat); err == nil {
st.AvgSize = sizeStat.Avg st.AvgSize = sizeStat.Avg
st.MaxSize = sizeStat.Max st.MaxSize = sizeStat.Max
st.MinSize = sizeStat.Min st.MinSize = sizeStat.Min
} }
// 入库趋势(近 30 天按日分组) // 入库趋势(近 30 天,按天):把时间字段截出日期再分组,倒序排完再翻正
createdRows := make([]KV, 0) createdRows := make([]KV, 0)
if err := s.engine.SQL("SELECT substr(created_at,1,10) AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE created_at <> '' AND COALESCE(category,'') <> '重复文件' GROUP BY key ORDER BY key DESC LIMIT 30").Find(&createdRows); err == nil { if err := s.engine.SQL("SELECT substr(created_at,1,10) AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE created_at <> '' GROUP BY key ORDER BY key DESC LIMIT 30").Find(&createdRows); err == nil {
for i, j := 0, len(createdRows)-1; i < j; i, j = i+1, j-1 { for i, j := 0, len(createdRows)-1; i < j; i, j = i+1, j-1 {
createdRows[i], createdRows[j] = createdRows[j], createdRows[i] createdRows[i], createdRows[j] = createdRows[j], createdRows[i]
} }
st.CreatedCounts = createdRows st.CreatedCounts = createdRows
} }
// 归类目录分布
catRows := make([]KV, 0)
if err := s.engine.SQL("SELECT category AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE category <> '' AND COALESCE(category,'') <> '重复文件' GROUP BY category ORDER BY count DESC LIMIT 15").Find(&catRows); err == nil {
st.CategoryCounts = catRows
}
// 扩展名分布 // 扩展名分布
extRows := make([]KV, 0) extRows := make([]KV, 0)
if err := s.engine.SQL("SELECT LOWER(ltrim(file_ext,'.')) AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE file_ext <> '' AND COALESCE(category,'') <> '重复文件' GROUP BY key ORDER BY count DESC LIMIT 10").Find(&extRows); err == nil { if err := s.engine.SQL("SELECT LOWER(ltrim(file_ext,'.')) AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE file_ext <> '' GROUP BY key ORDER BY count DESC LIMIT 10").Find(&extRows); err == nil {
st.ExtCounts = extRows st.ExtCounts = extRows
} }
// MIME 类型分布 // MIME 类型分布
mimeRows := make([]KV, 0) mimeRows := make([]KV, 0)
if err := s.engine.SQL("SELECT mime_type AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE mime_type <> '' AND COALESCE(category,'') <> '重复文件' GROUP BY mime_type ORDER BY count DESC LIMIT 10").Find(&mimeRows); err == nil { if err := s.engine.SQL("SELECT mime_type AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE mime_type <> '' GROUP BY mime_type ORDER BY count DESC LIMIT 10").Find(&mimeRows); err == nil {
st.MimeCounts = mimeRows st.MimeCounts = mimeRows
} }
// 音频编码分布 // 音频编码分布
audioRows := make([]KV, 0) audioRows := make([]KV, 0)
if err := s.engine.SQL("SELECT COALESCE(NULLIF(codec,''),'未知') AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE media_type='audio' AND codec <> '' AND COALESCE(category,'') <> '重复文件' GROUP BY codec ORDER BY count DESC LIMIT 10").Find(&audioRows); err == nil { if err := s.engine.SQL("SELECT COALESCE(NULLIF(codec,''),'未知') AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info WHERE media_type='audio' AND codec <> '' GROUP BY codec ORDER BY count DESC LIMIT 10").Find(&audioRows); err == nil {
st.AudioCodecCount = audioRows st.AudioCodecCount = audioRows
} }
// 重复文件统计(同一 MD5 出现多次)
dupRows := make([]KV, 0)
if err := s.engine.SQL("SELECT file_md5 AS key, COUNT(*) AS count, COALESCE(SUM(file_size),0) AS size FROM media_info GROUP BY file_md5 HAVING COUNT(*)>1").Find(&dupRows); err == nil {
for _, r := range dupRows {
st.Duplicate += r.Count - 1
st.DupSize += r.Size - r.Size/r.Count // 近似:等量分配单份体积
}
}
return st, nil return st, nil
} }
// sortKVs 按 count 降序排序 // sortKVs 把统计结果按数量从多到少排序。
func sortKVs(list []KV) { func sortKVs(list []KV) {
sort.Slice(list, func(i, j int) bool { return list[i].Count > list[j].Count }) sort.Slice(list, func(i, j int) bool { return list[i].Count > list[j].Count })
} }
+23 -5
View File
@@ -1,4 +1,12 @@
// kenaito-media-picker 主入口:启动 Web 服务(内嵌前端),媒体库扫描整理由页面点击触发。 // 主程序:程序从这里启动。
//
// 启动后做的事很简单:
// 1. 读配置文件(端口、数据库路径等);
// 2. 打开数据库(打不开也不影响启动,只是媒体库没得查);
// 3. 搭好网址指路(路由),把内嵌的前端页面交给浏览器;
// 4. 在浏览器里自动打开页面,然后开始招待网页请求。
//
// 真正的媒体整理不会在启动时发生——都是用户在页面上点「开始扫描」才触发的。
package main package main
import ( import (
@@ -23,10 +31,11 @@ import (
) )
func main() { func main() {
// 读配置文件,得到端口、数据库路径等
config.LoadAppConfig() config.LoadAppConfig()
cfg := config.GetAppConfig() cfg := config.GetAppConfig()
// 打开数据库;失败时服务照常启动,仅媒体库查询不可用、扫描不记录信息 // 打开数据库;失败时服务照常启动,只是媒体库查不了、扫描不记信息
st, err := store.Open(cfg.Dsn) st, err := store.Open(cfg.Dsn)
if err != nil { if err != nil {
fmt.Printf("=== 打开数据库失败(媒体库查询不可用): %v\n", err) fmt.Printf("=== 打开数据库失败(媒体库查询不可用): %v\n", err)
@@ -35,9 +44,12 @@ func main() {
defer st.Close() defer st.Close()
} }
// 搭好路由(网址 → 处理窗口)
r := api.NewRouter(st) r := api.NewRouter(st)
// 请求日志输出到 stderr,避免占用被扫描流程捕获的 stdout // 网页请求的访问日志写到 stderr,不占用 stdout——
// 因为扫描时 stdout 会被临时接管来采集日志,不能让访问日志混进去。
r.Use(gin.LoggerWithWriter(os.Stderr)) r.Use(gin.LoggerWithWriter(os.Stderr))
// 所有不是 /api 的地址,都交给内嵌的前端页面处理
r.NoRoute(staticHandler()) r.NoRoute(staticHandler())
addr := fmt.Sprintf(":%d", cfg.Port) addr := fmt.Sprintf(":%d", cfg.Port)
@@ -45,6 +57,7 @@ func main() {
fmt.Printf("=== Web 服务已启动: %s\n", url) fmt.Printf("=== Web 服务已启动: %s\n", url)
fmt.Printf("=== 数据库: %s\n", cfg.Dsn) fmt.Printf("=== 数据库: %s\n", cfg.Dsn)
fmt.Printf("=== 在页面上点击【开始扫描】执行媒体整理(会移动文件,注意备份)\n") fmt.Printf("=== 在页面上点击【开始扫描】执行媒体整理(会移动文件,注意备份)\n")
// 配置了自动打开浏览器的话,等服务起来后帮用户打开
if cfg.AutoOpen { if cfg.AutoOpen {
go func() { go func() {
time.Sleep(600 * time.Millisecond) // 等服务端口就绪再打开 time.Sleep(600 * time.Millisecond) // 等服务端口就绪再打开
@@ -56,7 +69,7 @@ func main() {
} }
} }
// openBrowser 调用系统默认浏览器打开指定 URL(Windows/macOS/Linux) // openBrowser 调用系统默认浏览器打开指定网址(Windows/macOS/Linux 各自处理)。
func openBrowser(url string) { func openBrowser(url string) {
var cmd *exec.Cmd var cmd *exec.Cmd
switch runtime.GOOS { switch runtime.GOOS {
@@ -72,15 +85,18 @@ func openBrowser(url string) {
} }
} }
// staticHandler 服务内嵌前端产物:命中静态资源则返回,否则回退 index.html(hash 路由)。 // staticHandler 把内嵌在程序里的前端页面(编译时打包进来的)交给浏览器。
// 命中真实文件(js/css/图片)就返回那个文件,否则一律返回首页(单页应用模式)。
func staticHandler() gin.HandlerFunc { func staticHandler() gin.HandlerFunc {
distFS, err := fs.Sub(frontend.Dist, "dist") distFS, err := fs.Sub(frontend.Dist, "dist")
if err != nil { if err != nil {
// 没编译前端产物的话,给个提示
return func(c *gin.Context) { return func(c *gin.Context) {
c.String(http.StatusNotFound, "前端产物缺失,请先 npm run build 后重新编译") c.String(http.StatusNotFound, "前端产物缺失,请先 npm run build 后重新编译")
} }
} }
return func(c *gin.Context) { return func(c *gin.Context) {
// 未知的 /api 地址统一回"接口不存在"
if strings.HasPrefix(c.Request.URL.Path, "/api") { if strings.HasPrefix(c.Request.URL.Path, "/api") {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "接口不存在", "data": nil}) c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "接口不存在", "data": nil})
return return
@@ -91,6 +107,7 @@ func staticHandler() gin.HandlerFunc {
} }
file, err := distFS.Open(path) file, err := distFS.Open(path)
if err != nil { if err != nil {
// 没找到文件就回首页(单页应用:路由由前端管理)
file, err = distFS.Open("index.html") file, err = distFS.Open("index.html")
} }
if err != nil { if err != nil {
@@ -103,6 +120,7 @@ func staticHandler() gin.HandlerFunc {
c.String(http.StatusInternalServerError, "读取前端资源失败") c.String(http.StatusInternalServerError, "读取前端资源失败")
return return
} }
// 尽量用 http.ServeContent 这种能断点续传的方式返回文件
if rs, ok := file.(io.ReadSeeker); ok { if rs, ok := file.(io.ReadSeeker); ok {
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), rs) http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), rs)
return return