feat: 断点续传与色情识别提速/修复,支持 Linux 交叉编译
- feat: 新增续传表 scan_log,重扫跳过已归类文件(尺寸/头尾指纹校验) - fix: 色情识别记录批量入库超 SQLite 999 变量上限,改每 100 条一批 - perf: NudeNet 检测改为多线程推理池(每线程独立模型实例) - perf: 明暗分析 JPEG(YCbCr) 快路径;SQLite 读并发放开;加阶段耗时统计 - fix: 前端色情识别状态由后端如实上报(配置开关+检测服务是否起来) - feat: ffprobe 内嵌按平台拆分,Linux 构建回退系统 ffprobe,支持交叉编译 - docs: README 补充断点续传与 CentOS 交叉编译步骤 - chore: organize.workers 默认 8
This commit is contained in:
+180
-20
@@ -18,6 +18,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"kenaito-media-picker/internal/classify"
|
||||
"kenaito-media-picker/internal/config"
|
||||
@@ -48,6 +49,13 @@ const (
|
||||
dirAudio = "音频" // 音频统一放这一个文件夹
|
||||
)
|
||||
|
||||
// nsfwProgressInterval 色情识别每隔多少张图打一行进度,让前端能看到识别在推进
|
||||
// (逐张打太吵,日志还会被 trim 掉一半)。
|
||||
const nsfwProgressInterval = 200
|
||||
|
||||
// scanLogFlushInterval 续传记录攒够多少条就落一次库(中断/出错时尽量少丢已归类的结果)。
|
||||
const scanLogFlushInterval = 200
|
||||
|
||||
// ============================ 整理器主体 ============================
|
||||
|
||||
// Organizer 整理器。它的工作方式:
|
||||
@@ -69,12 +77,24 @@ type Organizer struct {
|
||||
onProgress func(phase string, done, total int)
|
||||
pending []*store.MediaInfo // 待写入数据库的媒体记录(攒一批再批量写)
|
||||
pendingNsfw []store.NsfwRecord // 待写入数据库的色情识别记录(攒一批再批量写)
|
||||
resume map[string]store.ScanLog // 续传表:源路径 → 上次归类结果(重扫跳过重新探测)
|
||||
resumeSkip int32 // 本次续传跳过的文件数(并行累计)
|
||||
pendingScan []store.ScanLog // 待写入续传表的记录(攒一批再批量写)
|
||||
nsfwChecked int32 // 色情识别已检查的图片数(并行累计,只涨不跌)
|
||||
nsfwHits int32 // 色情识别命中的图片数(并行累计)
|
||||
// 阶段耗时统计(并行 worker 累计,单位纳秒,只用于日志诊断瓶颈)
|
||||
tImageProbe int64 // 图片尺寸探测总耗时
|
||||
tImgMd5 int64 // 图片 MD5/指纹计算总耗时
|
||||
tBright int64 // 明暗分析总耗时
|
||||
tNsfw int64 // 色情识别总耗时
|
||||
tMediaProbe int64 // 媒体探测(ffprobe)总耗时
|
||||
tMediaMd5 int64 // 媒体(视频/音频) MD5/指纹计算总耗时
|
||||
}
|
||||
|
||||
// New 创建整理器。store 可以为 nil——数据库打不开时,整理照常进行,只是不记入库信息。
|
||||
// ctx 可以为 nil——不提供中止能力,任务跑到完为止。
|
||||
func New(pickerDir, ffprobePath, ffmpegPath string, st *store.Store, ctx context.Context) *Organizer {
|
||||
return &Organizer{
|
||||
org := &Organizer{
|
||||
pickerDir: pickerDir,
|
||||
ffprobePath: ffprobePath,
|
||||
ffmpegPath: ffmpegPath,
|
||||
@@ -84,6 +104,16 @@ func New(pickerDir, ffprobePath, ffmpegPath string, st *store.Store, ctx context
|
||||
rotateImages: true, // 默认处理 EXIF 旋转;关掉用 SetRotateImages(false)
|
||||
buckets: make(map[string][]string),
|
||||
}
|
||||
// 加载续传表:上次归类过的文件,这次跳过重新探测,直接复用目标目录
|
||||
if st != nil {
|
||||
if m, err := st.LoadScanLog(); err != nil {
|
||||
fmt.Printf("=== 加载续传记录失败: %v\n", err)
|
||||
} else if len(m) > 0 {
|
||||
org.resume = m
|
||||
fmt.Printf("=== 续传: 上次已归类 %d 个文件,本次将跳过它们的重新探测/识别\n", len(m))
|
||||
}
|
||||
}
|
||||
return org
|
||||
}
|
||||
|
||||
// SetProgress 设置进度回调。回调参数:
|
||||
@@ -166,15 +196,76 @@ func runParallel[T any, R any](ctx context.Context, items []T, fn func(T) R, onP
|
||||
|
||||
// ============================ 分类与入库小工具 ============================
|
||||
|
||||
// add 把一个文件记到某个目标目录的名下(先攒着,最后统一搬)。
|
||||
// add 把一个文件记到某个目标目录(相对分类名)的名下(先攒着,最后统一搬)。
|
||||
func (o *Organizer) add(f scanner.File, subDir string) {
|
||||
dir := filepath.Join(o.pickerDir, subDir)
|
||||
o.addToDir(f, filepath.Join(o.pickerDir, subDir))
|
||||
}
|
||||
|
||||
// addToDir 把一个文件记到某个目标目录(完整路径)的名下。
|
||||
func (o *Organizer) addToDir(f scanner.File, dir string) {
|
||||
if _, ok := o.buckets[dir]; !ok {
|
||||
o.order = append(o.order, dir)
|
||||
}
|
||||
o.buckets[dir] = append(o.buckets[dir], f.Path)
|
||||
}
|
||||
|
||||
// addClassified 归档一个文件并记进续传表:把"源路径 → 目标目录"写进 scan_log,
|
||||
// 这样下次重扫时这个文件直接复用目标目录,不再重新探测/识别。
|
||||
func (o *Organizer) addClassified(f scanner.File, subDir string, size int64, headMd5, tailMd5 string) {
|
||||
o.add(f, subDir)
|
||||
if o.store == nil {
|
||||
return
|
||||
}
|
||||
o.pendingScan = append(o.pendingScan, store.ScanLog{
|
||||
SourcePath: f.Path,
|
||||
TargetDir: filepath.Join(o.pickerDir, subDir),
|
||||
FileSize: size,
|
||||
HeadMd5: headMd5,
|
||||
TailMd5: tailMd5,
|
||||
})
|
||||
if len(o.pendingScan) >= scanLogFlushInterval {
|
||||
o.flushScanLog()
|
||||
}
|
||||
}
|
||||
|
||||
// matchResume 判断一个文件上次是否已归类过,且内容没变。
|
||||
// 命中返回上次的目标目录和 ok=true;内容变了/没记录则 ok=false(要重新探测)。
|
||||
func (o *Organizer) matchResume(f scanner.File) (store.ScanLog, bool) {
|
||||
if o.resume == nil {
|
||||
return store.ScanLog{}, false
|
||||
}
|
||||
sl, ok := o.resume[f.Path]
|
||||
if !ok {
|
||||
return sl, false
|
||||
}
|
||||
st, err := os.Stat(f.Path)
|
||||
if err != nil {
|
||||
return sl, false
|
||||
}
|
||||
if st.Size() != sl.FileSize {
|
||||
return sl, false
|
||||
}
|
||||
// 大文件再核对头尾指纹,确认内容真没变才放心跳过
|
||||
if sl.FileSize > digest.FingerprintThreshold && sl.HeadMd5 != "" && sl.TailMd5 != "" {
|
||||
head, tail, ferr := digest.CalcFileFingerprint(f.Path, st.Size())
|
||||
if ferr != nil || head != sl.HeadMd5 || tail != sl.TailMd5 {
|
||||
return sl, false
|
||||
}
|
||||
}
|
||||
return sl, true
|
||||
}
|
||||
|
||||
// flushScanLog 把攒下的续传记录一次性写进数据库(中断时尽量少丢已归类的结果)。
|
||||
func (o *Organizer) flushScanLog() {
|
||||
if o.store == nil || len(o.pendingScan) == 0 {
|
||||
return
|
||||
}
|
||||
if err := o.store.SaveScanLogs(o.pendingScan); err != nil {
|
||||
fmt.Printf("=== 保存续传记录失败: %v\n", err)
|
||||
}
|
||||
o.pendingScan = nil
|
||||
}
|
||||
|
||||
// calcFileInfo 读取文件大小并算 MD5(文件的"身份证号")。
|
||||
//
|
||||
// 大文件算全量 MD5 很费时间,所以先用"指纹"(文件大小 + 头尾各 256KB 的 MD5)
|
||||
@@ -290,6 +381,8 @@ type imageProbeResult struct {
|
||||
psd bool // 是 PSD 设计稿,不做处理原样收走
|
||||
unsupported bool // 不支持的格式,归入"已忽略"
|
||||
probeErr bool // 体检失败(打不开/读不出),归入"读取异常"
|
||||
resume bool // 续传命中:上次已归类过,跳过全部体检,直接复用目标目录
|
||||
resumeDir string // 续传命中的目标目录
|
||||
res *probe.ProbeResult // 体检出的尺寸等信息(体检成功时才有)
|
||||
md5Str string // 文件 MD5
|
||||
headMd5 string // 文件头采样 MD5(大文件指纹)
|
||||
@@ -306,6 +399,13 @@ type imageProbeResult struct {
|
||||
// 只读数据库(查指纹),这是安全的。
|
||||
func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
|
||||
r := imageProbeResult{}
|
||||
// 续传:上次已归类过、内容没变 → 跳过全部体检,直接复用上次的目标目录
|
||||
if sl, ok := o.matchResume(f); ok {
|
||||
atomic.AddInt32(&o.resumeSkip, 1)
|
||||
r.resume = true
|
||||
r.resumeDir = sl.TargetDir
|
||||
return r
|
||||
}
|
||||
// PSD/PSB 设计稿不体检,直接标记"原样收走"
|
||||
if strings.EqualFold(f.Ext, ".psd") || strings.EqualFold(f.Ext, ".psb") {
|
||||
r.psd = true
|
||||
@@ -317,7 +417,9 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
|
||||
return r
|
||||
}
|
||||
// 探测尺寸
|
||||
probeStart := time.Now()
|
||||
res, err := probe.ProbeImage(f.Path, o.ffprobePath)
|
||||
atomic.AddInt64(&o.tImageProbe, int64(time.Since(probeStart)))
|
||||
if err != nil {
|
||||
r.probeErr = true
|
||||
return r
|
||||
@@ -328,10 +430,14 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
|
||||
// 必须在算 MD5 / 分析明暗之前做——转正会改文件内容,得让 MD5 对得上最终文件。
|
||||
o.correctImageRotation(f, r.res)
|
||||
// 算 MD5(顺便拿指纹,可能省掉全量计算)
|
||||
md5Start := time.Now()
|
||||
r.md5Str, r.headMd5, r.tailMd5, r.size = o.calcFileInfo(f)
|
||||
atomic.AddInt64(&o.tImgMd5, int64(time.Since(md5Start)))
|
||||
// 分析明暗(GIF 不做,动画图逐帧分析没意义)
|
||||
if !strings.EqualFold(f.Ext, ".gif") {
|
||||
brightStart := time.Now()
|
||||
dark, light, berr := probe.AnalyzeBrightness(f.Path)
|
||||
atomic.AddInt64(&o.tBright, int64(time.Since(brightStart)))
|
||||
if berr != nil {
|
||||
fmt.Printf("=== 明暗分析失败(按中性mid归类): %s \n", f.Path)
|
||||
} else {
|
||||
@@ -340,17 +446,25 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
|
||||
}
|
||||
// 色情识别(GIF 不做;没启用检测器就直接跳过)
|
||||
if o.nsfw != nil && !strings.EqualFold(f.Ext, ".gif") {
|
||||
nsfwStart := time.Now()
|
||||
score, hit, derr := o.nsfw.Detect(f.Path)
|
||||
atomic.AddInt64(&o.tNsfw, int64(time.Since(nsfwStart)))
|
||||
if derr != nil {
|
||||
// 检测失败按"未命中"处理,不影响整理流程
|
||||
fmt.Printf("=== 色情检测失败(按未命中归类): %s, 错误: %v \n", f.Path, derr)
|
||||
} else {
|
||||
r.nsfwScore = score
|
||||
r.nsfw = hit
|
||||
// 命中的图片把"文件名 + 检测分"打进运行日志,方便用户盯着看是否判对
|
||||
checked := atomic.AddInt32(&o.nsfwChecked, 1)
|
||||
if hit {
|
||||
atomic.AddInt32(&o.nsfwHits, 1)
|
||||
// 命中的图片把"文件名 + 检测分"打进运行日志,方便用户盯着看是否判对
|
||||
fmt.Printf("=== 色情命中: %s, 检测分=%.2f (阈值=%.2f)\n", f.Path, score, o.nsfw.Threshold())
|
||||
}
|
||||
// 每固定张数打一行进度,让前端能看到识别在推进(不逐张刷屏)
|
||||
if checked%nsfwProgressInterval == 0 {
|
||||
fmt.Printf("=== 色情识别进度: 已检查 %d 张, 命中 %d 张\n", checked, atomic.LoadInt32(&o.nsfwHits))
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
@@ -399,6 +513,10 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
|
||||
success, fail, ignore, dup := 0, 0, 0, 0
|
||||
nsfwCount := 0 // 命中的色情图片数
|
||||
total := len(images)
|
||||
phaseStart := time.Now()
|
||||
if o.nsfw != nil {
|
||||
fmt.Printf("=== 色情识别: 共 %d 张图片待检测(GIF跳过), 每 %d 张报一次进度\n", total, nsfwProgressInterval)
|
||||
}
|
||||
results := runParallel(o.ctx, images, o.probeImage, func(done int) {
|
||||
o.report(PhaseImages, done, total)
|
||||
})
|
||||
@@ -410,17 +528,21 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
|
||||
}
|
||||
f := images[i]
|
||||
switch {
|
||||
case r.resume:
|
||||
// 续传命中:上次已归类过,直接把文件归回原目标目录(没搬走的这次搬)
|
||||
o.addToDir(f, r.resumeDir)
|
||||
success++
|
||||
case r.psd:
|
||||
// PSD 设计稿:不做处理,原样收进「图片_PSD」
|
||||
o.add(f, dirImagePSD)
|
||||
o.addClassified(f, dirImagePSD, r.size, r.headMd5, r.tailMd5)
|
||||
success++
|
||||
case r.unsupported:
|
||||
// 不支持的格式:原样收进「图片_已忽略」
|
||||
o.add(f, dirImageIgnored)
|
||||
o.addClassified(f, dirImageIgnored, r.size, r.headMd5, r.tailMd5)
|
||||
ignore++
|
||||
case r.probeErr:
|
||||
// 体检失败:收进「图片_读取异常」
|
||||
o.add(f, dirImageErr)
|
||||
o.addClassified(f, dirImageErr, r.size, r.headMd5, r.tailMd5)
|
||||
fail++
|
||||
fmt.Printf("=== 异常图片: %s \n", f.Path)
|
||||
case o.isDuplicate(r.md5Str, f.Path):
|
||||
@@ -432,7 +554,7 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
|
||||
// 被判为色情的改去「图片_敏感_*」目录,明暗子目录照常保留
|
||||
o.record(f, "image", r.res, r.md5Str, r.headMd5, r.tailMd5, r.size)
|
||||
if r.nsfw {
|
||||
o.add(f, classify.ImageDirSensitive(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore))
|
||||
o.addClassified(f, classify.ImageDirSensitive(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore), r.size, r.headMd5, r.tailMd5)
|
||||
// 攒一条色情识别记录,最后连同"搬去哪了"一起写进数据库
|
||||
o.pendingNsfw = append(o.pendingNsfw, store.NsfwRecord{
|
||||
FilePath: f.Path,
|
||||
@@ -442,12 +564,23 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
|
||||
})
|
||||
nsfwCount++
|
||||
} else {
|
||||
o.add(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore))
|
||||
o.addClassified(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore), r.size, r.headMd5, r.tailMd5)
|
||||
}
|
||||
success++
|
||||
}
|
||||
}
|
||||
// 把续传记录落库(中断/出错时尽量少丢已归类的结果)
|
||||
o.flushScanLog()
|
||||
if skip := atomic.LoadInt32(&o.resumeSkip); skip > 0 {
|
||||
fmt.Printf("=== 续传跳过: %d 个文件(上次已归类,直接复用)\n", skip)
|
||||
}
|
||||
fmt.Printf("=== 图片处理完毕,成功: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", success, fail, ignore, dup)
|
||||
fmt.Printf("=== 图片归类耗时: 实际 %.1fs, 其中(并行累计) 探测 %.1fs, MD5 %.1fs, 明暗 %.1fs, 色情 %.1fs\n",
|
||||
time.Since(phaseStart).Seconds(),
|
||||
time.Duration(atomic.LoadInt64(&o.tImageProbe)).Seconds(),
|
||||
time.Duration(atomic.LoadInt64(&o.tImgMd5)).Seconds(),
|
||||
time.Duration(atomic.LoadInt64(&o.tBright)).Seconds(),
|
||||
time.Duration(atomic.LoadInt64(&o.tNsfw)).Seconds())
|
||||
if o.nsfw != nil && nsfwCount > 0 {
|
||||
fmt.Printf("=== 色情识别: 共命中 %d 张,已归入「图片_敏感_*」目录,可在【色情识别】页面查看并人工复核\n", nsfwCount)
|
||||
}
|
||||
@@ -457,18 +590,27 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
|
||||
|
||||
// mediaProbeResult 是"给一个视频/音频做完体检"的结论(并行阶段产出)。
|
||||
type mediaProbeResult struct {
|
||||
res *probe.ProbeResult // 体检结果(ffprobe 读出的信息)
|
||||
err error // 体检失败的原因
|
||||
md5 string // 文件 MD5
|
||||
head string // 文件头采样 MD5
|
||||
tail string // 文件尾采样 MD5
|
||||
size int64 // 文件大小
|
||||
res *probe.ProbeResult // 体检结果(ffprobe 读出的信息)
|
||||
err error // 体检失败的原因
|
||||
md5 string // 文件 MD5
|
||||
head string // 文件头采样 MD5
|
||||
tail string // 文件尾采样 MD5
|
||||
size int64 // 文件大小
|
||||
resume bool // 续传命中:上次已归类过,跳过全部体检
|
||||
resumeDir string // 续传命中的目标目录
|
||||
}
|
||||
|
||||
// probeMedia 给一个媒体文件做体检(并行阶段执行):调 ffprobe 读信息 + 算 MD5。
|
||||
// 不访问任何共享可变状态,多个工人可以同时跑。
|
||||
func (o *Organizer) probeMedia(f scanner.File) mediaProbeResult {
|
||||
// 续传:上次已归类过、内容没变 → 跳过 ffprobe/MD5,直接复用上次的目标目录
|
||||
if sl, ok := o.matchResume(f); ok {
|
||||
atomic.AddInt32(&o.resumeSkip, 1)
|
||||
return mediaProbeResult{resume: true, resumeDir: sl.TargetDir}
|
||||
}
|
||||
probeStart := time.Now()
|
||||
res, err := probe.ProbeMediaFile(o.ffprobePath, f.Path)
|
||||
atomic.AddInt64(&o.tMediaProbe, int64(time.Since(probeStart)))
|
||||
if err != nil {
|
||||
return mediaProbeResult{err: err}
|
||||
}
|
||||
@@ -476,7 +618,9 @@ func (o *Organizer) probeMedia(f scanner.File) mediaProbeResult {
|
||||
if !res.HasVideo && !res.HasAudio {
|
||||
return mediaProbeResult{res: res}
|
||||
}
|
||||
md5Start := time.Now()
|
||||
md5, head, tail, size := o.calcFileInfo(f)
|
||||
atomic.AddInt64(&o.tMediaMd5, int64(time.Since(md5Start)))
|
||||
return mediaProbeResult{res: res, md5: md5, head: head, tail: tail, size: size}
|
||||
}
|
||||
|
||||
@@ -488,6 +632,7 @@ func (o *Organizer) probeMedia(f scanner.File) mediaProbeResult {
|
||||
func (o *Organizer) ProcessMedia(media []scanner.File) {
|
||||
video, audio, fail, ignore, dup := 0, 0, 0, 0, 0
|
||||
total := len(media)
|
||||
phaseStart := time.Now()
|
||||
results := runParallel(o.ctx, media, o.probeMedia, func(done int) {
|
||||
o.report(PhaseMedia, done, total)
|
||||
})
|
||||
@@ -498,17 +643,22 @@ func (o *Organizer) ProcessMedia(media []scanner.File) {
|
||||
break
|
||||
}
|
||||
f := media[i]
|
||||
// 续传命中:上次已归类过,直接把文件归回原目标目录(没搬走的这次搬)
|
||||
if r.resume {
|
||||
o.addToDir(f, r.resumeDir)
|
||||
continue
|
||||
}
|
||||
// ---------- 体检失败 ----------
|
||||
if r.err != nil {
|
||||
// 只有 MIME 明确说是视频/音频的才收进"异常/忽略"目录;
|
||||
// 完全没头绪的文件(MIME 为空或 octet-stream)原地不管,不搬走
|
||||
if strings.HasPrefix(f.Mime, "video/") {
|
||||
fail++
|
||||
o.add(f, dirVideoErr)
|
||||
o.addClassified(f, dirVideoErr, r.size, r.head, r.tail)
|
||||
fmt.Printf("=== 异常视频: %s \n", f.Path)
|
||||
} else if scanner.IsExplicitMediaMime(f.Mime) {
|
||||
ignore++
|
||||
o.add(f, dirVideoIgnored)
|
||||
o.addClassified(f, dirVideoIgnored, r.size, r.head, r.tail)
|
||||
} else {
|
||||
ignore++
|
||||
fmt.Printf("=== 无法确认的非媒体文件, 原地忽略: %s \n", f.Path)
|
||||
@@ -527,7 +677,7 @@ func (o *Organizer) ProcessMedia(media []scanner.File) {
|
||||
}
|
||||
video++
|
||||
o.record(f, "video", r.res, r.md5, r.head, r.tail, r.size)
|
||||
o.add(f, classify.VideoDir(f.Ext, r.res.Width, r.res.Height))
|
||||
o.addClassified(f, classify.VideoDir(f.Ext, r.res.Width, r.res.Height), r.size, r.head, r.tail)
|
||||
case r.res.HasAudio:
|
||||
// 有音频流 → 音频
|
||||
if o.isDuplicate(r.md5, f.Path) {
|
||||
@@ -538,20 +688,29 @@ func (o *Organizer) ProcessMedia(media []scanner.File) {
|
||||
}
|
||||
audio++
|
||||
o.record(f, "audio", r.res, r.md5, r.head, r.tail, r.size)
|
||||
o.add(f, dirAudio)
|
||||
o.addClassified(f, dirAudio, r.size, r.head, r.tail)
|
||||
default:
|
||||
// 没有视频流也没有音频流 → 假媒体。
|
||||
// MIME 明确标注是媒体的,收进「已忽略」;否则原地不动。
|
||||
if scanner.IsExplicitMediaMime(f.Mime) {
|
||||
ignore++
|
||||
o.add(f, dirVideoIgnored)
|
||||
o.addClassified(f, dirVideoIgnored, r.size, r.head, r.tail)
|
||||
} else {
|
||||
ignore++
|
||||
fmt.Printf("=== 无法确认的非媒体文件, 原地忽略: %s \n", f.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 把续传记录落库(中断/出错时尽量少丢已归类的结果)
|
||||
o.flushScanLog()
|
||||
if skip := atomic.LoadInt32(&o.resumeSkip); skip > 0 {
|
||||
fmt.Printf("=== 续传跳过: %d 个文件(上次已归类,直接复用)\n", skip)
|
||||
}
|
||||
fmt.Printf("=== 媒体处理完毕,视频: %d, 音频: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", video, audio, fail, ignore, dup)
|
||||
fmt.Printf("=== 媒体归类耗时: 实际 %.1fs, 其中(并行累计) ffprobe %.1fs, MD5 %.1fs\n",
|
||||
time.Since(phaseStart).Seconds(),
|
||||
time.Duration(atomic.LoadInt64(&o.tMediaProbe)).Seconds(),
|
||||
time.Duration(atomic.LoadInt64(&o.tMediaMd5)).Seconds())
|
||||
}
|
||||
|
||||
// ============================ 移动(最后一步) ============================
|
||||
@@ -562,6 +721,7 @@ func (o *Organizer) ProcessMedia(media []scanner.File) {
|
||||
// 不需要记录每个文件搬去哪了——文件就在分类目录里,一眼能看到。
|
||||
func (o *Organizer) Flush() {
|
||||
o.flushRecords()
|
||||
o.flushScanLog()
|
||||
|
||||
// 把 buckets 里所有文件拼成一张"搬运清单"
|
||||
jobs := make([]mover.MoveJob, 0)
|
||||
|
||||
Reference in New Issue
Block a user