From 3c2bfb14ca408df29606fc2a6d746f7d5d96fc8c Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:43:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Emby=20=E5=A4=A7=E6=B5=B7?= =?UTF-8?q?=E6=8A=A5=E5=9C=A8=E5=89=A7=E9=9B=86=E5=9B=BE=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E8=A2=AB=E6=B7=98=E6=B1=B0=E5=90=8E=E6=98=BE=E7=A4=BA=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- internal/service/emby_artwork.go | 28 ++-- internal/service/emby_compat.go | 12 ++ internal/service/emby_items_cache.go | 6 +- internal/service/emby_items_detail.go | 17 +-- internal/service/emby_series_cache.go | 128 +++++++++++++++++- .../service/emby_series_hierarchy_test.go | 77 ++++++++++- internal/service/emby_series_payload.go | 56 ++++---- 7 files changed, 267 insertions(+), 57 deletions(-) diff --git a/internal/service/emby_artwork.go b/internal/service/emby_artwork.go index 4ba9d63..388284a 100644 --- a/internal/service/emby_artwork.go +++ b/internal/service/emby_artwork.go @@ -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,21 @@ 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 +} + // imageInfoTypes 是 GET /Items/{Id}/Images 会报告的图片类型。只列 MeBox // 真正存储的两类:ImageURL 对 Thumb / Logo / Banner 等其余类型会回退到 // 主图,若一并列出会让客户端以为存在这些图并去请求,实际拿到的却是主图。 diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 3d7d13b..1bac7c6 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -117,6 +117,18 @@ const ( embyVirtualCacheTTL = 10 * 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 ( diff --git a/internal/service/emby_items_cache.go b/internal/service/emby_items_cache.go index 7eb40cf..088af0f 100644 --- a/internal/service/emby_items_cache.go +++ b/internal/service/emby_items_cache.go @@ -15,7 +15,8 @@ type embyItemsCacheValue struct { } type embyLatestCacheValue struct { - Items []map[string]any `json:"items"` + Items []map[string]any `json:"items"` + Artwork map[string]embyArtworkRef `json:"artwork,omitempty"` } func (e *EmbyService) embyItemsCacheKey(kind string, p ItemsParams) string { @@ -43,7 +44,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[:]) } diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index e2f1e85..868ca8b 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -132,15 +132,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 +172,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 +181,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 +192,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 列出有未完成播放进度的媒体。 @@ -519,11 +520,11 @@ 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)} } } } diff --git a/internal/service/emby_series_cache.go b/internal/service/emby_series_cache.go index f65fcf8..7e08e83 100644 --- a/internal/service/emby_series_cache.go +++ b/internal/service/emby_series_cache.go @@ -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 +} diff --git a/internal/service/emby_series_hierarchy_test.go b/internal/service/emby_series_hierarchy_test.go index 4e1c85d..87b92e2 100644 --- a/internal/service/emby_series_hierarchy_test.go +++ b/internal/service/emby_series_hierarchy_test.go @@ -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) diff --git a/internal/service/emby_series_payload.go b/internal/service/emby_series_payload.go index 365848f..8a7e98b 100644 --- a/internal/service/emby_series_payload.go +++ b/internal/service/emby_series_payload.go @@ -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 }