Harden cleanup and client compatibility

This commit is contained in:
ShukeBta
2026-06-17 00:06:51 +08:00
parent 7766588f67
commit 801af37462
14 changed files with 559 additions and 115 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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{})
+65 -1
View File
@@ -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)
+10 -8
View File
@@ -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 {
+3 -14
View File
@@ -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, ";"))
}
+35 -7
View File
@@ -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
+52
View File
@@ -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{
+2
View File
@@ -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
+38 -35
View File
@@ -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
+81
View File
@@ -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
}
-1
View File
@@ -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" +
+1 -1
View File
@@ -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
+10 -25
View File
@@ -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 {