diff --git a/internal/config/config.go b/internal/config/config.go
index 49d0718..a54be88 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -241,8 +241,8 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("app.web_dir", "./web/dist")
v.SetDefault("app.ffmpeg_path", "ffmpeg")
v.SetDefault("app.ffprobe_path", "ffprobe")
- v.SetDefault("app.ffprobe_max_concurrent", 1)
- v.SetDefault("app.cloud_scan_max_concurrent", 4)
+ v.SetDefault("app.ffprobe_max_concurrent", 2)
+ v.SetDefault("app.cloud_scan_max_concurrent", 8)
v.SetDefault("app.max_cpu_threads", 2)
v.SetDefault("app.vaapi_device", "/dev/dri/renderD128")
v.SetDefault("app.cors_origins", []string{})
diff --git a/internal/handler/emby.go b/internal/handler/emby.go
index 823e873..f3eb9a4 100644
--- a/internal/handler/emby.go
+++ b/internal/handler/emby.go
@@ -138,6 +138,123 @@ func firstHeaderValue(c *gin.Context, names ...string) string {
return ""
}
+type embyClientInfo struct {
+ DeviceID string
+ DeviceName string
+ Client string
+}
+
+func embyClientInfoFromRequest(c *gin.Context) embyClientInfo {
+ auth := parseMediaBrowserAuthorization(firstHeaderValue(c,
+ "X-Emby-Authorization",
+ "X-MediaBrowser-Authorization",
+ "Authorization",
+ ))
+ info := embyClientInfo{
+ DeviceID: firstNonEmptyHeaderString(
+ firstHeaderValue(c, "X-Emby-Device-Id", "X-Emby-DeviceId", "X-MediaBrowser-Device-Id", "X-MediaBrowser-DeviceId"),
+ auth["DeviceId"],
+ auth["DeviceID"],
+ ),
+ DeviceName: firstNonEmptyHeaderString(
+ firstHeaderValue(c, "X-Emby-Device-Name", "X-Emby-DeviceName", "X-MediaBrowser-Device-Name", "X-MediaBrowser-DeviceName"),
+ auth["Device"],
+ ),
+ Client: firstNonEmptyHeaderString(
+ firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"),
+ auth["Client"],
+ ),
+ }
+ ua := strings.TrimSpace(c.GetHeader("User-Agent"))
+ if info.Client == "" {
+ info.Client = embyClientFromUserAgent(ua)
+ }
+ if info.DeviceName == "" {
+ info.DeviceName = embyDeviceFromUserAgent(ua)
+ }
+ return info
+}
+
+func parseMediaBrowserAuthorization(raw string) map[string]string {
+ out := map[string]string{}
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return out
+ }
+ for _, prefix := range []string{"MediaBrowser ", "Emby "} {
+ if strings.HasPrefix(raw, prefix) {
+ raw = strings.TrimSpace(strings.TrimPrefix(raw, prefix))
+ break
+ }
+ }
+ for _, part := range strings.Split(raw, ",") {
+ key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
+ if !ok {
+ continue
+ }
+ key = strings.TrimSpace(key)
+ value = strings.Trim(strings.TrimSpace(value), `"`)
+ if key != "" && value != "" {
+ out[key] = value
+ }
+ }
+ return out
+}
+
+func firstNonEmptyHeaderString(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
+
+func embyClientFromUserAgent(ua string) string {
+ ua = strings.TrimSpace(ua)
+ lower := strings.ToLower(ua)
+ switch {
+ case strings.Contains(lower, "infuse"):
+ return "Infuse"
+ case strings.Contains(lower, "emby"):
+ return "Emby"
+ case strings.Contains(lower, "jellyfin"):
+ return "Jellyfin"
+ case strings.Contains(lower, "yamby"):
+ return "Yamby"
+ case strings.Contains(lower, "vidhub"):
+ return "VidHub"
+ case strings.Contains(lower, "hills"):
+ return "Hills"
+ default:
+ return ua
+ }
+}
+
+func embyDeviceFromUserAgent(ua string) string {
+ lower := strings.ToLower(strings.TrimSpace(ua))
+ switch {
+ case strings.Contains(lower, "android"):
+ return "Android"
+ case strings.Contains(lower, "iphone"):
+ return "iPhone"
+ case strings.Contains(lower, "ipad"):
+ return "iPad"
+ case strings.Contains(lower, "ios"):
+ return "iOS"
+ case strings.Contains(lower, "windows"):
+ return "Windows PC"
+ case strings.Contains(lower, "macintosh") || strings.Contains(lower, "mac os"):
+ return "Mac"
+ case strings.Contains(lower, "linux"):
+ return "Linux PC"
+ case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv"):
+ return "Apple TV"
+ default:
+ return ""
+ }
+}
+
// ─── System ──────────────────────────────────────────────────────────────────
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
@@ -421,11 +538,12 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
return
}
// 记录登录设备会话并执行防共享检测(登录客户端数 / 设备指纹)。
+ clientInfo := embyClientInfoFromRequest(c)
if svc.Device != nil {
svc.Device.RecordLogin(c.Request.Context(), resp.User.ID,
- c.GetHeader("X-Emby-Device-Id"),
- c.GetHeader("X-Emby-Device-Name"),
- c.GetHeader("X-Emby-Client"),
+ clientInfo.DeviceID,
+ clientInfo.DeviceName,
+ clientInfo.Client,
c.ClientIP())
}
userPayload, _ := svc.Emby.FindUser(c.Request.Context(), resp.User.ID)
@@ -446,9 +564,9 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
"Id": resp.User.ID,
"UserId": resp.User.ID,
"UserName": resp.User.Username,
- "Client": c.GetHeader("X-Emby-Client"),
- "DeviceId": c.GetHeader("X-Emby-Device-Id"),
- "DeviceName": c.GetHeader("X-Emby-Device-Name"),
+ "Client": clientInfo.Client,
+ "DeviceId": clientInfo.DeviceID,
+ "DeviceName": clientInfo.DeviceName,
},
})
}
@@ -786,16 +904,16 @@ func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
// ─── Images ──────────────────────────────────────────────────────────────────
-var embyTransparentPNG = []byte{
+var embyPlaceholderPNG = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
- 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
- 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
- 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
- 0x42, 0x60, 0x82,
+ 0x54, 0x78, 0x9c, 0x63, 0x50, 0xd1, 0x30, 0xf8,
+ 0x0f, 0x00, 0x02, 0x6c, 0x01, 0x7c, 0x30, 0xed,
+ 0x6e, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
+ 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
}
// embyItemImageHandler 把 /Items/{id}/Images/Primary 等请求直接输出为图片。
@@ -810,7 +928,7 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
imgType := strings.ToLower(c.Param("type"))
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
if err != nil || raw == "" {
- embyServeTransparentImage(c)
+ embyServePlaceholderImage(c)
return
}
if typ, ref, ok := parseCloudPlayImageURL(raw); ok {
@@ -819,24 +937,24 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
return
}
if svc.ImageProxy == nil {
- embyServeTransparentImage(c)
+ embyServePlaceholderImage(c)
return
}
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
- embyServeTransparentImage(c)
+ embyServePlaceholderImage(c)
}
}
}
-func embyServeTransparentImage(c *gin.Context) {
+func embyServePlaceholderImage(c *gin.Context) {
c.Header("Content-Type", "image/png")
c.Header("Cache-Control", "public, max-age=3600")
- c.Header("Content-Length", strconv.Itoa(len(embyTransparentPNG)))
+ c.Header("Content-Length", strconv.Itoa(len(embyPlaceholderPNG)))
if c.Request.Method == http.MethodHead {
c.Status(http.StatusOK)
return
}
- c.Data(http.StatusOK, "image/png", embyTransparentPNG)
+ c.Data(http.StatusOK, "image/png", embyPlaceholderPNG)
}
func parseCloudPlayImageURL(raw string) (string, string, bool) {
@@ -1195,7 +1313,8 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
return
}
// 被「一键踢下线」的设备拒绝继续播放,直到重新登录。
- if svc.Device != nil && svc.Device.IsDeviceKicked(c.Request.Context(), uid, c.GetHeader("X-Emby-Device-Id")) {
+ clientInfo := embyClientInfoFromRequest(c)
+ if svc.Device != nil && svc.Device.IsDeviceKicked(c.Request.Context(), uid, clientInfo.DeviceID) {
c.Status(http.StatusUnauthorized)
return
}
@@ -1203,9 +1322,9 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
// 标记该设备正在播放并执行并发播放防共享检测。
if svc.Device != nil {
svc.Device.RecordPlayback(c.Request.Context(), uid,
- c.GetHeader("X-Emby-Device-Id"),
- c.GetHeader("X-Emby-Device-Name"),
- c.GetHeader("X-Emby-Client"))
+ clientInfo.DeviceID,
+ clientInfo.DeviceName,
+ clientInfo.Client)
}
c.Status(http.StatusNoContent)
}
@@ -1251,6 +1370,10 @@ func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+ if played && svc.Device != nil {
+ clientInfo := embyClientInfoFromRequest(c)
+ svc.Device.RecordPlayback(c.Request.Context(), uid, clientInfo.DeviceID, clientInfo.DeviceName, clientInfo.Client)
+ }
out, _ := svc.Emby.Item(c.Request.Context(), mid, uid)
if out != nil {
c.JSON(http.StatusOK, out["UserData"])
diff --git a/internal/handler/emby_test.go b/internal/handler/emby_test.go
index 4067659..649fd6d 100644
--- a/internal/handler/emby_test.go
+++ b/internal/handler/emby_test.go
@@ -116,6 +116,122 @@ func TestEmbyAuthenticateByNameAcceptsCaseVariantUsernameAndPath(t *testing.T) {
}
}
+func TestEmbyAuthenticateRecordsMediaBrowserClientInfo(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ if err := db.AutoMigrate(model.AllModels()...); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ repos := repository.New(db)
+ cfg := &config.Config{}
+ cfg.Secrets.JWTSecret = "test-secret"
+ log := zap.NewNop()
+ permissions := service.NewPermissionService(log, repos)
+ auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
+ if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+
+ router := gin.New()
+ registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
+ Repo: repos,
+ Auth: auth,
+ Emby: service.NewEmbyService(cfg, log, repos),
+ Device: service.NewDeviceService(log, repos),
+ Audit: service.NewAuditService(log, repos),
+ Permissions: permissions,
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(`{"Username":"viewer","Pw":"secret-pass"}`))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="PC", DeviceId="device-42"`)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
+ }
+ user, err := repos.User.FindByUsername(context.Background(), "viewer")
+ if err != nil {
+ t.Fatalf("find user: %v", err)
+ }
+ devices, err := repos.UserDevice.ListByUser(context.Background(), user.ID)
+ if err != nil {
+ t.Fatalf("list devices: %v", err)
+ }
+ if len(devices) != 1 {
+ t.Fatalf("devices = %#v, want one recorded device", devices)
+ }
+ if devices[0].DeviceID != "device-42" || devices[0].DeviceName != "PC" || devices[0].Client != "Infuse" {
+ t.Fatalf("device info not parsed from MediaBrowser header: %#v", devices[0])
+ }
+}
+
+func TestEmbyMarkPlayedRefreshesPlaybackDevice(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ if err := db.AutoMigrate(model.AllModels()...); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ repos := repository.New(db)
+ if err := repos.User.Create(t.Context(), &model.User{
+ Base: model.Base{ID: "user-1"},
+ Username: "tester",
+ PasswordHash: "x",
+ Role: "admin",
+ Tier: "plus",
+ IsActive: true,
+ }); err != nil {
+ t.Fatalf("create user: %v", err)
+ }
+ lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
+ if err := repos.Library.Create(t.Context(), &lib); err != nil {
+ t.Fatalf("create library: %v", err)
+ }
+ if err := repos.DB.Create(&model.Media{
+ Base: model.Base{ID: "media-1"},
+ LibraryID: lib.ID,
+ Title: "Watched Movie",
+ Path: `/media/movies/Watched Movie.mkv`,
+ }).Error; err != nil {
+ t.Fatalf("create media: %v", err)
+ }
+
+ const secret = "test-secret"
+ router := gin.New()
+ registerEmbyRoutes(router, secret, &service.Container{
+ Repo: repos,
+ Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
+ Device: service.NewDeviceService(zap.NewNop(), repos),
+ })
+
+ token := signedTestToken(t, secret)
+ req := httptest.NewRequest(http.MethodPost, "/emby/Users/user-1/PlayedItems/media-1", nil)
+ req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="iPhone", DeviceId="played-device", Token="`+token+`"`)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
+ }
+ devices, err := repos.UserDevice.ListByUser(context.Background(), "user-1")
+ if err != nil {
+ t.Fatalf("list devices: %v", err)
+ }
+ if len(devices) != 1 || devices[0].LastPlayAt == nil {
+ t.Fatalf("mark played should refresh playback device, got %#v", devices)
+ }
+ if devices[0].DeviceID != "played-device" || devices[0].DeviceName != "iPhone" || devices[0].Client != "Infuse" {
+ t.Fatalf("playback device info not parsed: %#v", devices[0])
+ }
+}
+
func TestEmbyCompatSessionAllowsSameClientRequestsWithoutToken(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
diff --git a/internal/service/bot_features_test.go b/internal/service/bot_features_test.go
index 67fe01b..3cff6d0 100644
--- a/internal/service/bot_features_test.go
+++ b/internal/service/bot_features_test.go
@@ -300,9 +300,12 @@ func TestBotAdminCommandsManageDevicePolicy(t *testing.T) {
t.Fatal(err)
}
cfg = loadBotConfig(ctx, repos)
- if cfg.AccountCleanupKeepMode != "count" || cfg.AccountCleanupRequiredCount != 2 {
+ if cfg.AccountCleanupKeepMode != "any" || cfg.AccountCleanupRequiredCount != 1 {
t.Fatalf("unexpected cleanup mode: %+v; reply=%q", cfg, reply.Text)
}
+ if !strings.Contains(reply.Text, "满足任意一条") {
+ t.Fatalf("cleanup mode should explain fixed any-rule policy, got %q", reply.Text)
+ }
reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule add recent_login login_7d 七天内登录 7")
if err != nil {
@@ -494,6 +497,67 @@ func TestBotCleanupRunPreviewsBeforeConfirm(t *testing.T) {
}
}
+func TestBotCleanupLegacyCountModeStillKeepsSingleMatchedRule(t *testing.T) {
+ ctx := context.Background()
+ repos, bot := newBotTestService(t)
+ admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}
+ if err := repos.User.Create(ctx, admin); err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ old := now.Add(-30 * 24 * time.Hour)
+ recent := &model.User{Username: "recent", PasswordHash: "x", Role: "user", IsActive: true}
+ recent.CreatedAt = old
+ recent.LastLoginAt = &now
+ stale := &model.User{Username: "stale", PasswordHash: "x", Role: "user", IsActive: true}
+ stale.CreatedAt = old
+ stale.LastLoginAt = &old
+ for _, user := range []*model.User{recent, stale} {
+ if err := repos.User.Create(ctx, user); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := repos.Setting.Set(ctx, SettingAccountCleanupEnabled, "true"); err != nil {
+ t.Fatal(err)
+ }
+ if err := repos.Setting.Set(ctx, SettingAccountCleanupKeepMode, "count"); err != nil {
+ t.Fatal(err)
+ }
+ if err := repos.Setting.Set(ctx, SettingAccountCleanupRequiredCount, "2"); err != nil {
+ t.Fatal(err)
+ }
+ if err := repos.Setting.Set(ctx, SettingAccountCleanupRules, `[
+ {"id":"login_7d","name":"最近登录","type":"recent_login","enabled":true,"window_days_max":7},
+ {"id":"new_7d","name":"新号宽限","type":"account_age_grace","enabled":true,"min_count":7}
+ ]`); err != nil {
+ t.Fatal(err)
+ }
+ channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`}
+ msg := &TelegramMessage{From: TelegramUser{ID: 9001, Username: "root"}, Chat: TelegramChat{ID: 9001, Type: "private"}}
+
+ reply, err := bot.executeCommand(ctx, channel, msg, "/cleanup run")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(reply.Text, "recent") {
+ t.Fatalf("user matching one keep rule must not be a cleanup candidate, got %q", reply.Text)
+ }
+ if !strings.Contains(reply.Text, "stale") {
+ t.Fatalf("user matching no keep rules should be a candidate, got %q", reply.Text)
+ }
+
+ reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup run confirm")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := repos.User.FindByID(ctx, recent.ID); got == nil {
+ t.Fatal("legacy count mode must not delete a user matching one keep rule")
+ }
+ if got, _ := repos.User.FindByID(ctx, stale.ID); got != nil {
+ t.Fatalf("stale user should be deleted after confirm, reply=%q", reply.Text)
+ }
+}
+
func TestBotCleanupConfirmRequiresEnabledRules(t *testing.T) {
ctx := context.Background()
repos, bot := newBotTestService(t)
diff --git a/internal/service/bot_settings.go b/internal/service/bot_settings.go
index ae00235..33dd0bc 100644
--- a/internal/service/bot_settings.go
+++ b/internal/service/bot_settings.go
@@ -35,11 +35,11 @@ const (
SettingPlayWindowSeconds = "device.play_window_seconds" // 并发播放判定窗口(秒)
SettingClientActiveDays = "device.client_active_days" // 登录设备活跃天数窗口
- // Mgo 保号规则。规则默认关闭;开启后按 KeepMode 计算用户
- // 是否满足足够的保号条件,未满足才会被清理。
+ // Mgo 保号规则。规则默认关闭;开启后满足任意一条保号规则即保留,
+ // 所有启用规则都不满足才会进入清理候选。
SettingAccountCleanupEnabled = "device.account_cleanup_enabled"
- SettingAccountCleanupKeepMode = "device.account_cleanup_keep_mode" // any / all / count
- SettingAccountCleanupRequiredCount = "device.account_cleanup_required_count" // keep_mode=count 时需要满足几条
+ SettingAccountCleanupKeepMode = "device.account_cleanup_keep_mode" // legacy; normalized to any
+ SettingAccountCleanupRequiredCount = "device.account_cleanup_required_count" // legacy; normalized to 1
SettingAccountCleanupRules = "device.account_cleanup_rules" // JSON []accountCleanupRule
SettingProtectedUserIDs = "device.protected_user_ids" // comma separated user IDs; Mgo /prouser
)
@@ -60,8 +60,8 @@ type botConfig struct {
}
// accountCleanupRule is one admin-defined "保号" condition. A user is deleted
-// only when the cleanup policy is enabled and the user does not satisfy the
-// configured combination of enabled keep rules.
+// only when the cleanup policy is enabled and the user does not satisfy any
+// enabled keep rule.
//
// Supported types:
// - watch_hours: watched hours in a random [min,max] day window >= MinHours
@@ -114,8 +114,10 @@ func loadBotConfig(ctx context.Context, repo *repository.Container) botConfig {
cfg.PlayWindowSeconds = parseIntSettingDefault(get(SettingPlayWindowSeconds), cfg.PlayWindowSeconds)
cfg.ClientActiveDays = parseIntSettingDefault(get(SettingClientActiveDays), cfg.ClientActiveDays)
cfg.AccountCleanupEnabled = parseBoolSetting(get(SettingAccountCleanupEnabled), cfg.AccountCleanupEnabled)
- cfg.AccountCleanupKeepMode = normalizeCleanupKeepMode(get(SettingAccountCleanupKeepMode), cfg.AccountCleanupKeepMode)
- cfg.AccountCleanupRequiredCount = parseIntSettingDefault(get(SettingAccountCleanupRequiredCount), cfg.AccountCleanupRequiredCount)
+ // Historical builds allowed all/count modes, but the Mgo policy is now
+ // explicitly "any": matching one keep rule is enough to avoid deletion.
+ cfg.AccountCleanupKeepMode = "any"
+ cfg.AccountCleanupRequiredCount = 1
if raw := strings.TrimSpace(get(SettingAccountCleanupRules)); raw != "" {
var rules []accountCleanupRule
if err := json.Unmarshal([]byte(raw), &rules); err == nil {
diff --git a/internal/service/device_service.go b/internal/service/device_service.go
index 349d703..ed3ad51 100644
--- a/internal/service/device_service.go
+++ b/internal/service/device_service.go
@@ -21,7 +21,7 @@ import (
// account immediately; fingerprint mismatch is warning-based and disables
// the account after the configured warning threshold.
// ② Mgo 保号规则: admins define one or more keep rules; a sweep deletes
-// accounts that do not satisfy the configured any/all/count rule set.
+// accounts only when none of the enabled keep rules match.
//
// Safeguards: admin / protected accounts are never auto disabled or deleted;
// a Telegram notification is sent before a destructive action; every policy
@@ -212,8 +212,8 @@ func (s *DeviceService) SweepInactiveUsers(ctx context.Context) (int, error) {
}
// SweepAccountCleanup runs the admin-defined account cleanup policy once.
-// Users are kept when they satisfy enough enabled keep rules according to
-// keep_mode: any / all / count. Users that do not meet the policy are deleted.
+// Users are kept when they satisfy any enabled keep rule. Users that do not
+// meet any enabled rule are deleted.
func (s *DeviceService) SweepAccountCleanup(ctx context.Context) (int, error) {
cfg := loadBotConfig(ctx, s.repo)
if !cfg.AccountCleanupEnabled {
@@ -349,17 +349,6 @@ func (s *DeviceService) userMatchesCleanupPolicy(ctx context.Context, u *model.U
}
}
required := 1
- switch cfg.AccountCleanupKeepMode {
- case "all":
- required = len(rules)
- case "count":
- required = cfg.AccountCleanupRequiredCount
- if required > len(rules) {
- required = len(rules)
- }
- default:
- required = 1
- }
return matches >= required, fmt.Sprintf("满足 %d/%d 条,需要 %d 条;%s", matches, len(rules), required, strings.Join(parts, ";"))
}
diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go
index 5210927..0e952af 100644
--- a/internal/service/emby_compat.go
+++ b/internal/service/emby_compat.go
@@ -478,10 +478,21 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
GROUP BY media_id
) AS resume ON resume.media_id = media.id`, p.UserID, false)
}
- if containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") {
+ filterBySeasonNumbers := true
+ parentKnownNonEpisodic := false
+ if p.ParentID != "" {
+ if episodic, err := e.libraryIsEpisodic(ctx, p.ParentID); err == nil && !episodic {
+ filterBySeasonNumbers = false
+ parentKnownNonEpisodic = true
+ }
+ }
+ if parentKnownNonEpisodic && containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") {
+ return emptyItemsEnvelope(p.StartIndex), nil
+ }
+ if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") {
q = q.Where("season_num = 0 AND episode_num = 0")
}
- if containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") {
+ if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") {
q = q.Where("season_num > 0 OR episode_num > 0")
}
@@ -829,7 +840,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
seriesID := m.SeriesID
seriesName := ""
seasonID := ""
- if m.SeasonNum > 0 || m.EpisodeNum > 0 {
+ if e.mediaBelongsToEpisodicLibrary(ctx, m) && (m.SeasonNum > 0 || m.EpisodeNum > 0) {
itemType = "Episode"
seriesID = e.seriesIDForMedia(m)
seriesName = e.seriesNameForMedia(m)
@@ -944,10 +955,7 @@ func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (
if lib, err := e.repo.Library.FindByID(ctx, libraryID); err != nil {
return false, err
} else if lib != nil {
- switch lib.Type {
- case "tv", "anime", "variety":
- return true, nil
- }
+ return embyLibraryTypeIsEpisodic(lib.Type), nil
}
var count int64
err := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
@@ -956,6 +964,26 @@ func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (
return count > 0, err
}
+func (e *EmbyService) mediaBelongsToEpisodicLibrary(ctx context.Context, m *model.Media) bool {
+ if e == nil || m == nil || strings.TrimSpace(m.LibraryID) == "" {
+ return false
+ }
+ lib, err := e.repo.Library.FindByID(ctx, m.LibraryID)
+ if err != nil || lib == nil {
+ return false
+ }
+ return embyLibraryTypeIsEpisodic(lib.Type)
+}
+
+func embyLibraryTypeIsEpisodic(typ string) bool {
+ switch strings.ToLower(strings.TrimSpace(typ)) {
+ case "tv", "anime", "variety":
+ return true
+ default:
+ return false
+ }
+}
+
func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) {
if e == nil || strings.TrimSpace(group.ID) == "" {
return
diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go
index f00cce1..19dd412 100644
--- a/internal/service/emby_compat_test.go
+++ b/internal/service/emby_compat_test.go
@@ -226,6 +226,58 @@ func TestEmbyCloudAnimeUsesSeriesNameFromChineseSeasonFolder(t *testing.T) {
}
}
+func TestEmbyMovieLibrarySeasonNumbersStayMovies(t *testing.T) {
+ svc := newTestEmbyService(t)
+ lib := model.Library{Name: "动画电影", Path: `/media/movies/animation`, Type: "movie", Enabled: true}
+ if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
+ t.Fatalf("create library: %v", err)
+ }
+ media := model.Media{
+ Base: model.Base{ID: "movie-with-episode-numbers"},
+ LibraryID: lib.ID,
+ Title: "Movie Mistaken S01E01",
+ Path: `/media/movies/animation/Movie.Mistaken.S01E01.mkv`,
+ PosterURL: `/poster.jpg`,
+ SeasonNum: 1,
+ EpisodeNum: 1,
+ }
+ if err := svc.repo.DB.Create(&media).Error; err != nil {
+ t.Fatalf("create media: %v", err)
+ }
+
+ out, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, IncludeItemTypes: []string{"Movie"}, Limit: 50})
+ if err != nil {
+ t.Fatalf("items: %v", err)
+ }
+ items := out["Items"].([]map[string]any)
+ if len(items) != 1 {
+ t.Fatalf("movie library item filtered out by season numbers: %#v", out)
+ }
+ if items[0]["Type"] != "Movie" || items[0]["ParentId"] != lib.ID {
+ t.Fatalf("movie library item should stay Movie, got %#v", items[0])
+ }
+ tags := items[0]["ImageTags"].(map[string]string)
+ if tags["Primary"] == "" {
+ t.Fatalf("movie poster should expose Primary image tag: %#v", items[0])
+ }
+
+ episodes, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, IncludeItemTypes: []string{"Episode"}, Limit: 50})
+ if err != nil {
+ t.Fatalf("episode query: %v", err)
+ }
+ if len(episodes["Items"].([]map[string]any)) != 0 {
+ t.Fatalf("movie library should not expose movies as episodes, got %#v", episodes)
+ }
+
+ item, err := svc.Item(t.Context(), media.ID, "user-1")
+ if err != nil {
+ t.Fatalf("item: %v", err)
+ }
+ if item["Type"] != "Movie" || item["ParentId"] != lib.ID {
+ t.Fatalf("direct item should stay Movie, got %#v", item)
+ }
+}
+
func TestEmbyRootItemsExposeLibraries(t *testing.T) {
svc := newTestEmbyService(t)
for _, lib := range []model.Library{
diff --git a/internal/service/qbittorrent.go b/internal/service/qbittorrent.go
index 02942d7..039ac7e 100644
--- a/internal/service/qbittorrent.go
+++ b/internal/service/qbittorrent.go
@@ -490,6 +490,7 @@ func (q *QBitClient) Delete(ctx context.Context, hash string, deleteFiles bool)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", q.cfg.BaseURL)
+ req.Header.Set("Origin", q.cfg.BaseURL)
resp, err := q.client.Do(req)
if err != nil {
return err
@@ -529,6 +530,7 @@ func (q *QBitClient) SetLocation(ctx context.Context, hash, location string) err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", q.cfg.BaseURL)
+ req.Header.Set("Origin", q.cfg.BaseURL)
resp, err := q.client.Do(req)
if err != nil {
return err
diff --git a/internal/service/qbittorrent_adp.go b/internal/service/qbittorrent_adp.go
index 779e2d2..740e755 100644
--- a/internal/service/qbittorrent_adp.go
+++ b/internal/service/qbittorrent_adp.go
@@ -85,16 +85,20 @@ func (a *QBitAdapter) AddTorrent(ctx context.Context, torrentURL, savePath strin
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Referer", baseURL)
+ req.Header.Set("Origin", baseURL)
resp, err := a.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
+ raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
- raw, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("qbittorrent add torrent: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
+ if strings.EqualFold(strings.TrimSpace(string(raw)), "Fails.") {
+ return "", fmt.Errorf("qbittorrent add torrent: rejected by downloader")
+ }
return "", nil
}
@@ -110,25 +114,7 @@ func (a *QBitAdapter) Pause(ctx context.Context, hash string) error {
if err := a.ensureAuthLocked(ctx); err != nil {
return err
}
- baseURL := strings.TrimRight(a.cfg.Host, "/")
- form := url.Values{}
- form.Set("hashes", hash)
- req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost,
- baseURL+"/api/v2/torrents/pause", strings.NewReader(form.Encode()))
- if err != nil {
- return err
- }
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("Referer", baseURL)
- resp, err := a.client.Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- if resp.StatusCode >= 400 {
- return fmt.Errorf("qbittorrent pause: %d", resp.StatusCode)
- }
- return nil
+ return a.postTorrentActionLocked(ctx, hash, "pause", "stop")
}
// Resume 恢复种子。
@@ -138,25 +124,41 @@ func (a *QBitAdapter) Resume(ctx context.Context, hash string) error {
if err := a.ensureAuthLocked(ctx); err != nil {
return err
}
+ return a.postTorrentActionLocked(ctx, hash, "resume", "start")
+}
+
+func (a *QBitAdapter) postTorrentActionLocked(ctx context.Context, hash string, primary, fallback string) error {
baseURL := strings.TrimRight(a.cfg.Host, "/")
form := url.Values{}
form.Set("hashes", hash)
- req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost,
- baseURL+"/api/v2/torrents/resume", strings.NewReader(form.Encode()))
- if err != nil {
- return err
+ var lastErr error
+ for _, action := range []string{primary, fallback} {
+ if action == "" {
+ continue
+ }
+ req, err := newDownloadClientHTTPRequest(ctx, http.MethodPost,
+ baseURL+"/api/v2/torrents/"+action, strings.NewReader(form.Encode()))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Referer", baseURL)
+ req.Header.Set("Origin", baseURL)
+ resp, err := a.client.Do(req)
+ if err != nil {
+ return err
+ }
+ body, _ := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ if resp.StatusCode < 400 {
+ return nil
+ }
+ lastErr = fmt.Errorf("qbittorrent %s: %d: %s", action, resp.StatusCode, strings.TrimSpace(string(body)))
+ if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusMethodNotAllowed {
+ break
+ }
}
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.Header.Set("Referer", baseURL)
- resp, err := a.client.Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- if resp.StatusCode >= 400 {
- return fmt.Errorf("qbittorrent resume: %d", resp.StatusCode)
- }
- return nil
+ return lastErr
}
// Remove 删除种子。
@@ -181,6 +183,7 @@ func (a *QBitAdapter) Remove(ctx context.Context, hash string, deleteFiles bool)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", baseURL)
+ req.Header.Set("Origin", baseURL)
resp, err := a.client.Do(req)
if err != nil {
return err
diff --git a/internal/service/qbittorrent_test.go b/internal/service/qbittorrent_test.go
index 72cc3fa..49e0f3a 100644
--- a/internal/service/qbittorrent_test.go
+++ b/internal/service/qbittorrent_test.go
@@ -301,3 +301,84 @@ func TestQBitSetLocationSurfacesConflict(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}
+
+func TestQBitAdapterPauseResumeFallsBackToQBit52Actions(t *testing.T) {
+ var pauseCalled, stopCalled, resumeCalled, startCalled atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v2/auth/login":
+ _, _ = w.Write([]byte("Ok."))
+ case "/api/v2/torrents/pause":
+ pauseCalled.Add(1)
+ http.NotFound(w, r)
+ case "/api/v2/torrents/stop":
+ stopCalled.Add(1)
+ if r.Header.Get("Origin") != serverOrigin(r) {
+ t.Errorf("stop Origin = %q, want %q", r.Header.Get("Origin"), serverOrigin(r))
+ }
+ _, _ = w.Write([]byte("Ok."))
+ case "/api/v2/torrents/resume":
+ resumeCalled.Add(1)
+ http.NotFound(w, r)
+ case "/api/v2/torrents/start":
+ startCalled.Add(1)
+ if r.Header.Get("Origin") != serverOrigin(r) {
+ t.Errorf("start Origin = %q, want %q", r.Header.Get("Origin"), serverOrigin(r))
+ }
+ _, _ = w.Write([]byte("Ok."))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ adapter := NewQBitAdapter()
+ if err := adapter.Initialize(context.Background(), DownloadClientConfig{Host: server.URL, Username: "admin", Password: "adminadmin"}); err != nil {
+ t.Fatalf("initialize: %v", err)
+ }
+ if err := adapter.Pause(context.Background(), "abc123"); err != nil {
+ t.Fatalf("pause: %v", err)
+ }
+ if err := adapter.Resume(context.Background(), "abc123"); err != nil {
+ t.Fatalf("resume: %v", err)
+ }
+ if pauseCalled.Load() != 1 || stopCalled.Load() != 1 || resumeCalled.Load() != 1 || startCalled.Load() != 1 {
+ t.Fatalf("calls pause=%d stop=%d resume=%d start=%d, want all 1",
+ pauseCalled.Load(), stopCalled.Load(), resumeCalled.Load(), startCalled.Load())
+ }
+}
+
+func TestQBitAdapterAddTorrentSendsOriginAndRejectsFailsBody(t *testing.T) {
+ var addCalled atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v2/auth/login":
+ _, _ = w.Write([]byte("Ok."))
+ case "/api/v2/torrents/add":
+ addCalled.Add(1)
+ if r.Header.Get("Origin") != serverOrigin(r) {
+ t.Errorf("Origin = %q, want %q", r.Header.Get("Origin"), serverOrigin(r))
+ }
+ _, _ = w.Write([]byte("Fails."))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ adapter := NewQBitAdapter()
+ if err := adapter.Initialize(context.Background(), DownloadClientConfig{Host: server.URL, Username: "admin", Password: "adminadmin"}); err != nil {
+ t.Fatalf("initialize: %v", err)
+ }
+ _, err := adapter.AddTorrent(context.Background(), "magnet:?xt=urn:btih:abc", "/downloads")
+ if err == nil || !strings.Contains(err.Error(), "rejected") {
+ t.Fatalf("expected rejected add error, got %v", err)
+ }
+ if addCalled.Load() != 1 {
+ t.Fatalf("add calls = %d, want 1", addCalled.Load())
+ }
+}
+
+func serverOrigin(r *http.Request) string {
+ return "http://" + r.Host
+}
diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go
index 326b670..f370cdb 100644
--- a/internal/service/telegram_bot.go
+++ b/internal/service/telegram_bot.go
@@ -526,7 +526,6 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
"/cleanup run — 预览保号清理候选(管理员)\n" +
"/cleanup run confirm — 确认清理候选账号(管理员)\n" +
"/cleanup on|off — 保号规则开关(管理员)\n" +
- "/cleanup_mode any|all|count 2 — 保号模式(管理员)\n" +
"/cleanup_rule list|add|edit|修改|del|enable|disable — Mgo 保号规则(管理员)\n" +
"/ban 用户名 / /unban 用户名 — 禁用/解禁用户(管理员)\n" +
"/hideadult on|off — 隐藏/显示当前绑定账号的成人目录\n" +
diff --git a/internal/service/telegram_commands.go b/internal/service/telegram_commands.go
index 5226cc7..cd8da51 100644
--- a/internal/service/telegram_commands.go
+++ b/internal/service/telegram_commands.go
@@ -253,7 +253,7 @@ func telegramAdminBotCommandMenu() []telegramBotCommand {
telegramBotCommand{Command: "stats", Description: "媒体库统计(管理员)"},
telegramBotCommand{Command: "users", Description: "用户管理(管理员)"},
telegramBotCommand{Command: "cleanup", Description: "保号清理预览/确认(管理员)"},
- telegramBotCommand{Command: "cleanup_mode", Description: "设置保号规则匹配模式(管理员)"},
+ telegramBotCommand{Command: "cleanup_mode", Description: "查看固定保号模式(管理员)"},
telegramBotCommand{Command: "cleanup_rule", Description: "Mgo保号规则管理(管理员)"},
)
return commands
diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go
index ae1cfd9..d732972 100644
--- a/internal/service/telegram_menu.go
+++ b/internal/service/telegram_menu.go
@@ -1125,9 +1125,9 @@ func (s *TelegramBotService) protectReason(ctx context.Context, userID string) s
func (s *TelegramBotService) replyDevicePolicy(ctx context.Context) telegramCommandReply {
cfg := loadBotConfig(ctx, s.repo)
text := fmt.Sprintf(
- "设备策略\n\n① 防共享:%s\n 并发播放上限 %d / 登录客户端上限 %d;超限会禁用账号,管理员可解禁。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② Mgo 保号规则:%s\n 保号模式:%s;需要满足 %d 条;启用规则 %d 条。\n\n命令:\n/antishare on play=3 login=3 warn=2\n/cleanup run 预览候选\n/cleanup run confirm 确认清理\n/cleanup on|off\n/cleanup_mode any|all|count 2\n/cleanup_rule list|add|edit|修改|del|enable|disable\n\n策略默认关闭;清理前会先预览候选;管理员/受保护账号永不自动处理。",
+ "设备策略\n\n① 防共享:%s\n 并发播放上限 %d / 登录客户端上限 %d;超限会禁用账号,管理员可解禁。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② Mgo 保号规则:%s\n 保号模式:%s;启用规则 %d 条。\n\n命令:\n/antishare on play=3 login=3 warn=2\n/cleanup run 预览候选\n/cleanup run confirm 确认清理\n/cleanup on|off\n/cleanup_rule list|add|edit|修改|del|enable|disable\n\n策略默认关闭;清理前会先预览候选;满足任意一条保号规则即保留;管理员/受保护账号永不自动处理。",
onOff(cfg.AntiShareEnabled), cfg.MaxConcurrentPlay, cfg.MaxLoggedClients, cfg.WarnThreshold,
- onOff(cfg.AccountCleanupEnabled), cleanupModeLabel(cfg.AccountCleanupKeepMode), cfg.AccountCleanupRequiredCount, countEnabledCleanupRules(cfg.AccountCleanupRules))
+ onOff(cfg.AccountCleanupEnabled), cleanupModeLabel(cfg.AccountCleanupKeepMode), countEnabledCleanupRules(cfg.AccountCleanupRules))
return telegramCommandReply{
Text: text,
Buttons: [][]telegramInlineButton{
@@ -1264,23 +1264,15 @@ func (s *TelegramBotService) formatCleanupPreview(ctx context.Context, candidate
}
func (s *TelegramBotService) cmdCleanupMode(ctx context.Context, args []string) telegramCommandReply {
- if len(args) == 0 {
- return telegramCommandReply{Text: "用法:/cleanup_mode any、/cleanup_mode all 或 /cleanup_mode count 2"}
- }
- mode := strings.ToLower(strings.TrimSpace(args[0]))
- if mode != "any" && mode != "all" && mode != "count" {
- return telegramCommandReply{Text: "保号模式无效,只支持 any / all / count。"}
- }
- if err := s.repo.Setting.Set(ctx, SettingAccountCleanupKeepMode, mode); err != nil {
+ if err := s.repo.Setting.Set(ctx, SettingAccountCleanupKeepMode, "any"); err != nil {
return telegramCommandReply{Text: "更新失败:" + err.Error()}
}
- if mode == "count" && len(args) > 1 {
- n, err := strconv.Atoi(args[1])
- if err == nil && n > 0 {
- _ = s.repo.Setting.Set(ctx, SettingAccountCleanupRequiredCount, strconv.Itoa(n))
- }
+ if err := s.repo.Setting.Set(ctx, SettingAccountCleanupRequiredCount, "1"); err != nil {
+ return telegramCommandReply{Text: "更新失败:" + err.Error()}
}
- return s.replyDevicePolicy(ctx)
+ reply := s.replyDevicePolicy(ctx)
+ reply.Text = "Mgo 保号模式固定为:满足任意一条启用规则即保留;只有全部规则都不满足才进入清理候选。\n\n" + reply.Text
+ return reply
}
func (s *TelegramBotService) cmdCleanupRule(ctx context.Context, args []string) telegramCommandReply {
@@ -1574,7 +1566,7 @@ func cleanupRuleHelp() string {
"/cleanup_rule 修改 规则类型 规则ID 名称 参数... — 中文修改入口\n" +
"/cleanup_rule enable 规则ID / disable 规则ID\n" +
"/cleanup_rule del 规则ID\n\n" +
- "保号模式:/cleanup_mode any|all|count 2"
+ "保号模式固定为:满足任意一条启用规则即保留;全部不满足才会清理。"
}
func onOff(b bool) string {
@@ -1589,14 +1581,7 @@ func toggleLabel(name string, enabled bool) string {
}
func cleanupModeLabel(mode string) string {
- switch mode {
- case "all":
- return "满足全部规则"
- case "count":
- return "满足指定数量"
- default:
- return "满足任意一条"
- }
+ return "满足任意一条"
}
func countEnabledCleanupRules(rules []accountCleanupRule) int {