mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
优化客户端封面获取逻辑
修改emby客户端获取封面逻辑,修复没有设置封面导致封面为空的问题
This commit is contained in:
+68506
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,13 @@ func embyVirtualFoldersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
case "music":
|
||||
collectionType = "music"
|
||||
}
|
||||
// 库封面广告:能解析出封面时才填 ImageTags.Primary,让客户端去请求
|
||||
// /Items/{libID}/Images/Primary(MediaFolders 是老式端点,客户端主要
|
||||
// 靠 ImageTags 判断有无主图)。
|
||||
imageTags := map[string]string{}
|
||||
if svc.Emby.LibraryHasCover(c.Request.Context(), lib.ID) {
|
||||
imageTags["Primary"] = lib.ID
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"Name": lib.Name,
|
||||
"Locations": []string{lib.Path},
|
||||
@@ -54,6 +61,7 @@ func embyVirtualFoldersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
"ItemId": lib.ID,
|
||||
"Id": lib.ID,
|
||||
"PrimaryImageItemId": lib.ID,
|
||||
"ImageTags": imageTags,
|
||||
"RefreshStatus": "Idle",
|
||||
"LibraryOptions": gin.H{},
|
||||
})
|
||||
|
||||
@@ -3,8 +3,10 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// ImageURL returns artwork for a media/series/season item id.
|
||||
@@ -51,9 +53,97 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin
|
||||
} else if ok {
|
||||
return pick(series.PosterURL, series.BackdropURL), nil
|
||||
}
|
||||
// id 既不是媒体也不是剧集组时,还可能是一个媒体库(Emby 客户端通过
|
||||
// /Items/{libraryID}/Images/Primary 请求库封面)。复用网页的封面来源:
|
||||
// cover_url 优先,否则挑库内评分最高的一张成员海报。
|
||||
if raw := e.libraryCoverArtwork(ctx, id); raw != "" {
|
||||
return raw, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// cachedLibraryCover returns a previously resolved library cover URL within TTL.
|
||||
func (e *EmbyService) cachedLibraryCover(id string) (string, bool) {
|
||||
if e == nil || strings.TrimSpace(id) == "" {
|
||||
return "", false
|
||||
}
|
||||
now := time.Now()
|
||||
e.libraryCoverMu.Lock()
|
||||
defer e.libraryCoverMu.Unlock()
|
||||
entry, ok := e.libraryCoverCache[id]
|
||||
if !ok || now.After(entry.expiresAt) {
|
||||
if ok {
|
||||
delete(e.libraryCoverCache, id)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
return entry.primary, true
|
||||
}
|
||||
|
||||
func (e *EmbyService) rememberLibraryCover(id, cover string) {
|
||||
if e == nil || strings.TrimSpace(id) == "" || strings.TrimSpace(cover) == "" {
|
||||
return
|
||||
}
|
||||
e.libraryCoverMu.Lock()
|
||||
defer e.libraryCoverMu.Unlock()
|
||||
if e.libraryCoverCache == nil {
|
||||
e.libraryCoverCache = make(map[string]embyArtworkCacheEntry)
|
||||
}
|
||||
if len(e.libraryCoverCache) > 4000 {
|
||||
e.libraryCoverCache = make(map[string]embyArtworkCacheEntry)
|
||||
}
|
||||
e.libraryCoverCache[id] = embyArtworkCacheEntry{
|
||||
primary: cover,
|
||||
expiresAt: time.Now().Add(embyVirtualCacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
// libraryCoverArtwork 解析媒体库封面,与网页媒体库封面逻辑一致:
|
||||
// 1. 用户手动设置的 cover_url 优先;
|
||||
// 2. 否则取库内最近的一批成员,按 seriesArtworkScore(同网页 artworkScore)
|
||||
// 挑出评分最高的那张海报作为封面。
|
||||
//
|
||||
// Emby 主图只能显示单张,因此返回的正是网页拼图里最靠前的那张。
|
||||
func (e *EmbyService) libraryCoverArtwork(ctx context.Context, id string) string {
|
||||
if cached, ok := e.cachedLibraryCover(id); ok {
|
||||
return cached
|
||||
}
|
||||
lib, err := e.repo.Library.FindByID(ctx, id)
|
||||
if err != nil || lib == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(lib.CoverURL) != "" {
|
||||
cover := lib.CoverURL
|
||||
e.rememberLibraryCover(id, cover)
|
||||
return cover
|
||||
}
|
||||
var cover string
|
||||
if rows, _, err := e.repo.Media.ListByLibraryFiltered(ctx, id, 0, 12, repository.MediaQueryFilter{IncludeNSFW: true}); err == nil {
|
||||
var bestScore = -1
|
||||
for i := range rows {
|
||||
score := seriesArtworkScore(rows[i])
|
||||
if score <= bestScore {
|
||||
continue
|
||||
}
|
||||
bestScore = score
|
||||
cover = rows[i].PosterURL
|
||||
if cover == "" {
|
||||
cover = rows[i].BackdropURL
|
||||
}
|
||||
}
|
||||
}
|
||||
if cover != "" {
|
||||
e.rememberLibraryCover(id, cover)
|
||||
}
|
||||
return cover
|
||||
}
|
||||
|
||||
// LibraryHasCover reports whether a library has an Emby-servable cover, so the
|
||||
// Views / virtual-folders payloads can advertise ImageTags.Primary to clients.
|
||||
func (e *EmbyService) LibraryHasCover(ctx context.Context, id string) bool {
|
||||
return e.libraryCoverArtwork(ctx, id) != ""
|
||||
}
|
||||
|
||||
func (e *EmbyService) mediaPrimaryArtwork(ctx context.Context, m *model.Media) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
func TestImageURLFallsBackToLibraryCoverURL(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true, CoverURL: "https://example.com/lib-cover.jpg"}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
|
||||
raw, err := svc.ImageURL(t.Context(), lib.ID, "Primary")
|
||||
if err != nil {
|
||||
t.Fatalf("ImageURL: %v", err)
|
||||
}
|
||||
if raw != lib.CoverURL {
|
||||
t.Fatalf("ImageURL for library = %q, want cover_url %q", raw, lib.CoverURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageURLFallsBackToBestMemberPoster(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true} // no cover_url
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
members := []model.Media{
|
||||
{LibraryID: lib.ID, Title: "A", Path: "/media/movies/a.mkv", PosterURL: "https://example.com/a/backdrop.jpg"},
|
||||
{LibraryID: lib.ID, Title: "B", Path: "/media/movies/b.mkv", PosterURL: "https://example.com/b/poster.jpg"},
|
||||
{LibraryID: lib.ID, Title: "C", Path: "/media/movies/c.mkv", PosterURL: "https://example.com/c/actor.jpg"},
|
||||
}
|
||||
for i := range members {
|
||||
if err := svc.repo.DB.Create(&members[i]).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := svc.ImageURL(t.Context(), lib.ID, "Primary")
|
||||
if err != nil {
|
||||
t.Fatalf("ImageURL: %v", err)
|
||||
}
|
||||
// 最高分是 poster.jpg(score 40),其次是默认海报(30),actor(10)。
|
||||
if raw != "https://example.com/b/poster.jpg" {
|
||||
t.Fatalf("ImageURL for library = %q, want best member poster", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageURLReturnsEmptyForUnknownID(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
raw, err := svc.ImageURL(t.Context(), "not-a-library-or-media", "Primary")
|
||||
if err != nil {
|
||||
t.Fatalf("ImageURL: %v", err)
|
||||
}
|
||||
if raw != "" {
|
||||
t.Fatalf("ImageURL for unknown id = %q, want empty", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewsAdvertisesPrimaryImageWhenLibraryHasCover(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
withCover := model.Library{Name: "封库", Path: "/media/set", Type: "movie", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &withCover); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
if err := svc.repo.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "m1"},
|
||||
LibraryID: withCover.ID,
|
||||
Title: "M1",
|
||||
Path: "/media/set/m1.mkv",
|
||||
PosterURL: "https://example.com/m1/poster.jpg",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
noCover := model.Library{Name: "空库", Path: "/media/empty", Type: "movie", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &noCover); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
|
||||
views, err := svc.Views(t.Context(), "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Views: %v", err)
|
||||
}
|
||||
items := views["Items"].([]map[string]any)
|
||||
found := map[string]map[string]any{}
|
||||
for _, it := range items {
|
||||
found[it["Id"].(string)] = it
|
||||
}
|
||||
|
||||
withImageTags, ok := found[withCover.ID]["ImageTags"].(map[string]string)
|
||||
if !ok {
|
||||
t.Fatalf("library with cover should have ImageTags, got %#v", found[withCover.ID]["ImageTags"])
|
||||
}
|
||||
if withImageTags["Primary"] != withCover.ID {
|
||||
t.Fatalf("library with cover should advertise ImageTags.Primary, got %#v", withImageTags)
|
||||
}
|
||||
|
||||
emptyTags, ok := found[noCover.ID]["ImageTags"].(map[string]string)
|
||||
if !ok {
|
||||
t.Fatalf("library without cover should have empty ImageTags, got %#v", found[noCover.ID]["ImageTags"])
|
||||
}
|
||||
if _, has := emptyTags["Primary"]; has {
|
||||
t.Fatalf("library without cover should NOT advertise Primary, got %#v", emptyTags)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,9 @@ type EmbyService struct {
|
||||
|
||||
cloudProbeMu sync.Mutex
|
||||
cloudProbeInFlight map[string]struct{}
|
||||
|
||||
libraryCoverMu sync.Mutex
|
||||
libraryCoverCache map[string]embyArtworkCacheEntry
|
||||
}
|
||||
|
||||
type cloudPlaybackResolver interface {
|
||||
|
||||
@@ -22,7 +22,7 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
if !e.libraryVisibleFromCachedVisibility(libs[0], visibility) {
|
||||
return nil, nil
|
||||
}
|
||||
return e.libraryAsView(&libs[0]), nil
|
||||
return e.libraryAsView(ctx, &libs[0]), nil
|
||||
}
|
||||
if strings.HasPrefix(mediaID, embyVirtualSeasonPrefix) {
|
||||
if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil {
|
||||
|
||||
@@ -137,12 +137,12 @@ func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any,
|
||||
if !e.libraryVisibleFromCachedVisibility(l, visibility) {
|
||||
continue
|
||||
}
|
||||
items = append(items, e.libraryAsView(&l))
|
||||
items = append(items, e.libraryAsView(ctx, &l))
|
||||
}
|
||||
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
|
||||
func (e *EmbyService) libraryAsView(ctx context.Context, l *model.Library) map[string]any {
|
||||
collectionType := "movies"
|
||||
switch l.Type {
|
||||
case "tv":
|
||||
@@ -154,6 +154,12 @@ func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
|
||||
case "music":
|
||||
collectionType = "music"
|
||||
}
|
||||
// 库封面:与剧集/电影一样,只有在能解析出封面时才广告 ImageTags.Primary,
|
||||
// 否则客户端会认为该库无主图而不去请求 /Images/Primary。
|
||||
imageTags := map[string]string{}
|
||||
if e.LibraryHasCover(ctx, l.ID) {
|
||||
imageTags["Primary"] = l.ID
|
||||
}
|
||||
return map[string]any{
|
||||
"Id": l.ID,
|
||||
"Name": l.Name,
|
||||
@@ -178,7 +184,7 @@ func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
|
||||
"ProviderIds": map[string]string{},
|
||||
"Genres": []string{},
|
||||
"Tags": []string{},
|
||||
"ImageTags": map[string]string{},
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": []string{},
|
||||
"UserData": map[string]any{
|
||||
"PlaybackPositionTicks": 0,
|
||||
|
||||
Reference in New Issue
Block a user