This commit is contained in:
2025-09-15 21:17:57 +08:00
parent 524da0830a
commit 413ac308ac
9 changed files with 394 additions and 26 deletions
-2
View File
@@ -10,5 +10,3 @@ ffmpeg.path=
# 是否处理图片 EXIF 旋转(true=是,默认):有 ffmpeg 就物理转正像素,没有则按显示方向归类
# false=完全不处理:图片按存储方向/尺寸归类,不做任何旋转相关改动
organize.rotateImages=false
# 探测/认类型/移动阶段的并发 worker 数(默认 4;0 表示用满全部 CPU 核数)
organize.workers=8
+102 -7
View File
@@ -1,13 +1,47 @@
<template>
<div class="settings-page">
<el-card class="box-card">
<div slot="header" class="card-header">
<span>系统信息</span>
</div>
<el-row :gutter="20" class="system-info">
<el-col :span="8">
<div class="info-item">
<div class="info-label">CPU 核心数</div>
<div class="info-value">{{ systemInfo.cpuCores }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="info-item">
<div class="info-label">内存大小</div>
<div class="info-value">{{ systemInfo.memoryGB.toFixed(1) }} GB</div>
</div>
</el-col>
<el-col :span="8">
<div class="info-item">
<div class="info-label">推荐 Worker</div>
<div class="info-value">
Go {{ systemInfo.recommendedGo }} / 人类 {{ systemInfo.recommendedNsfw }} / 二次元 {{ systemInfo.recommendedAnime }}
<el-button type="text" size="mini" @click="applyRecommended">一键应用</el-button>
</div>
</div>
</el-col>
</el-row>
</el-card>
<el-card class="box-card" style="margin-top: 20px">
<div slot="header" class="card-header">
<span>色情检测配置</span>
</div>
<el-form :model="form" label-width="120px" class="settings-form">
<el-form :model="form" label-width="140px" class="settings-form">
<el-form-item label="Go 端 Worker 数">
<el-input-number v-model="form.goWorkers" :min="0" :max="64"></el-input-number>
<div class="form-tip">探测/MIME/移动的并发数,0 表示使用文件配置(当前推荐 {{ systemInfo.recommendedGo }})</div>
</el-form-item>
<el-form-item label="检测类型">
<el-radio-group v-model="form.type" @change="handleTypeChange">
<el-radio-group v-model="form.type">
<el-radio label="">关闭</el-radio>
<el-radio label="human">人类 (NudeNet)</el-radio>
<el-radio label="anime">二次元 (WD Tagger)</el-radio>
@@ -24,11 +58,21 @@
<div class="form-tip">分数高于此值判定为色情(推荐 0.2-0.4)</div>
</el-form-item>
<el-form-item v-if="form.type === 'human'" label="推理 Worker 数">
<el-input-number v-model="form.humanWorkers" :min="1" :max="32"></el-input-number>
<div class="form-tip">并行推理的 worker 数量,越大越快但占用更多内存(每个 worker 约 300MB)</div>
</el-form-item>
<el-form-item v-if="form.type === 'anime'" label="检测阈值">
<el-slider v-model="form.animeThreshold" :min="0.1" :max="0.9" :step="0.05" show-input></el-slider>
<div class="form-tip">explicit/questionable 分数高于此值判定为色情(推荐 0.5-0.7)</div>
</el-form-item>
<el-form-item v-if="form.type === 'anime'" label="推理 Worker 数">
<el-input-number v-model="form.animeWorkers" :min="1" :max="32"></el-input-number>
<div class="form-tip">并行推理的 worker 数量,越大越快但占用更多内存(每个 worker 约 1.5GB)</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveConfig" :loading="saving">保存配置</el-button>
</el-form-item>
@@ -47,13 +91,24 @@ export default {
form: {
type: '',
humanThreshold: 0.5,
animeThreshold: 0.5
animeThreshold: 0.5,
humanWorkers: 4,
animeWorkers: 2,
goWorkers: 0
},
systemInfo: {
cpuCores: 0,
memoryGB: 0,
recommendedGo: 0,
recommendedNsfw: 4,
recommendedAnime: 2
},
saving: false
}
},
created() {
this.loadConfig()
this.loadSystemInfo()
},
methods: {
async loadConfig() {
@@ -63,13 +118,29 @@ export default {
this.form.type = res.data.type || ''
this.form.humanThreshold = res.data.humanThreshold || 0.5
this.form.animeThreshold = res.data.animeThreshold || 0.5
this.form.humanWorkers = res.data.humanWorkers || 4
this.form.animeWorkers = res.data.animeWorkers || 2
this.form.goWorkers = res.data.goWorkers || 0
}
} catch (error) {
this.$message.error('加载配置失败')
}
},
handleTypeChange() {
// 切换类型时不需要特殊处理
async loadSystemInfo() {
try {
const res = await request.get('/api/config/system')
if (res.data) {
this.systemInfo = res.data
}
} catch (error) {
// 忽略
}
},
applyRecommended() {
this.form.goWorkers = this.systemInfo.recommendedGo
this.form.humanWorkers = this.systemInfo.recommendedNsfw
this.form.animeWorkers = this.systemInfo.recommendedAnime
this.$message.success('已应用推荐值,请点击保存配置')
},
async saveConfig() {
this.saving = true
@@ -77,7 +148,10 @@ export default {
await request.put('/api/config/nsfw', {
type: this.form.type,
humanThreshold: this.form.humanThreshold,
animeThreshold: this.form.animeThreshold
animeThreshold: this.form.animeThreshold,
humanWorkers: this.form.humanWorkers,
animeWorkers: this.form.animeWorkers,
goWorkers: this.form.goWorkers
})
this.$message.success('配置已保存,下次扫描生效')
} catch (error) {
@@ -100,6 +174,27 @@ export default {
font-weight: 600;
}
.system-info {
margin-top: 10px;
}
.info-item {
text-align: center;
padding: 10px 0;
}
.info-label {
font-size: 13px;
color: #909399;
margin-bottom: 8px;
}
.info-value {
font-size: 20px;
font-weight: 600;
color: #1f3a6b;
}
.settings-form {
margin-top: 20px;
}
+11 -5
View File
@@ -46,6 +46,7 @@ const requestTimeout = 60 * time.Second
type Detector struct {
url string
threshold float64
workers int
client *http.Client
cmd *exec.Cmd
scriptDir string
@@ -59,10 +60,13 @@ type Detector struct {
}
// Start 拉起本地 python 检测服务并等它就绪。
func Start(pythonPath string, threshold float64) (*Detector, error) {
func Start(pythonPath string, threshold float64, workers int) (*Detector, error) {
if threshold <= 0 || threshold >= 1 {
threshold = 0.5
}
if workers <= 0 {
workers = 2 // 默认 2 个 worker
}
py := resolvePython(pythonPath)
if py == "" {
return nil, fmt.Errorf("未找到 python,动漫敏感识别不可用")
@@ -85,6 +89,7 @@ func Start(pythonPath string, threshold float64) (*Detector, error) {
d := &Detector{
threshold: threshold,
workers: workers,
client: &http.Client{Timeout: requestTimeout},
python: py,
modelDir: modelDir,
@@ -108,6 +113,7 @@ func (d *Detector) launch() error {
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, d.python, filepath.Join(d.scriptDir, "server.py"), fmt.Sprintf("%d", port), d.modelDir)
cmd.Env = append(os.Environ(), fmt.Sprintf("KM_ANIME_NSFW_WORKERS=%d", d.workers))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stdout
if err := cmd.Start(); err != nil {
@@ -256,10 +262,10 @@ func (d *Detector) detectOnce(path string) (score float64, nsfw bool, ratings An
Nsfw bool `json:"nsfw"`
Score float64 `json:"score"`
Ratings struct {
General float64 `json:"general"`
Sensitive float64 `json:"sensitive"`
Questionable float64 `json:"questionable"`
Explicit float64 `json:"explicit"`
General float64 `json:"general"`
Sensitive float64 `json:"sensitive"`
Questionable float64 `json:"questionable"`
Explicit float64 `json:"explicit"`
} `json:"ratings"`
TopTags []map[string]interface{} `json:"top_tags"`
}
+85 -5
View File
@@ -8,15 +8,19 @@ import (
"github.com/gin-gonic/gin"
"kenaito-media-picker/internal/store"
"kenaito-media-picker/internal/sysinfo"
)
// 配置键名(存储在 AppConfig 表中)
const (
ConfigKeyNsfwType = "nsfw.type" // 检测类型:"" / "human" / "anime"
ConfigKeyNsfwThreshold = "nsfw.threshold" // 人类检测阈值
ConfigKeyAnimeNsfwThreshold = "anime.threshold" // 二次元检测阈值
ConfigKeyScanDir = "scan.scanDir" // 扫描目录
ConfigKeyDestDir = "scan.destDir" // 存放目录
ConfigKeyNsfwType = "nsfw.type" // 检测类型:"" / "human" / "anime"
ConfigKeyNsfwThreshold = "nsfw.threshold" // 人类检测阈值
ConfigKeyAnimeNsfwThreshold = "anime.threshold" // 二次元检测阈值
ConfigKeyNsfwWorkers = "nsfw.workers" // 人类检测 worker 数量
ConfigKeyAnimeNsfwWorkers = "anime.workers" // 二次元检测 worker 数量
ConfigKeyOrganizeWorkers = "organize.workers" // Go 端探测/MIME/移动 worker 数量
ConfigKeyScanDir = "scan.scanDir" // 扫描目录
ConfigKeyDestDir = "scan.destDir" // 存放目录
)
// ConfigController 配置相关接口。
@@ -34,6 +38,9 @@ type NsfwConfig struct {
Type string `json:"type"` // 检测类型:"" / "human" / "anime"
HumanThreshold float64 `json:"humanThreshold"` // 人类检测阈值
AnimeThreshold float64 `json:"animeThreshold"` // 二次元检测阈值
HumanWorkers int `json:"humanWorkers"` // 人类检测 worker 数量
AnimeWorkers int `json:"animeWorkers"` // 二次元检测 worker 数量
GoWorkers int `json:"goWorkers"` // Go 端 worker 数量
}
// GetNsfwConfig 处理 GET /api/config/nsfw:获取 NSFW 配置。
@@ -54,10 +61,34 @@ func (c *ConfigController) GetNsfwConfig(ctx *gin.Context) {
}
}
humanWorkers := 4
if v := c.store.GetConfig(ConfigKeyNsfwWorkers); v != "" {
if w, err := strconv.Atoi(v); err == nil && w > 0 {
humanWorkers = w
}
}
animeWorkers := 2
if v := c.store.GetConfig(ConfigKeyAnimeNsfwWorkers); v != "" {
if w, err := strconv.Atoi(v); err == nil && w > 0 {
animeWorkers = w
}
}
goWorkers := 0 // 0 表示使用文件配置或自动计算
if v := c.store.GetConfig(ConfigKeyOrganizeWorkers); v != "" {
if w, err := strconv.Atoi(v); err == nil && w >= 0 {
goWorkers = w
}
}
Success(ctx, NsfwConfig{
Type: nsfwType,
HumanThreshold: humanThreshold,
AnimeThreshold: animeThreshold,
HumanWorkers: humanWorkers,
AnimeWorkers: animeWorkers,
GoWorkers: goWorkers,
})
}
@@ -66,6 +97,9 @@ type UpdateNsfwConfigReq struct {
Type string `json:"type"` // 检测类型:"" / "human" / "anime"
HumanThreshold float64 `json:"humanThreshold"` // 人类检测阈值
AnimeThreshold float64 `json:"animeThreshold"` // 二次元检测阈值
HumanWorkers int `json:"humanWorkers"` // 人类检测 worker 数量
AnimeWorkers int `json:"animeWorkers"` // 二次元检测 worker 数量
GoWorkers int `json:"goWorkers"` // Go 端 worker 数量(0 表示使用文件配置)
}
// UpdateNsfwConfig 处理 PUT /api/config/nsfw:更新 NSFW 配置。
@@ -90,6 +124,17 @@ func (c *ConfigController) UpdateNsfwConfig(ctx *gin.Context) {
req.AnimeThreshold = 0.5
}
// 校验 worker 数量
if req.HumanWorkers <= 0 {
req.HumanWorkers = 4
}
if req.AnimeWorkers <= 0 {
req.AnimeWorkers = 2
}
if req.GoWorkers < 0 {
req.GoWorkers = 0
}
// 保存配置
if err := c.store.SaveConfig(ConfigKeyNsfwType, req.Type); err != nil {
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
@@ -103,10 +148,45 @@ func (c *ConfigController) UpdateNsfwConfig(ctx *gin.Context) {
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
return
}
if err := c.store.SaveConfig(ConfigKeyNsfwWorkers, strconv.Itoa(req.HumanWorkers)); err != nil {
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
return
}
if err := c.store.SaveConfig(ConfigKeyAnimeNsfwWorkers, strconv.Itoa(req.AnimeWorkers)); err != nil {
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
return
}
if err := c.store.SaveConfig(ConfigKeyOrganizeWorkers, strconv.Itoa(req.GoWorkers)); err != nil {
Error(ctx, http.StatusInternalServerError, "保存配置失败:"+err.Error())
return
}
Success(ctx, nil)
}
// SystemInfo 系统信息响应。
type SystemInfo struct {
CPUCores int `json:"cpuCores"` // CPU 逻辑核心数
MemoryGB float64 `json:"memoryGB"` // 内存大小(GB)
RecommendedGo int `json:"recommendedGo"` // 推荐的 Go 端 worker 数
RecommendedNsfw int `json:"recommendedNsfw"` // 推荐的人类检测 worker 数
RecommendedAnime int `json:"recommendedAnime"` // 推荐的二次元检测 worker 数
}
// GetSystemInfo 处理 GET /api/config/system:获取系统信息和推荐配置。
func (c *ConfigController) GetSystemInfo(ctx *gin.Context) {
info := sysinfo.GetSystemInfo()
workers := sysinfo.CalculateWorkerConfig(info)
Success(ctx, SystemInfo{
CPUCores: info.CPUCores,
MemoryGB: info.MemoryGB,
RecommendedGo: workers.GoWorkers,
RecommendedNsfw: workers.NsfwWorkers,
RecommendedAnime: workers.AnimeNsfwWorkers,
})
}
// UpdateScanDirReq 更新扫描目录配置请求。
type UpdateScanDirReq struct {
ScanDir string `json:"scanDir"` // 扫描目录
+1
View File
@@ -52,6 +52,7 @@ func NewRouter(st *store.Store) *gin.Engine {
cc := NewConfigController(st)
apiGroup.GET("/config/nsfw", cc.GetNsfwConfig)
apiGroup.PUT("/config/nsfw", cc.UpdateNsfwConfig)
apiGroup.GET("/config/system", cc.GetSystemInfo)
apiGroup.PUT("/config/scan-dir", cc.UpdateScanDir)
}
return r
+8
View File
@@ -81,3 +81,11 @@ func WorkerCount() int {
}
return n
}
// SetWorkers 运行时更新 worker 数量(用于从数据库读取配置后覆盖文件配置)。
func SetWorkers(workers int) {
if workers < 0 {
workers = 0
}
appConfig.Workers = workers
}
+9 -1
View File
@@ -60,6 +60,7 @@ const requestTimeout = 30 * time.Second
type Detector struct {
url string // 本地服务地址,如 http://127.0.0.1:54321
threshold float64 // 判定阈值 0~1
workers int // Python 推理 worker 数量
client *http.Client
cmd *exec.Cmd // python 进程句柄(Stop 时杀掉)
scriptDir string // 临时脚本所在目录(Stop 时清理)
@@ -75,12 +76,16 @@ type Detector struct {
// Start 拉起本地 python 检测服务并等它就绪。
// - pythonPath:python 可执行文件路径;为空时在 PATH 里找 python/python3。
// - threshold:判定阈值 0~1。
// - workers:Python 推理 worker 数量;<=0 表示使用默认值(4)。
//
// 找不到 python、装不了 nudenet、或服务起不来时返回错误(调用方跳过识别即可)。
func Start(pythonPath string, threshold float64) (*Detector, error) {
func Start(pythonPath string, threshold float64, workers int) (*Detector, error) {
if threshold <= 0 || threshold >= 1 {
threshold = 0.5 // 阈值传得不合理就用默认
}
if workers <= 0 {
workers = 4 // 默认 4 个 worker
}
// 找 python
py := resolvePython(pythonPath)
if py == "" {
@@ -107,6 +112,7 @@ func Start(pythonPath string, threshold float64) (*Detector, error) {
d := &Detector{
threshold: threshold,
workers: workers,
client: &http.Client{Timeout: requestTimeout},
python: py,
modelDir: modelDir,
@@ -135,6 +141,8 @@ func (d *Detector) launch() error {
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, d.python, filepath.Join(d.scriptDir, "server.py"), fmt.Sprintf("%d", port), d.modelDir)
// 设置环境变量,传递 worker 数量给 Python 服务
cmd.Env = append(os.Environ(), fmt.Sprintf("KM_NSFW_WORKERS=%d", d.workers))
// 标准输出/错误都接到程序自身的输出:
// - 扫描期间 os.Stdout 会被接管进"运行日志",这样 python 的报错(崩溃原因)也能在前端看到
// - 方便排查服务为什么挂掉
+27 -6
View File
@@ -74,8 +74,9 @@ type ScanStatus struct {
// 应用级配置键。目前只存了两个:上次使用的存放目录、色情检测服务是否真的起来了。
const (
ConfigKeyScanDir = "scan.scanDir"
ConfigKeyDestDir = "scan.destDir"
ConfigKeyScanDir = "scan.scanDir"
ConfigKeyDestDir = "scan.destDir"
ConfigKeyOrganizeWorkers = "organize.workers"
)
// nsfwDetectorReadyKey 记录最近一次扫描时色情检测服务是否真的起来了(python+nudenet 可用)。
@@ -625,6 +626,14 @@ func sanitizeLine(line string) string {
// 3. 准备解码器:把内嵌的 ffprobe 程序释放到当前目录(用它读视频/音频信息)
// 4. 整理:给每个文件探测信息 → 判断是否重复 → 分到分类目录 → 搬进去
func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) error {
// 从数据库读取 organize.workers 配置(支持运行时修改,立即生效)
if v := s.store.GetConfig(ConfigKeyOrganizeWorkers); v != "" {
if w, err := strconv.Atoi(v); err == nil && w >= 0 {
config.SetWorkers(w)
fmt.Printf("=== 使用数据库配置 organize.workers=%d\n", w)
}
}
// 确定整理后文件的存放目录:统一都放进「KMP已整理」这一层——
// 用户填了存放目录就放在它下面(存放目录/KMP已整理),没填就放在扫描目录下(扫描目录/KMP已整理)。
pickerDir := filepath.Join(rootDir, DefaultPickerDirName)
@@ -696,13 +705,19 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
threshold = t
}
}
if d, err := nsfw.Start("", threshold); err != nil {
workers := 4
if v := s.store.GetConfig("nsfw.workers"); v != "" {
if w, err := strconv.Atoi(v); err == nil && w > 0 {
workers = w
}
}
if d, err := nsfw.Start("", threshold, workers); err != nil {
fmt.Printf("=== 色情识别未启用(图片照常处理): %v\n", err)
} else {
nsfwDet = d
defer nsfwDet.Stop()
s.setNsfwDetectorReady(true)
fmt.Println("=== 色情识别已启用(NudeNet)")
fmt.Printf("=== 色情识别已启用(NudeNet, workers=%d)\n", workers)
}
}
@@ -715,12 +730,18 @@ func (s *ScanService) runScan(ctx context.Context, rootDir, destDir string) erro
threshold = t
}
}
if d, err := anime_nsfw.Start("", threshold); err != nil {
workers := 2
if v := s.store.GetConfig("anime.workers"); v != "" {
if w, err := strconv.Atoi(v); err == nil && w > 0 {
workers = w
}
}
if d, err := anime_nsfw.Start("", threshold, workers); err != nil {
fmt.Printf("=== 动漫色情识别未启用(图片照常处理): %v\n", err)
} else {
animeNsfwDet = d
defer animeNsfwDet.Stop()
fmt.Println("=== 动漫色情识别已启用(WD Tagger v3)")
fmt.Printf("=== 动漫色情识别已启用(WD Tagger v3, workers=%d)\n", workers)
}
}
+151
View File
@@ -0,0 +1,151 @@
// Package sysinfo 提供系统资源检测和 worker 数量自动计算功能。
package sysinfo
import (
"os/exec"
"runtime"
"strconv"
"strings"
)
// SystemInfo 系统资源信息
type SystemInfo struct {
CPUCores int // CPU 逻辑核心数
MemoryGB float64 // 内存大小(GB)
MemoryMB int // 内存大小(MB)
}
// GetSystemInfo 获取系统资源信息
func GetSystemInfo() SystemInfo {
info := SystemInfo{
CPUCores: runtime.NumCPU(),
}
// 获取内存信息
info.MemoryMB = getMemoryMB()
info.MemoryGB = float64(info.MemoryMB) / 1024.0
return info
}
// getMemoryMB 获取系统内存大小(MB)
func getMemoryMB() int {
switch runtime.GOOS {
case "windows":
return getMemoryWindows()
case "linux":
return getMemoryLinux()
case "darwin":
return getMemoryDarwin()
default:
return 0
}
}
// getMemoryWindows 使用 wmic 获取 Windows 内存
func getMemoryWindows() int {
cmd := exec.Command("wmic", "computersystem", "get", "totalphysicalmemory", "/value")
output, err := cmd.Output()
if err != nil {
return 0
}
// 解析输出:TotalPhysicalMemory=1234567890
for _, line := range strings.Split(string(output), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "TotalPhysicalMemory=") {
value := strings.TrimPrefix(line, "TotalPhysicalMemory=")
if bytes, err := strconv.ParseInt(value, 10, 64); err == nil {
return int(bytes / 1024 / 1024)
}
}
}
return 0
}
// getMemoryLinux 从 /proc/meminfo 获取 Linux 内存
func getMemoryLinux() int {
cmd := exec.Command("cat", "/proc/meminfo")
output, err := cmd.Output()
if err != nil {
return 0
}
// 解析 MemTotal: 12345678 kB
for _, line := range strings.Split(string(output), "\n") {
if strings.HasPrefix(line, "MemTotal:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
if kb, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
return int(kb / 1024)
}
}
}
}
return 0
}
// getMemoryDarwin 使用 sysctl 获取 macOS 内存
func getMemoryDarwin() int {
cmd := exec.Command("sysctl", "-n", "hw.memsize")
output, err := cmd.Output()
if err != nil {
return 0
}
value := strings.TrimSpace(string(output))
if bytes, err := strconv.ParseInt(value, 10, 64); err == nil {
return int(bytes / 1024 / 1024)
}
return 0
}
// WorkerConfig worker 配置
type WorkerConfig struct {
GoWorkers int // Go 端 worker 数量(探测/MIME/移动)
NsfwWorkers int // NudeNet Python 推理 worker 数量
AnimeNsfwWorkers int // WD Tagger Python 推理 worker 数量
}
// CalculateWorkerConfig 根据系统资源自动计算 worker 配置
// 规则:
// - Go worker: CPU核心数 - 2(留2核给Python推理和系统)
// - NudeNet worker: 因为模型小(~300MB),可以开更多,上限为 CPU核心数
// - WD Tagger worker: 因为模型大(~1.5GB),受内存限制,每个worker约需2GB
func CalculateWorkerConfig(info SystemInfo) WorkerConfig {
config := WorkerConfig{}
// Go worker: CPU核心数 - 2,最少为1
goWorkers := info.CPUCores - 2
if goWorkers < 1 {
goWorkers = 1
}
config.GoWorkers = goWorkers
// NudeNet worker: 模型小,可以开到 CPU核心数,但留一些给系统
nsfwWorkers := info.CPUCores
if nsfwWorkers > 12 {
nsfwWorkers = 12 // 上限12,避免过度并发
}
config.NsfwWorkers = nsfwWorkers
// WD Tagger worker: 受内存限制,每个worker约需2GB
// 预留4GB给系统和其他进程
availableMemoryGB := info.MemoryGB - 4
if availableMemoryGB < 0 {
availableMemoryGB = 0
}
animeWorkers := int(availableMemoryGB / 2)
if animeWorkers < 1 {
animeWorkers = 1
}
if animeWorkers > info.CPUCores {
animeWorkers = info.CPUCores // 不超过CPU核心数
}
if animeWorkers > 12 {
animeWorkers = 12 // 上限12
}
config.AnimeNsfwWorkers = animeWorkers
return config
}