Compare commits

...

1 Commits

Author SHA1 Message Date
truewhile 28aa466107 feat: 支持临时登录密码并完善 Emby 媒体详情兼容
- 新增 6 位临时密码生成与验证接口,支持 TV 及客户端快速登录
- 媒体及剧集详情补充 People 演职员信息解析与返回
- 优化剧集背景图与海报标签继承机制及播放时长兜底逻辑
- 增加临时密码登录相关的单元测试
2026-09-06 22:58:41 +08:00
8 changed files with 629 additions and 44 deletions
+20 -1
View File
@@ -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,
})
}
}
+278 -2
View File
@@ -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
}
+12 -5
View File
@@ -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 {
@@ -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))
}
+115 -1
View File
@@ -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
}
+96
View File
@@ -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])
}
+87 -30
View File
@@ -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
}
+19 -5
View File
@@ -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
}