优化客户端封面获取逻辑

修改emby客户端获取封面逻辑,修复没有设置封面导致封面为空的问题
This commit is contained in:
truewhile
2026-08-20 09:20:42 +08:00
parent 0d9ba2c33a
commit 541de2165e
7 changed files with 68725 additions and 4 deletions
+68506
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -47,6 +47,13 @@ func embyVirtualFoldersHandler(svc *service.Container) gin.HandlerFunc {
case "music": case "music":
collectionType = "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{ out = append(out, gin.H{
"Name": lib.Name, "Name": lib.Name,
"Locations": []string{lib.Path}, "Locations": []string{lib.Path},
@@ -54,6 +61,7 @@ func embyVirtualFoldersHandler(svc *service.Container) gin.HandlerFunc {
"ItemId": lib.ID, "ItemId": lib.ID,
"Id": lib.ID, "Id": lib.ID,
"PrimaryImageItemId": lib.ID, "PrimaryImageItemId": lib.ID,
"ImageTags": imageTags,
"RefreshStatus": "Idle", "RefreshStatus": "Idle",
"LibraryOptions": gin.H{}, "LibraryOptions": gin.H{},
}) })
+90
View File
@@ -3,8 +3,10 @@ package service
import ( import (
"context" "context"
"strings" "strings"
"time"
"github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
) )
// ImageURL returns artwork for a media/series/season item id. // 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 { } else if ok {
return pick(series.PosterURL, series.BackdropURL), nil 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 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 { func (e *EmbyService) mediaPrimaryArtwork(ctx context.Context, m *model.Media) string {
if m == nil { if m == nil {
return "" return ""
+108
View File
@@ -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)
}
}
+3
View File
@@ -62,6 +62,9 @@ type EmbyService struct {
cloudProbeMu sync.Mutex cloudProbeMu sync.Mutex
cloudProbeInFlight map[string]struct{} cloudProbeInFlight map[string]struct{}
libraryCoverMu sync.Mutex
libraryCoverCache map[string]embyArtworkCacheEntry
} }
type cloudPlaybackResolver interface { type cloudPlaybackResolver interface {
+1 -1
View File
@@ -22,7 +22,7 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
if !e.libraryVisibleFromCachedVisibility(libs[0], visibility) { if !e.libraryVisibleFromCachedVisibility(libs[0], visibility) {
return nil, nil return nil, nil
} }
return e.libraryAsView(&libs[0]), nil return e.libraryAsView(ctx, &libs[0]), nil
} }
if strings.HasPrefix(mediaID, embyVirtualSeasonPrefix) { if strings.HasPrefix(mediaID, embyVirtualSeasonPrefix) {
if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil { if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil {
+9 -3
View File
@@ -137,12 +137,12 @@ func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any,
if !e.libraryVisibleFromCachedVisibility(l, visibility) { if !e.libraryVisibleFromCachedVisibility(l, visibility) {
continue 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 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" collectionType := "movies"
switch l.Type { switch l.Type {
case "tv": case "tv":
@@ -154,6 +154,12 @@ func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
case "music": case "music":
collectionType = "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{ return map[string]any{
"Id": l.ID, "Id": l.ID,
"Name": l.Name, "Name": l.Name,
@@ -178,7 +184,7 @@ func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
"ProviderIds": map[string]string{}, "ProviderIds": map[string]string{},
"Genres": []string{}, "Genres": []string{},
"Tags": []string{}, "Tags": []string{},
"ImageTags": map[string]string{}, "ImageTags": imageTags,
"BackdropImageTags": []string{}, "BackdropImageTags": []string{},
"UserData": map[string]any{ "UserData": map[string]any{
"PlaybackPositionTicks": 0, "PlaybackPositionTicks": 0,