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 {