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:
2025-06-19 21:07:35 +08:00
parent ea134afa27
commit 957dfe1134
18 changed files with 908 additions and 111 deletions
+180 -20
View File
@@ -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)
+74
View File
@@ -0,0 +1,74 @@
package organize
import (
"os"
"path/filepath"
"testing"
"kenaito-media-picker/internal/digest"
"kenaito-media-picker/internal/scanner"
"kenaito-media-picker/internal/store"
)
func TestResumeMatchSize(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "a.jpg")
os.WriteFile(p, []byte("hello world hello world"), 0644)
st, _ := os.Stat(p)
org := New(filepath.Join(dir, "out"), "", "", nil, nil)
org.resume = map[string]store.ScanLog{
p: {SourcePath: p, TargetDir: filepath.Join(dir, "out", "图片_测试"), FileSize: st.Size()},
}
f := scanner.File{Path: p, Name: "a.jpg", Ext: ".jpg", Mime: "image/jpeg"}
sl, ok := org.matchResume(f)
if !ok || sl.TargetDir != filepath.Join(dir, "out", "图片_测试") {
t.Fatalf("内容未变应命中续传, ok=%v", ok)
}
// 内容变大 → 大小不同 → 不命中
os.WriteFile(p, []byte("hello world hello world extra bytes"), 0644)
if _, ok := org.matchResume(f); ok {
t.Fatal("内容变了(大小不同)应不命中续传")
}
// 没记录 → 不命中
org.resume = nil
if _, ok := org.matchResume(f); ok {
t.Fatal("无续传记录应不命中")
}
}
func TestResumeMatchFingerprint(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "big.bin")
data := make([]byte, 2<<20)
for i := range data {
data[i] = byte(i)
}
os.WriteFile(p, data, 0644)
st, _ := os.Stat(p)
head, tail, err := digest.CalcFileFingerprint(p, st.Size())
if err != nil {
t.Fatalf("fingerprint: %v", err)
}
org := New(filepath.Join(dir, "out"), "", "", nil, nil)
org.resume = map[string]store.ScanLog{
p: {SourcePath: p, TargetDir: filepath.Join(dir, "out", "视频_测试"), FileSize: st.Size(), HeadMd5: head, TailMd5: tail},
}
f := scanner.File{Path: p, Name: "big.bin", Ext: ".bin", Mime: "video/mp4"}
if _, ok := org.matchResume(f); !ok {
t.Fatal("指纹一致应命中续传")
}
// 改文件头里一个字节、大小不变 → 指纹不同 → 不命中
mod := append([]byte(nil), data...)
mod[1024] ^= 0xFF
os.WriteFile(p, mod, 0644)
if _, ok := org.matchResume(f); ok {
t.Fatal("内容变了(大小相同)应不命中续传")
}
}