mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e337d9932b | |||
| df654ea47b | |||
| 41fe75e136 | |||
| e65accf2cd | |||
| f54ef2228c | |||
| 3c2bfb14ca |
@@ -29,24 +29,41 @@ var embyPlaceholderPNG = []byte{
|
||||
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
|
||||
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clearEmbyImageNoStoreHeaders(c)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
req := c.Request.WithContext(ctx)
|
||||
id := c.Param("id")
|
||||
imgType := strings.ToLower(c.Param("type"))
|
||||
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
|
||||
if err != nil || raw == "" {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if svc.ImageProxy == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
||||
embyServePlaceholderImage(c)
|
||||
}
|
||||
embyServeImage(c, svc, c.Param("id"), c.Param("type"), c.Query("tag"))
|
||||
}
|
||||
}
|
||||
|
||||
// embyPersonImageHandler 兼容 Emby 官方的 /Persons/{Name}/Images/{Type}。
|
||||
// Name 可能是伪装后的远程人物 ID,也可能是电影详情 People 中的显示名称。
|
||||
func embyPersonImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
embyServeImage(c, svc, c.Param("name"), c.Param("type"), c.Query("tag"))
|
||||
}
|
||||
}
|
||||
|
||||
func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag string) {
|
||||
clearEmbyImageNoStoreHeaders(c)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
req := c.Request.WithContext(ctx)
|
||||
if svc == nil || svc.Emby == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
// PersonImageURL handles TMDb/remote people first and falls back to regular
|
||||
// media/library artwork, so the official /Items/{personId}/Images route
|
||||
// works for synthetic person IDs as well as normal item IDs.
|
||||
raw, err := svc.Emby.PersonImageURL(ctx, id, imageType, tag)
|
||||
if err != nil || raw == "" {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if svc.ImageProxy == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
||||
embyServePlaceholderImage(c)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestEmbyItemImageRouteResolvesTMDbPersonID(t *testing.T) {
|
||||
imageData, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl9sAAAAASUVORK5CYII=")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/t/p/w300/actor.jpg" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(imageData)
|
||||
}))
|
||||
defer imageServer.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
Cache: config.CacheConfig{CacheDir: t.TempDir()},
|
||||
Secrets: config.SecretsConfig{TMDbImageProxy: imageServer.URL + "/t/p"},
|
||||
}
|
||||
tmdb := service.NewTMDbProvider(cfg, zap.NewNop(), nil)
|
||||
emby := service.NewEmbyService(cfg, zap.NewNop(), nil).SetTMDbProvider(tmdb)
|
||||
proxy := service.NewImageProxy(cfg, zap.NewNop())
|
||||
imageURL, _ := url.Parse(imageServer.URL)
|
||||
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{imageURL.Host} })
|
||||
svc := &service.Container{Emby: emby, ImageProxy: proxy}
|
||||
|
||||
raw, err := emby.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg?p2")
|
||||
if err != nil || raw != imageServer.URL+"/t/p/w300/actor.jpg" {
|
||||
t.Fatalf("resolved raw=%q err=%v", raw, err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{
|
||||
{Key: "id", Value: "person~tmdb~101"},
|
||||
{Key: "type", Value: "Primary"},
|
||||
}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/emby/Items/person~tmdb~101/Images/Primary?tag=tmdb%3A%2Factor.jpg%3Fp2", nil)
|
||||
embyItemImageHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := len(w.Body.Bytes()); got != len(imageData) {
|
||||
t.Fatalf("image bytes=%d want %d", got, len(imageData))
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,13 @@ func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container)
|
||||
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
// 官方 Emby 客户端也可能使用 /Persons/{Name}/Images/{Type} 获取演职人员头像。
|
||||
grp.GET("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/Persons/:name/Images/:type/:index", embyPersonImageHandler(svc))
|
||||
grp.HEAD("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/persons/:name/images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/persons/:name/images/:type/:index", embyPersonImageHandler(svc))
|
||||
grp.HEAD("/persons/:name/images/:type", embyPersonImageHandler(svc))
|
||||
// 用户头像。没有头像时返回带缓存头的 404,避免客户端反复重试。
|
||||
grp.GET("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||
grp.HEAD("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||
|
||||
@@ -563,6 +563,39 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return remoteItems
|
||||
}
|
||||
|
||||
if c.DefaultQuery("group_series", "0") != "0" {
|
||||
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
remoteItems := fetchRemote(50000)
|
||||
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
|
||||
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
paged := paginateSlice(all, page, size)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": paged,
|
||||
"total": len(all),
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": all})
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
|
||||
@@ -447,6 +447,89 @@ func TestEmptyLibraryListsReturnEmptyArraysNotNull(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMediaGroupsSeriesBeforeLimit(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rows := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-1", CreatedAt: now.Add(-2 * time.Minute), UpdatedAt: now.Add(-2 * time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E01.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-2", CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E02.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-3", CreatedAt: now, UpdatedAt: now},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E03.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 3, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "db-movie", CreatedAt: now.Add(-3 * time.Minute), UpdatedAt: now.Add(-3 * time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠超:布罗利", Path: "/media/anime/龙珠超:布罗利 (2018)/龙珠超:布罗利.mkv",
|
||||
TMDbID: 503314,
|
||||
},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Set(middleware.CtxUserRole, "user")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠&limit=2&group_series=1", nil)
|
||||
searchMediaHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
Items []model.Media `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Items) != 2 {
|
||||
t.Fatalf("expected one representative per series after limit, got %d: %#v", len(res.Items), res.Items)
|
||||
}
|
||||
seenSeries := false
|
||||
seenMovie := false
|
||||
for _, item := range res.Items {
|
||||
switch item.TMDbID {
|
||||
case 61709:
|
||||
seenSeries = true
|
||||
if item.EpisodeNum != 1 {
|
||||
t.Fatalf("series representative episode=%d, want first episode", item.EpisodeNum)
|
||||
}
|
||||
case 503314:
|
||||
seenMovie = true
|
||||
}
|
||||
}
|
||||
if !seenSeries || !seenMovie {
|
||||
t.Fatalf("expected one Dragon Ball series and one movie, got %#v", res.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// GetPeople resolves adult cast/crew on demand from MetaTube. The provider and
|
||||
// remote movie ID are persisted in the existing external-ID columns; the
|
||||
// returned people are intentionally not written to the database.
|
||||
func (p *AdultProvider) GetPeople(ctx context.Context, media *model.Media) []map[string]any {
|
||||
if p == nil || media == nil || !media.NSFW {
|
||||
return nil
|
||||
}
|
||||
provider := strings.TrimSpace(media.TheTVDBID)
|
||||
movieID := strings.TrimSpace(media.DoubanID)
|
||||
if provider == "" || movieID == "" {
|
||||
return nil
|
||||
}
|
||||
match, err := p.GetMetaTubeCandidate(ctx, provider, movieID)
|
||||
if err != nil || match == nil {
|
||||
return nil
|
||||
}
|
||||
return match.People
|
||||
}
|
||||
@@ -32,17 +32,14 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
|
||||
}
|
||||
return backdrop
|
||||
}
|
||||
if strings.HasPrefix(id, embyVirtualSeasonPrefix) {
|
||||
if strings.HasPrefix(id, embyVirtualSeasonPrefix) || strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||
if raw, ok := e.cachedArtworkURL(id, imageType); ok {
|
||||
return raw, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||
if raw, ok := e.cachedArtworkURL(id, imageType); ok {
|
||||
return raw, nil
|
||||
}
|
||||
return "", nil
|
||||
// Latest JSON can outlive (or be served after) the in-memory artwork
|
||||
// map. Rebuild from the library instead of handing the client a 1x1
|
||||
// placeholder that it then caches as a successful image.
|
||||
return e.resolveVirtualArtwork(ctx, id, imageType, pick)
|
||||
}
|
||||
m, err := e.repo.Media.FindByID(ctx, id)
|
||||
if err == nil && m != nil {
|
||||
@@ -71,6 +68,59 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) resolveVirtualArtwork(ctx context.Context, id, imageType string, pick func(primary, backdrop string) string) (string, error) {
|
||||
if strings.HasPrefix(id, embyVirtualSeasonPrefix) {
|
||||
season, ok, err := e.findSeasonGroup(ctx, id, "")
|
||||
if err != nil || !ok {
|
||||
return "", err
|
||||
}
|
||||
return pick(season.Series.PosterURL, season.Series.BackdropURL), nil
|
||||
}
|
||||
series, ok, err := e.findSeriesGroup(ctx, id, "")
|
||||
if err != nil || !ok {
|
||||
return "", err
|
||||
}
|
||||
return pick(series.PosterURL, series.BackdropURL), nil
|
||||
}
|
||||
|
||||
// PersonImageURL resolves a person avatar from either a disguised remote ID
|
||||
// or a person display name captured from a remote item's People field.
|
||||
func (e *EmbyService) PersonImageURL(ctx context.Context, idOrName, imageType, tag string) (string, error) {
|
||||
idOrName = strings.TrimSpace(idOrName)
|
||||
if idOrName == "" || e == nil {
|
||||
return "", nil
|
||||
}
|
||||
if IsEmbyRemoteID(idOrName) {
|
||||
return e.ImageURL(ctx, idOrName, imageType)
|
||||
}
|
||||
if personID, ok := parseTMDbPersonID(idOrName); ok && e.tmdb != nil {
|
||||
profilePath := tmdbProfilePathFromTag(tag)
|
||||
if profilePath == "" {
|
||||
profilePath = e.cachedPersonImage(idOrName)
|
||||
}
|
||||
if profilePath != "" {
|
||||
if raw := e.tmdb.ProfileImageURL(profilePath); raw != "" {
|
||||
e.rememberPersonImage(idOrName, profilePath)
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
profilePath, err := e.tmdb.PersonProfilePathByID(ctx, personID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if raw := e.tmdb.ProfileImageURL(profilePath); raw != "" {
|
||||
e.rememberPersonImage(idOrName, profilePath)
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
if e.remote != nil {
|
||||
if raw, ok := e.remote.ResolveRemotePersonImageURL(ctx, idOrName, imageType); ok {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
return e.ImageURL(ctx, idOrName, imageType)
|
||||
}
|
||||
|
||||
// imageInfoTypes 是 GET /Items/{Id}/Images 会报告的图片类型。只列 MeBox
|
||||
// 真正存储的两类:ImageURL 对 Thumb / Logo / Banner 等其余类型会回退到
|
||||
// 主图,若一并列出会让客户端以为存在这些图并去请求,实际拿到的却是主图。
|
||||
|
||||
@@ -61,6 +61,14 @@ type EmbyService struct {
|
||||
|
||||
libraryCoverMu sync.Mutex
|
||||
libraryCoverCache map[string]embyArtworkCacheEntry
|
||||
|
||||
peopleMu sync.RWMutex
|
||||
peopleCache map[string]embyPeopleCacheEntry
|
||||
|
||||
tmdb *TMDbProvider
|
||||
adult *AdultProvider
|
||||
personImageMu sync.RWMutex
|
||||
personImages map[string]string
|
||||
}
|
||||
|
||||
// NewEmbyService is the constructor.
|
||||
@@ -76,6 +84,23 @@ func (e *EmbyService) SetEmbyRemote(remote *EmbyRemoteService) *EmbyService {
|
||||
return e
|
||||
}
|
||||
|
||||
// SetTMDbProvider wires the TMDb client used for detail-time cast/crew lookup.
|
||||
func (e *EmbyService) SetTMDbProvider(tmdb *TMDbProvider) *EmbyService {
|
||||
if e != nil {
|
||||
e.tmdb = tmdb
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// SetAdultProvider wires the on-demand adult-metadata provider used when a
|
||||
// detail request needs cast/crew not stored in the database.
|
||||
func (e *EmbyService) SetAdultProvider(adult *AdultProvider) *EmbyService {
|
||||
if e != nil {
|
||||
e.adult = adult
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *EmbyService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyService {
|
||||
if e != nil {
|
||||
e.cache = cache
|
||||
@@ -115,8 +140,22 @@ const (
|
||||
embyVirtualSeriesPrefix = "msgo-series-"
|
||||
embyVirtualSeasonPrefix = "msgo-season-"
|
||||
embyVirtualCacheTTL = 10 * time.Minute
|
||||
embyPeopleCacheTTL = 6 * time.Hour
|
||||
embyPeopleEmptyCacheTTL = 15 * time.Minute
|
||||
embyVisibilityCacheTTL = 30 * time.Second
|
||||
embySeriesGroupingLimit = maxMediaSearchLimit
|
||||
// Virtual artwork used to be wiped entirely once the in-memory map crossed
|
||||
// a few thousand entries. A homepage refresh asks Latest for every library
|
||||
// at once, so that wipe dropped the series the client was about to paint.
|
||||
// Caps are sized for that fan-out; overflow evicts the oldest entries only.
|
||||
embyVirtualSeriesCap = 8000
|
||||
embyVirtualSeasonCap = 16000
|
||||
embyVirtualArtworkCap = 24000
|
||||
// Clients that already cached the 1x1 placeholder treat a stable tag as
|
||||
// immutable. Virtual ids are the ones that served that placeholder, so
|
||||
// only those tags get a suffix that forces a refetch.
|
||||
embyVirtualPrimaryTagSuffix = "-p2"
|
||||
embyVirtualBackdropTagSuffix = "-bd2"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -131,6 +170,13 @@ type embyVisibilityCacheEntry struct {
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// embyPeopleCacheEntry avoids re-statting/decoding the same NFO for every
|
||||
// list refresh. TV clients commonly request the same posters/items repeatedly.
|
||||
type embyPeopleCacheEntry struct {
|
||||
people []map[string]any
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// Items paginates media in Emby's hierarchy. Episodic libraries are exposed as
|
||||
// Series -> Season -> Episode so Infuse/Vidhub/SenPlayer stop treating every
|
||||
// episode as a separate movie card. 带 embyremote~ 前缀的 ParentID / 搜索自动
|
||||
|
||||
@@ -3,12 +3,24 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (e *EmbyService) ItemCounts(ctx context.Context, userID string) (map[string]any, error) {
|
||||
cacheKey := e.embyItemsCacheKey("counts-v1", ItemsParams{UserID: userID})
|
||||
var cached embyCountsCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return map[string]any{
|
||||
"MovieCount": cached.MovieCount,
|
||||
"SeriesCount": int(cached.SeriesCount),
|
||||
"EpisodeCount": cached.EpisodeCount,
|
||||
"ItemCount": cached.ItemCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
base := func() *gorm.DB {
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
||||
return e.applyUserMediaVisibility(ctx, q, userID)
|
||||
@@ -34,6 +46,14 @@ func (e *EmbyService) ItemCounts(ctx context.Context, userID string) (map[string
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyCountsCacheValue{
|
||||
MovieCount: movieCount,
|
||||
SeriesCount: int64(seriesCount),
|
||||
EpisodeCount: episodeCount,
|
||||
ItemCount: itemCount,
|
||||
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return map[string]any{
|
||||
"MovieCount": movieCount,
|
||||
"SeriesCount": seriesCount,
|
||||
|
||||
@@ -9,13 +9,22 @@ import (
|
||||
)
|
||||
|
||||
type embyItemsCacheValue struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
TotalRecordCount int64 `json:"total_record_count"`
|
||||
StartIndex int `json:"start_index"`
|
||||
Items []map[string]any `json:"items"`
|
||||
TotalRecordCount int64 `json:"total_record_count"`
|
||||
StartIndex int `json:"start_index"`
|
||||
Artwork map[string]embyArtworkRef `json:"artwork,omitempty"`
|
||||
}
|
||||
|
||||
type embyLatestCacheValue struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Items []map[string]any `json:"items"`
|
||||
Artwork map[string]embyArtworkRef `json:"artwork,omitempty"`
|
||||
}
|
||||
|
||||
type embyCountsCacheValue struct {
|
||||
MovieCount int64 `json:"movie_count"`
|
||||
SeriesCount int64 `json:"series_count"`
|
||||
EpisodeCount int64 `json:"episode_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string {
|
||||
@@ -43,7 +52,8 @@ func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string {
|
||||
}
|
||||
|
||||
func (e *EmbyService) embyLatestCacheKey(userID, parentID string, limit int) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{"latest", userID, parentID, strconv.Itoa(limit)}, "|")))
|
||||
// v2: payload tags for virtual artwork changed so clients drop cached placeholders.
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{"latest-v2", userID, parentID, strconv.Itoa(limit)}, "|")))
|
||||
return "media:emby:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -62,14 +64,22 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return e.seasonPayload(season), nil
|
||||
item := e.seasonPayload(season)
|
||||
if media := seriesPeopleMedia(season.Series); media != nil {
|
||||
item["People"] = e.resolveMediaPeople(ctx, media)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) {
|
||||
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return e.seriesPayload(series), nil
|
||||
item := e.seriesPayload(series)
|
||||
if media := seriesPeopleMedia(series); media != nil {
|
||||
item["People"] = e.resolveMediaPeople(ctx, media)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
||||
@@ -80,7 +90,11 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
return e.seriesPayload(series), nil
|
||||
item := e.seriesPayload(series)
|
||||
if media := seriesPeopleMedia(series); media != nil {
|
||||
item["People"] = e.resolveMediaPeople(ctx, media)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -103,7 +117,7 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
}
|
||||
}
|
||||
// 单条目 payload 内部对库类型/series 标题有多次查找,挂请求级缓存合并。
|
||||
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos), nil
|
||||
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos, true), nil
|
||||
}
|
||||
|
||||
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。
|
||||
@@ -132,15 +146,16 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
|
||||
var cached embyLatestCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
e.rememberArtworkRefs(cached.Artwork)
|
||||
return cached.Items, nil
|
||||
}
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if parentID != "" {
|
||||
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
|
||||
out, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
|
||||
out, artwork, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
|
||||
if err == nil && e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
|
||||
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out, Artwork: artwork}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
@@ -171,7 +186,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, error) {
|
||||
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, map[string]embyArtworkRef, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
@@ -180,7 +195,7 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
var rows []model.Media
|
||||
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
|
||||
@@ -191,7 +206,7 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
|
||||
for _, group := range groups {
|
||||
items = append(items, e.seriesPayload(group))
|
||||
}
|
||||
return items, nil
|
||||
return items, e.artworkRefsForSeriesGroups(groups), nil
|
||||
}
|
||||
|
||||
// ResumeItems 列出有未完成播放进度的媒体。
|
||||
@@ -255,7 +270,7 @@ func (e *EmbyService) favoriteItems(ctx context.Context, p ItemsParams) (map[str
|
||||
continue
|
||||
}
|
||||
}
|
||||
items = append(items, e.itemPayload(ctx, m, true, 0))
|
||||
items = append(items, e.itemPayload(ctx, m, true, 0, false))
|
||||
continue
|
||||
}
|
||||
if e.remote == nil || !IsEmbyRemoteID(fav.MediaID) {
|
||||
@@ -387,7 +402,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
}
|
||||
localTotal++
|
||||
if produced := len(items); produced < needed {
|
||||
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs))
|
||||
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs, false))
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -426,7 +441,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any {
|
||||
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64, includePeople bool) map[string]any {
|
||||
itemType := "Movie"
|
||||
name := m.Title
|
||||
parentID := m.LibraryID
|
||||
@@ -499,7 +514,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),
|
||||
"People": []map[string]any{},
|
||||
"ProviderIds": map[string]string{
|
||||
"Tmdb": intToStr(m.TMDbID),
|
||||
"Bangumi": intToStr(m.BangumiID),
|
||||
@@ -519,21 +534,101 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
if seriesID != "" {
|
||||
if sEntry, ok, _ := e.payloadSeriesEntry(ctx, seriesID); ok {
|
||||
if sEntry.posterURL != "" {
|
||||
item["SeriesPrimaryImageTag"] = seriesID
|
||||
item["SeriesPrimaryImageTag"] = embyVirtualImageTag(seriesID, embyVirtualPrimaryTagSuffix)
|
||||
}
|
||||
if len(backdropTags) == 0 && sEntry.backdropURL != "" {
|
||||
if len(backdropTags) == 0 && (sEntry.backdropURL != "" || sEntry.posterURL != "") {
|
||||
item["ParentBackdropItemId"] = seriesID
|
||||
item["ParentBackdropImageTags"] = []string{seriesID + "-bd"}
|
||||
item["ParentBackdropImageTags"] = []string{embyVirtualImageTag(seriesID, embyVirtualBackdropTagSuffix)}
|
||||
}
|
||||
}
|
||||
}
|
||||
if premiered, ok := embyPremiereDate(m.ReleaseDate); ok {
|
||||
item["PremiereDate"] = premiered
|
||||
}
|
||||
if includePeople {
|
||||
item["People"] = e.resolveMediaPeople(ctx, m)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []map[string]any {
|
||||
if m == nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
cacheKey := embyMediaPeopleCacheKey(m)
|
||||
if cacheKey == "" {
|
||||
return []map[string]any{}
|
||||
}
|
||||
if people, ok := e.cachedMediaPeople(cacheKey); ok {
|
||||
return people
|
||||
}
|
||||
people := e.fetchTMDbPeople(ctx, m)
|
||||
if len(people) == 0 {
|
||||
people = e.resolveNFOMediaPeople(m)
|
||||
}
|
||||
e.rememberMediaPeople(cacheKey, people)
|
||||
return people
|
||||
}
|
||||
|
||||
func seriesPeopleMedia(group embySeriesGroup) *model.Media {
|
||||
if group.TMDbID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if len(group.Episodes) > 0 {
|
||||
media := group.Episodes[0]
|
||||
media.TMDbID = group.TMDbID
|
||||
return &media
|
||||
}
|
||||
return &model.Media{Title: group.Name, TMDbID: group.TMDbID, SeasonNum: 1}
|
||||
}
|
||||
|
||||
func (e *EmbyService) fetchTMDbPeople(ctx context.Context, m *model.Media) []map[string]any {
|
||||
if e == nil || m == nil {
|
||||
return nil
|
||||
}
|
||||
var people []map[string]any
|
||||
if e.tmdb != nil && m.TMDbID > 0 {
|
||||
mediaType := "movie"
|
||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||
mediaType = "tv"
|
||||
}
|
||||
people, err := e.tmdb.GetPeople(ctx, m.TMDbID, mediaType)
|
||||
if err != nil {
|
||||
if e.log != nil {
|
||||
e.log.Debug("tmdb: detail people lookup failed", zap.Int("tmdb_id", m.TMDbID), zap.String("type", mediaType), zap.Error(err))
|
||||
}
|
||||
} else if len(people) > 0 {
|
||||
e.rememberPersonImages(people)
|
||||
return people
|
||||
}
|
||||
}
|
||||
if m.NSFW && e.adult != nil {
|
||||
people = e.adult.GetPeople(ctx, m)
|
||||
}
|
||||
return people
|
||||
}
|
||||
|
||||
func embyMediaPeopleCacheKey(m *model.Media) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if m.TMDbID > 0 {
|
||||
mediaType := "movie"
|
||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||
mediaType = "tv"
|
||||
}
|
||||
return fmt.Sprintf("tmdb:%d:%s", m.TMDbID, mediaType)
|
||||
}
|
||||
if strings.TrimSpace(m.ID) != "" {
|
||||
return "media:" + strings.TrimSpace(m.ID)
|
||||
}
|
||||
if strings.TrimSpace(m.Path) != "" {
|
||||
return "path:" + strings.ToLower(filepath.Clean(m.Path))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *EmbyService) resolveNFOMediaPeople(m *model.Media) []map[string]any {
|
||||
if m == nil || strings.TrimSpace(m.Path) == "" {
|
||||
return []map[string]any{}
|
||||
}
|
||||
@@ -562,7 +657,6 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
|
||||
|
||||
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, _, err := decodeNFOFile(p)
|
||||
@@ -612,6 +706,89 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
|
||||
return people
|
||||
}
|
||||
|
||||
func (e *EmbyService) cachedMediaPeople(key string) ([]map[string]any, bool) {
|
||||
if e == nil || strings.TrimSpace(key) == "" {
|
||||
return nil, false
|
||||
}
|
||||
now := time.Now()
|
||||
e.peopleMu.RLock()
|
||||
entry, ok := e.peopleCache[key]
|
||||
e.peopleMu.RUnlock()
|
||||
if !ok || now.After(entry.expiresAt) {
|
||||
if ok {
|
||||
e.peopleMu.Lock()
|
||||
delete(e.peopleCache, key)
|
||||
e.peopleMu.Unlock()
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
out := make([]map[string]any, len(entry.people))
|
||||
copy(out, entry.people)
|
||||
return out, true
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberMediaPeople(key string, people []map[string]any) {
|
||||
if e == nil || strings.TrimSpace(key) == "" {
|
||||
return
|
||||
}
|
||||
e.peopleMu.Lock()
|
||||
defer e.peopleMu.Unlock()
|
||||
if e.peopleCache == nil || len(e.peopleCache) > 8000 {
|
||||
e.peopleCache = make(map[string]embyPeopleCacheEntry, 128)
|
||||
}
|
||||
stored := make([]map[string]any, len(people))
|
||||
copy(stored, people)
|
||||
ttl := embyPeopleCacheTTL
|
||||
if len(stored) == 0 {
|
||||
ttl = embyPeopleEmptyCacheTTL
|
||||
}
|
||||
e.peopleCache[key] = embyPeopleCacheEntry{
|
||||
people: stored,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberPersonImages(people []map[string]any) {
|
||||
if e == nil || len(people) == 0 {
|
||||
return
|
||||
}
|
||||
e.personImageMu.Lock()
|
||||
defer e.personImageMu.Unlock()
|
||||
if e.personImages == nil || len(e.personImages) > 20000 {
|
||||
e.personImages = make(map[string]string, 128)
|
||||
}
|
||||
for _, person := range people {
|
||||
id := strings.TrimSpace(fmt.Sprint(person["Id"]))
|
||||
profilePath := tmdbProfilePathFromTag(fmt.Sprint(person["PrimaryImageTag"]))
|
||||
if _, ok := parseTMDbPersonID(id); ok && profilePath != "" {
|
||||
e.personImages[id] = profilePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmbyService) cachedPersonImage(id string) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
e.personImageMu.RLock()
|
||||
profilePath := e.personImages[strings.TrimSpace(id)]
|
||||
e.personImageMu.RUnlock()
|
||||
return profilePath
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberPersonImage(id, profilePath string) {
|
||||
profilePath = strings.TrimSpace(profilePath)
|
||||
if e == nil || strings.TrimSpace(id) == "" || !tmdbProfilePathRE.MatchString(profilePath) {
|
||||
return
|
||||
}
|
||||
e.personImageMu.Lock()
|
||||
if e.personImages == nil {
|
||||
e.personImages = make(map[string]string, 128)
|
||||
}
|
||||
e.personImages[strings.TrimSpace(id)] = profilePath
|
||||
e.personImageMu.Unlock()
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
@@ -75,9 +75,9 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
|
||||
orderIncludesDirection = false
|
||||
case "premieredate", "productionyear":
|
||||
order = mediaReleaseOrderSQL(desc)
|
||||
case "datecreated", "datelastmediaadded", "datelastcontentadded":
|
||||
order = "media.created_at"
|
||||
orderIncludesDirection = false
|
||||
case "datecreated", "datelastmediaadded", "datelastcontentadded":
|
||||
order = "media.created_at"
|
||||
orderIncludesDirection = false
|
||||
case "dateplayed":
|
||||
order = "resume.watched_at"
|
||||
orderIncludesDirection = false
|
||||
@@ -181,7 +181,7 @@ func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media,
|
||||
|
||||
items := make([]map[string]any, 0, len(rows))
|
||||
for _, m := range rows {
|
||||
items = append(items, e.itemPayload(ctx, &m, userFavs[m.ID], userPos[m.ID]))
|
||||
items = append(items, e.itemPayload(ctx, &m, userFavs[m.ID], userPos[m.ID], false))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -225,6 +225,17 @@ func (e *EmbyService) collapseMediaVersionRows(ctx context.Context, rows []model
|
||||
}
|
||||
|
||||
func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID string, p ItemsParams) (map[string]any, error) {
|
||||
cacheKey := e.embyItemsCacheKey("series-items-v1", p)
|
||||
var cached embyItemsCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
e.rememberArtworkRefs(cached.Artwork)
|
||||
return map[string]any{
|
||||
"Items": cached.Items,
|
||||
"TotalRecordCount": int(cached.TotalRecordCount),
|
||||
"StartIndex": cached.StartIndex,
|
||||
}, nil
|
||||
}
|
||||
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0")
|
||||
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||
if libraryID != "" {
|
||||
@@ -246,9 +257,19 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
|
||||
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||
sortSeriesGroups(groups, p)
|
||||
total := len(groups)
|
||||
items := make([]map[string]any, 0, minInt(p.Limit, len(groups)))
|
||||
for _, group := range pageSlice(groups, p.StartIndex, p.Limit) {
|
||||
pageGroups := pageSlice(groups, p.StartIndex, p.Limit)
|
||||
items := make([]map[string]any, 0, len(pageGroups))
|
||||
for _, group := range pageGroups {
|
||||
items = append(items, e.seriesPayload(group))
|
||||
}
|
||||
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{
|
||||
Items: items,
|
||||
TotalRecordCount: int64(total),
|
||||
StartIndex: p.StartIndex,
|
||||
Artwork: e.artworkRefsForSeriesGroups(pageGroups),
|
||||
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -35,6 +35,17 @@ func (e *EmbyService) movieLibraryHasEpisodicContent(ctx context.Context, librar
|
||||
// 与 mediaItems 的区别: 后者会把剧集结构行当散装 Episode 漏出;这里改为聚合成
|
||||
// Series,从根本上消除「电影库里整部剧被拆成单集」的现象。
|
||||
func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||
cacheKey := e.embyItemsCacheKey("movie-library-items-v1", p)
|
||||
var cached embyItemsCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
e.rememberArtworkRefs(cached.Artwork)
|
||||
return map[string]any{
|
||||
"Items": cached.Items,
|
||||
"TotalRecordCount": int(cached.TotalRecordCount),
|
||||
"StartIndex": cached.StartIndex,
|
||||
}, nil
|
||||
}
|
||||
|
||||
libIDs := e.mergedLibraryIDs(ctx, p.ParentID)
|
||||
apply := func(q *gorm.DB) *gorm.DB {
|
||||
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||
@@ -78,33 +89,72 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
|
||||
if err := movieQ.Find(&movieRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
movieItems, err := e.payloadsForMedia(ctx, movieRows, p.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 先按版本去重,再参与排序。这里不立即构建 payload:大电影库可能有
|
||||
// 数万行,而客户端一页通常只要几十条,提前构建会触发大量 NFO / 数据
|
||||
// 查询并把响应时间浪费在用户根本看不到的条目上。
|
||||
movieRows = e.collapseMediaVersionRows(ctx, movieRows)
|
||||
|
||||
// 合并: Series 卡片 + Movie 项, 统一按首播/上映日期倒序。
|
||||
type entry struct {
|
||||
sortAt time.Time
|
||||
payload map[string]any
|
||||
sortAt time.Time
|
||||
media *model.Media
|
||||
group *embySeriesGroup
|
||||
}
|
||||
entries := make([]entry, 0, len(seriesGroups)+len(movieItems))
|
||||
for _, g := range seriesGroups {
|
||||
entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(g), payload: e.seriesPayload(g)})
|
||||
entries := make([]entry, 0, len(seriesGroups)+len(movieRows))
|
||||
for i := range seriesGroups {
|
||||
group := &seriesGroups[i]
|
||||
entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(*group), group: group})
|
||||
}
|
||||
for _, item := range movieItems {
|
||||
entries = append(entries, entry{sortAt: embyPayloadReleaseSortTime(item), payload: item})
|
||||
for i := range movieRows {
|
||||
media := &movieRows[i]
|
||||
entries = append(entries, entry{sortAt: embyMediaReleaseSortTime(*media), media: media})
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
return entries[i].sortAt.After(entries[j].sortAt)
|
||||
})
|
||||
total := len(entries)
|
||||
paged := pageSlice(entries, p.StartIndex, p.Limit)
|
||||
items := make([]map[string]any, 0, len(paged))
|
||||
|
||||
pageMovies := make([]model.Media, 0, len(paged))
|
||||
for _, en := range paged {
|
||||
items = append(items, en.payload)
|
||||
if en.media != nil {
|
||||
pageMovies = append(pageMovies, *en.media)
|
||||
}
|
||||
}
|
||||
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
moviePayloads, err := e.payloadsForMedia(ctx, pageMovies, p.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payloadByID := make(map[string]map[string]any, len(moviePayloads))
|
||||
for _, item := range moviePayloads {
|
||||
if id, ok := item["Id"].(string); ok {
|
||||
payloadByID[id] = item
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(paged))
|
||||
pageGroups := make([]embySeriesGroup, 0, len(paged))
|
||||
for _, en := range paged {
|
||||
switch {
|
||||
case en.group != nil:
|
||||
pageGroups = append(pageGroups, *en.group)
|
||||
items = append(items, e.seriesPayload(*en.group))
|
||||
case en.media != nil:
|
||||
if item := payloadByID[en.media.ID]; item != nil {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}
|
||||
if e.cache != nil {
|
||||
e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{
|
||||
Items: items,
|
||||
TotalRecordCount: int64(total),
|
||||
StartIndex: p.StartIndex,
|
||||
Artwork: e.artworkRefsForSeriesGroups(pageGroups),
|
||||
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// embyPayloadCreatedAt 从 item payload 里取 DateCreated(time.Time),用于合并排序。
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -55,3 +62,48 @@ func TestResolveMediaPeopleStillReadsLegacyKeepExtNFO(t *testing.T) {
|
||||
t.Fatalf("expected legacy keep_ext nfo people, got %#v", people)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaPeopleFetchesTMDbAndCaches(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/movie/11/credits" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
hits.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"cast": []map[string]any{
|
||||
{"id": 101, "name": "演员甲", "character": "主角", "profile_path": "/actor.jpg", "order": 0},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := newTestEmbyService(t)
|
||||
svc.SetTMDbProvider(NewTMDbProvider(&config.Config{Secrets: config.SecretsConfig{
|
||||
TMDbAPIKey: "test-key",
|
||||
TMDbAPIProxy: server.URL,
|
||||
TMDbImageProxy: "https://image.example/t/p",
|
||||
}}, zap.NewNop(), nil))
|
||||
media := &model.Media{Base: model.Base{ID: "movie-people-1"}, Title: "测试电影", Path: "/media/movies/test.mkv", TMDbID: 11}
|
||||
|
||||
people := svc.resolveMediaPeople(t.Context(), media)
|
||||
peopleAgain := svc.resolveMediaPeople(t.Context(), media)
|
||||
if len(people) != 1 || len(peopleAgain) != 1 {
|
||||
t.Fatalf("people=%#v again=%#v", people, peopleAgain)
|
||||
}
|
||||
if hits.Load() != 1 {
|
||||
t.Fatalf("TMDb credits hits=%d want 1", hits.Load())
|
||||
}
|
||||
if people[0]["Id"] != "person~tmdb~101" || people[0]["PrimaryImageTag"] != "tmdb:/actor.jpg?p2" {
|
||||
t.Fatalf("unexpected person: %#v", people[0])
|
||||
}
|
||||
raw, err := svc.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg?p2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw != "https://image.example/t/p/w300/actor.jpg" {
|
||||
t.Fatalf("person image URL=%q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,14 @@ type EmbyRemoteService struct {
|
||||
http *http.Client
|
||||
stream *http.Client // 流式代理专用(视频/字幕),无整体 Timeout
|
||||
cache *RuntimeCacheService
|
||||
|
||||
personMu sync.RWMutex
|
||||
personImages map[string]embyRemotePersonImageRef
|
||||
}
|
||||
|
||||
type embyRemotePersonImageRef struct {
|
||||
accountID string
|
||||
remoteID string
|
||||
}
|
||||
|
||||
// NewEmbyRemoteService 构造远程 Emby 聚合服务。
|
||||
@@ -802,10 +810,12 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou
|
||||
return nil, err
|
||||
}
|
||||
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID)
|
||||
q := url.Values{"Fields": {"Overview,Genres,ProviderIds,People,Studios,Path,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating"}}
|
||||
var out map[string]any
|
||||
if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil {
|
||||
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.rememberRemotePeople(mount, out)
|
||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||
return out, nil
|
||||
}
|
||||
@@ -959,6 +969,78 @@ func (r *EmbyRemoteService) RemoteImageURL(ctx context.Context, acct *model.Strm
|
||||
"?api_key=" + url.QueryEscape(cfg.Token), nil
|
||||
}
|
||||
|
||||
// rememberRemotePeople 记录远程人物名称到远程人物 ID 的映射,供旧式
|
||||
// /Persons/{Name}/Images/{Type} 图片请求回源。客户端详情页通常先取条目详情,
|
||||
// 此时 People 中的名称和 ID 已同时拿到,因此无需额外搜索远程人物。
|
||||
func (r *EmbyRemoteService) rememberRemotePeople(mount *model.EmbyMount, payload map[string]any) {
|
||||
if r == nil || mount == nil || strings.TrimSpace(mount.AccountID) == "" || payload == nil {
|
||||
return
|
||||
}
|
||||
people := remotePeopleMaps(payload["People"])
|
||||
if len(people) == 0 {
|
||||
return
|
||||
}
|
||||
r.personMu.Lock()
|
||||
defer r.personMu.Unlock()
|
||||
if r.personImages == nil || len(r.personImages) > 20000 {
|
||||
r.personImages = make(map[string]embyRemotePersonImageRef, 256)
|
||||
}
|
||||
for _, person := range people {
|
||||
name := strings.TrimSpace(remoteItemString(person, "Name"))
|
||||
remoteID := strings.TrimSpace(remoteItemString(person, "Id"))
|
||||
if name == "" || remoteID == "" || IsEmbyRemoteID(remoteID) {
|
||||
continue
|
||||
}
|
||||
r.personImages[strings.ToLower(name)] = embyRemotePersonImageRef{
|
||||
accountID: mount.AccountID,
|
||||
remoteID: remoteID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveRemotePersonImageURL 按人物名称解析其远程头像地址。
|
||||
func (r *EmbyRemoteService) ResolveRemotePersonImageURL(ctx context.Context, name, imageType string) (string, bool) {
|
||||
if r == nil {
|
||||
return "", false
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if key == "" {
|
||||
return "", false
|
||||
}
|
||||
r.personMu.RLock()
|
||||
ref, ok := r.personImages[key]
|
||||
r.personMu.RUnlock()
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
acct := r.AccountByID(ctx, ref.accountID)
|
||||
if acct == nil {
|
||||
return "", false
|
||||
}
|
||||
raw, err := r.RemoteImageURL(ctx, acct, ref.remoteID, imageType)
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
func remotePeopleMaps(value any) []map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case []map[string]any:
|
||||
return typed
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if person, ok := item.(map[string]any); ok {
|
||||
out = append(out, person)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 播放代理 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ProxyVideoStream 反向代理远程 Emby 视频流(保留 Range 以支持拖动)。
|
||||
|
||||
@@ -106,4 +106,9 @@ func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) {
|
||||
if items, ok := m["Items"]; ok {
|
||||
RewriteEmbyRemoteIDs(items, accountID)
|
||||
}
|
||||
// 人物条目同样以 Id 回指 /Items/{Id}/Images/...。若不递归重写,客户端会
|
||||
// 把远程演员 ID 当成本地 ID,头像最终只能命中占位图。
|
||||
if people, ok := m["People"]; ok {
|
||||
RewriteEmbyRemoteIDs(people, accountID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,14 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
|
||||
"Items": []any{
|
||||
map[string]any{"Id": "item-2", "ParentId": "folder-2"},
|
||||
},
|
||||
"People": []any{
|
||||
map[string]any{
|
||||
"Id": "person-1",
|
||||
"Name": "演员甲",
|
||||
"Type": "Actor",
|
||||
"PrimaryImageTag": "person-tag-1",
|
||||
},
|
||||
},
|
||||
// MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。
|
||||
"MediaSources": []any{
|
||||
map[string]any{
|
||||
@@ -90,6 +98,13 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
|
||||
if nested["Id"] != "embyremote~acct-1~item-2" {
|
||||
t.Fatalf("nested Id = %v", nested["Id"])
|
||||
}
|
||||
person := payload["People"].([]any)[0].(map[string]any)
|
||||
if person["Id"] != "embyremote~acct-1~person-1" {
|
||||
t.Fatalf("person Id = %v", person["Id"])
|
||||
}
|
||||
if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" {
|
||||
t.Fatalf("person display fields changed: %#v", person)
|
||||
}
|
||||
|
||||
// MediaSource.Id 与 URL 不被 ID 重写器触碰(URL 由代理模式函数改写)。
|
||||
ms := payload["MediaSources"].([]any)[0].(map[string]any)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestRemoteItemRequestsPeopleAndRewritesPersonIDs(t *testing.T) {
|
||||
var requestedFields string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/Users/user-1/Items/item-1") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
requestedFields = r.URL.Query().Get("Fields")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Id": "item-1",
|
||||
"Name": "测试电影",
|
||||
"People": []map[string]any{
|
||||
{
|
||||
"Id": "person-1",
|
||||
"Name": "演员甲",
|
||||
"Type": "Actor",
|
||||
"PrimaryImageTag": "person-tag-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"token": "fake-token",
|
||||
"remote_user_id": "user-1",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-people"},
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mount := &model.EmbyMount{Base: model.Base{ID: "mount-people"}, AccountID: acct.ID}
|
||||
|
||||
out, err := svc.RemoteItem(t.Context(), mount, acct, "item-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(requestedFields, "People") {
|
||||
t.Fatalf("RemoteItem Fields = %q, want People", requestedFields)
|
||||
}
|
||||
people, ok := out["People"].([]any)
|
||||
if !ok || len(people) != 1 {
|
||||
t.Fatalf("People = %#v, want one person", out["People"])
|
||||
}
|
||||
person := people[0].(map[string]any)
|
||||
if got := person["Id"]; got != EncodeEmbyRemoteID("mount-people", "person-1") {
|
||||
t.Fatalf("person Id = %v, want encoded remote id", got)
|
||||
}
|
||||
if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" {
|
||||
t.Fatalf("person display fields changed: %#v", person)
|
||||
}
|
||||
raw, ok := svc.ResolveRemotePersonImageURL(t.Context(), "演员甲", "Primary")
|
||||
if !ok || !strings.Contains(raw, "/Items/person-1/Images/primary") {
|
||||
t.Fatalf("resolved person image URL = %q, ok=%v", raw, ok)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -37,11 +38,7 @@ func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) {
|
||||
if e.virtualArtwork == nil {
|
||||
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
|
||||
}
|
||||
if len(e.virtualSeries) > 2000 || len(e.virtualSeasons) > 5000 || len(e.virtualArtwork) > 7000 {
|
||||
e.virtualSeries = make(map[string]embySeriesCacheEntry)
|
||||
e.virtualSeasons = make(map[string]embySeasonCacheEntry)
|
||||
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
|
||||
}
|
||||
e.trimVirtualCachesLocked(time.Now())
|
||||
e.virtualSeries[group.ID] = embySeriesCacheEntry{group: group, expiresAt: expiresAt}
|
||||
e.virtualArtwork[group.ID] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt}
|
||||
e.virtualArtwork[group.ID+"-bd"] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt}
|
||||
@@ -65,6 +62,7 @@ func (e *EmbyService) rememberSeasonGroup(season embySeasonGroup) {
|
||||
if e.virtualArtwork == nil {
|
||||
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
|
||||
}
|
||||
e.trimVirtualCachesLocked(time.Now())
|
||||
e.virtualSeasons[season.ID] = embySeasonCacheEntry{season: season, expiresAt: expiresAt}
|
||||
e.virtualArtwork[season.ID] = embyArtworkCacheEntry{primary: season.Series.PosterURL, backdrop: season.Series.BackdropURL, expiresAt: expiresAt}
|
||||
e.virtualArtwork[season.ID+"-bd"] = embyArtworkCacheEntry{primary: season.Series.PosterURL, backdrop: season.Series.BackdropURL, expiresAt: expiresAt}
|
||||
@@ -135,3 +133,123 @@ func (e *EmbyService) cachedArtworkURL(id, imageType string) (string, bool) {
|
||||
}
|
||||
return entry.backdrop, entry.backdrop != ""
|
||||
}
|
||||
|
||||
// embyArtworkRef is the poster/backdrop pair stored next to a Latest payload
|
||||
// so a JSON-cache hit can refill the in-memory artwork map before the client
|
||||
// asks for the image.
|
||||
type embyArtworkRef struct {
|
||||
Primary string `json:"primary,omitempty"`
|
||||
Backdrop string `json:"backdrop,omitempty"`
|
||||
}
|
||||
|
||||
func (e *EmbyService) artworkRefsForSeriesGroups(groups []embySeriesGroup) map[string]embyArtworkRef {
|
||||
if e == nil || len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
refs := make(map[string]embyArtworkRef, len(groups)*2)
|
||||
for _, group := range groups {
|
||||
if group.PosterURL == "" && group.BackdropURL == "" {
|
||||
continue
|
||||
}
|
||||
ref := embyArtworkRef{Primary: group.PosterURL, Backdrop: group.BackdropURL}
|
||||
refs[group.ID] = ref
|
||||
for _, season := range e.seasonsForSeries(group) {
|
||||
refs[season.ID] = ref
|
||||
}
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberArtworkRefs(refs map[string]embyArtworkRef) {
|
||||
if e == nil || len(refs) == 0 {
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(embyVirtualCacheTTL)
|
||||
e.virtualMu.Lock()
|
||||
defer e.virtualMu.Unlock()
|
||||
if e.virtualArtwork == nil {
|
||||
e.virtualArtwork = make(map[string]embyArtworkCacheEntry, len(refs))
|
||||
}
|
||||
e.trimVirtualArtworkLocked(time.Now())
|
||||
for id, ref := range refs {
|
||||
if strings.TrimSpace(id) == "" || (ref.Primary == "" && ref.Backdrop == "") {
|
||||
continue
|
||||
}
|
||||
e.virtualArtwork[id] = embyArtworkCacheEntry{primary: ref.Primary, backdrop: ref.Backdrop, expiresAt: expiresAt}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmbyService) trimVirtualCachesLocked(now time.Time) {
|
||||
e.trimVirtualSeriesLocked(now)
|
||||
e.trimVirtualSeasonsLocked(now)
|
||||
e.trimVirtualArtworkLocked(now)
|
||||
}
|
||||
|
||||
func (e *EmbyService) trimVirtualSeriesLocked(now time.Time) {
|
||||
for id, entry := range e.virtualSeries {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(e.virtualSeries, id)
|
||||
}
|
||||
}
|
||||
evictOldest(e.virtualSeries, embyVirtualSeriesCap, func(entry embySeriesCacheEntry) time.Time {
|
||||
return entry.expiresAt
|
||||
})
|
||||
}
|
||||
|
||||
func (e *EmbyService) trimVirtualSeasonsLocked(now time.Time) {
|
||||
for id, entry := range e.virtualSeasons {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(e.virtualSeasons, id)
|
||||
}
|
||||
}
|
||||
evictOldest(e.virtualSeasons, embyVirtualSeasonCap, func(entry embySeasonCacheEntry) time.Time {
|
||||
return entry.expiresAt
|
||||
})
|
||||
}
|
||||
|
||||
func (e *EmbyService) trimVirtualArtworkLocked(now time.Time) {
|
||||
for id, entry := range e.virtualArtwork {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(e.virtualArtwork, id)
|
||||
}
|
||||
}
|
||||
evictOldest(e.virtualArtwork, embyVirtualArtworkCap, func(entry embyArtworkCacheEntry) time.Time {
|
||||
return entry.expiresAt
|
||||
})
|
||||
}
|
||||
|
||||
// evictOldest drops the soonest-expiring entries until the map is under cap.
|
||||
// It must not replace the map: a homepage refresh remembers many series at
|
||||
// once, and wiping the whole cache made the just-advertised backdrops 404
|
||||
// into a 1x1 placeholder.
|
||||
func evictOldest[T any](items map[string]T, cap int, expiresAt func(T) time.Time) {
|
||||
excess := len(items) - cap
|
||||
if cap <= 0 || excess <= 0 {
|
||||
return
|
||||
}
|
||||
type pair struct {
|
||||
id string
|
||||
at time.Time
|
||||
}
|
||||
ordered := make([]pair, 0, len(items))
|
||||
for id, entry := range items {
|
||||
ordered = append(ordered, pair{id: id, at: expiresAt(entry)})
|
||||
}
|
||||
sort.Slice(ordered, func(i, j int) bool { return ordered[i].at.Before(ordered[j].at) })
|
||||
if excess > len(ordered) {
|
||||
excess = len(ordered)
|
||||
}
|
||||
for i := 0; i < excess; i++ {
|
||||
delete(items, ordered[i].id)
|
||||
}
|
||||
}
|
||||
|
||||
func embyVirtualImageTag(id, suffix string) string {
|
||||
if strings.HasPrefix(id, embyVirtualSeriesPrefix) || strings.HasPrefix(id, embyVirtualSeasonPrefix) {
|
||||
return id + suffix
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ func TestEmbyEpisodeStillIsPrimaryImageNotArt(t *testing.T) {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
item := svc.itemPayload(t.Context(), &media, false, 0)
|
||||
item := svc.itemPayload(t.Context(), &media, false, 0, false)
|
||||
if tags, ok := item["ImageTags"].(map[string]string); !ok || tags["Primary"] != "ep-still" {
|
||||
t.Fatalf("episode should expose a primary image tag: %#v", item["ImageTags"])
|
||||
}
|
||||
@@ -296,6 +296,71 @@ func TestEmbyVirtualSeriesArtworkUsesListCache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyVirtualSeriesArtworkRebuildsAfterMemoryDrop(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
svc.cache = NewRuntimeCacheService(nil, nil)
|
||||
lib := model.Library{Name: "番剧", Path: `/media/anime`, Type: "anime", 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: "ep-hero"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "树海之魔",
|
||||
Path: `/media/anime/树海之魔/Season 01/树海之魔 - S01E01.mkv`,
|
||||
PosterURL: `/poster.jpg`,
|
||||
BackdropURL: `/backdrop.jpg`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
if err := svc.repo.DB.Create(&media).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
items, err := svc.LatestItems(t.Context(), "", lib.ID, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("latest items: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("latest len = %d, want 1", len(items))
|
||||
}
|
||||
seriesID, _ := items[0]["Id"].(string)
|
||||
tags, _ := items[0]["BackdropImageTags"].([]string)
|
||||
if seriesID == "" || len(tags) != 1 || tags[0] != seriesID+embyVirtualBackdropTagSuffix {
|
||||
t.Fatalf("hero item should advertise a cache-busted backdrop tag, got id=%q tags=%#v", seriesID, items[0]["BackdropImageTags"])
|
||||
}
|
||||
|
||||
svc.virtualMu.Lock()
|
||||
svc.virtualArtwork = nil
|
||||
svc.virtualSeries = nil
|
||||
svc.virtualSeasons = nil
|
||||
svc.virtualMu.Unlock()
|
||||
|
||||
backdrop, err := svc.ImageURL(t.Context(), seriesID, "Backdrop")
|
||||
if err != nil {
|
||||
t.Fatalf("backdrop after memory drop: %v", err)
|
||||
}
|
||||
if backdrop != "/backdrop.jpg" {
|
||||
t.Fatalf("backdrop = %q, want rebuilt backdrop", backdrop)
|
||||
}
|
||||
|
||||
svc.virtualMu.Lock()
|
||||
svc.virtualArtwork = nil
|
||||
svc.virtualMu.Unlock()
|
||||
if _, err := svc.LatestItems(t.Context(), "", lib.ID, 5); err != nil {
|
||||
t.Fatalf("cached latest: %v", err)
|
||||
}
|
||||
cancelled, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
backdrop, err = svc.ImageURL(cancelled, seriesID, "Backdrop")
|
||||
if err != nil {
|
||||
t.Fatalf("backdrop from rewarmed cache: %v", err)
|
||||
}
|
||||
if backdrop != "/backdrop.jpg" {
|
||||
t.Fatalf("rewarmed backdrop = %q, want cached backdrop", backdrop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyCloudAnimeUsesSeriesNameFromChineseSeasonFolder(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "OpenList · 国漫", Path: `cloud://openlist/国漫`, Type: "anime", Enabled: true}
|
||||
@@ -429,13 +494,13 @@ func TestInferSeriesNameFromPath(t *testing.T) {
|
||||
want: "间谍过家家",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := inferSeriesNameFromPath(tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := inferSeriesNameFromPath(tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySeriesSortByDateLastMediaAdded(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
@@ -513,3 +578,45 @@ func TestEmbySeriesSortByDateLastMediaAdded(t *testing.T) {
|
||||
t.Fatalf("DateLastMediaAdded = %v, want %v", items[0]["DateLastMediaAdded"], tNew)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySeriesLibraryListUsesRuntimeCache(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
svc.cache = NewRuntimeCacheService(nil, nil)
|
||||
lib := model.Library{Name: "番剧", Path: `/media/anime`, Type: "anime", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
for i := 1; i <= 2; i++ {
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: fmt.Sprintf("cache-ep-%d", i)},
|
||||
LibraryID: lib.ID,
|
||||
Title: "缓存测试番",
|
||||
Path: fmt.Sprintf(`/media/anime/缓存测试番/Season 01/缓存测试番.S01E%02d.mkv`, i),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: i,
|
||||
}
|
||||
if err := svc.repo.DB.Create(&media).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
first, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("first items call: %v", err)
|
||||
}
|
||||
if first["TotalRecordCount"] != 1 {
|
||||
t.Fatalf("first series total = %#v, want 1", first["TotalRecordCount"])
|
||||
}
|
||||
if err := svc.repo.DB.Unscoped().Where("library_id = ?", lib.ID).Delete(&model.Media{}).Error; err != nil {
|
||||
t.Fatalf("delete media: %v", err)
|
||||
}
|
||||
|
||||
second, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("second items call: %v", err)
|
||||
}
|
||||
items, _ := second["Items"].([]map[string]any)
|
||||
if second["TotalRecordCount"] != 1 || len(items) != 1 {
|
||||
t.Fatalf("cached series list = %#v, want the first response", second)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,28 +5,28 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
|
||||
imageTags := map[string]string{}
|
||||
backdropTags := []string{}
|
||||
if group.PosterURL != "" {
|
||||
imageTags["Primary"] = group.ID
|
||||
imageTags["Primary"] = embyVirtualImageTag(group.ID, embyVirtualPrimaryTagSuffix)
|
||||
}
|
||||
if group.BackdropURL != "" {
|
||||
backdropTags = append(backdropTags, group.ID+"-bd")
|
||||
if group.BackdropURL != "" || group.PosterURL != "" {
|
||||
backdropTags = append(backdropTags, embyVirtualImageTag(group.ID, embyVirtualBackdropTagSuffix))
|
||||
}
|
||||
lastMediaAdded := group.DateLastMediaAdded
|
||||
if lastMediaAdded.IsZero() {
|
||||
lastMediaAdded = group.CreatedAt
|
||||
}
|
||||
item := map[string]any{
|
||||
"Id": group.ID,
|
||||
"Name": group.Name,
|
||||
"ServerId": embyServerID,
|
||||
"Type": "Series",
|
||||
"MediaType": "Video",
|
||||
"IsFolder": true,
|
||||
"ParentId": group.LibraryID,
|
||||
"ProductionYear": group.Year,
|
||||
"Overview": group.Overview,
|
||||
"CommunityRating": group.Rating,
|
||||
"RecursiveItemCount": len(group.Episodes),
|
||||
"ChildCount": len(e.seasonsForSeries(group)),
|
||||
lastMediaAdded := group.DateLastMediaAdded
|
||||
if lastMediaAdded.IsZero() {
|
||||
lastMediaAdded = group.CreatedAt
|
||||
}
|
||||
item := map[string]any{
|
||||
"Id": group.ID,
|
||||
"Name": group.Name,
|
||||
"ServerId": embyServerID,
|
||||
"Type": "Series",
|
||||
"MediaType": "Video",
|
||||
"IsFolder": true,
|
||||
"ParentId": group.LibraryID,
|
||||
"ProductionYear": group.Year,
|
||||
"Overview": group.Overview,
|
||||
"CommunityRating": group.Rating,
|
||||
"RecursiveItemCount": len(group.Episodes),
|
||||
"ChildCount": len(e.seasonsForSeries(group)),
|
||||
"DateCreated": group.CreatedAt,
|
||||
"DateLastMediaAdded": lastMediaAdded,
|
||||
"ImageTags": imageTags,
|
||||
@@ -39,7 +39,7 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
|
||||
"UserData": emptyUserData(),
|
||||
}
|
||||
if group.PosterURL != "" {
|
||||
item["PrimaryImageTag"] = group.ID
|
||||
item["PrimaryImageTag"] = embyVirtualImageTag(group.ID, embyVirtualPrimaryTagSuffix)
|
||||
}
|
||||
if premiered, ok := embyPremiereDate(group.ReleaseDate); ok {
|
||||
item["PremiereDate"] = premiered
|
||||
@@ -52,10 +52,10 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
|
||||
imageTags := map[string]string{}
|
||||
backdropTags := []string{}
|
||||
if season.Series.PosterURL != "" {
|
||||
imageTags["Primary"] = season.ID
|
||||
imageTags["Primary"] = embyVirtualImageTag(season.ID, embyVirtualPrimaryTagSuffix)
|
||||
}
|
||||
if season.Series.BackdropURL != "" {
|
||||
backdropTags = append(backdropTags, season.ID+"-bd")
|
||||
if season.Series.BackdropURL != "" || season.Series.PosterURL != "" {
|
||||
backdropTags = append(backdropTags, embyVirtualImageTag(season.ID, embyVirtualBackdropTagSuffix))
|
||||
}
|
||||
item := map[string]any{
|
||||
"Id": season.ID,
|
||||
@@ -75,12 +75,12 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
|
||||
"UserData": emptyUserData(),
|
||||
}
|
||||
if season.Series.PosterURL != "" {
|
||||
item["PrimaryImageTag"] = season.ID
|
||||
item["SeriesPrimaryImageTag"] = season.Series.ID
|
||||
item["PrimaryImageTag"] = embyVirtualImageTag(season.ID, embyVirtualPrimaryTagSuffix)
|
||||
item["SeriesPrimaryImageTag"] = embyVirtualImageTag(season.Series.ID, embyVirtualPrimaryTagSuffix)
|
||||
}
|
||||
if season.Series.BackdropURL != "" {
|
||||
if season.Series.BackdropURL != "" || season.Series.PosterURL != "" {
|
||||
item["ParentBackdropItemId"] = season.Series.ID
|
||||
item["ParentBackdropImageTags"] = []string{season.Series.ID + "-bd"}
|
||||
item["ParentBackdropImageTags"] = []string{embyVirtualImageTag(season.Series.ID, embyVirtualBackdropTagSuffix)}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -35,6 +36,12 @@ type ImageProxy struct {
|
||||
cacheDir string
|
||||
mu sync.Mutex
|
||||
|
||||
// resizeSem bounds concurrent decode/resize jobs. Emby TV clients request
|
||||
// poster grids in bursts; letting every request decode a source image at
|
||||
// once causes CPU and memory spikes that make the whole UI feel sluggish.
|
||||
resizeSemMu sync.Mutex
|
||||
resizeSem chan struct{}
|
||||
|
||||
// libraryRootsFn returns the configured media library roots so that
|
||||
// sidecar poster/artwork files stored alongside media (under arbitrary
|
||||
// per-library paths) are allowed by isAllowedLocalPath. It is provided
|
||||
@@ -55,6 +62,7 @@ type ImageProxy struct {
|
||||
const (
|
||||
imageBrowserCacheControl = "public, max-age=2592000, immutable"
|
||||
imagePlaceholderCacheControl = "no-store"
|
||||
imageMaxResizeConcurrency = 4
|
||||
)
|
||||
|
||||
// NewImageProxy is the constructor.
|
||||
@@ -64,6 +72,7 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
log: log,
|
||||
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
|
||||
}
|
||||
proxy.resizeSem = make(chan struct{}, imageResizeConcurrency())
|
||||
|
||||
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
|
||||
// from image.tmdb.org via their HTTP proxy without extra config. On
|
||||
@@ -181,6 +190,20 @@ func (p *ImageProxy) isAllowedRemoteHost(host string) bool {
|
||||
return p.allowedHostsCache[host]
|
||||
}
|
||||
|
||||
// imageResizeConcurrency keeps decode/resize concurrency within the number
|
||||
// of CPU threads the process is allowed to use, capped to avoid large
|
||||
// temporary RGBA buffers on tiny hosts.
|
||||
func imageResizeConcurrency() int {
|
||||
n := runtime.GOMAXPROCS(0)
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if n > imageMaxResizeConcurrency {
|
||||
n = imageMaxResizeConcurrency
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Prune removes oldest cached images until disk usage is within the configured limit.
|
||||
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
|
||||
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -134,12 +135,38 @@ func (p *ImageProxy) serveCachedImage(w http.ResponseWriter, r *http.Request, ke
|
||||
}
|
||||
|
||||
func (p *ImageProxy) removeUnusableImageCache(cachePath, failPath string) {
|
||||
data, err := os.ReadFile(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir.
|
||||
// 只读取文件头判断缓存是否可用。旧实现每次命中远程图片缓存都会把整个
|
||||
// 原图读进内存再丢弃,电视端批量加载海报时会产生大量无意义的磁盘 I/O。
|
||||
file, err := os.Open(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir.
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctype := detectContentType(data)
|
||||
if len(data) > 0 && isImageContentType(ctype) && !isTransparentPlaceholderData(data) {
|
||||
stat, err := file.Stat()
|
||||
if err != nil || stat.IsDir() || stat.Size() <= 0 {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(cachePath)
|
||||
_ = os.Remove(failPath)
|
||||
return
|
||||
}
|
||||
headerSize := 512
|
||||
if stat.Size() < int64(headerSize) {
|
||||
headerSize = int(stat.Size())
|
||||
}
|
||||
header := make([]byte, headerSize)
|
||||
n, readErr := io.ReadFull(file, header)
|
||||
_ = file.Close()
|
||||
if readErr != nil && readErr != io.ErrUnexpectedEOF {
|
||||
_ = os.Remove(cachePath)
|
||||
_ = os.Remove(failPath)
|
||||
return
|
||||
}
|
||||
header = header[:n]
|
||||
// A transparent placeholder is exactly 67 bytes; checking the header alone
|
||||
// is enough for the normal image cache entries (they are much larger but
|
||||
// detectContentType only inspects the same leading 512 bytes anyway).
|
||||
// Close the handle before deleting: Windows refuses to delete an open file.
|
||||
if n > 0 && isImageContentType(detectContentType(header)) &&
|
||||
!(n == len(transparent1x1PNG) && bytes.Equal(header, transparent1x1PNG)) {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(cachePath)
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -203,6 +204,28 @@ func (p *ImageProxy) resizeCachePath(key string) string {
|
||||
return filepath.Join(p.cacheDir, imageResizeCacheSubdir, key+".img")
|
||||
}
|
||||
|
||||
// acquireResizeSlot bounds CPU-heavy decode/resize work. Returning false means
|
||||
// the caller should fall back to the original image instead of blocking after
|
||||
// the request has already been canceled.
|
||||
func (p *ImageProxy) acquireResizeSlot(ctx context.Context) (func(), bool) {
|
||||
if p == nil {
|
||||
return func() {}, true
|
||||
}
|
||||
p.resizeSemMu.Lock()
|
||||
if p.resizeSem == nil {
|
||||
p.resizeSem = make(chan struct{}, imageResizeConcurrency())
|
||||
}
|
||||
sem := p.resizeSem
|
||||
p.resizeSemMu.Unlock()
|
||||
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
return func() { <-sem }, true
|
||||
case <-ctx.Done():
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存
|
||||
// 到磁盘以免每次请求都重新解码。原图已满足目标尺寸时直接输出原文件。
|
||||
// 返回 false 表示缩放不可用,调用方应回退到原图直出。
|
||||
@@ -211,6 +234,25 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
|
||||
if err != nil || stat.IsDir() || stat.Size() <= 0 {
|
||||
return false
|
||||
}
|
||||
// 缓存命中必须发生在读原图和解码之前。否则电视端每次刷新海报墙都会
|
||||
// 把已经是缩略图缓存的原图重新解码、缩放一遍,造成明显的 CPU 抖动。
|
||||
key := o.resizeCacheKey(srcPath, stat)
|
||||
cachePath := p.resizeCachePath(key)
|
||||
if serveCachedImageFile(w, r, key, cachePath) {
|
||||
return true
|
||||
}
|
||||
|
||||
release, ok := p.acquireResizeSlot(r.Context())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
defer release()
|
||||
|
||||
// 等待并发槽期间,别的请求可能已经生成了同一张缩略图。
|
||||
if serveCachedImageFile(w, r, key, cachePath) {
|
||||
return true
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(srcPath) // #nosec G304 -- srcPath comes from an allowed local path or a SHA-derived cache path.
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -224,11 +266,6 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
|
||||
return serveImageFile(w, r, filepath.Base(srcPath), srcPath, imageBrowserCacheControl)
|
||||
}
|
||||
|
||||
key := o.resizeCacheKey(srcPath, stat)
|
||||
cachePath := p.resizeCachePath(key)
|
||||
if serveCachedImageFile(w, r, key, cachePath) {
|
||||
return true
|
||||
}
|
||||
p.writeResizeCache(cachePath, out)
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Cache-Control", imageBrowserCacheControl)
|
||||
|
||||
@@ -230,3 +230,47 @@ func TestServeResizedFromFileCachesScaledResult(t *testing.T) {
|
||||
t.Fatal("expected a non-empty body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileUsesCacheBeforeDecodingSource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mediaDir := dir + string(os.PathSeparator) + "media"
|
||||
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
src := mediaDir + string(os.PathSeparator) + "poster.png"
|
||||
original := encodeTestPNG(t, 529, 911, 255)
|
||||
if err := os.WriteFile(src, original, 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||
opts := imageResizeOptions{MaxWidth: 400, Quality: 90}
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(first, httptest.NewRequest("GET", "/x?maxWidth=400", nil), src, opts) {
|
||||
t.Fatal("expected first call to be served")
|
||||
}
|
||||
|
||||
stat, err := os.Stat(src)
|
||||
if err != nil {
|
||||
t.Fatalf("stat source: %v", err)
|
||||
}
|
||||
// Same size and mtime keep the resize cache key stable, but the source is
|
||||
// now invalid image data. A correct implementation serves the cached
|
||||
// thumbnail before reading/decoding the source again.
|
||||
broken := bytes.Repeat([]byte{0}, len(original))
|
||||
if err := os.WriteFile(src, broken, 0o644); err != nil {
|
||||
t.Fatalf("overwrite source: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(src, stat.ModTime(), stat.ModTime()); err != nil {
|
||||
t.Fatalf("restore mtime: %v", err)
|
||||
}
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(second, httptest.NewRequest("GET", "/x?maxWidth=400", nil), src, opts) {
|
||||
t.Fatal("expected second call to be served from resize cache")
|
||||
}
|
||||
if !bytes.Equal(first.Body.Bytes(), second.Body.Bytes()) {
|
||||
t.Fatal("expected cached thumbnail to be reused without decoding the source")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestReadAdultLocalMetadataAndArtwork(t *testing.T) {
|
||||
if got.PosterURL != poster || got.BackdropURL != fanart {
|
||||
t.Fatalf("artwork poster=%q fanart=%q", got.PosterURL, got.BackdropURL)
|
||||
}
|
||||
if got.Genres != "剧情,中文字幕,测试片商,演员A" {
|
||||
if got.Genres != "剧情,中文字幕,测试片商" {
|
||||
t.Fatalf("genres = %q", got.Genres)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func adultAwareGenres(doc *nfoDocument) []string {
|
||||
if doc == nil {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(doc.Genres)+len(doc.Tags)+len(doc.Actors)+4)
|
||||
values := make([]string, 0, len(doc.Genres)+len(doc.Tags)+4)
|
||||
values = append(values, doc.Genres...)
|
||||
values = append(values, doc.Tags...)
|
||||
for _, value := range []string{doc.Studio, doc.Maker, doc.Publisher, doc.Label} {
|
||||
@@ -69,18 +69,8 @@ func adultAwareGenres(doc *nfoDocument) []string {
|
||||
values = append(values, cleanXMLText(value))
|
||||
}
|
||||
}
|
||||
for _, value := range doc.Directors {
|
||||
if cleanXMLText(value) != "" {
|
||||
values = append(values, cleanXMLText(value))
|
||||
}
|
||||
}
|
||||
for _, actor := range doc.Actors {
|
||||
if cleanXMLText(actor.Name) != "" {
|
||||
values = append(values, cleanXMLText(actor.Name))
|
||||
} else if cleanXMLText(actor.Role) != "" {
|
||||
values = append(values, cleanXMLText(actor.Role))
|
||||
}
|
||||
}
|
||||
// Cast and crew are intentionally not folded into genres. They are read
|
||||
// from NFO/TMDb on detail requests and kept only in the in-memory cache.
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,18 @@ func mediaReleaseOrderSQL(desc bool) string {
|
||||
return fmt.Sprintf("media.release_date %s, media.year %s, media.created_at %s, media.id %s", dir, dir, dir, dir)
|
||||
}
|
||||
|
||||
// embyMediaReleaseSortTime matches the original payload-based ordering used by
|
||||
// the Emby movie-library merge: release date, then year, then created_at.
|
||||
func embyMediaReleaseSortTime(media model.Media) time.Time {
|
||||
if t, ok := embyPremiereDate(media.ReleaseDate); ok {
|
||||
return t
|
||||
}
|
||||
if media.Year > 0 {
|
||||
return time.Date(media.Year, time.December, 31, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
return media.CreatedAt
|
||||
}
|
||||
|
||||
func embyPremiereDate(value string) (time.Time, bool) {
|
||||
value = normalizeReleaseDate(value)
|
||||
if value == "" {
|
||||
|
||||
@@ -327,6 +327,22 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
|
||||
return cards
|
||||
}
|
||||
|
||||
// GroupMediaSeriesItems folds episode-level rows into one representative media
|
||||
// row per series. It is intended for search surfaces where applying a small
|
||||
// limit before series grouping would otherwise return several episodes from
|
||||
// the same show.
|
||||
func GroupMediaSeriesItems(items []model.Media) []model.Media {
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) == 0 {
|
||||
return []model.Media{}
|
||||
}
|
||||
out := make([]model.Media, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
out = append(out, card.Rep)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func betterSeriesRepresentative(candidate, current model.Media) bool {
|
||||
// A theatrical feature can have local poster.jpg/background.jpg files and
|
||||
// therefore a higher artwork score than its TV episodes. Keep the TV row as
|
||||
|
||||
@@ -276,6 +276,64 @@ func (p *MetaTubeProvider) applyAuthHeader(req *http.Request, token string) {
|
||||
req.Header.Set("User-Agent", "MeBox/1.0 (MetaTube Client)")
|
||||
}
|
||||
|
||||
func metaTubePeople(movie *MetaTubeMovieInfo, enableActor bool) []map[string]any {
|
||||
if movie == nil {
|
||||
return nil
|
||||
}
|
||||
people := make([]map[string]any, 0, len(movie.Actors)+len(movie.Directors)+1)
|
||||
add := func(name, personType string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
personID := embyPersonID(name, personType)
|
||||
for _, existing := range people {
|
||||
if existing["Id"] == personID {
|
||||
return
|
||||
}
|
||||
}
|
||||
people = append(people, map[string]any{
|
||||
"Id": personID,
|
||||
"Name": name,
|
||||
"Type": personType,
|
||||
"Role": personType,
|
||||
})
|
||||
}
|
||||
if enableActor {
|
||||
for _, actor := range movie.Actors {
|
||||
add(actor, "Actor")
|
||||
}
|
||||
}
|
||||
add(movie.Director, "Director")
|
||||
for _, director := range movie.Directors {
|
||||
add(director, "Director")
|
||||
}
|
||||
return people
|
||||
}
|
||||
|
||||
func metaTubePeopleFromActors(actors []string) []map[string]any {
|
||||
people := make([]map[string]any, 0, len(actors))
|
||||
seen := map[string]bool{}
|
||||
for _, actor := range actors {
|
||||
actor = strings.TrimSpace(actor)
|
||||
if actor == "" {
|
||||
continue
|
||||
}
|
||||
personID := embyPersonID(actor, "Actor")
|
||||
if seen[personID] {
|
||||
continue
|
||||
}
|
||||
seen[personID] = true
|
||||
people = append(people, map[string]any{
|
||||
"Id": personID,
|
||||
"Name": actor,
|
||||
"Type": "Actor",
|
||||
"Role": "Actor",
|
||||
})
|
||||
}
|
||||
return people
|
||||
}
|
||||
|
||||
func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query string, res *MetaTubeSearchResult) *Match {
|
||||
if res == nil {
|
||||
return nil
|
||||
@@ -293,13 +351,6 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query
|
||||
|
||||
year := parseYearFromDate(res.ReleaseDate)
|
||||
|
||||
genres := make([]string, 0, len(res.Actors))
|
||||
for _, a := range res.Actors {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
genres = append(genres, strings.TrimSpace(a))
|
||||
}
|
||||
}
|
||||
|
||||
posterSource := firstNonEmpty(res.BigCoverURL, res.CoverURL, res.BigThumbURL, res.ThumbURL)
|
||||
posterURL, backdropURL := metaTubeArtworkURLs(cfg, res.Provider, res.ID, posterSource)
|
||||
if posterURL == "" {
|
||||
@@ -319,8 +370,8 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query
|
||||
Year: year,
|
||||
ReleaseDate: cleanDateString(res.ReleaseDate),
|
||||
Rating: res.Score,
|
||||
Genres: genres,
|
||||
NSFW: true,
|
||||
People: metaTubePeopleFromActors(res.Actors),
|
||||
DoubanID: res.ID, // 借用字段存储原始 ID 便于详情反查
|
||||
TheTVDBID: res.Provider, // 借用字段存储 Provider
|
||||
}
|
||||
@@ -357,7 +408,7 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me
|
||||
}
|
||||
}
|
||||
|
||||
genres := make([]string, 0, len(movie.Genres)+len(movie.Actors)+4)
|
||||
genres := make([]string, 0, len(movie.Genres)+2)
|
||||
for _, g := range movie.Genres {
|
||||
if strings.TrimSpace(g) != "" {
|
||||
genres = append(genres, strings.TrimSpace(g))
|
||||
@@ -369,19 +420,6 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me
|
||||
if strings.TrimSpace(movie.Label) != "" && movie.Label != movie.Maker {
|
||||
genres = append(genres, strings.TrimSpace(movie.Label))
|
||||
}
|
||||
for _, a := range movie.Actors {
|
||||
if strings.TrimSpace(a) != "" {
|
||||
genres = append(genres, strings.TrimSpace(a))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(movie.Director) != "" {
|
||||
genres = append(genres, strings.TrimSpace(movie.Director))
|
||||
}
|
||||
for _, d := range movie.Directors {
|
||||
if strings.TrimSpace(d) != "" {
|
||||
genres = append(genres, strings.TrimSpace(d))
|
||||
}
|
||||
}
|
||||
|
||||
return &Match{
|
||||
Provider: "metatube",
|
||||
@@ -396,6 +434,7 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me
|
||||
Rating: movie.Score,
|
||||
Genres: dedupeStrings(genres),
|
||||
NSFW: true,
|
||||
People: metaTubePeople(movie, cfg.EnableActor),
|
||||
DoubanID: movie.ID,
|
||||
TheTVDBID: movie.Provider,
|
||||
}
|
||||
|
||||
@@ -74,8 +74,11 @@ func TestMetaTubeProviderSearch(t *testing.T) {
|
||||
if !m.NSFW {
|
||||
t.Errorf("expected NSFW true")
|
||||
}
|
||||
if len(m.Genres) != 1 || m.Genres[0] != "相沢みなみ" {
|
||||
t.Errorf("unexpected genres: %v", m.Genres)
|
||||
if len(m.Genres) != 0 {
|
||||
t.Errorf("actor names must not be persisted as genres: %v", m.Genres)
|
||||
}
|
||||
if len(m.People) != 1 || m.People[0]["Name"] != "相沢みなみ" || m.People[0]["Type"] != "Actor" {
|
||||
t.Errorf("unexpected people: %#v", m.People)
|
||||
}
|
||||
if want := server.URL + "/v1/images/primary/javdb/123456?auto=false&pos=-1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fcover.jpg"; m.PosterURL != want {
|
||||
t.Errorf("poster URL = %q, want %q", m.PosterURL, want)
|
||||
|
||||
@@ -106,7 +106,7 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.FileManager = NewFileManagerService(b.cfg, b.log, b.repos)
|
||||
b.c.DLNA = NewDLNAService(b.log)
|
||||
b.c.Storage = NewStorageService(b.log, b.repos)
|
||||
b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos)
|
||||
b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos).SetTMDbProvider(b.c.TMDb).SetAdultProvider(b.c.Scraper.adult)
|
||||
b.c.EmbyRemote = NewEmbyRemoteService(b.cfg, b.log, b.repos, b.c.Crypto).SetRuntimeCache(b.c.Cache)
|
||||
b.c.Emby.SetEmbyRemote(b.c.EmbyRemote)
|
||||
b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB)
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
tmdbPersonIDPrefix = "person~tmdb~"
|
||||
tmdbProfileTagPrefix = "tmdb:"
|
||||
tmdbProfileTagVersion = "p2"
|
||||
tmdbPeopleMax = 80
|
||||
)
|
||||
|
||||
var tmdbProfilePathRE = regexp.MustCompile(`^/[A-Za-z0-9._-]+$`)
|
||||
|
||||
type tmdbCreditCast struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Character string `json:"character"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
Order int `json:"order"`
|
||||
Roles []struct {
|
||||
Character string `json:"character"`
|
||||
} `json:"roles"`
|
||||
}
|
||||
|
||||
type tmdbCreditCrew struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Job string `json:"job"`
|
||||
Department string `json:"department"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
Jobs []struct {
|
||||
Job string `json:"job"`
|
||||
} `json:"jobs"`
|
||||
}
|
||||
|
||||
type tmdbCreditsResponse struct {
|
||||
Cast []tmdbCreditCast `json:"cast"`
|
||||
Crew []tmdbCreditCrew `json:"crew"`
|
||||
}
|
||||
|
||||
// GetPeople fetches cast/crew for a movie or series from TMDb. Nothing is
|
||||
// persisted; callers should keep the returned entries in a short-lived cache.
|
||||
func (t *TMDbProvider) GetPeople(ctx context.Context, tmdbID int, mediaType string) ([]map[string]any, error) {
|
||||
if t == nil || t.cfg == nil || tmdbID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
apiKey := t.resolveAPIKey(ctx)
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
|
||||
endpoint := "/movie/" + strconv.Itoa(tmdbID) + "/credits"
|
||||
if mediaType == "tv" {
|
||||
endpoint = "/tv/" + strconv.Itoa(tmdbID) + "/aggregate_credits"
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("api_key", apiKey)
|
||||
q.Set("language", "zh-CN")
|
||||
var out tmdbCreditsResponse
|
||||
if err := t.getJSON(ctx, t.resolveBaseURL(ctx)+endpoint+"?"+q.Encode(), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
people := make([]map[string]any, 0, 32)
|
||||
seen := map[string]int{}
|
||||
for _, cast := range out.Cast {
|
||||
name := strings.TrimSpace(cast.Name)
|
||||
if name == "" || cast.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
role := strings.TrimSpace(cast.Character)
|
||||
if role == "" {
|
||||
for _, item := range cast.Roles {
|
||||
if role = strings.TrimSpace(item.Character); role != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if role == "" {
|
||||
role = "Actor"
|
||||
}
|
||||
person := newTMDbPerson(cast.ID, name, "Actor", role, cast.ProfilePath)
|
||||
key := personIdentity(person)
|
||||
if idx, ok := seen[key]; ok {
|
||||
mergePersonRole(people[idx], role)
|
||||
continue
|
||||
}
|
||||
seen[key] = len(people)
|
||||
people = append(people, person)
|
||||
if len(people) >= tmdbPeopleMax {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, crew := range out.Crew {
|
||||
name := strings.TrimSpace(crew.Name)
|
||||
if name == "" || crew.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
jobs := make([]string, 0, 1+len(crew.Jobs))
|
||||
if strings.TrimSpace(crew.Job) != "" {
|
||||
jobs = append(jobs, strings.TrimSpace(crew.Job))
|
||||
}
|
||||
for _, item := range crew.Jobs {
|
||||
if job := strings.TrimSpace(item.Job); job != "" {
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
}
|
||||
jobs = deduplicateNonEmpty(jobs)
|
||||
added := false
|
||||
for _, job := range jobs {
|
||||
personType := embyPersonTypeForJob(job)
|
||||
if personType == "" {
|
||||
continue
|
||||
}
|
||||
person := newTMDbPerson(crew.ID, name, personType, job, crew.ProfilePath)
|
||||
key := personIdentity(person)
|
||||
if idx, ok := seen[key]; ok {
|
||||
mergePersonRole(people[idx], job)
|
||||
} else if len(people) < tmdbPeopleMax {
|
||||
seen[key] = len(people)
|
||||
people = append(people, person)
|
||||
}
|
||||
added = true
|
||||
}
|
||||
if !added && len(people) >= tmdbPeopleMax {
|
||||
break
|
||||
}
|
||||
}
|
||||
return people, nil
|
||||
}
|
||||
|
||||
func newTMDbPerson(personID int, name, personType, role, profilePath string) map[string]any {
|
||||
person := map[string]any{
|
||||
"Id": tmdbPersonID(personID),
|
||||
"Name": name,
|
||||
"Type": personType,
|
||||
"Role": role,
|
||||
}
|
||||
if tag := tmdbProfileTag(profilePath); tag != "" {
|
||||
person["PrimaryImageTag"] = tag
|
||||
}
|
||||
return person
|
||||
}
|
||||
|
||||
func personIdentity(person map[string]any) string {
|
||||
return fmt.Sprint(person["Id"]) + ":" + fmt.Sprint(person["Type"])
|
||||
}
|
||||
|
||||
func mergePersonRole(person map[string]any, role string) {
|
||||
role = strings.TrimSpace(role)
|
||||
if role == "" {
|
||||
return
|
||||
}
|
||||
existing := strings.TrimSpace(fmt.Sprint(person["Role"]))
|
||||
if existing == "" {
|
||||
person["Role"] = role
|
||||
return
|
||||
}
|
||||
for _, part := range strings.Split(existing, " / ") {
|
||||
if strings.EqualFold(strings.TrimSpace(part), role) {
|
||||
return
|
||||
}
|
||||
}
|
||||
person["Role"] = existing + " / " + role
|
||||
}
|
||||
|
||||
func deduplicateNonEmpty(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
key := strings.ToLower(value)
|
||||
if value == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func embyPersonTypeForJob(job string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(job)) {
|
||||
case "director":
|
||||
return "Director"
|
||||
case "writer", "screenplay", "story", "teleplay", "author":
|
||||
return "Writer"
|
||||
case "producer", "executive producer", "co-producer", "associate producer":
|
||||
return "Producer"
|
||||
case "composer", "original music composer", "music":
|
||||
return "Composer"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func tmdbPersonID(personID int) string {
|
||||
return tmdbPersonIDPrefix + strconv.Itoa(personID)
|
||||
}
|
||||
|
||||
func parseTMDbPersonID(id string) (int, bool) {
|
||||
id = strings.TrimSpace(id)
|
||||
if !strings.HasPrefix(id, tmdbPersonIDPrefix) {
|
||||
return 0, false
|
||||
}
|
||||
raw := strings.TrimPrefix(id, tmdbPersonIDPrefix)
|
||||
personID, err := strconv.Atoi(raw)
|
||||
return personID, err == nil && personID > 0
|
||||
}
|
||||
|
||||
func tmdbProfileTag(profilePath string) string {
|
||||
profilePath = strings.TrimSpace(profilePath)
|
||||
if !tmdbProfilePathRE.MatchString(profilePath) {
|
||||
return ""
|
||||
}
|
||||
return tmdbProfileTagPrefix + profilePath + "?" + tmdbProfileTagVersion
|
||||
}
|
||||
|
||||
func tmdbProfilePathFromTag(tag string) string {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if !strings.HasPrefix(tag, tmdbProfileTagPrefix) {
|
||||
return ""
|
||||
}
|
||||
profilePath := strings.TrimPrefix(tag, tmdbProfileTagPrefix)
|
||||
if idx := strings.IndexByte(profilePath, '?'); idx >= 0 {
|
||||
profilePath = profilePath[:idx]
|
||||
}
|
||||
if !tmdbProfilePathRE.MatchString(profilePath) {
|
||||
return ""
|
||||
}
|
||||
return profilePath
|
||||
}
|
||||
|
||||
// ProfileImageURL builds the public TMDb image CDN URL for a profile path.
|
||||
func (t *TMDbProvider) ProfileImageURL(profilePath string) string {
|
||||
if t == nil || !tmdbProfilePathRE.MatchString(strings.TrimSpace(profilePath)) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(t.imgCDN, "/") + "/w300" + strings.TrimSpace(profilePath)
|
||||
}
|
||||
|
||||
// PersonProfilePathByID resolves a person's current profile path directly
|
||||
// from TMDb. Used when the client does not return the PrimaryImageTag.
|
||||
func (t *TMDbProvider) PersonProfilePathByID(ctx context.Context, personID int) (string, error) {
|
||||
if t == nil || t.cfg == nil || personID <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
apiKey := t.resolveAPIKey(ctx)
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
return "", nil
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("api_key", apiKey)
|
||||
q.Set("language", "zh-CN")
|
||||
endpoint := t.resolveBaseURL(ctx) + "/person/" + strconv.Itoa(personID) + "?" + q.Encode()
|
||||
var out struct {
|
||||
ProfilePath string `json:"profile_path"`
|
||||
}
|
||||
if err := t.getJSON(ctx, endpoint, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(out.ProfilePath), nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
func TestTMDbGetPeopleAndProfileImage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/movie/11/credits" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"cast": []map[string]any{
|
||||
{"id": 101, "name": "演员甲", "character": "主角", "profile_path": "/actor.jpg", "order": 0},
|
||||
},
|
||||
"crew": []map[string]any{
|
||||
{"id": 201, "name": "导演甲", "job": "Director", "department": "Directing", "profile_path": "/director.jpg"},
|
||||
{"id": 202, "name": "编剧甲", "job": "Writer", "department": "Writing", "profile_path": "/writer.jpg"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &config.Config{Secrets: config.SecretsConfig{
|
||||
TMDbAPIKey: "test-key",
|
||||
TMDbAPIProxy: server.URL,
|
||||
TMDbImageProxy: "https://image.example/t/p",
|
||||
}}
|
||||
provider := NewTMDbProvider(cfg, zap.NewNop(), nil)
|
||||
people, err := provider.GetPeople(context.Background(), 11, "movie")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(people) != 3 {
|
||||
t.Fatalf("people=%d want 3: %#v", len(people), people)
|
||||
}
|
||||
if people[0]["Id"] != "person~tmdb~101" || people[0]["Role"] != "主角" {
|
||||
t.Fatalf("unexpected actor: %#v", people[0])
|
||||
}
|
||||
if people[0]["PrimaryImageTag"] != "tmdb:/actor.jpg?p2" {
|
||||
t.Fatalf("unexpected actor image tag: %#v", people[0])
|
||||
}
|
||||
if got := provider.ProfileImageURL("/actor.jpg"); got != "https://image.example/t/p/w300/actor.jpg" {
|
||||
t.Fatalf("profile image URL=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTMDbGetPeopleUsesAggregateCreditsForTV(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/tv/22/aggregate_credits" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"cast": []map[string]any{
|
||||
{"id": 301, "name": "演员乙", "roles": []map[string]any{{"character": "角色乙"}}, "profile_path": "/tv-actor.jpg"},
|
||||
},
|
||||
"crew": []map[string]any{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &config.Config{Secrets: config.SecretsConfig{TMDbAPIKey: "test-key", TMDbAPIProxy: server.URL}}
|
||||
provider := NewTMDbProvider(cfg, zap.NewNop(), nil)
|
||||
people, err := provider.GetPeople(context.Background(), 22, "tv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(people) != 1 || people[0]["Role"] != "角色乙" {
|
||||
t.Fatalf("unexpected TV people: %#v", people)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type Match struct {
|
||||
Countries []string `json:"countries,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
People []map[string]any `json:"-"`
|
||||
NSFW bool `json:"nsfw,omitempty"`
|
||||
SearchKeyword string `json:"-"`
|
||||
}
|
||||
|
||||
+12
-3
@@ -193,10 +193,18 @@ export const mediaAPI = {
|
||||
recent: (limit = 24) =>
|
||||
api.get<SeriesCard[]>('/media/recent', { params: { limit } }).then((r) => r.data),
|
||||
|
||||
search: (q: string, limit = 50) =>
|
||||
api.get<MediaSearchPage>('/media', { params: { q, limit } }).then((r) => r.data),
|
||||
search: (q: string, limit = 50, options?: { groupSeries?: boolean }) =>
|
||||
api
|
||||
.get<MediaSearchPage>('/media', {
|
||||
params: {
|
||||
q,
|
||||
limit,
|
||||
...(options?.groupSeries ? { group_series: 1 } : {}),
|
||||
},
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean }) =>
|
||||
searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean; groupSeries?: boolean }) =>
|
||||
api
|
||||
.get<MediaSearchPage>('/media', {
|
||||
params: {
|
||||
@@ -204,6 +212,7 @@ export const mediaAPI = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
group_versions: options?.groupVersions === false ? 0 : undefined,
|
||||
...(options?.groupSeries ? { group_series: 1 } : {}),
|
||||
},
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
@@ -108,43 +108,99 @@ export function LayoutHeader({
|
||||
)
|
||||
}
|
||||
|
||||
const SEARCH_PAGE_SIZE = 8
|
||||
|
||||
function LayoutHeaderSearch() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [results, setResults] = useState<Media[]>([])
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const isOpenRef = useRef(false)
|
||||
const suppressInputRef = useRef(false)
|
||||
// 递增序号守卫:快速连续输入时丢弃过期响应
|
||||
const searchSeqRef = useRef(0)
|
||||
const pageRef = useRef(0)
|
||||
const loadingMoreRef = useRef(false)
|
||||
const resultsRef = useRef<Media[]>([])
|
||||
const hasMoreRef = useRef(false)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const locationKey = `${location.pathname}${location.search}`
|
||||
const prevLocationKeyRef = useRef(locationKey)
|
||||
|
||||
const setSearchOpen = (open: boolean) => {
|
||||
isOpenRef.current = open
|
||||
setIsOpen(open)
|
||||
}
|
||||
|
||||
const dismissSearch = (clearQuery: boolean) => {
|
||||
suppressInputRef.current = true
|
||||
setSearchOpen(false)
|
||||
if (clearQuery) {
|
||||
searchSeqRef.current += 1
|
||||
pageRef.current = 0
|
||||
loadingMoreRef.current = false
|
||||
resultsRef.current = []
|
||||
hasMoreRef.current = false
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
setHasMore(false)
|
||||
}
|
||||
inputRef.current?.blur()
|
||||
window.setTimeout(() => {
|
||||
suppressInputRef.current = false
|
||||
}, 0)
|
||||
}
|
||||
const dismissSearchRef = useRef(dismissSearch)
|
||||
dismissSearchRef.current = dismissSearch
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
searchSeqRef.current += 1
|
||||
pageRef.current = 0
|
||||
loadingMoreRef.current = false
|
||||
resultsRef.current = []
|
||||
hasMoreRef.current = false
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
setHasMore(false)
|
||||
isOpenRef.current = false
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++searchSeqRef.current
|
||||
pageRef.current = 0
|
||||
loadingMoreRef.current = false
|
||||
resultsRef.current = []
|
||||
hasMoreRef.current = false
|
||||
setResults([])
|
||||
setHasMore(false)
|
||||
setLoading(true)
|
||||
setLoadingMore(false)
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await mediaAPI.search(trimmed, 8)
|
||||
const res = await mediaAPI.searchPage(trimmed, 1, SEARCH_PAGE_SIZE, { groupSeries: true })
|
||||
if (seq !== searchSeqRef.current) return
|
||||
setResults(res.items || [])
|
||||
if (isOpenRef.current) setIsOpen(true)
|
||||
const items = res.items || []
|
||||
const total = res.total ?? items.length
|
||||
const more = items.length < total
|
||||
pageRef.current = 1
|
||||
resultsRef.current = items
|
||||
hasMoreRef.current = more
|
||||
setResults(items)
|
||||
setHasMore(more)
|
||||
if (isOpenRef.current && !suppressInputRef.current) setIsOpen(true)
|
||||
} catch {
|
||||
// 请求失败时保留旧结果,避免网络抖动清空下拉
|
||||
// 请求失败时保持空结果,用户继续输入或滚动时会重新请求
|
||||
} finally {
|
||||
if (seq === searchSeqRef.current) setLoading(false)
|
||||
}
|
||||
@@ -153,26 +209,67 @@ function LayoutHeaderSearch() {
|
||||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (prevLocationKeyRef.current === locationKey) return
|
||||
prevLocationKeyRef.current = locationKey
|
||||
dismissSearchRef.current(true)
|
||||
}, [locationKey])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
isOpenRef.current = false
|
||||
setIsOpen(false)
|
||||
}
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (!isOpenRef.current) return
|
||||
const root = containerRef.current
|
||||
if (root && e.composedPath().includes(root)) return
|
||||
dismissSearchRef.current(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('pointerdown', handlePointerDown, true)
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown, true)
|
||||
}, [])
|
||||
|
||||
const loadMore = async () => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed || loading || loadingMoreRef.current || !hasMoreRef.current) return
|
||||
|
||||
const seq = searchSeqRef.current
|
||||
const nextPage = pageRef.current + 1
|
||||
loadingMoreRef.current = true
|
||||
setLoadingMore(true)
|
||||
|
||||
try {
|
||||
const res = await mediaAPI.searchPage(trimmed, nextPage, SEARCH_PAGE_SIZE, { groupSeries: true })
|
||||
if (seq !== searchSeqRef.current) return
|
||||
|
||||
const incoming = res.items || []
|
||||
const currentResults = resultsRef.current
|
||||
const known = new Set(currentResults.map((item) => item.id))
|
||||
const nextResults = [...currentResults, ...incoming.filter((item) => !known.has(item.id))]
|
||||
const total = res.total ?? nextResults.length
|
||||
const more = nextPage * SEARCH_PAGE_SIZE < total
|
||||
pageRef.current = nextPage
|
||||
resultsRef.current = nextResults
|
||||
hasMoreRef.current = more
|
||||
setResults(nextResults)
|
||||
setHasMore(more)
|
||||
} catch {
|
||||
// 请求失败时保留当前结果,继续滚动可重试
|
||||
} finally {
|
||||
if (seq === searchSeqRef.current) {
|
||||
loadingMoreRef.current = false
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (item: Media) => {
|
||||
setSearchOpen(false)
|
||||
setQuery('')
|
||||
navigate(favouriteMediaLink(item))
|
||||
if (suppressInputRef.current) return
|
||||
const to = favouriteMediaLink(item)
|
||||
dismissSearch(true)
|
||||
navigate(to)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearchOpen(false)
|
||||
dismissSearch(false)
|
||||
} else if (e.key === 'Enter' && results.length > 0) {
|
||||
handleSelect(results[0])
|
||||
}
|
||||
@@ -186,14 +283,21 @@ function LayoutHeaderSearch() {
|
||||
className="absolute left-3 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500 sm:left-3.5 sm:text-[16px]"
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
if (suppressInputRef.current) return
|
||||
const nextQuery = e.target.value
|
||||
setQuery(nextQuery)
|
||||
setSearchOpen(Boolean(nextQuery.trim()))
|
||||
if (nextQuery.trim() && document.activeElement === inputRef.current) {
|
||||
setSearchOpen(true)
|
||||
} else if (!nextQuery.trim()) {
|
||||
setSearchOpen(false)
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (suppressInputRef.current) return
|
||||
if (query.trim()) setSearchOpen(true)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -206,9 +310,7 @@ function LayoutHeaderSearch() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setSearchOpen(false)
|
||||
dismissSearch(true)
|
||||
}}
|
||||
className="absolute right-3 text-[var(--app-muted)] hover:text-[var(--app-text)] p-0.5 rounded-lg"
|
||||
>
|
||||
@@ -218,16 +320,32 @@ function LayoutHeaderSearch() {
|
||||
</div>
|
||||
|
||||
{/* Search Dropdown Results */}
|
||||
<AnimatePresence>
|
||||
{isOpen && query.trim() && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 6 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-2xl z-50 backdrop-blur-xl"
|
||||
{isOpen && query.trim() && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="fixed inset-0 z-[25]"
|
||||
onPointerDown={() => dismissSearch(false)}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
{isOpen && query.trim() ? (
|
||||
<div
|
||||
onScroll={(e) => {
|
||||
const target = e.currentTarget
|
||||
if (target.scrollHeight - target.scrollTop - target.clientHeight < 80) {
|
||||
void loadMore()
|
||||
}
|
||||
}}
|
||||
className="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto overscroll-contain rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-2xl z-50 backdrop-blur-xl"
|
||||
>
|
||||
{results.length === 0 && !loading ? (
|
||||
{results.length === 0 && loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-xs text-[var(--app-muted)]">
|
||||
<LoaderCircle size={14} className="text-brand-500 animate-spin" />
|
||||
正在搜索…
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-[var(--app-muted)]">
|
||||
未搜索到与 “{query}” 相关的媒体内容
|
||||
</div>
|
||||
@@ -236,6 +354,12 @@ function LayoutHeaderSearch() {
|
||||
{results.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
handleSelect(item)
|
||||
}}
|
||||
onClick={() => handleSelect(item)}
|
||||
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
|
||||
>
|
||||
@@ -282,11 +406,21 @@ function LayoutHeaderSearch() {
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{loadingMore && (
|
||||
<div className="flex items-center justify-center gap-2 py-3 text-[10px] text-[var(--app-muted)]">
|
||||
<LoaderCircle size={12} className="text-brand-500 animate-spin" />
|
||||
正在加载更多…
|
||||
</div>
|
||||
)}
|
||||
{!loadingMore && !hasMore && results.length > 0 && (
|
||||
<div className="py-2 text-center text-[10px] text-[var(--app-muted)]">
|
||||
已显示全部 {results.length} 条结果
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user