mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 03:36:37 +08:00
Harden cleanup and client compatibility
This commit is contained in:
@@ -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{})
|
||||
|
||||
+144
-21
@@ -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"])
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ";"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -526,7 +526,6 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
|
||||
"<b>/cleanup run</b> — 预览保号清理候选(管理员)\n" +
|
||||
"<b>/cleanup run confirm</b> — 确认清理候选账号(管理员)\n" +
|
||||
"<b>/cleanup on|off</b> — 保号规则开关(管理员)\n" +
|
||||
"<b>/cleanup_mode any|all|count 2</b> — 保号模式(管理员)\n" +
|
||||
"<b>/cleanup_rule list|add|edit|修改|del|enable|disable</b> — Mgo 保号规则(管理员)\n" +
|
||||
"<b>/ban 用户名</b> / <b>/unban 用户名</b> — 禁用/解禁用户(管理员)\n" +
|
||||
"<b>/hideadult on|off</b> — 隐藏/显示当前绑定账号的成人目录\n" +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
"<b>设备策略</b>\n\n① 防共享:<b>%s</b>\n 并发播放上限 %d / 登录客户端上限 %d;超限会禁用账号,管理员可解禁。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② Mgo 保号规则:<b>%s</b>\n 保号模式:%s;需要满足 %d 条;启用规则 %d 条。\n\n<b>命令:</b>\n<code>/antishare on play=3 login=3 warn=2</code>\n<code>/cleanup run</code> 预览候选\n<code>/cleanup run confirm</code> 确认清理\n<code>/cleanup on|off</code>\n<code>/cleanup_mode any|all|count 2</code>\n<code>/cleanup_rule list|add|edit|修改|del|enable|disable</code>\n\n策略默认关闭;清理前会先预览候选;管理员/受保护账号永不自动处理。",
|
||||
"<b>设备策略</b>\n\n① 防共享:<b>%s</b>\n 并发播放上限 %d / 登录客户端上限 %d;超限会禁用账号,管理员可解禁。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② Mgo 保号规则:<b>%s</b>\n 保号模式:%s;启用规则 %d 条。\n\n<b>命令:</b>\n<code>/antishare on play=3 login=3 warn=2</code>\n<code>/cleanup run</code> 预览候选\n<code>/cleanup run confirm</code> 确认清理\n<code>/cleanup on|off</code>\n<code>/cleanup_rule list|add|edit|修改|del|enable|disable</code>\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: "用法:<code>/cleanup_mode any</code>、<code>/cleanup_mode all</code> 或 <code>/cleanup_mode count 2</code>"}
|
||||
}
|
||||
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 {
|
||||
"<code>/cleanup_rule 修改 规则类型 规则ID 名称 参数...</code> — 中文修改入口\n" +
|
||||
"<code>/cleanup_rule enable 规则ID</code> / <code>disable 规则ID</code>\n" +
|
||||
"<code>/cleanup_rule del 规则ID</code>\n\n" +
|
||||
"保号模式:<code>/cleanup_mode any|all|count 2</code>"
|
||||
"保号模式固定为:满足任意一条启用规则即保留;全部不满足才会清理。"
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user