diff --git a/.gitignore b/.gitignore index fc9a622..fc67153 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea/ media-info.db +__pycache__/ diff --git a/README.md b/README.md index 100f872..7fc05b6 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ lightScore = 亮度 > 175 的像素占比 - 同一时间只允许一次扫描整理;重复点击会提示「正在运行中」 - GIF 图片单独归入「图片_GIF」;GIF 视频按方向归入「视频_*_GIF」 - ffprobe 内嵌(Windows),运行前释放到当前目录、结束后自动删除 -- EXIF 旋转转正需要 ffmpeg:配置 `ffmpeg.path` 指定路径,或让 ffmpeg 在 PATH 里;找不到 ffmpeg 时不转正像素,仍按显示方向归类。可用 `organize.rotateImages=false` 关闭 +- EXIF 旋转转正需要 ffmpeg:配置 `ffmpeg.path` 指定路径,或让 ffmpeg 在 PATH 里;找不到 ffmpeg 时不转正像素,仍按显示方向归类。想完全关闭旋转处理(不转正、也不按显示方向调宽高,图片按存储方向原样归类)可在 `application.properties` 里设 `organize.rotateImages=false` ## 色情图片识别(可选) diff --git a/application.properties b/application.properties index 4fd36ea..ad86ea9 100644 --- a/application.properties +++ b/application.properties @@ -7,14 +7,15 @@ server.autoOpen=true database.dsn=media-info.db # ffmpeg 程序路径(用于把带 EXIF 旋转标记的图片转正);留空自动在 PATH 里找 ffmpeg.path= -# 整理时是否自动转正带 EXIF 旋转标记的图片(true=是,默认;false=只按显示方向归类不转正) -organize.rotateImages=true +# 是否处理图片 EXIF 旋转(true=是,默认):有 ffmpeg 就物理转正像素,没有则按显示方向归类 +# false=完全不处理:图片按存储方向/尺寸归类,不做任何旋转相关改动 +organize.rotateImages=false # 探测/认类型/移动阶段的并发 worker 数(默认 4;0 表示用满全部 CPU 核数) organize.workers=4 -# 色情图片识别(需本机安装 python 与 nudenet:pip install nudenet): +# 色情图片识别(需本机安装 python 与 nudenet:pip install nudenet),需要科技下载模型 # true=开启,判为色情的图片移入「图片_敏感_*」目录(明暗子目录照常);false=关闭(默认) -nsfw.enabled=false +nsfw.enabled=true # python 可执行文件路径;留空自动在 PATH 里找 python/python3/py -nsfw.python= -# 判定阈值 0~1,敏感部位检测分高于此才算色情(默认 0.5) -nsfw.threshold=0.5 +nsfw.python=C:\\Users\\Administrator\\AppData\\Local\\Programs\\Python\\Python312\\python.exe +# 判定阈值 0~1,敏感部位检测分高于此才算色情(默认 0.2,实测) +nsfw.threshold=0.2 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 71f2df6..37374b2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -24,6 +24,10 @@ 媒体库 + + + 色情识别 + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index bdff5c0..529f35c 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -2,6 +2,7 @@ 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) @@ -11,6 +12,7 @@ export default new Router({ {path: '/', redirect: '/scan'}, {path: '/dashboard', component: Dashboard}, {path: '/media', component: MediaList}, + {path: '/nsfw', component: NsfwList}, {path: '/scan', component: Scan} ] }) diff --git a/frontend/src/views/NsfwList.vue b/frontend/src/views/NsfwList.vue new file mode 100644 index 0000000..f2c4830 --- /dev/null +++ b/frontend/src/views/NsfwList.vue @@ -0,0 +1,183 @@ + + + + + \ No newline at end of file diff --git a/internal/api/nsfw_controller.go b/internal/api/nsfw_controller.go new file mode 100644 index 0000000..19302b2 --- /dev/null +++ b/internal/api/nsfw_controller.go @@ -0,0 +1,75 @@ +// 色情识别相关的两个接口:查记录、看图片预览。 +// 前端「色情识别」页面用它展示每次扫描判出的色情图片(检测分 + 路径 + 预览), +// 方便用户核对识别结果准不准。只读数据库 + 读本地图片文件,不干别的活。 +package api + +import ( + "net/http" + "os" + "strings" + + "github.com/gin-gonic/gin" + + "kenaito-media-picker/internal/store" +) + +// 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)}) +} + +// 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) +} diff --git a/internal/api/router.go b/internal/api/router.go index 6ec66a5..fb89fb6 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -33,6 +33,11 @@ func NewRouter(st *store.Store) *gin.Engine { 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) + // 扫描整理(开始 / 中止 / 查进度 / 实时进度) sc := NewScanController(service.NewScanService(st)) apiGroup.POST("/scan/start", sc.Start) diff --git a/internal/config/config.go b/internal/config/config.go index 459b72a..57eddb9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,7 +17,7 @@ import ( // - Dsn:数据库文件叫什么(默认 media-info.db,放在程序目录下) // - AutoOpen:启动后要不要自动打开浏览器 // - FFmpegPath:ffmpeg 程序路径(用于把带 EXIF 旋转标记的图片转正);留空自动在 PATH 里找 -// - RotateImages:整理时是否自动转正带 EXIF 旋转标记的图片(默认 true) +// - RotateImages:是否处理图片 EXIF 旋转(默认 true);false 则完全不处理,按存储方向/尺寸归类 // - Workers:探测/MIME/移动阶段的并发 worker 数(默认 4;设 0 表示用满全部 CPU 核数) // - NSFWEnabled:是否开启色情图片识别(本地 python + NudeNet,默认 false) // - NSFWPython:python 可执行文件路径;留空自动在 PATH 里找 diff --git a/internal/nsfw/detector.go b/internal/nsfw/detector.go index 364ac68..40d6df7 100644 --- a/internal/nsfw/detector.go +++ b/internal/nsfw/detector.go @@ -216,6 +216,11 @@ func (d *Detector) Stop() { } } +// Threshold 返回当前使用的判定阈值(0~1)。给上层记录结果用。 +func (d *Detector) Threshold() float64 { + return d.threshold +} + // resolvePython 在配置值 / PATH 里找可用的 python 解释器。 // Windows 上常见 python / python3 / py;Linux/mac 常见 python3 / python。 func resolvePython(configured string) string { diff --git a/internal/nsfw/server.py b/internal/nsfw/server.py index dd84227..0a2ad0e 100644 --- a/internal/nsfw/server.py +++ b/internal/nsfw/server.py @@ -2,14 +2,29 @@ """NudeNet 本地检测服务:由 Go 侧启动,常驻提供 /health 和 /detect。 只依赖 Python 标准库 + nudenet 包,模型在进程里只加载一次。 -收到 /detect 就把图片路径丢给 NudeDetector,返回结果 JSON。 +收到 /detect 就把图片喂给 NudeDetector,返回结果 JSON。 + +Windows 超长路径说明:OpenCV 的 imread 走老式 C API,路径超过 260 字符就 +打不开(报 can't open/read file)。所以这里改用 Python 的 open() 读字节, +再把字节直接交给 NudeNet(v3 的 detect 支持 bytes)——从内存解码,彻底 +绕开路径限制,同时兼容老版 v2(只收路径时落到临时短路径文件再试)。 """ import json +import os import sys +import tempfile from http.server import BaseHTTPRequestHandler, HTTPServer from nudenet import NudeDetector +# 只报 OpenCV 致命错误,别让 imread 的 WARN 刷屏(也避免误导排查) +try: + import cv2 + + cv2.setLogLevel(8) # LOG_LEVEL_ERROR +except Exception: + pass + # 只有这些"暴露部位"才算色情;其它如肚子/腋窝/脚踝暴露不判。 SENSITIVE = { "FEMALE_GENITALIA_EXPOSED", @@ -28,6 +43,51 @@ def get_detector(): return _detector +def _longpath(path): + """Windows 下给绝对路径加 \\\\?\\ 前缀,让 Python 原生 open 也能处理超长路径。""" + if os.name != "nt": + return path + if path.startswith("\\\\?\\"): + return path + if path.startswith("\\\\"): + # UNC 网络路径:\\server\share → \\?\UNC\server\share + return "\\\\?\\UNC\\" + path.lstrip("\\") + if os.path.isabs(path): + return "\\\\?\\" + os.path.normpath(path) + return path + + +def _read_bytes(path): + """用 Python 读文件字节(不经过 OpenCV),读不了就抛异常由上层兜底。""" + lp = _longpath(path) + if not os.path.exists(lp): + raise OSError("文件不存在: %s" % path) + with open(lp, "rb") as f: + data = f.read() + if not data: + raise OSError("空文件: %s" % path) + return data + + +def _detect_bytes(det, data, path): + """把图片字节喂给 NudeNet。v3 直接吃 bytes;老版 v2 只收路径时落到临时短路径重试。""" + try: + return det.detect(data) + except (TypeError, AttributeError): + tmp = None + try: + fd, tmp = tempfile.mkstemp(suffix=os.path.splitext(path)[1] or ".jpg") + with os.fdopen(fd, "wb") as f: + f.write(data) + return det.detect(tmp) + finally: + if tmp: + try: + os.unlink(tmp) + except OSError: + pass + + class Handler(BaseHTTPRequestHandler): def _send(self, obj, status=200): data = json.dumps(obj, ensure_ascii=False).encode("utf-8") @@ -51,7 +111,10 @@ class Handler(BaseHTTPRequestHandler): body = json.loads(self.rfile.read(length) or b"{}") path = body.get("path", "") threshold = float(body.get("threshold", 0.5)) - detections = get_detector().detect(path) + if not path: + return self._send({"ok": False, "error": "缺少图片路径"}) + data = _read_bytes(path) + detections = _detect_bytes(get_detector(), data, path) except Exception as exc: # noqa: BLE001 - 服务端任何异常都要回给 Go,别让进程崩 return self._send({"ok": False, "error": str(exc)}) @@ -73,4 +136,4 @@ class Handler(BaseHTTPRequestHandler): if __name__ == "__main__": port = int(sys.argv[1]) if len(sys.argv) > 1 else 0 - HTTPServer(("127.0.0.1", port), Handler).serve_forever() + HTTPServer(("127.0.0.1", port), Handler).serve_forever() \ No newline at end of file diff --git a/internal/organize/organize.go b/internal/organize/organize.go index 5605d70..fe9fa2b 100644 --- a/internal/organize/organize.go +++ b/internal/organize/organize.go @@ -56,30 +56,33 @@ const ( // // 这样设计是为了避免"边分类边搬"导致目录反复开关、文件搬来搬去,效率更高。 type Organizer struct { - pickerDir string // 整理后的文件都放到这个根目录下 - ffprobePath string // ffprobe 程序路径;为空表示没有可用解码器 - ffmpegPath string // ffmpeg 程序路径;为空表示没有转正 EXIF 旋转图片的能力 - store *store.Store // 数据库;为 nil 表示不记录媒体信息 - mover *mover.Mover // 负责实际搬文件的家伙 - ctx context.Context // 中止信号;ctx 被取消时探测/归类立即停下 - nsfw *nsfw.Detector // 色情检测器;为 nil 表示没启用,图片不检测 - buckets map[string][]string // 目标目录 → 里面该放哪些文件(只记源路径) - order []string // 目标目录的先后顺序(保证输出稳定、不来回跳) - onProgress func(phase string, done, total int) - pending []*store.MediaInfo // 待写入数据库的媒体记录(攒一批再批量写) + pickerDir string // 整理后的文件都放到这个根目录下 + ffprobePath string // ffprobe 程序路径;为空表示没有可用解码器 + ffmpegPath string // ffmpeg 程序路径;为空表示没有转正 EXIF 旋转图片的能力 + store *store.Store // 数据库;为 nil 表示不记录媒体信息 + mover *mover.Mover // 负责实际搬文件的家伙 + ctx context.Context // 中止信号;ctx 被取消时探测/归类立即停下 + nsfw *nsfw.Detector // 色情检测器;为 nil 表示没启用,图片不检测 + rotateImages bool // 是否处理 EXIF 旋转(转正/按显示方向归类);false 则完全不动,按存储方向归类 + buckets map[string][]string // 目标目录 → 里面该放哪些文件(只记源路径) + order []string // 目标目录的先后顺序(保证输出稳定、不来回跳) + onProgress func(phase string, done, total int) + pending []*store.MediaInfo // 待写入数据库的媒体记录(攒一批再批量写) + pendingNsfw []store.NsfwRecord // 待写入数据库的色情识别记录(攒一批再批量写) } // New 创建整理器。store 可以为 nil——数据库打不开时,整理照常进行,只是不记入库信息。 // ctx 可以为 nil——不提供中止能力,任务跑到完为止。 func New(pickerDir, ffprobePath, ffmpegPath string, st *store.Store, ctx context.Context) *Organizer { return &Organizer{ - pickerDir: pickerDir, - ffprobePath: ffprobePath, - ffmpegPath: ffmpegPath, - store: st, - mover: &mover.Mover{}, - ctx: ctx, - buckets: make(map[string][]string), + pickerDir: pickerDir, + ffprobePath: ffprobePath, + ffmpegPath: ffmpegPath, + store: st, + mover: &mover.Mover{}, + ctx: ctx, + rotateImages: true, // 默认处理 EXIF 旋转;关掉用 SetRotateImages(false) + buckets: make(map[string][]string), } } @@ -96,6 +99,13 @@ func (o *Organizer) SetNSFWDetector(d *nsfw.Detector) { o.nsfw = d } +// SetRotateImages 设置是否处理图片 EXIF 旋转(默认 true)。 +// - true:带旋转标记的图片做转正处理(有 ffmpeg 就烧进像素,没有则按显示方向归类); +// - false:完全不动,图片按文件里存储的方向/尺寸归类,不做任何旋转相关处理。 +func (o *Organizer) SetRotateImages(b bool) { + o.rotateImages = b +} + // report 上报进度。total 为 0 表示这一步没活干,就不用报了。 func (o *Organizer) report(phase string, done, total int) { if o.onProgress != nil && total > 0 { @@ -337,6 +347,10 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult { } else { r.nsfwScore = score r.nsfw = hit + // 命中的图片把"文件名 + 检测分"打进运行日志,方便用户盯着看是否判对 + if hit { + fmt.Printf("=== 色情命中: %s, 检测分=%.2f (阈值=%.2f)\n", f.Path, score, o.nsfw.Threshold()) + } } } return r @@ -353,6 +367,9 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult { // // 只有方向 5/6/7/8(90°/270° 旋转)才需要宽高对调;2/3/4 是镜像或 180°,横竖不变。 func (o *Organizer) correctImageRotation(f scanner.File, res *probe.ProbeResult) { + if !o.rotateImages { + return // 配置关闭:完全不处理旋转,按存储方向/尺寸归类 + } ori := probe.ReadExifOrientation(f.Path) if ori == 0 { return // 没有旋转标记,不用管 @@ -380,6 +397,7 @@ func (o *Organizer) correctImageRotation(f scanner.File, res *probe.ProbeResult) func (o *Organizer) ProcessImages(images []scanner.File) { // 统计结果,最后打一行总结给用户看 success, fail, ignore, dup := 0, 0, 0, 0 + nsfwCount := 0 // 命中的色情图片数 total := len(images) results := runParallel(o.ctx, images, o.probeImage, func(done int) { o.report(PhaseImages, done, total) @@ -415,6 +433,14 @@ 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.pendingNsfw = append(o.pendingNsfw, store.NsfwRecord{ + FilePath: f.Path, + FileName: filepath.Base(f.Path), + Score: r.nsfwScore, + Threshold: o.nsfw.Threshold(), + }) + nsfwCount++ } else { o.add(f, classify.ImageDir(f.Ext, r.res.Width, r.res.Height, r.darkScore, r.lightScore)) } @@ -422,6 +448,9 @@ func (o *Organizer) ProcessImages(images []scanner.File) { } } fmt.Printf("=== 图片处理完毕,成功: %d, 读取异常: %d, 忽略: %d, 重复: %d \n\n", success, fail, ignore, dup) + if o.nsfw != nil && nsfwCount > 0 { + fmt.Printf("=== 色情识别: 共命中 %d 张,已归入「图片_敏感_*」目录,可在【色情识别】页面查看并人工复核\n", nsfwCount) + } } // ============================ 视频/音频整理 ============================ @@ -544,7 +573,27 @@ func (o *Organizer) Flush() { // 全局并行搬完:不管文件分散在几个目录,所有文件同时开搬。 // onProgress 收到的是全局累计已搬数,正好和总数对得上。 - o.mover.MoveAll(jobs, func(done int) { + // moved 是"源路径 → 新位置"的对照表,色情记录要用它补上整理后的实际路径。 + moved := 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 + } } diff --git a/internal/service/scan.go b/internal/service/scan.go index a9a9de2..7d40473 100644 --- a/internal/service/scan.go +++ b/internal/service/scan.go @@ -505,11 +505,18 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro nsfwDet = d defer nsfwDet.Stop() 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) // 整个"整理"分两个阶段,界面上共用一条进度条: // 归类阶段:图片、视频/音频分两批处理。进度 = 已处理文件数 / 全部待整理文件数 diff --git a/internal/store/store.go b/internal/store/store.go index 5262334..6e2459a 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -63,6 +63,18 @@ 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"` // 检测时间 +} + // Open 打开(或第一次时创建)数据库,并保证表结构齐全。 func Open(dbPath string) (*Store, error) { e, err := xorm.NewEngine("sqlite3", dbPath) @@ -72,8 +84,8 @@ func Open(dbPath string) (*Store, error) { e.ShowSQL(false) // SQLite 同一时刻只允许一个写连接,这里限制成 1 个,避免并发写冲突 e.SetMaxOpenConns(1) - // 自动创建/补全两张表(媒体记录表、配置表) - if err := e.Sync(new(MediaInfo), new(AppConfig)); err != nil { + // 自动创建/补全三张表(媒体记录表、配置表、色情识别记录表) + if err := e.Sync(new(MediaInfo), new(AppConfig), new(NsfwRecord)); err != nil { _ = e.Close() return nil, err } @@ -123,6 +135,37 @@ func (s *Store) GetConfig(key string) string { return cfg.Value } +// ============================ 色情识别记录 ============================ + +// SaveNsfwBatch 批量保存色情识别记录(一次扫描攒一批,最后统一写入)。 +func (s *Store) SaveNsfwBatch(records []*NsfwRecord) error { + if s == nil || s.engine == nil || len(records) == 0 { + return nil + } + _, err := s.engine.Insert(records) + return err +} + +// 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 +} + +// 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 找,有就更新,没有就新增。 diff --git a/main.go b/main.go index 94dff1c..7e9cf20 100644 --- a/main.go +++ b/main.go @@ -55,7 +55,12 @@ func main() { addr := fmt.Sprintf(":%d", cfg.Port) url := "http://localhost" + addr fmt.Printf("=== Web 服务已启动: %s\n", url) - fmt.Printf("=== 数据库: %s\n", cfg.Dsn) + // 数据库路径:相对路径转成绝对路径打印,方便用户知道账本在哪 + dbAbs := cfg.Dsn + if abs, err := filepath.Abs(cfg.Dsn); err == nil { + dbAbs = abs + } + fmt.Printf("=== 数据库: %s\n", dbAbs) fmt.Printf("=== 在页面上点击【开始扫描】执行媒体整理(会移动文件,注意备份)\n") // 配置了自动打开浏览器的话,等服务起来后帮用户打开 if cfg.AutoOpen {