mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41fe75e136 | |||
| e65accf2cd |
@@ -29,24 +29,44 @@ 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"), false)
|
||||
}
|
||||
}
|
||||
|
||||
// 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"), true)
|
||||
}
|
||||
}
|
||||
|
||||
func embyServeImage(c *gin.Context, svc *service.Container, id, imageType string, person bool) {
|
||||
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
|
||||
}
|
||||
var raw string
|
||||
var err error
|
||||
if person {
|
||||
raw, err = svc.Emby.PersonImageURL(ctx, id, imageType)
|
||||
} else {
|
||||
raw, err = svc.Emby.ImageURL(ctx, id, imageType)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -83,6 +83,24 @@ func (e *EmbyService) resolveVirtualArtwork(ctx context.Context, id, imageType s
|
||||
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 string) (string, error) {
|
||||
idOrName = strings.TrimSpace(idOrName)
|
||||
if idOrName == "" || e == nil {
|
||||
return "", nil
|
||||
}
|
||||
if IsEmbyRemoteID(idOrName) {
|
||||
return e.ImageURL(ctx, idOrName, imageType)
|
||||
}
|
||||
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,9 @@ type EmbyService struct {
|
||||
|
||||
libraryCoverMu sync.Mutex
|
||||
libraryCoverCache map[string]embyArtworkCacheEntry
|
||||
|
||||
peopleMu sync.RWMutex
|
||||
peopleCache map[string]embyPeopleCacheEntry
|
||||
}
|
||||
|
||||
// NewEmbyService is the constructor.
|
||||
@@ -143,6 +146,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,9 +9,10 @@ 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 {
|
||||
@@ -19,6 +20,13 @@ type embyLatestCacheValue struct {
|
||||
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 {
|
||||
includeTypes := append([]string(nil), p.IncludeItemTypes...)
|
||||
filters := append([]string(nil), p.Filters...)
|
||||
|
||||
@@ -538,6 +538,14 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
|
||||
if m == nil || strings.TrimSpace(m.Path) == "" {
|
||||
return []map[string]any{}
|
||||
}
|
||||
cacheKey := strings.TrimSpace(m.ID)
|
||||
if cacheKey == "" {
|
||||
cacheKey = strings.ToLower(filepath.Clean(m.Path))
|
||||
}
|
||||
if people, ok := e.cachedMediaPeople(cacheKey); ok {
|
||||
return people
|
||||
}
|
||||
|
||||
dir := filepath.Dir(m.Path)
|
||||
candidates := make([]string, 0, 6)
|
||||
seenPath := map[string]struct{}{}
|
||||
@@ -610,9 +618,48 @@ func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []
|
||||
}
|
||||
}
|
||||
}
|
||||
e.rememberMediaPeople(cacheKey, people)
|
||||
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)
|
||||
e.peopleCache[key] = embyPeopleCacheEntry{
|
||||
people: stored,
|
||||
expiresAt: time.Now().Add(embyVirtualCacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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),用于合并排序。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -578,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+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,
|
||||
})
|
||||
|
||||
@@ -108,15 +108,23 @@ 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 isOpenRef = 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 setSearchOpen = (open: boolean) => {
|
||||
@@ -128,23 +136,43 @@ function LayoutHeaderSearch() {
|
||||
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 || [])
|
||||
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) setIsOpen(true)
|
||||
} catch {
|
||||
// 请求失败时保留旧结果,避免网络抖动清空下拉
|
||||
// 请求失败时保持空结果,用户继续输入或滚动时会重新请求
|
||||
} finally {
|
||||
if (seq === searchSeqRef.current) setLoading(false)
|
||||
}
|
||||
@@ -164,6 +192,40 @@ function LayoutHeaderSearch() {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
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('')
|
||||
@@ -225,9 +287,20 @@ function LayoutHeaderSearch() {
|
||||
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"
|
||||
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>
|
||||
@@ -282,6 +355,17 @@ 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>
|
||||
|
||||
Reference in New Issue
Block a user