refactor: 合并 NSFW 数据到 media_info 表
This commit is contained in:
@@ -23,4 +23,4 @@ nsfw.enabled=true
|
||||
# python 可执行文件路径;留空自动在 PATH 里找 python/python3/py
|
||||
nsfw.python=C:\\Users\\Administrator\\AppData\\Local\\Programs\\Python\\Python312\\python.exe
|
||||
# 判定阈值 0~1,敏感部位检测分高于此才算色情(默认 0.2,实测)
|
||||
nsfw.threshold=0.2
|
||||
nsfw.threshold=0.35
|
||||
|
||||
@@ -24,10 +24,6 @@
|
||||
<i class="el-icon-film"></i>
|
||||
<span slot="title">媒体库</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/nsfw">
|
||||
<i class="el-icon-view"></i>
|
||||
<span slot="title">色情识别</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<div class="aside-footer">kenaito-media-picker</div>
|
||||
</el-aside>
|
||||
|
||||
@@ -2,7 +2,6 @@ import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
import Dashboard from '../views/Dashboard.vue'
|
||||
import MediaList from '../views/MediaList.vue'
|
||||
import NsfwList from '../views/NsfwList.vue'
|
||||
import Scan from '../views/Scan.vue'
|
||||
|
||||
Vue.use(Router)
|
||||
@@ -12,7 +11,6 @@ export default new Router({
|
||||
{path: '/', redirect: '/scan'},
|
||||
{path: '/dashboard', component: Dashboard},
|
||||
{path: '/media', component: MediaList},
|
||||
{path: '/nsfw', component: NsfwList},
|
||||
{path: '/scan', component: Scan}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
<el-select v-model="query.format" placeholder="格式" clearable filterable size="small" style="width: 120px" @change="handleFilterChange">
|
||||
<el-option v-for="f in formatOptions" :key="f" :label="f" :value="f"/>
|
||||
</el-select>
|
||||
<el-select v-model="query.nsfwOnly" placeholder="色情" clearable size="small" style="width: 110px" @change="handleFilterChange">
|
||||
<el-option label="仅色情" :value="true"/>
|
||||
</el-select>
|
||||
<el-input v-model="query.keyword" placeholder="搜索文件名" size="small" clearable style="width: 180px" @keyup.enter.native="handleFilterChange" @clear="handleFilterChange">
|
||||
<i slot="prefix" class="el-input__icon el-icon-search"/>
|
||||
</el-input>
|
||||
@@ -32,6 +35,21 @@
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border stripe @sort-change="handleSortChange">
|
||||
<el-table-column label="预览" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-image
|
||||
v-if="isImage(scope.row.mediaType)"
|
||||
:src="getPreviewUrl(scope.row.filePath)"
|
||||
:preview-src-list="[getPreviewUrl(scope.row.filePath)]"
|
||||
style="width: 60px; height: 60px"
|
||||
fit="cover">
|
||||
<div slot="error" class="image-error">
|
||||
<i class="el-icon-picture-outline"></i>
|
||||
</div>
|
||||
</el-image>
|
||||
<span v-else class="muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="fileName" label="文件名" min-width="100" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span class="file-name" :title="scope.row.filePath">{{ scope.row.fileName }}</span>
|
||||
@@ -60,6 +78,17 @@
|
||||
<span v-else class="muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="色情检测" width="140" align="center">
|
||||
<template slot-scope="scope">
|
||||
<div v-if="scope.row.nsfwScore > 0">
|
||||
<el-tag :type="scope.row.nsfwScore >= scope.row.nsfwThreshold ? 'danger' : 'warning'" size="small">
|
||||
{{ (scope.row.nsfwScore * 100).toFixed(1) }}%
|
||||
</el-tag>
|
||||
<div class="nsfw-threshold">阈值: {{ (scope.row.nsfwThreshold * 100).toFixed(0) }}%</div>
|
||||
</div>
|
||||
<span v-else class="muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="codec" label="编码" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span class="muted" v-if="!scope.row.codec">-</span>
|
||||
@@ -134,6 +163,7 @@ export default {
|
||||
resolution: '',
|
||||
codec: '',
|
||||
format: '',
|
||||
nsfwOnly: null,
|
||||
keyword: '',
|
||||
sortField: '',
|
||||
sortOrder: ''
|
||||
@@ -196,6 +226,12 @@ export default {
|
||||
typeTag(type) {
|
||||
return {image: '', video: 'primary', audio: 'warning'}[type] || 'info'
|
||||
},
|
||||
isImage(type) {
|
||||
return type === 'image'
|
||||
},
|
||||
getPreviewUrl(filePath) {
|
||||
return '/api/media/preview?path=' + encodeURIComponent(filePath)
|
||||
},
|
||||
formatTime(value) {
|
||||
if (!value) return '-'
|
||||
const d = new Date(value)
|
||||
@@ -226,6 +262,23 @@ export default {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.image-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: #f5f7fa;
|
||||
color: #c0c4cc;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.nsfw-threshold {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
text-align: right;
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<div class="toolbar">
|
||||
<span class="title">色情识别</span>
|
||||
<div class="toolbar-right">
|
||||
<span class="hint" v-if="!nsfwEnabled">识别功能未开启:请在 application.properties 设 nsfw.enabled=true 并重新扫描</span>
|
||||
<span class="hint" v-else-if="!nsfwDetector">识别已配置开启,但最近一次扫描时检测服务没起来:请确认本机已安装 python 和 nudenet,再重新扫描</span>
|
||||
<el-button type="primary" size="small" icon="el-icon-refresh" @click="loadList">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-bar">
|
||||
<i class="el-icon-view"></i>
|
||||
这是最近一次开启色情识别的扫描判出的图片,按检测分从高到低排列。点缩略图可放大预览,用来核对识别结果准不准。
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border stripe>
|
||||
<el-table-column label="预览" width="110" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-image
|
||||
class="thumb"
|
||||
:src="previewUrl(scope.row.path)"
|
||||
:preview-src-list="[previewUrl(scope.row.path)]"
|
||||
fit="cover"
|
||||
lazy
|
||||
style="width: 84px; height: 84px; border-radius: 6px; border: 1px solid #e6efff">
|
||||
<div slot="error" class="thumb-error">
|
||||
<i class="el-icon-picture-outline"></i>
|
||||
</div>
|
||||
</el-image>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="fileName" label="文件名" min-width="140" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span class="file-name" :title="scope.row.path">{{ scope.row.fileName }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="检测分" width="160" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span class="score" :class="scoreTag(scope.row)">{{ (scope.row.score * 100).toFixed(1) }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="阈值" width="100" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span class="muted">{{ (scope.row.threshold * 100).toFixed(1) }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="完整路径" min-width="240" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span class="path-text">{{ scope.row.path }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="检测时间" width="180" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span class="muted">{{ scope.row.createdAt }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty
|
||||
v-if="!loading && list.length === 0"
|
||||
:description="emptyDesc"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import request from '../utils/request'
|
||||
|
||||
/**
|
||||
* 色情识别页面。
|
||||
* 展示最近一次开启色情识别的扫描判出的图片:检测分 + 完整路径 + 图片预览。
|
||||
* 数据来自 /api/nsfw/list,预览走 /api/nsfw/preview(后端按路径读本地文件)。
|
||||
*/
|
||||
export default {
|
||||
name: 'NsfwList',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
list: [],
|
||||
nsfwEnabled: true, // 配置里是否开了 nsfw.enabled
|
||||
nsfwDetector: true, // 最近一次扫描时检测服务是否真的起来了
|
||||
nsfwConfigured: true // 上两者都满足才算"识别可用"
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 空列表时的提示文案:识别可用说明是"没命中",否则是"没开启/没起来"
|
||||
emptyDesc() {
|
||||
if (this.nsfwConfigured) {
|
||||
return '识别已开启,最近一次扫描没有判出色情图片。'
|
||||
}
|
||||
return '暂无记录。开启 nsfw.enabled 并确保检测服务可用后重新扫描,判为色情的图片会出现在这里。'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadList()
|
||||
},
|
||||
methods: {
|
||||
async loadList() {
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await request.get('/api/nsfw/list')
|
||||
this.list = res.data.list || []
|
||||
// 后端真实上报:配置是否开启、检测服务上次是否真的起来了
|
||||
this.nsfwEnabled = res.data.nsfwEnabled !== false
|
||||
this.nsfwDetector = res.data.nsfwDetector === true
|
||||
this.nsfwConfigured = this.nsfwEnabled && this.nsfwDetector
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
previewUrl(path) {
|
||||
if (!path) return ''
|
||||
return '/api/nsfw/preview?path=' + encodeURIComponent(path)
|
||||
},
|
||||
// 检测分越接近 1 越"实锤":>=90% 红色,>=75% 橙色,否则灰色
|
||||
scoreTag(row) {
|
||||
if (row.score >= 0.9) return 'score-high'
|
||||
if (row.score >= 0.75) return 'score-mid'
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #e6a23c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tip-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f8fbff;
|
||||
border: 1px solid #e6efff;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
color: #4a5a78;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tip-bar .el-icon-view {
|
||||
color: #1677ff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.thumb-error {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
color: #c0c4cc;
|
||||
font-size: 26px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
color: #1f3a6b;
|
||||
}
|
||||
|
||||
.path-text {
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-weight: 700;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.score-high {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.score-mid {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,9 @@ package api
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -34,6 +37,7 @@ type mediaListReq struct {
|
||||
Codec string `json:"codec"`
|
||||
Format string `json:"format"`
|
||||
Resolution string `json:"resolution"`
|
||||
NsfwOnly interface{} `json:"nsfwOnly"`
|
||||
SortField string `json:"sortField"`
|
||||
SortOrder string `json:"sortOrder"`
|
||||
}
|
||||
@@ -65,6 +69,7 @@ func (c *MediaController) List(ctx *gin.Context) {
|
||||
Codec: req.Codec,
|
||||
Format: req.Format,
|
||||
Resolution: req.Resolution,
|
||||
NsfwOnly: req.NsfwOnly == true, // interface{} 转 bool:只有明确的 true 才算
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
SortField: req.SortField,
|
||||
@@ -101,6 +106,37 @@ func (c *MediaController) Stats(ctx *gin.Context) {
|
||||
Success(ctx, st)
|
||||
}
|
||||
|
||||
// Preview 处理 GET /api/media/preview?path=xxx:预览图片文件。
|
||||
// 只允许预览图片类型(jpeg/jpg/png/gif/webp/bmp),其他类型返回 403。
|
||||
func (c *MediaController) Preview(ctx *gin.Context) {
|
||||
path := strings.TrimSpace(ctx.Query("path"))
|
||||
if path == "" {
|
||||
Error(ctx, http.StatusBadRequest, "缺少 path 参数")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
Error(ctx, http.StatusNotFound, "文件不存在: "+path)
|
||||
return
|
||||
}
|
||||
|
||||
// 只允许预览图片
|
||||
ext := strings.ToLower(filepath.Ext(info.Name()))
|
||||
allowedExts := map[string]bool{
|
||||
".jpg": true, ".jpeg": true, ".png": true,
|
||||
".gif": true, ".webp": true, ".bmp": true,
|
||||
}
|
||||
if !allowedExts[ext] {
|
||||
Error(ctx, http.StatusForbidden, "仅支持预览图片文件")
|
||||
return
|
||||
}
|
||||
|
||||
// 返回图片文件
|
||||
ctx.File(path)
|
||||
}
|
||||
|
||||
// toMediaView 给一条数据库记录补上展示用的派生字段。
|
||||
func toMediaView(m store.MediaInfo) mediaView {
|
||||
v := mediaView{MediaInfo: m}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// 色情识别相关的两个接口:查记录、看图片预览。
|
||||
// 前端「色情识别」页面用它展示每次扫描判出的色情图片(检测分 + 路径 + 预览),
|
||||
// 方便用户核对识别结果准不准。只读数据库 + 读本地图片文件,不干别的活。
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"kenaito-media-picker/internal/config"
|
||||
"kenaito-media-picker/internal/store"
|
||||
)
|
||||
|
||||
// nsfwDetectorReadyKey 记录"最近一次扫描时色情检测服务是否真的起来了"(存 AppConfig)。
|
||||
// 配置开了但检测器没起来(没装 python/模型下载失败等)也能区分出来,方便前端提示。
|
||||
// 由 scan 流程在服务启动成功/失败时写 1/0。
|
||||
const nsfwDetectorReadyKey = "nsfw.detectorReady"
|
||||
|
||||
// NsfwController 色情识别相关接口的"窗口"。
|
||||
type NsfwController struct {
|
||||
store *store.Store // 数据库(只读)
|
||||
}
|
||||
|
||||
// NewNsfwController 创建色情识别控制器,注入数据库。
|
||||
func NewNsfwController(st *store.Store) *NsfwController {
|
||||
return &NsfwController{store: st}
|
||||
}
|
||||
|
||||
// nsfwView 列表里展示的一条记录:路径用"整理后位置",没有就用源路径。
|
||||
type nsfwView struct {
|
||||
Id int64 `json:"id"` // 主键
|
||||
FileName string `json:"fileName"` // 文件名
|
||||
Score float64 `json:"score"` // 检测分 0~1
|
||||
Threshold float64 `json:"threshold"` // 判定阈值
|
||||
Path string `json:"path"` // 预览/打开用的完整路径
|
||||
CreatedAt string `json:"createdAt"` // 检测时间
|
||||
}
|
||||
|
||||
// List 处理 GET /api/nsfw/list:查全部色情识别记录(按检测分从高到低)。
|
||||
func (c *NsfwController) List(ctx *gin.Context) {
|
||||
list, err := c.store.ListNsfw()
|
||||
if err != nil {
|
||||
Error(ctx, http.StatusInternalServerError, "查询色情识别记录失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
views := make([]nsfwView, 0, len(list))
|
||||
for _, r := range list {
|
||||
path := r.MovedPath
|
||||
if path == "" {
|
||||
path = r.FilePath
|
||||
}
|
||||
views = append(views, nsfwView{
|
||||
Id: r.Id,
|
||||
FileName: r.FileName,
|
||||
Score: r.Score,
|
||||
Threshold: r.Threshold,
|
||||
Path: path,
|
||||
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
Success(ctx, map[string]interface{}{
|
||||
"list": views,
|
||||
"total": len(views),
|
||||
"nsfwEnabled": config.GetAppConfig().NSFWEnabled,
|
||||
// 没扫描过(空)或上次成功(1)都算可用;只有明确记过失败(0)才提示检测服务没起来
|
||||
"nsfwDetector": c.store == nil || c.store.GetConfig(nsfwDetectorReadyKey) != "0",
|
||||
})
|
||||
}
|
||||
|
||||
// Preview 处理 GET /api/nsfw/preview?path=xxx:把指定图片文件返回给浏览器做预览。
|
||||
// 这是本地单机工具,直接按路径读文件;文件不存在/已被移动时返回 404。
|
||||
func (c *NsfwController) Preview(ctx *gin.Context) {
|
||||
path := strings.TrimSpace(ctx.Query("path"))
|
||||
if path == "" {
|
||||
Error(ctx, http.StatusBadRequest, "缺少 path 参数")
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
Error(ctx, http.StatusNotFound, "图片不存在或已被移动: "+path)
|
||||
return
|
||||
}
|
||||
// 交给 gin 读文件并带上正确的 Content-Type / 断点续传
|
||||
ctx.File(path)
|
||||
}
|
||||
@@ -32,11 +32,7 @@ func NewRouter(st *store.Store) *gin.Engine {
|
||||
mc := NewMediaController(st)
|
||||
apiGroup.POST("/media/list", mc.List)
|
||||
apiGroup.POST("/media/stats", mc.Stats)
|
||||
|
||||
// 色情识别(查看检测分 + 图片预览)
|
||||
nc := NewNsfwController(st)
|
||||
apiGroup.GET("/nsfw/list", nc.List)
|
||||
apiGroup.GET("/nsfw/preview", nc.Preview)
|
||||
apiGroup.GET("/media/preview", mc.Preview)
|
||||
|
||||
// 扫描整理(开始 / 中止 / 查进度 / 实时进度)
|
||||
sc := NewScanController(service.NewScanService(st))
|
||||
|
||||
@@ -72,11 +72,11 @@ type Organizer struct {
|
||||
ctx context.Context // 中止信号;ctx 被取消时探测/归类立即停下
|
||||
nsfw *nsfw.Detector // 色情检测器;为 nil 表示没启用,图片不检测
|
||||
rotateImages bool // 是否处理 EXIF 旋转(转正/按显示方向归类);false 则完全不动,按存储方向归类
|
||||
moveFiles bool // 扫描完成后是否移动文件;false 时禁用续传跳过,每次都完整探测
|
||||
buckets map[string][]string // 目标目录 → 里面该放哪些文件(只记源路径)
|
||||
order []string // 目标目录的先后顺序(保证输出稳定、不来回跳)
|
||||
onProgress func(phase string, done, total int)
|
||||
pending []*store.MediaInfo // 待写入数据库的媒体记录(攒一批再批量写)
|
||||
pendingNsfw []store.NsfwRecord // 待写入数据库的色情识别记录(攒一批再批量写)
|
||||
resume map[string]store.ScanLog // 续传表:源路径 → 上次归类结果(重扫跳过重新探测)
|
||||
resumeSkip int32 // 本次续传跳过的文件数(并行累计)
|
||||
pendingScan []store.ScanLog // 待写入续传表的记录(攒一批再批量写)
|
||||
@@ -136,6 +136,13 @@ func (o *Organizer) SetRotateImages(b bool) {
|
||||
o.rotateImages = b
|
||||
}
|
||||
|
||||
// SetMoveFiles 设置扫描完成后是否移动文件(默认 true)。
|
||||
// - true:文件会被移动到分类目录,启用续传优化
|
||||
// - false:文件留在原位,禁用续传跳过,每次都完整探测并记录到媒体库
|
||||
func (o *Organizer) SetMoveFiles(b bool) {
|
||||
o.moveFiles = b
|
||||
}
|
||||
|
||||
// report 上报进度。total 为 0 表示这一步没活干,就不用报了。
|
||||
func (o *Organizer) report(phase string, done, total int) {
|
||||
if o.onProgress != nil && total > 0 {
|
||||
@@ -211,8 +218,12 @@ func (o *Organizer) addToDir(f scanner.File, dir string) {
|
||||
|
||||
// addClassified 归档一个文件并记进续传表:把"源路径 → 目标目录"写进 scan_log,
|
||||
// 这样下次重扫时这个文件直接复用目标目录,不再重新探测/识别。
|
||||
// 注意:moveFiles=false 时不写续传记录,因为文件没有真正移动。
|
||||
func (o *Organizer) addClassified(f scanner.File, subDir string, size int64, headMd5, tailMd5 string) {
|
||||
o.add(f, subDir)
|
||||
if !o.moveFiles {
|
||||
return // 不移动文件时不写续传记录
|
||||
}
|
||||
if o.store == nil {
|
||||
return
|
||||
}
|
||||
@@ -230,7 +241,11 @@ func (o *Organizer) addClassified(f scanner.File, subDir string, size int64, hea
|
||||
|
||||
// matchResume 判断一个文件上次是否已归类过,且内容没变。
|
||||
// 命中返回上次的目标目录和 ok=true;内容变了/没记录则 ok=false(要重新探测)。
|
||||
// 注意:moveFiles=false 时禁用续传跳过,每次都完整探测。
|
||||
func (o *Organizer) matchResume(f scanner.File) (store.ScanLog, bool) {
|
||||
if !o.moveFiles {
|
||||
return store.ScanLog{}, false // 不移动文件时禁用续传
|
||||
}
|
||||
if o.resume == nil {
|
||||
return store.ScanLog{}, false
|
||||
}
|
||||
@@ -321,11 +336,14 @@ func (o *Organizer) isDuplicate(md5Str, path string) bool {
|
||||
}
|
||||
|
||||
// record 把一份媒体信息记到"待入库"缓冲里(先攒着,最后统一批量写库)。
|
||||
func (o *Organizer) record(f scanner.File, mediaType string, res *probe.ProbeResult, md5Str, headMd5, tailMd5 string, size int64) {
|
||||
// 返回创建的 MediaInfo 指针,调用方可继续设置额外字段(如 NsfwScore)。
|
||||
func (o *Organizer) record(f scanner.File, mediaType string, res *probe.ProbeResult, md5Str, headMd5, tailMd5 string, size int64) *store.MediaInfo {
|
||||
if o.store == nil || md5Str == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
o.pending = append(o.pending, buildMediaInfo(f, mediaType, res, md5Str, headMd5, tailMd5, size))
|
||||
info := buildMediaInfo(f, mediaType, res, md5Str, headMd5, tailMd5, size)
|
||||
o.pending = append(o.pending, info)
|
||||
return info
|
||||
}
|
||||
|
||||
// buildMediaInfo 把探测结果整理成一条要入库的记录。
|
||||
@@ -553,16 +571,14 @@ func (o *Organizer) ProcessImages(images []scanner.File) {
|
||||
default:
|
||||
// 正常图片:按分辨率/方向/明暗归入对应的分类目录;
|
||||
// 被判为色情的改去「图片_敏感_*」目录,明暗子目录照常保留
|
||||
o.record(f, "image", r.res, r.md5Str, r.headMd5, r.tailMd5, r.size)
|
||||
info := o.record(f, "image", r.res, r.md5Str, r.headMd5, r.tailMd5, r.size)
|
||||
if r.nsfw {
|
||||
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,
|
||||
FileName: filepath.Base(f.Path),
|
||||
Score: r.nsfwScore,
|
||||
Threshold: o.nsfw.Threshold(),
|
||||
})
|
||||
// NSFW 结果直接写入 MediaInfo 记录
|
||||
if info != nil {
|
||||
info.NsfwScore = r.nsfwScore
|
||||
info.NsfwThreshold = o.nsfw.Threshold()
|
||||
}
|
||||
nsfwCount++
|
||||
} else {
|
||||
o.addClassified(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore), r.size, r.headMd5, r.tailMd5)
|
||||
@@ -716,13 +732,19 @@ func (o *Organizer) ProcessMedia(media []scanner.File) {
|
||||
|
||||
// ============================ 移动(最后一步) ============================
|
||||
|
||||
// FlushRecordsOnly 只写入数据库记录,不移动文件。
|
||||
// 用于 moveFiles=false 时,仍然保存媒体信息和续传记录。
|
||||
func (o *Organizer) FlushRecordsOnly() {
|
||||
o.flushRecords()
|
||||
o.flushScanLog()
|
||||
}
|
||||
|
||||
// Flush 收尾两步:
|
||||
// 1. 把之前攒的媒体记录一次性写进数据库;
|
||||
// 2. 把每个分类目录攒的文件**全局并行**搬过去(所有目录的文件一起搬)。
|
||||
// 不需要记录每个文件搬去哪了——文件就在分类目录里,一眼能看到。
|
||||
func (o *Organizer) Flush() {
|
||||
o.flushRecords()
|
||||
o.flushScanLog()
|
||||
o.FlushRecordsOnly()
|
||||
|
||||
// 把 buckets 里所有文件拼成一张"搬运清单"
|
||||
jobs := make([]mover.MoveJob, 0)
|
||||
@@ -734,27 +756,7 @@ func (o *Organizer) Flush() {
|
||||
|
||||
// 全局并行搬完:不管文件分散在几个目录,所有文件同时开搬。
|
||||
// onProgress 收到的是全局累计已搬数,正好和总数对得上。
|
||||
// moved 是"源路径 → 新位置"的对照表,色情记录要用它补上整理后的实际路径。
|
||||
moved := o.mover.MoveAll(jobs, func(done int) {
|
||||
o.mover.MoveAll(jobs, func(done int) {
|
||||
o.report(PhaseMove, done, len(jobs))
|
||||
})
|
||||
|
||||
// 把色情识别记录补上"整理后位置"(预览就用它),再批量写进数据库
|
||||
if len(o.pendingNsfw) > 0 {
|
||||
records := make([]*store.NsfwRecord, 0, len(o.pendingNsfw))
|
||||
for i := range o.pendingNsfw {
|
||||
if target, ok := moved[o.pendingNsfw[i].FilePath]; ok {
|
||||
o.pendingNsfw[i].MovedPath = target
|
||||
} else {
|
||||
o.pendingNsfw[i].MovedPath = o.pendingNsfw[i].FilePath // 没搬走就用源路径
|
||||
}
|
||||
records = append(records, &o.pendingNsfw[i])
|
||||
}
|
||||
if o.store != nil {
|
||||
if err := o.store.SaveNsfwBatch(records); err != nil {
|
||||
fmt.Printf("=== 保存色情识别记录失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
o.pendingNsfw = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,18 +524,13 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
|
||||
defer nsfwDet.Stop()
|
||||
s.setNsfwDetectorReady(true)
|
||||
fmt.Println("=== 色情识别已启用(NudeNet)")
|
||||
// 清掉上一次的色情识别记录,保证「色情识别」页面显示的是本次扫描的结果
|
||||
if s.store != nil {
|
||||
if err := s.store.ClearNsfw(); err != nil {
|
||||
fmt.Printf("=== 清空旧色情识别记录失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
org := organize.New(pickerDir, ffprobePath, ffmpegPath, s.store, ctx)
|
||||
org.SetNSFWDetector(nsfwDet)
|
||||
org.SetRotateImages(config.GetAppConfig().RotateImages)
|
||||
org.SetMoveFiles(config.GetAppConfig().MoveFiles)
|
||||
|
||||
// 整个"整理"分两个阶段,界面上共用一条进度条:
|
||||
// 归类阶段:图片、视频/音频分两批处理。进度 = 已处理文件数 / 全部待整理文件数
|
||||
@@ -575,7 +570,9 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
|
||||
if config.GetAppConfig().MoveFiles {
|
||||
org.Flush()
|
||||
} else {
|
||||
fmt.Println("=== 配置 organize.moveFiles=false,跳过文件移动")
|
||||
// 不移动文件,但仍需写入媒体记录到数据库
|
||||
org.FlushRecordsOnly()
|
||||
fmt.Println("=== 配置 organize.moveFiles=false,跳过文件移动(媒体记录已保存)")
|
||||
}
|
||||
|
||||
// 把进度条顶满 100%,再打印"整理完成"
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSaveNsfwBatchLarge(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := Open(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
s.Close()
|
||||
os.Remove(db)
|
||||
}()
|
||||
|
||||
// 3000 条,远超 SQLite 999 变量上限(旧代码会报 too many SQL variables)
|
||||
records := make([]*NsfwRecord, 0, 3000)
|
||||
for i := 0; i < 3000; i++ {
|
||||
records = append(records, &NsfwRecord{
|
||||
FilePath: fmt.Sprintf("/path/%d.jpg", i),
|
||||
FileName: fmt.Sprintf("%d.jpg", i),
|
||||
Score: 0.5,
|
||||
Threshold: 0.2,
|
||||
MovedPath: fmt.Sprintf("/moved/%d.jpg", i),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
if err := s.SaveNsfwBatch(records); err != nil {
|
||||
t.Fatalf("SaveNsfwBatch: %v", err)
|
||||
}
|
||||
list, err := s.ListNsfw()
|
||||
if err != nil {
|
||||
t.Fatalf("ListNsfw: %v", err)
|
||||
}
|
||||
if len(list) != 3000 {
|
||||
t.Fatalf("got %d records, want 3000", len(list))
|
||||
}
|
||||
}
|
||||
+9
-62
@@ -47,6 +47,8 @@ type MediaInfo struct {
|
||||
RawInfo string `xorm:"text 'raw_info'" json:"rawInfo"` // ffprobe 读出的原始信息(JSON),留底备用
|
||||
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(指纹用)
|
||||
NsfwScore float64 `xorm:"real 'nsfw_score'" json:"nsfwScore"` // NSFW 检测分 0~1(0 表示未检测或非图片)
|
||||
NsfwThreshold float64 `xorm:"real 'nsfw_threshold'" json:"nsfwThreshold"` // NSFW 判定阈值
|
||||
CreatedAt time.Time `xorm:"created 'created_at'" json:"createdAt"` // 首次入库时间
|
||||
UpdatedAt time.Time `xorm:"updated 'updated_at'" json:"updatedAt"` // 最近更新时间
|
||||
}
|
||||
@@ -63,18 +65,6 @@ type AppConfig struct {
|
||||
Value string `xorm:"text 'value'"` // 配置值
|
||||
}
|
||||
|
||||
// NsfwRecord 一条"色情图片识别"的记录:哪张图、检测分多少、判了没有、现在在哪。
|
||||
// 前端「色情识别」页面就是查这张表来展示每张图片的检测结果,方便人工复核准确性。
|
||||
type NsfwRecord struct {
|
||||
Id int64 `xorm:"pk autoincr 'id'" json:"id"` // 自增主键
|
||||
FilePath string `xorm:"text 'file_path'" json:"filePath"` // 检测时的源路径(在哪发现的)
|
||||
FileName string `xorm:"varchar(255) 'file_name'" json:"fileName"` // 文件名(含扩展名)
|
||||
Score float64 `xorm:"real 'score'" json:"score"` // 命中的敏感部位最高分 0~1
|
||||
Threshold float64 `xorm:"real 'threshold'" json:"threshold"` // 判定阈值(>= 它才算色情)
|
||||
MovedPath string `xorm:"text 'moved_path'" json:"movedPath"` // 整理后实际位置(预览用);没搬走时同源路径
|
||||
CreatedAt time.Time `xorm:"created 'created_at'" json:"createdAt"` // 检测时间
|
||||
}
|
||||
|
||||
// ScanLog 一条"续传"记录:某个源文件上次被归类到了哪个目录。
|
||||
// 用途:扫描中断/出错后重跑时,已经归类过的文件直接复用上次的目标目录,
|
||||
// 不再重新探测尺寸 / 算 MD5 / 分析明暗 / 色情识别,省下几小时的重扫时间。
|
||||
@@ -106,8 +96,8 @@ func Open(dbPath string) (*Store, error) {
|
||||
_ = e.Close()
|
||||
return nil, err
|
||||
}
|
||||
// 自动创建/补全四张表(媒体记录表、配置表、色情识别记录表、续传表)
|
||||
if err := e.Sync(new(MediaInfo), new(AppConfig), new(NsfwRecord), new(ScanLog)); err != nil {
|
||||
// 自动创建/补全四张表(媒体记录表、配置表、续传表)
|
||||
if err := e.Sync(new(MediaInfo), new(AppConfig), new(ScanLog)); err != nil {
|
||||
_ = e.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -157,44 +147,6 @@ func (s *Store) GetConfig(key string) string {
|
||||
return cfg.Value
|
||||
}
|
||||
|
||||
// ============================ 色情识别记录 ============================
|
||||
|
||||
// SaveNsfwBatch 批量保存色情识别记录(一次扫描攒一批,最后统一写入)。
|
||||
// 一次扫描可能命中几千张,一条 INSERT 塞不下(SQLite 单条语句最多 999 个变量),
|
||||
// 所以按 chunkSize 一组一组插,外面套一个事务保证要么全成功要么全回滚。
|
||||
func (s *Store) SaveNsfwBatch(records []*NsfwRecord) error {
|
||||
if s == nil || s.engine == nil || len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
const chunkSize = 100
|
||||
sess := s.engine.NewSession()
|
||||
defer sess.Close()
|
||||
if err := sess.Begin(); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := 0; i < len(records); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(records) {
|
||||
end = len(records)
|
||||
}
|
||||
if _, err := sess.Insert(records[i:end]); err != nil {
|
||||
_ = sess.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return sess.Commit()
|
||||
}
|
||||
|
||||
// ClearNsfw 清空色情识别记录。每次开启色情识别的扫描开始时调用,
|
||||
// 保证「色情识别」页面看到的是最近一次扫描的结果。
|
||||
func (s *Store) ClearNsfw() error {
|
||||
if s == nil || s.engine == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := s.engine.Where("1 = 1").Delete(new(NsfwRecord))
|
||||
return err
|
||||
}
|
||||
|
||||
// ============================ 续传表 ============================
|
||||
|
||||
// LoadScanLog 把全部续传记录读出来,按源路径建索引,供扫描时快速判断"这个文件上次归到哪了"。
|
||||
@@ -237,16 +189,6 @@ func (s *Store) SaveScanLogs(entries []ScanLog) error {
|
||||
return sess.Commit()
|
||||
}
|
||||
|
||||
// ListNsfw 查全部色情识别记录,按检测分从高到低排(方便先看最"实锤"的)。
|
||||
func (s *Store) ListNsfw() ([]NsfwRecord, error) {
|
||||
if s == nil || s.engine == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list := make([]NsfwRecord, 0)
|
||||
err := s.engine.Desc("score").Find(&list)
|
||||
return list, err
|
||||
}
|
||||
|
||||
// ============================ 媒体记录的新增/更新 ============================
|
||||
|
||||
// SaveMedia 保存/更新一条媒体记录:按 MD5 找,有就更新,没有就新增。
|
||||
@@ -339,6 +281,7 @@ type MediaQuery struct {
|
||||
Codec string // 编码
|
||||
Format string // 容器格式
|
||||
Resolution string // 分辨率档位(如 1080P / 5K / 普通 / 原图)
|
||||
NsfwOnly bool // 只看色情图片(nsfw_score >= nsfw_threshold 且 threshold > 0)
|
||||
Page int // 第几页(从 1 开始)
|
||||
PageSize int // 每页几条
|
||||
SortField string // 按哪个字段排序:id / fileSize / width / duration / createdAt
|
||||
@@ -437,6 +380,10 @@ func buildMediaCond(q MediaQuery) (string, []interface{}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 色情筛选:只看检测分 >= 阈值的图片
|
||||
if q.NsfwOnly {
|
||||
appendCond("nsfw_score >= nsfw_threshold AND nsfw_threshold > 0")
|
||||
}
|
||||
if cond == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user