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":
|
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{},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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
|
cloudProbeMu sync.Mutex
|
||||||
cloudProbeInFlight map[string]struct{}
|
cloudProbeInFlight map[string]struct{}
|
||||||
|
|
||||||
|
libraryCoverMu sync.Mutex
|
||||||
|
libraryCoverCache map[string]embyArtworkCacheEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
type cloudPlaybackResolver interface {
|
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) {
|
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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user