From 28aa4661073684720190ed3bf296792c0f019fc0 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:58:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=B8=B4=E6=97=B6?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=AF=86=E7=A0=81=E5=B9=B6=E5=AE=8C=E5=96=84?= =?UTF-8?q?=20Emby=20=E5=AA=92=E4=BD=93=E8=AF=A6=E6=83=85=E5=85=BC?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 6 位临时密码生成与验证接口,支持 TV 及客户端快速登录 - 媒体及剧集详情补充 People 演职员信息解析与返回 - 优化剧集背景图与海报标签继承机制及播放时长兜底逻辑 - 增加临时密码登录相关的单元测试 --- internal/handler/auth.go | 21 +- internal/handler/emby_compat_features_test.go | 280 +++++++++++++++++- internal/handler/emby_users.go | 17 +- internal/handler/routes_authenticated_core.go | 2 + internal/service/auth.go | 116 +++++++- internal/service/emby_items_detail.go | 96 ++++++ internal/service/emby_payload_ctx.go | 117 ++++++-- internal/service/emby_series_payload.go | 24 +- 8 files changed, 629 insertions(+), 44 deletions(-) diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 2be8c0c..a1b3e9e 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -120,6 +120,25 @@ func changePasswordHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - c.Status(http.StatusNoContent) + c.Status(http.StatusNoContent) + } + } + +func temporaryPasswordHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + uid, ok := c.Get(middleware.CtxUserID) + if !ok || uid == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + code, expireSec, err := svc.Auth.CreateTemporaryPassword(c.Request.Context(), uid.(string)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": code, + "expires_in": expireSec, + }) } } diff --git a/internal/handler/emby_compat_features_test.go b/internal/handler/emby_compat_features_test.go index cfa4be3..60178fa 100644 --- a/internal/handler/emby_compat_features_test.go +++ b/internal/handler/emby_compat_features_test.go @@ -9,8 +9,11 @@ import ( "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" + "go.uber.org/zap" "gorm.io/gorm" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/middleware" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" "github.com/truewhile/MeBox/internal/service" @@ -312,7 +315,280 @@ func TestEmbyImageClearNoStore(t *testing.T) { if strings.Contains(cacheControl, "no-store") { t.Fatalf("image response should not have no-store, got: %s", cacheControl) } - if !strings.Contains(cacheControl, "public") { - t.Fatalf("image response should have public cache-control, got: %s", cacheControl) + if !strings.Contains(cacheControl, "public") { + t.Fatalf("image response should have public cache-control, got: %s", cacheControl) + } + } + +func TestEmbyTemporaryPasswordLogin(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-jwt-secret-very-secure-key-12345" + log := zap.NewNop() + permissions := service.NewPermissionService(log, repos) + tokenSvc := service.NewTokenService(cfg, log, repos) + authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions) + embySvc := service.NewEmbyService(nil, nil, repos) + svc := &service.Container{ + Repo: repos, + Auth: authSvc, + Token: tokenSvc, + Emby: embySvc, + } + + user, _, err := authSvc.Register(t.Context(), "tvuser", "strongpassword123") + if err != nil { + t.Fatalf("register: %v", err) + } + + // 1. 生成 6 位纯数字临时密码 (OTP) + code, expireSec, err := authSvc.CreateTemporaryPassword(t.Context(), user.ID) + if err != nil { + t.Fatalf("create temp password: %v", err) + } + if len(code) != 6 || expireSec <= 0 { + t.Fatalf("invalid temp password format: %s, expire: %d", code, expireSec) + } + + router := gin.New() + registerEmbyRoutes(router, "test-jwt-secret-very-secure-key-12345", svc) + + // 2. 使用临时密码在 Emby 接口登录 + body := `{"Username":"tvuser","Pw":"` + code + `"}` + req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("login with temp password code = %d: %s", w.Code, w.Body.String()) + } + var loginResp struct { + AccessToken string `json:"AccessToken"` + User struct { + ID string `json:"Id"` + Name string `json:"Name"` + } `json:"User"` + } + if err := json.Unmarshal(w.Body.Bytes(), &loginResp); err != nil { + t.Fatalf("unmarshal login resp: %v", err) + } + if loginResp.AccessToken == "" || loginResp.User.ID != user.ID { + t.Fatalf("unexpected login payload: %#v", loginResp) + } + + // 3. 验证阅后即焚:第二次使用同一临时密码应登录失败 (401) + req2 := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(body)) + req2.Header.Set("Content-Type", "application/json") + w2 := httptest.NewRecorder() + router.ServeHTTP(w2, req2) + + if w2.Code != http.StatusUnauthorized { + t.Fatalf("second login with consumed temp password should fail, got %d", w2.Code) } } + +func TestEmbySeriesArtworkInheritanceAndRunTimeTicksFallback(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-compat" + log := zap.NewNop() + permissions := service.NewPermissionService(log, repos) + tokenSvc := service.NewTokenService(cfg, log, repos) + authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions) + embySvc := service.NewEmbyService(nil, nil, repos) + svc := &service.Container{ + Repo: repos, + Auth: authSvc, + Token: tokenSvc, + Emby: embySvc, + } + + user, _, err := authSvc.Register(t.Context(), "artworkuser", "password123") + if err != nil { + t.Fatalf("register: %v", err) + } + token, err := authSvc.IssueEmbyToken(user) + if err != nil { + t.Fatalf("issue token: %v", err) + } + + // 创建 TV Library + lib := &model.Library{ + Name: "电视剧", + Path: "/media/电视剧", + Type: "tv", + } + lib.ID = "lib-tv-1" + if err := db.Create(lib).Error; err != nil { + t.Fatalf("create lib: %v", err) + } + + // 创建 Series + series := &model.Series{ + LibraryID: "lib-tv-1", + Title: "Test Drama", + PosterURL: "https://example.com/series_poster.jpg", + BackdropURL: "https://example.com/series_backdrop.jpg", + } + series.ID = "s-test-1" + if err := db.Create(series).Error; err != nil { + t.Fatalf("create series: %v", err) + } + + // 创建单集 Episode(DurationSec 为 0,但有播放进度 posMs,用于测试 RunTimeTicks 兜底) + ep := &model.Media{ + LibraryID: "lib-tv-1", + SeriesID: "s-test-1", + Title: "Test Episode 1", + Path: "/media/电视剧/Test Drama/Season 1/S01E01.mp4", + SeasonNum: 1, + EpisodeNum: 1, + DurationSec: 0, // 未知时长 + PosterURL: "https://example.com/ep1_still.jpg", + } + ep.ID = "ep-test-1" + if err := db.Create(ep).Error; err != nil { + t.Fatalf("create ep: %v", err) + } + + router := gin.New() + registerEmbyRoutes(router, cfg.Secrets.JWTSecret, svc) + + // 1. 获取 Series 详情 + reqSeries := httptest.NewRequest(http.MethodGet, "/emby/Items/s-test-1", nil) + reqSeries.Header.Set("X-Emby-Token", token) + wSeries := httptest.NewRecorder() + router.ServeHTTP(wSeries, reqSeries) + if wSeries.Code != http.StatusOK { + t.Fatalf("get series code = %d: %s", wSeries.Code, wSeries.Body.String()) + } + var seriesPayload map[string]any + _ = json.Unmarshal(wSeries.Body.Bytes(), &seriesPayload) + if seriesPayload["PrimaryImageTag"] != "s-test-1" { + t.Fatalf("series PrimaryImageTag should match series ID, got %v", seriesPayload["PrimaryImageTag"]) + } + if _, ok := seriesPayload["People"]; !ok { + t.Fatalf("series payload should include People array") + } + + // 2. 获取 Episode 详情,验证继承 SeriesPrimaryImageTag 和 ParentBackdropItemId + // 添加一条播放进度记录 (posMs = 60000) + hist := &model.PlaybackHistory{ + UserID: user.ID, + MediaID: ep.ID, + PositionMs: 60000, + } + _ = db.Create(hist).Error + + reqEp := httptest.NewRequest(http.MethodGet, "/emby/Users/"+user.ID+"/Items/ep-test-1", nil) + reqEp.Header.Set("X-Emby-Token", token) + wEp := httptest.NewRecorder() + router.ServeHTTP(wEp, reqEp) + if wEp.Code != http.StatusOK { + t.Fatalf("get ep code = %d: %s", wEp.Code, wEp.Body.String()) + } + var epPayload map[string]any + _ = json.Unmarshal(wEp.Body.Bytes(), &epPayload) + t.Logf("epPayload: %#v", epPayload) + + // 验证图片继承 + if epPayload["SeriesPrimaryImageTag"] != "s-test-1" { + t.Fatalf("ep SeriesPrimaryImageTag should inherit series ID, got %v", epPayload["SeriesPrimaryImageTag"]) + } + if epPayload["ParentBackdropItemId"] != "s-test-1" { + t.Fatalf("ep ParentBackdropItemId should inherit series ID, got %v", epPayload["ParentBackdropItemId"]) + } + if _, ok := epPayload["People"]; !ok { + t.Fatalf("ep payload should include People array") + } + + // 验证 RunTimeTicks 兜底 + runTimeTicks, _ := epPayload["RunTimeTicks"].(float64) + if runTimeTicks <= 0 { + t.Fatalf("ep RunTimeTicks should be safely fallback to positive value, got %v", runTimeTicks) + } + userData, _ := epPayload["UserData"].(map[string]any) + playedPct, _ := userData["PlayedPercentage"].(float64) + if playedPct <= 0 { + t.Fatalf("ep PlayedPercentage should be > 0, got %v", playedPct) + } +} + +func TestMeTemporaryPasswordEndpoint(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-temp" + log := zap.NewNop() + permissions := service.NewPermissionService(log, repos) + tokenSvc := service.NewTokenService(cfg, log, repos) + authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions) + svc := &service.Container{ + Repo: repos, + Auth: authSvc, + Token: tokenSvc, + } + + user, tokens, err := authSvc.Register(t.Context(), "optuser", "password123") + if err != nil { + t.Fatalf("register: %v", err) + } + + router := gin.New() + api := router.Group("/api") + authed := api.Group("") + authed.Use(func(c *gin.Context) { + c.Set(middleware.CtxUserID, user.ID) + c.Next() + }) + registerAuthedUserAndLicenseRoutes(authed, svc) + + req := httptest.NewRequest(http.MethodPost, "/api/me/temporary-password", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("generate temp password code = %d: %s", w.Code, w.Body.String()) + } + var resp struct { + Code string `json:"code"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal resp: %v", err) + } + if len(resp.Code) != 6 || resp.ExpiresIn <= 0 { + t.Fatalf("invalid temp password resp: %#v", resp) + } + + // 验证使用生成的临时密码能登录 + loginResp, err := authSvc.LoginWithTemporaryPassword(t.Context(), "optuser", resp.Code) + if err != nil || loginResp == nil || loginResp.User.ID != user.ID { + t.Fatalf("login with temp pass failed: %v", err) + } + _ = tokens +} diff --git a/internal/handler/emby_users.go b/internal/handler/emby_users.go index a0419c4..e5de0aa 100644 --- a/internal/handler/emby_users.go +++ b/internal/handler/emby_users.go @@ -34,11 +34,18 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc { embyError(c, http.StatusBadRequest, "missing username or password") return } - resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password) - if err != nil { - embyError(c, http.StatusUnauthorized, err.Error()) - return - } + resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password) + if err != nil { + // 支持电视端/客户端一次性 6 位临时密码登录 (OTP) + if tempResp, tempErr := svc.Auth.LoginWithTemporaryPassword(c.Request.Context(), req.Username, password); tempErr == nil { + resp = tempResp + err = nil + } + } + if err != nil { + embyError(c, http.StatusUnauthorized, err.Error()) + return + } // 记录登录设备会话并执行防共享检测(登录客户端数 / 设备指纹)。 clientInfo := embyClientInfoFromRequest(c) if svc.Sessions != nil { diff --git a/internal/handler/routes_authenticated_core.go b/internal/handler/routes_authenticated_core.go index 5489d5e..c50af69 100644 --- a/internal/handler/routes_authenticated_core.go +++ b/internal/handler/routes_authenticated_core.go @@ -14,6 +14,8 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc)) authed.POST("/me/password", changePasswordHandler(svc)) authed.POST("/me/logout", logoutHandler(svc)) + authed.GET("/me/temporary-password", temporaryPasswordHandler(svc)) + authed.POST("/me/temporary-password", temporaryPasswordHandler(svc)) authed.GET("/auth/permissions", getMyPermissionsHandler(svc)) } diff --git a/internal/service/auth.go b/internal/service/auth.go index a8ac532..7ca85fb 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -3,10 +3,13 @@ package service import ( "context" + "crypto/rand" "errors" "fmt" + "math/big" "os" "strings" + "sync" "time" "go.uber.org/zap" @@ -18,6 +21,12 @@ import ( "github.com/truewhile/MeBox/internal/repository" ) +type temporaryPasswordEntry struct { + userID string + username string + expiresAt time.Time +} + // AuthService handles registration, login, and JWT issuance. type AuthService struct { cfg *config.Config @@ -25,11 +34,21 @@ type AuthService struct { repo *repository.Container tokenSvc *TokenService permissionSvc *PermissionService + + tempPassMu sync.RWMutex + tempPasswords map[string]temporaryPasswordEntry } // NewAuthService is the constructor. func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Container, tokenSvc *TokenService, permissionSvc *PermissionService) *AuthService { - return &AuthService{cfg: cfg, log: log, repo: repo, tokenSvc: tokenSvc, permissionSvc: permissionSvc} + return &AuthService{ + cfg: cfg, + log: log, + repo: repo, + tokenSvc: tokenSvc, + permissionSvc: permissionSvc, + tempPasswords: make(map[string]temporaryPasswordEntry), + } } // Common service-level errors. @@ -254,3 +273,98 @@ func hashPassword(p string) (string, error) { } return string(h), nil } + +const temporaryPasswordTTL = 5 * time.Minute + +// CreateTemporaryPassword 为指定用户生成一个 6 位数字的临时登录密码(有效期 5 分钟), +// 供 Emby 电视端/客户端进行无键盘或快速输入登录。 +func (s *AuthService) CreateTemporaryPassword(ctx context.Context, userID string) (string, int, error) { + if s == nil || s.repo == nil { + return "", 0, errors.New("auth service unavailable") + } + user, err := s.repo.User.FindByID(ctx, userID) + if err != nil || user == nil { + return "", 0, ErrInvalidCredentials + } + if !user.IsActive { + return "", 0, ErrUserInactive + } + if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) { + return "", 0, ErrUserExpired + } + + n, err := rand.Int(rand.Reader, big.NewInt(900000)) + if err != nil { + return "", 0, err + } + code := fmt.Sprintf("%06d", n.Int64()+100000) + + s.tempPassMu.Lock() + defer s.tempPassMu.Unlock() + now := time.Now() + for k, v := range s.tempPasswords { + if now.After(v.expiresAt) { + delete(s.tempPasswords, k) + } + } + s.tempPasswords[code] = temporaryPasswordEntry{ + userID: user.ID, + username: user.Username, + expiresAt: now.Add(temporaryPasswordTTL), + } + + return code, int(temporaryPasswordTTL.Seconds()), nil +} + +// VerifyAndConsumeTemporaryPassword 校验并消费临时登录密码(阅后即焚)。 +func (s *AuthService) VerifyAndConsumeTemporaryPassword(ctx context.Context, username, code string) (*model.User, bool) { + if s == nil || s.repo == nil { + return nil, false + } + code = strings.TrimSpace(code) + if len(code) != 6 { + return nil, false + } + s.tempPassMu.Lock() + entry, ok := s.tempPasswords[code] + if ok { + delete(s.tempPasswords, code) + } + s.tempPassMu.Unlock() + + if !ok || time.Now().After(entry.expiresAt) { + return nil, false + } + + if strings.TrimSpace(username) != "" && !strings.EqualFold(strings.TrimSpace(username), entry.username) { + return nil, false + } + + user, err := s.repo.User.FindByID(ctx, entry.userID) + if err != nil || user == nil || !user.IsActive { + return nil, false + } + if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) { + return nil, false + } + return user, true +} + +// LoginWithTemporaryPassword 尝试使用 6 位数字临时登录密码 (OTP) 进行登录。 +func (s *AuthService) LoginWithTemporaryPassword(ctx context.Context, username, code string) (*LoginResponse, error) { + user, ok := s.VerifyAndConsumeTemporaryPassword(ctx, username, code) + if !ok || user == nil { + return nil, ErrInvalidCredentials + } + if s.tokenSvc == nil { + return nil, errors.New("token service unavailable") + } + tokens, err := s.tokenSvc.IssuePair(ctx, user.ID, user.Role, user.Tier) + if err != nil { + return nil, err + } + return &LoginResponse{ + User: user, + Tokens: tokens, + }, nil +} diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index b8dbdd6..c606710 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -2,7 +2,11 @@ package service import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" + "os" + "path/filepath" "strings" "time" @@ -455,6 +459,13 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, runTimeTicks := int64(m.DurationSec) * 10_000_000 durationMs := int64(m.DurationSec) * 1000 + if durationMs <= 0 && posMs > 0 { + durationMs = posMs * 2 + if durationMs < 30*60*1000 { + durationMs = 30 * 60 * 1000 + } + runTimeTicks = durationMs * 10_000 + } played := posMs > 0 && durationMs > 0 && posMs >= durationMs*9/10 pct := 0.0 if durationMs > 0 { @@ -488,6 +499,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, "ImageTags": imageTags, "BackdropImageTags": backdropTags, "Genres": splitCSV(m.Genres), + "People": e.resolveMediaPeople(ctx, m), "ProviderIds": map[string]string{ "Tmdb": intToStr(m.TMDbID), "Bangumi": intToStr(m.BangumiID), @@ -501,8 +513,92 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, }, "MediaSources": e.mediaSourcesForItem(ctx, m, true, false), } + if primaryArtwork != "" { + item["PrimaryImageTag"] = m.ID + } + if seriesID != "" { + if sEntry, ok, _ := e.payloadSeriesEntry(ctx, seriesID); ok { + if sEntry.posterURL != "" { + item["SeriesPrimaryImageTag"] = seriesID + } + if len(backdropTags) == 0 && sEntry.backdropURL != "" { + item["ParentBackdropItemId"] = seriesID + item["ParentBackdropImageTags"] = []string{seriesID + "-bd"} + } + } + } if premiered, ok := embyPremiereDate(m.ReleaseDate); ok { item["PremiereDate"] = premiered } return item } + +func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []map[string]any { + if m == nil || strings.TrimSpace(m.Path) == "" { + return []map[string]any{} + } + dir := filepath.Dir(m.Path) + ext := filepath.Ext(m.Path) + base := strings.TrimSuffix(m.Path, ext) + candidates := []string{ + base + ".nfo", + filepath.Join(dir, "movie.nfo"), + filepath.Join(dir, "tvshow.nfo"), + } + + people := make([]map[string]any, 0) + seen := make(map[string]bool) + + for _, p := range candidates { + if fi, err := os.Stat(p); err == nil && !fi.IsDir() { + doc, ok, err := decodeNFOFile(p) + if err == nil && ok && doc != nil { + for _, d := range doc.Directors { + name := strings.TrimSpace(d) + if name == "" { + continue + } + personID := embyPersonID(name, "Director") + if seen[personID] { + continue + } + seen[personID] = true + people = append(people, map[string]any{ + "Id": personID, + "Name": name, + "Type": "Director", + "Role": "Director", + }) + } + for _, a := range doc.Actors { + name := strings.TrimSpace(a.Name) + if name == "" { + continue + } + personID := embyPersonID(name, "Actor") + if seen[personID] { + continue + } + seen[personID] = true + role := strings.TrimSpace(a.Role) + if role == "" { + role = "Actor" + } + people = append(people, map[string]any{ + "Id": personID, + "Name": name, + "Type": "Actor", + "Role": role, + }) + } + break + } + } + } + return people +} + +func embyPersonID(name, roleType string) string { + sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(name)) + ":" + strings.ToLower(strings.TrimSpace(roleType)))) + return "person-" + hex.EncodeToString(sum[:8]) +} diff --git a/internal/service/emby_payload_ctx.go b/internal/service/emby_payload_ctx.go index 9bb7068..af4ab4e 100644 --- a/internal/service/emby_payload_ctx.go +++ b/internal/service/emby_payload_ctx.go @@ -23,10 +23,17 @@ type embyLibraryTypeEntry struct { found bool // 库不存在时 found=false,调用方可退回计数启发式 } +type embyPayloadSeriesEntry struct { + title string + posterURL string + backdropURL string + found bool +} + type embyPayloadCache struct { mu sync.Mutex libTypes map[string]embyLibraryTypeEntry - series map[string]string // series_id -> title("" 表示不存在/无标题) + series map[string]embyPayloadSeriesEntry } func (c *embyPayloadCache) libraryType(id string) (embyLibraryTypeEntry, bool) { @@ -45,14 +52,33 @@ func (c *embyPayloadCache) setLibraryType(id string, entry embyLibraryTypeEntry) func (c *embyPayloadCache) seriesTitle(id string) (string, bool) { c.mu.Lock() defer c.mu.Unlock() - title, ok := c.series[id] - return title, ok + entry, ok := c.series[id] + if !ok { + return "", false + } + return entry.title, true +} + +func (c *embyPayloadCache) seriesEntry(id string) (embyPayloadSeriesEntry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.series[id] + return entry, ok +} + +func (c *embyPayloadCache) setSeriesEntry(id string, entry embyPayloadSeriesEntry) { + c.mu.Lock() + defer c.mu.Unlock() + c.series[id] = entry } func (c *embyPayloadCache) setSeriesTitle(id, title string) { c.mu.Lock() defer c.mu.Unlock() - c.series[id] = title + entry := c.series[id] + entry.title = title + entry.found = true + c.series[id] = entry } // withPayloadCache attaches a fresh request-scoped cache if none exists yet. @@ -65,7 +91,7 @@ func (e *EmbyService) withPayloadCache(ctx context.Context) context.Context { } return context.WithValue(ctx, embyPayloadCacheKey{}, &embyPayloadCache{ libTypes: map[string]embyLibraryTypeEntry{}, - series: map[string]string{}, + series: map[string]embyPayloadSeriesEntry{}, }) } @@ -113,17 +139,22 @@ func (e *EmbyService) prefetchPayloadCache(ctx context.Context, rows []model.Med } } } - if len(seriesIDs) > 0 { - var series []model.Series - if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil { - for _, s := range series { - cache.setSeriesTitle(s.ID, s.Title) + if len(seriesIDs) > 0 { + var series []model.Series + if err := e.repo.DB.WithContext(ctx).Select("id, title, poster_url, backdrop_url").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil { + for _, s := range series { + cache.setSeriesEntry(s.ID, embyPayloadSeriesEntry{ + title: s.Title, + posterURL: s.PosterURL, + backdropURL: s.BackdropURL, + found: true, + }) + } } } } -} -// payloadLibraryType resolves a library type through the request cache, + // payloadLibraryType resolves a library type through the request cache, // falling back to a direct lookup when no cache is attached. found=false // means the library row does not exist (soft-deleted or orphaned id). func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string) (typ string, found bool, err error) { @@ -152,24 +183,50 @@ func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string) // payloadSeriesTitle resolves a series title through the request cache, // falling back to a direct lookup when no cache is attached. func (e *EmbyService) payloadSeriesTitle(ctx context.Context, seriesID string) (string, bool, error) { - if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok { - if title, hit := cache.seriesTitle(seriesID); hit { - return title, true, nil - } - var s model.Series - if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id = ?", seriesID).First(&s).Error; err != nil { - cache.setSeriesTitle(seriesID, "") - return "", true, nil - } - cache.setSeriesTitle(s.ID, s.Title) - return s.Title, true, nil - } - series, err := e.repo.Series.FindByID(ctx, seriesID) - if err != nil { + entry, ok, err := e.payloadSeriesEntry(ctx, seriesID) + if err != nil || !ok { return "", false, err } - if series == nil { - return "", false, nil - } - return series.Title, true, nil + return entry.title, true, nil } + + // payloadSeriesEntry resolves a series entry through the request cache, + // falling back to a direct lookup when no cache is attached. + func (e *EmbyService) payloadSeriesEntry(ctx context.Context, seriesID string) (embyPayloadSeriesEntry, bool, error) { + if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok { + if entry, hit := cache.seriesEntry(seriesID); hit { + return entry, entry.found, nil + } + if e.repo != nil && e.repo.Series != nil { + s, err := e.repo.Series.FindByID(ctx, seriesID) + if err != nil || s == nil { + cache.setSeriesEntry(seriesID, embyPayloadSeriesEntry{}) + return embyPayloadSeriesEntry{}, false, err + } + entry := embyPayloadSeriesEntry{ + title: s.Title, + posterURL: s.PosterURL, + backdropURL: s.BackdropURL, + found: true, + } + cache.setSeriesEntry(seriesID, entry) + return entry, true, nil + } + } + if e.repo != nil && e.repo.Series != nil { + series, err := e.repo.Series.FindByID(ctx, seriesID) + if err != nil { + return embyPayloadSeriesEntry{}, false, err + } + if series == nil { + return embyPayloadSeriesEntry{}, false, nil + } + return embyPayloadSeriesEntry{ + title: series.Title, + posterURL: series.PosterURL, + backdropURL: series.BackdropURL, + found: true, + }, true, nil + } + return embyPayloadSeriesEntry{}, false, nil + } diff --git a/internal/service/emby_series_payload.go b/internal/service/emby_series_payload.go index 2acea9d..365848f 100644 --- a/internal/service/emby_series_payload.go +++ b/internal/service/emby_series_payload.go @@ -27,16 +27,20 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any { "CommunityRating": group.Rating, "RecursiveItemCount": len(group.Episodes), "ChildCount": len(e.seasonsForSeries(group)), - "DateCreated": group.CreatedAt, - "DateLastMediaAdded": lastMediaAdded, - "ImageTags": imageTags, - "BackdropImageTags": backdropTags, + "DateCreated": group.CreatedAt, + "DateLastMediaAdded": lastMediaAdded, + "ImageTags": imageTags, + "BackdropImageTags": backdropTags, + "People": []map[string]any{}, "ProviderIds": map[string]string{ "Tmdb": intToStr(group.TMDbID), "Bangumi": intToStr(group.BangumiID), }, "UserData": emptyUserData(), } + if group.PosterURL != "" { + item["PrimaryImageTag"] = group.ID + } if premiered, ok := embyPremiereDate(group.ReleaseDate); ok { item["PremiereDate"] = premiered } @@ -53,7 +57,7 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any { if season.Series.BackdropURL != "" { backdropTags = append(backdropTags, season.ID+"-bd") } - return map[string]any{ + item := map[string]any{ "Id": season.ID, "Name": season.Name, "ServerId": embyServerID, @@ -67,6 +71,16 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any { "ChildCount": len(season.Episodes), "ImageTags": imageTags, "BackdropImageTags": backdropTags, + "People": []map[string]any{}, "UserData": emptyUserData(), } + if season.Series.PosterURL != "" { + item["PrimaryImageTag"] = season.ID + item["SeriesPrimaryImageTag"] = season.Series.ID + } + if season.Series.BackdropURL != "" { + item["ParentBackdropItemId"] = season.Series.ID + item["ParentBackdropImageTags"] = []string{season.Series.ID + "-bd"} + } + return item }