From 5c85478883f5b45e595c7b251c46b9321517e356 Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Mon, 14 Sep 2026 12:33:59 +0800 Subject: [PATCH] 1 --- internal/database/schema_migration.go | 5 + internal/handler/media.go | 16 ++- internal/repository/media_repository.go | 49 ++++++++ .../repository/media_search_repository.go | 3 + internal/service/image_proxy.go | 12 +- internal/service/image_proxy_remote.go | 36 +++++- internal/service/media.go | 11 +- internal/service/media_cache.go | 4 + internal/service/media_library.go | 64 ++++++---- internal/service/media_library_test.go | 29 +++++ internal/service/media_listing.go | 39 ++++-- internal/service/media_series.go | 117 ++++++++++++++---- internal/service/media_series_test.go | 83 +++++++++++++ web/src/api/playback.ts | 5 + web/src/pages/HomePage.tsx | 22 +++- web/src/pages/HomePageSections.tsx | 4 +- web/src/pages/LibrariesPage.tsx | 41 ++++-- web/src/pages/LibrariesPageSections.tsx | 8 +- web/src/pages/LibrarySeriesEpisodes.tsx | 8 +- web/src/pages/MediaDetailArtwork.tsx | 16 ++- web/src/pages/librariesPageModel.ts | 2 +- web/src/pages/useMediaDetailPageState.ts | 5 +- web/src/utils/groupSeries.ts | 16 ++- 23 files changed, 492 insertions(+), 103 deletions(-) diff --git a/internal/database/schema_migration.go b/internal/database/schema_migration.go index 3c2048e..856ce11 100644 --- a/internal/database/schema_migration.go +++ b/internal/database/schema_migration.go @@ -99,6 +99,11 @@ func ensurePerformanceIndexes(db *gorm.DB) error { `CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`, + `CREATE INDEX IF NOT EXISTS idx_media_library_series_active ON media(library_id, series_id, season_num, episode_num) WHERE deleted_at IS NULL`, + `CREATE INDEX IF NOT EXISTS idx_media_library_tmdb_active ON media(library_id, tm_db_id) WHERE deleted_at IS NULL`, + `CREATE INDEX IF NOT EXISTS idx_media_library_bangumi_active ON media(library_id, bangumi_id) WHERE deleted_at IS NULL`, + `CREATE INDEX IF NOT EXISTS idx_media_library_douban_active ON media(library_id, douban_id) WHERE deleted_at IS NULL`, + `CREATE INDEX IF NOT EXISTS idx_media_library_thetvdb_active ON media(library_id, thetvdb_id) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_favorites_user_media_active ON favorites(user_id, media_id) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_playback_histories_user_media_active ON playback_histories(user_id, media_id, watched_at DESC) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_playback_histories_resume_active ON playback_histories(user_id, completed, watched_at DESC) WHERE deleted_at IS NULL`, diff --git a/internal/handler/media.go b/internal/handler/media.go index b9db987..afd095e 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -33,10 +33,10 @@ type createLibraryReq struct { // 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。 type webLibraryPayload struct { model.Library - IsRemoteEmby bool `json:"is_remote_emby,omitempty"` - RemoteSource string `json:"remote_source,omitempty"` - Total int64 `json:"total,omitempty"` - Cards []service.SeriesCard `json:"cards,omitempty"` + IsRemoteEmby bool `json:"is_remote_emby,omitempty"` + RemoteSource string `json:"remote_source,omitempty"` + Total int64 `json:"total,omitempty"` + Cards []service.SeriesCardView `json:"cards,omitempty"` } // remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目, @@ -115,7 +115,11 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { return } for _, p := range previews { - out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards}) + out = append(out, webLibraryPayload{ + Library: p.Library, + Total: p.Total, + Cards: service.NewSeriesCardViews(p.Cards), + }) } } else { visibility := mediaVisibilityForRequest(c, svc) @@ -202,7 +206,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { remotePayloads[i].Total = total } if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil { - remotePayloads[i].Cards = cards + remotePayloads[i].Cards = service.NewSeriesCardViews(cards) } }) }() diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go index a0aaa0b..2f5c186 100644 --- a/internal/repository/media_repository.go +++ b/internal/repository/media_repository.go @@ -43,6 +43,7 @@ type MediaQueryFilter struct { IncludeNSFW bool AllowedLibraryIDs []string HiddenLibraryIDs []string + SeriesID string } func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB { @@ -55,6 +56,9 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB { if len(filter.AllowedLibraryIDs) > 0 { q = q.Where("library_id IN ?", filter.AllowedLibraryIDs) } + if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" { + q = q.Where("series_id = ?", seriesID) + } return q } @@ -120,6 +124,47 @@ func (r *MediaRepository) ListAllByLibrariesFilteredNoCount(ctx context.Context, return items, err } +// ListVersionCandidates loads a bounded candidate set for version grouping +// using the strongest identity stored on the row. Returning ok=false keeps the +// caller's full-library fallback for rows without external IDs or SeriesID. +func (r *MediaRepository) ListVersionCandidates(ctx context.Context, libraryIDs []string, media model.Media, limit int) ([]model.Media, bool, error) { + items := make([]model.Media, 0) + if len(libraryIDs) == 0 { + return items, false, nil + } + if limit <= 0 { + limit = 5000 + } + q := r.db.WithContext(ctx).Model(&model.Media{}) + if len(libraryIDs) == 1 { + q = q.Where("library_id = ?", libraryIDs[0]) + } else { + q = q.Where("library_id IN ?", libraryIDs) + } + found := true + switch { + case strings.TrimSpace(media.SeriesID) != "": + q = q.Where("series_id = ?", strings.TrimSpace(media.SeriesID)) + case media.TMDbID > 0: + q = q.Where("tm_db_id = ?", media.TMDbID) + case media.BangumiID > 0: + q = q.Where("bangumi_id = ?", media.BangumiID) + case strings.TrimSpace(media.DoubanID) != "": + q = q.Where("douban_id = ?", strings.TrimSpace(media.DoubanID)) + case strings.TrimSpace(media.TheTVDBID) != "": + q = q.Where("thetvdb_id = ?", strings.TrimSpace(media.TheTVDBID)) + default: + found = false + } + if !found { + return items, false, nil + } + err := q.Order("release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC"). + Limit(limit). + Find(&items).Error + return items, true, err +} + func (r *MediaRepository) listByLibrariesFiltered(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter, withCount bool) ([]model.Media, int64, error) { var items []model.Media var total int64 @@ -216,6 +261,10 @@ func mediaQueryFilterSQL(filter MediaQueryFilter) (string, []interface{}) { parts = append(parts, "library_id IN ?") args = append(args, filter.AllowedLibraryIDs) } + if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" { + parts = append(parts, "series_id = ?") + args = append(args, seriesID) + } return strings.Join(parts, " AND "), args } diff --git a/internal/repository/media_search_repository.go b/internal/repository/media_search_repository.go index d7c94d6..d7772dd 100644 --- a/internal/repository/media_search_repository.go +++ b/internal/repository/media_search_repository.go @@ -146,6 +146,9 @@ func applyQualifiedMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.D if len(filter.AllowedLibraryIDs) > 0 { q = q.Where("media.library_id IN ?", filter.AllowedLibraryIDs) } + if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" { + q = q.Where("media.series_id = ?", seriesID) + } return q } diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index eae60e6..1f753ec 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -24,17 +24,19 @@ import ( "time" "go.uber.org/zap" + "golang.org/x/sync/singleflight" "github.com/truewhile/MeBox/internal/config" ) // ImageProxy fetches and caches remote images on behalf of the browser. type ImageProxy struct { - cfg *config.Config - log *zap.Logger - client *http.Client - cacheDir string - mu sync.Mutex + cfg *config.Config + log *zap.Logger + client *http.Client + cacheDir string + mu sync.Mutex + fetchGroup singleflight.Group // resizeSem bounds concurrent decode/resize jobs. Emby TV clients request // poster grids in bursts; letting every request decode a source image at diff --git a/internal/service/image_proxy_remote.go b/internal/service/image_proxy_remote.go index e95444b..eed9d02 100644 --- a/internal/service/image_proxy_remote.go +++ b/internal/service/image_proxy_remote.go @@ -95,7 +95,7 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter } // No negative caching: a previously failed fetch is retried on every // subsequent request, so the image recovers as soon as upstream does. - data, ctype, contentLength, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath) + data, ctype, contentLength, err := p.fetchAndCacheRemoteImageShared(ctx, raw, host, cachePath, failPath) if err != nil { if forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) { return nil @@ -206,6 +206,40 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca return nil, "", "", lastErr } +type sharedRemoteImageResult struct { + data []byte + contentType string + contentLength string +} + +// fetchAndCacheRemoteImageShared coalesces concurrent requests for the same +// upstream image. A poster can appear in the hero, a shelf and the detail page +// at the same time; without this guard every resize variant may fetch the same +// original before the first cache write finishes. +func (p *ImageProxy) fetchAndCacheRemoteImageShared(ctx context.Context, raw, host, cachePath, failPath string) ([]byte, string, string, error) { + value, err, _ := p.fetchGroup.Do(cachePath, func() (any, error) { + loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 45*time.Second) + defer cancel() + data, contentType, contentLength, err := p.fetchAndCacheRemoteImage(loadCtx, raw, host, cachePath, failPath) + if err != nil { + return nil, err + } + return sharedRemoteImageResult{ + data: data, + contentType: contentType, + contentLength: contentLength, + }, nil + }) + if err != nil { + return nil, "", "", err + } + result, ok := value.(sharedRemoteImageResult) + if !ok { + return nil, "", "", errors.New("upstream image fetch failed") + } + return result.data, result.contentType, result.contentLength, nil +} + // Fetch pulls a remote image and returns bytes plus Content-Type using cache. func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, error) { u, err := p.validateURL(raw) diff --git a/internal/service/media.go b/internal/service/media.go index da9baea..56af9c0 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -3,6 +3,7 @@ package service import ( "go.uber.org/zap" + "golang.org/x/sync/singleflight" "github.com/truewhile/MeBox/internal/config" "github.com/truewhile/MeBox/internal/model" @@ -11,10 +12,12 @@ import ( // MediaService offers high-level CRUD over libraries and media items. type MediaService struct { - cfg *config.Config - log *zap.Logger - repo *repository.Container - cache *RuntimeCacheService + cfg *config.Config + log *zap.Logger + repo *repository.Container + cache *RuntimeCacheService + groupedMediaFlight singleflight.Group + libraryRowsFlight singleflight.Group } type MediaVisibility struct { diff --git a/internal/service/media_cache.go b/internal/service/media_cache.go index 10ad7a8..485b4d6 100644 --- a/internal/service/media_cache.go +++ b/internal/service/media_cache.go @@ -31,6 +31,7 @@ func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string, fmt.Sprintf("%d:%d:%t", page, pageSize, filter.IncludeNSFW), strings.Join(allowed, ","), strings.Join(hidden, ","), + filter.SeriesID, }, "|"))) return "media:list:" + hex.EncodeToString(sum[:]) } @@ -51,6 +52,7 @@ func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLim fmt.Sprintf("%d:%t:%t", cardLimit, filter.IncludeNSFW, includeCounts), strings.Join(allowed, ","), strings.Join(hidden, ","), + filter.SeriesID, }, "|"))) return "media:preview:" + hex.EncodeToString(sum[:]) } @@ -82,6 +84,7 @@ func (s *MediaService) libraryCountCacheKey(libraryIDs []string, filter reposito fmt.Sprintf("%t", filter.IncludeNSFW), strings.Join(allowed, ","), strings.Join(hidden, ","), + filter.SeriesID, }, "|"))) return "media:lib-counts:" + hex.EncodeToString(sum[:]) } @@ -134,6 +137,7 @@ func (s *MediaService) groupedItemsCacheKey(libraryID string, libraryIDs []strin fmt.Sprintf("%t", filter.IncludeNSFW), strings.Join(allowed, ","), strings.Join(hidden, ","), + filter.SeriesID, }) } diff --git a/internal/service/media_library.go b/internal/service/media_library.go index fa959f2..cedd6c6 100644 --- a/internal/service/media_library.go +++ b/internal/service/media_library.go @@ -14,10 +14,6 @@ type LibraryPreviewItem struct { Cards []SeriesCard `json:"cards"` } -type libraryPreviewCacheValue struct { - Items []LibraryPreviewItem `json:"items"` -} - // ListLibraries returns every library configured on the server. func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) { return s.repo.Library.List(ctx) @@ -68,27 +64,34 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [ if len(libraries) == 0 { return out, nil } - visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) filter := repository.MediaQueryFilter{ IncludeNSFW: visibility.IncludeNSFW, AllowedLibraryIDs: visibility.AllowedLibraryIDs, HiddenLibraryIDs: visibility.HiddenLibraryIDs, } - cacheKey := s.libraryPreviewCacheKey(libraries, cardLimit, filter, includeCounts) - var cached libraryPreviewCacheValue - if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) { - return cached.Items, nil - } - - libIDs := make([]string, 0, len(libraries)) + pending := make([]model.Library, 0, len(libraries)) + pendingSet := make(map[string]struct{}, len(libraries)) for i, lib := range libraries { out[i] = LibraryPreviewItem{ Library: lib, Total: 0, Cards: []SeriesCard{}, } - libIDs = append(libIDs, lib.ID) + itemKey := s.libraryPreviewCacheKey([]model.Library{lib}, cardLimit, filter, includeCounts) + if s.cache != nil && s.cache.GetJSON(ctx, itemKey, &out[i]) { + continue + } + pending = append(pending, lib) + pendingSet[lib.ID] = struct{}{} + } + if len(pending) == 0 { + return out, nil + } + + libIDs := make([]string, len(pending)) + for i := range pending { + libIDs[i] = pending[i].ID } if includeCounts { @@ -97,9 +100,10 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [ return nil, err } for i := range out { - if total, ok := counts[out[i].ID]; ok { - out[i].Total = total + if _, pendingItem := pendingSet[out[i].ID]; !pendingItem { + continue } + out[i].Total = counts[out[i].ID] } } @@ -107,8 +111,16 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [ // library with a very long series cannot turn a homepage request into a full // 50k-row scan merely to find another distinct card. fetchCount := cardLimit * 12 - if fetchCount < 120 { - fetchCount = 120 + minFetchCount := 120 + if cardLimit <= 2 { + minFetchCount = 24 + } else if cardLimit <= 4 { + // 首页/媒体库入口的马赛克只需要少量代表图,没必要为暂时不会 + // 展示的横向货架扫描一整批 120 行候选。 + minFetchCount = 48 + } + if fetchCount < minFetchCount { + fetchCount = minFetchCount } else if fetchCount > 400 { fetchCount = 400 } @@ -120,6 +132,9 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [ allPreviewItems := make([]model.Media, 0, len(libIDs)*fetchCount) for i := range out { + if _, pendingItem := pendingSet[out[i].ID]; !pendingItem { + continue + } items := recentByLibrary[out[i].ID] if len(items) == 0 { continue @@ -129,8 +144,15 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [ s.attachLibraryMetadata(ctx, allPreviewItems) for i := range out { + if _, pendingItem := pendingSet[out[i].ID]; !pendingItem { + continue + } items := recentByLibrary[out[i].ID] if len(items) == 0 { + if s.cache != nil { + itemKey := s.libraryPreviewCacheKey([]model.Library{out[i].Library}, cardLimit, filter, includeCounts) + s.cache.SetJSON(ctx, itemKey, out[i], s.derivedReadCacheTTL()) + } continue } cards := groupMediaSeriesCards(items) @@ -141,10 +163,10 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [ cards = []SeriesCard{} } out[i].Cards = cards - } - - if s.cache != nil { - s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, s.derivedReadCacheTTL()) + if s.cache != nil { + itemKey := s.libraryPreviewCacheKey([]model.Library{out[i].Library}, cardLimit, filter, includeCounts) + s.cache.SetJSON(ctx, itemKey, out[i], s.derivedReadCacheTTL()) + } } return out, nil diff --git a/internal/service/media_library_test.go b/internal/service/media_library_test.go index 02dd5a5..d0eb6fe 100644 --- a/internal/service/media_library_test.go +++ b/internal/service/media_library_test.go @@ -180,3 +180,32 @@ func TestListLibraryPreviewsSkipsTotals(t *testing.T) { t.Fatalf("preview-only result = %#v, want one card and no count", previews) } } + +func TestListLibraryPreviewsCachesEachLibraryIndependently(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + if err := repos.DB.Create(&model.Media{ + LibraryID: lib.ID, + Title: "预览电影", + Path: "/media/movies/预览电影.mkv", + }).Error; err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos). + SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop())) + visibility := MediaVisibility{IncludeNSFW: true} + if _, err := svc.ListLibraryPreviews(t.Context(), []model.Library{lib}, visibility, 10); err != nil { + t.Fatal(err) + } + filter := repository.MediaQueryFilter{IncludeNSFW: true} + key := svc.libraryPreviewCacheKey([]model.Library{lib}, 10, filter, false) + var cached LibraryPreviewItem + if !svc.cache.GetJSON(t.Context(), key, &cached) || len(cached.Cards) != 1 { + t.Fatalf("per-library preview cache = %#v, want one cached card", cached) + } +} diff --git a/internal/service/media_listing.go b/internal/service/media_listing.go index 44bfadc..edb0e6b 100644 --- a/internal/service/media_listing.go +++ b/internal/service/media_listing.go @@ -77,22 +77,33 @@ func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string HiddenLibraryIDs: visibility.HiddenLibraryIDs, } itemsCacheKey := s.groupedItemsCacheKey(libraryID, libraryIDs, filter) - if s.cache != nil { - if cachedObj, ok := s.cache.GetObject(itemsCacheKey); ok { - if cached, ok := cachedObj.([]MediaItem); ok { - return cached, nil + value, err, _ := s.groupedMediaFlight.Do(itemsCacheKey, func() (any, error) { + if s.cache != nil { + if cachedObj, ok := s.cache.GetObject(itemsCacheKey); ok { + if cached, ok := cachedObj.([]MediaItem); ok { + return cached, nil + } } } - } - items, err := s.listMediaVisibleForGrouping(ctx, libraryID, visibility) + loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute) + defer cancel() + items, err := s.listMediaVisibleForGrouping(loadCtx, libraryID, visibility) + if err != nil { + return nil, err + } + grouped := groupMediaVersions(items) + if s.cache != nil && len(grouped) > 0 { + s.cache.SetObject(itemsCacheKey, grouped, s.mediaObjectTTL()) + } + return grouped, nil + }) if err != nil { return nil, err } - grouped := groupMediaVersions(items) - if s.cache != nil && len(grouped) > 0 { - s.cache.SetObject(itemsCacheKey, grouped, s.mediaObjectTTL()) + if grouped, ok := value.([]MediaItem); ok { + return grouped, nil } - return grouped, nil + return nil, nil } func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, error) { @@ -168,10 +179,16 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med libraryIDs = []string{media.LibraryID} } filter := repository.MediaQueryFilter{IncludeNSFW: true} - candidates, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter) + candidates, narrowed, err := s.repo.Media.ListVersionCandidates(ctx, libraryIDs, *media, 5000) if err != nil { return nil, err } + if !narrowed { + candidates, err = s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter) + if err != nil { + return nil, err + } + } s.attachLibraryMetadata(ctx, candidates) matched := make([]model.Media, 0, 4) for _, row := range candidates { diff --git a/internal/service/media_series.go b/internal/service/media_series.go index 7d1311c..f0ff509 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -36,6 +36,35 @@ type SeriesCard struct { LastAddedAt *time.Time `json:"last_added_at,omitempty"` } +// SeriesCardView is the compact payload used by homepage and library preview +// endpoints. LinkMedia is only needed to resolve the target library ID, so +// sending the full media row twice roughly doubles the preview JSON for no UI +// benefit. +type SeriesCardView struct { + Key string `json:"key"` + Rep model.Media `json:"rep"` + LinkLibraryID string `json:"linkLibraryId,omitempty"` + Count int `json:"count"` + LastAddedAt *time.Time `json:"last_added_at,omitempty"` +} + +func NewSeriesCardViews(cards []SeriesCard) []SeriesCardView { + if len(cards) == 0 { + return []SeriesCardView{} + } + out := make([]SeriesCardView, len(cards)) + for i, card := range cards { + out[i] = SeriesCardView{ + Key: card.Key, + Rep: card.Rep, + LinkLibraryID: mediaTargetLibraryID(card.LinkMedia), + Count: card.Count, + LastAddedAt: card.LastAddedAt, + } + } + return out +} + type seriesCardGroup struct { card SeriesCard latest time.Time @@ -45,34 +74,45 @@ type seriesCardGroup struct { func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID string, visibility MediaVisibility) (*libraryRowsCacheValue, error) { visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) cacheKey := s.libraryRowsCacheKey(libraryID, visibility) - if s.cache != nil { - if obj, ok := s.cache.GetObject(cacheKey); ok { - if cached, ok := obj.(*libraryRowsCacheValue); ok { - return cached, nil + value, err, _ := s.libraryRowsFlight.Do(cacheKey, func() (any, error) { + if s.cache != nil { + if obj, ok := s.cache.GetObject(cacheKey); ok { + if cached, ok := obj.(*libraryRowsCacheValue); ok { + return cached, nil + } } } - } - rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility) + loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute) + defer cancel() + rows, _, err := s.listAllMediaVisible(loadCtx, libraryID, visibility) + if err != nil { + return nil, err + } + // listAllMediaVisible 走 ListMediaVisible,行已带库元数据(resolver 的 + // key 计算依赖 DisplayLibraryPath/ID)。 + resolver, keys := resolveMediaSeriesKeys(rows) + episodes := make(map[string][]model.Media, len(rows)/4+1) + for i, row := range rows { + k := keys[i] + if k == "" { + continue + } + episodes[k] = append(episodes[k], row) + } + cards := groupMediaSeriesCardsByKeys(rows, keys) + value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes, Cards: cards} + if s.cache != nil { + s.cache.SetObject(cacheKey, value, s.derivedReadCacheTTL()) + } + return value, nil + }) if err != nil { return nil, err } - // listAllMediaVisible 走 ListMediaVisible,行已带库元数据(resolver 的 - // key 计算依赖 DisplayLibraryPath/ID)。 - resolver, keys := resolveMediaSeriesKeys(rows) - episodes := make(map[string][]model.Media, len(rows)/4+1) - for i, row := range rows { - k := keys[i] - if k == "" { - continue - } - episodes[k] = append(episodes[k], row) + if cached, ok := value.(*libraryRowsCacheValue); ok { + return cached, nil } - cards := groupMediaSeriesCardsByKeys(rows, keys) - value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes, Cards: cards} - if s.cache != nil { - s.cache.SetObject(cacheKey, value, s.derivedReadCacheTTL()) - } - return value, nil + return nil, nil } func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) { @@ -179,6 +219,30 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi if target.LibraryID == "" { return []model.Media{*target}, nil } + // 电影、音乐等单条目库不需要为了返回自身而加载整库。详情页会并行 + // 请求 /media/:id/episodes,未短路时每次冷缓存都会触发一次全库分组。 + if lib, err := s.repo.Library.FindByID(ctx, target.LibraryID); err == nil && lib != nil && + libraryUsesSingleMediaRows(lib.Type) && + strings.TrimSpace(target.SeriesID) == "" && + !mediaLooksEpisodicForGrouping(*target) { + return []model.Media{*target}, nil + } + if seriesID := strings.TrimSpace(target.SeriesID); seriesID != "" { + libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, target.LibraryID) + if err == nil && len(libraryIDs) > 0 { + rows, queryErr := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, maxMediaSearchLimit, repository.MediaQueryFilter{ + IncludeNSFW: visibility.IncludeNSFW, + AllowedLibraryIDs: visibility.AllowedLibraryIDs, + HiddenLibraryIDs: visibility.HiddenLibraryIDs, + SeriesID: seriesID, + }) + if queryErr == nil && len(rows) > 1 { + s.attachLibraryMetadata(ctx, rows) + sortEpisodesForDisplay(rows) + return rows, nil + } + } + } cache, err := s.libraryRowsWithIndex(ctx, target.LibraryID, visibility) if err != nil { return nil, err @@ -253,6 +317,15 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi return out, nil } +func libraryUsesSingleMediaRows(libraryType string) bool { + switch strings.ToLower(strings.TrimSpace(libraryType)) { + case "movie", "movies", "music", "adult": + return true + default: + return false + } +} + func (s *MediaService) listAllMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, int64, error) { visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID) diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go index 387203a..72a0d1d 100644 --- a/internal/service/media_series_test.go +++ b/internal/service/media_series_test.go @@ -594,6 +594,89 @@ func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) { } } +func TestListMediaEpisodesMovieTypeSkipsFullLibraryIndex(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + lib := model.Library{Base: model.Base{ID: "lib-movie-fast"}, Name: "电影", Type: "movie", Enabled: true} + if err := repos.DB.Create(&lib).Error; err != nil { + t.Fatal(err) + } + movie := model.Media{ + Base: model.Base{ID: "movie-fast"}, + LibraryID: lib.ID, + Title: "电影", + Path: "/media/movies/电影/电影.mkv", + } + if err := repos.DB.Create(&movie).Error; err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos). + SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop())) + eps, err := svc.ListMediaEpisodes(t.Context(), movie.ID, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if len(eps) != 1 || eps[0].ID != movie.ID { + t.Fatalf("ListMediaEpisodes got %#v, want the movie itself", eps) + } + if _, ok := svc.cache.GetObject(svc.libraryRowsCacheKey(lib.ID, MediaVisibility{IncludeNSFW: true})); ok { + t.Fatal("movie detail should not build a full-library episode index") + } +} + +func TestListMediaEpisodesUsesSeriesIDFastPath(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + lib := model.Library{Base: model.Base{ID: "lib-series-fast"}, Name: "剧集", Type: "tv", Enabled: true} + if err := repos.DB.Create(&lib).Error; err != nil { + t.Fatal(err) + } + rows := []model.Media{ + { + Base: model.Base{ID: "episode-2"}, + LibraryID: lib.ID, + SeriesID: "series-fast", + Title: "示例剧", + Path: "/media/tv/示例剧/Season 1/示例剧.S01E02.mkv", + SeasonNum: 1, + EpisodeNum: 2, + }, + { + Base: model.Base{ID: "episode-1"}, + LibraryID: lib.ID, + SeriesID: "series-fast", + Title: "示例剧", + Path: "/media/tv/示例剧/Season 1/示例剧.S01E01.mkv", + SeasonNum: 1, + EpisodeNum: 1, + }, + { + Base: model.Base{ID: "other-series"}, + LibraryID: lib.ID, + SeriesID: "other", + Title: "其他剧", + Path: "/media/tv/其他剧/Season 1/其他剧.S01E01.mkv", + }, + } + if err := repos.DB.Create(&rows).Error; err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos). + SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop())) + eps, err := svc.ListMediaEpisodes(t.Context(), "episode-1", MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if len(eps) != 2 || eps[0].ID != "episode-1" || eps[1].ID != "episode-2" { + t.Fatalf("SeriesID fast path got %#v, want both episodes in order", eps) + } + if _, ok := svc.cache.GetObject(svc.libraryRowsCacheKey(lib.ID, MediaVisibility{IncludeNSFW: true})); ok { + t.Fatal("SeriesID detail fast path should not build a full-library episode index") + } +} + func TestListLibrarySeriesCardsCachesPrecomputedCards(t *testing.T) { db := newServiceTestDB(t, &model.Library{}, &model.Media{}) repos := repository.New(db) diff --git a/web/src/api/playback.ts b/web/src/api/playback.ts index 3be394d..e5f1ee5 100644 --- a/web/src/api/playback.ts +++ b/web/src/api/playback.ts @@ -64,6 +64,11 @@ export const playbackAPI = { listFavouriteIDs: () => api.get<{ ids: string[] }>('/favourites', { params: { ids: 1 } }).then((r) => r.data.ids ?? []), + favouriteStatus: (mediaId: string) => + api + .get<{ favourite: boolean }>(`/media/${encodeURIComponent(mediaId)}/favorite/status`) + .then((r) => r.data.favourite), + listPlaylists: () => api.get<{ items: Playlist[] }>('/playlists').then((r) => r.data.items), diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 7e91859..9444ddf 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -112,16 +112,25 @@ export function HomePage() { try { const rows = await libraryAPI.listPreviews(batch, limit) loaded = true + const accepted = rows.filter( + (row) => (fetchedPreviewLimitsRef.current.get(row.id) ?? 0) < limit, + ) + accepted.forEach((row) => { + fetchedPreviewLimitsRef.current.set( + row.id, + Math.max(fetchedPreviewLimitsRef.current.get(row.id) ?? 0, limit), + ) + }) setLibraryData((prev) => { const next = { ...prev } - for (const row of rows) { + for (const row of accepted) { next[row.id] = { cards: row.cards ?? [], items: [], total: row.total ?? 0, } } - return next + return accepted.length > 0 ? next : prev }) } catch { // 单个批次失败不影响其他批次;导航回来时会重试。 @@ -155,8 +164,13 @@ export function HomePage() { .slice(0, 20) .filter((l) => !l.cover_url) .map((l) => l.id) - const initialTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds, ...topGridLibIds])) - void fetchPreviews(initialTargets, 10) + const shelfTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds])) + const shelfTargetSet = new Set(shelfTargets) + const gridTargets = topGridLibIds.filter((id) => !shelfTargetSet.has(id)) + // 入口网格只需要 2 张封面,横向货架才需要 10 张。分开请求可以避免 + // 为暂时不会出现的货架预取完整卡片窗口。 + void fetchPreviews(shelfTargets, 10) + void fetchPreviews(gridTargets, 2) }, [sortedLibraries, fetchPreviews]) // 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库 diff --git a/web/src/pages/HomePageSections.tsx b/web/src/pages/HomePageSections.tsx index df51cdb..31d548b 100644 --- a/web/src/pages/HomePageSections.tsx +++ b/web/src/pages/HomePageSections.tsx @@ -359,13 +359,13 @@ export function HomeLibrariesSection({ libraries: Library[] libraryData?: Record libraryCounts: Record - onNeedPreviews?: (ids: string[]) => void + onNeedPreviews?: (ids: string[], limit?: number) => void }) { const PAGE_SIZE = 20 const [currentPage, setCurrentPage] = useState(1) const totalPages = Math.max(1, Math.ceil(libraries.length / PAGE_SIZE)) const effectivePage = Math.min(currentPage, totalPages) - const queuePreview = useLazyPreviewBatch(onNeedPreviews) + const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2)) const pagedLibraries = useMemo(() => { const start = (effectivePage - 1) * PAGE_SIZE diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index 299beba..f02a838 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -26,37 +26,58 @@ export function LibrariesPage() { const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference() const [repairMsg, setRepairMsg] = useState('') - const fetchedLibIdsRef = useRef>(new Set()) - const fetchingRef = useRef>(new Set()) + // 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。 + const fetchedPreviewLimitsRef = useRef>(new Map()) + const fetchingPreviewLimitsRef = useRef>(new Map()) - const fetchPreviews = useCallback(async (ids: string[]) => { - const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id)) + const fetchPreviews = useCallback(async (ids: string[], limit = 10) => { + const targets = ids.filter( + (id) => + (fetchedPreviewLimitsRef.current.get(id) ?? 0) < limit && + (fetchingPreviewLimitsRef.current.get(id) ?? 0) < limit, + ) if (targets.length === 0) return - targets.forEach((id) => fetchingRef.current.add(id)) + targets.forEach((id) => fetchingPreviewLimitsRef.current.set(id, limit)) const batches = partitionPreviewIDs(targets) await Promise.allSettled( batches.map(async (batch) => { let loaded = false try { - const rows = await libraryAPI.listPreviews(batch, 10) + const rows = await libraryAPI.listPreviews(batch, limit) loaded = true + const accepted = rows.filter( + (row) => (fetchedPreviewLimitsRef.current.get(row.id) ?? 0) < limit, + ) + accepted.forEach((row) => { + fetchedPreviewLimitsRef.current.set( + row.id, + Math.max(fetchedPreviewLimitsRef.current.get(row.id) ?? 0, limit), + ) + }) setLibraryData((prev) => { const next = { ...prev } - for (const row of rows) { + for (const row of accepted) { next[row.id] = { cards: row.cards ?? [], total: row.total ?? 0, } } - return next + return accepted.length > 0 ? next : prev }) } catch { // 单个批次失败不影响其他批次。 } finally { batch.forEach((id) => { - if (loaded) fetchedLibIdsRef.current.add(id) - fetchingRef.current.delete(id) + if (loaded) { + fetchedPreviewLimitsRef.current.set( + id, + Math.max(fetchedPreviewLimitsRef.current.get(id) ?? 0, limit), + ) + } + if (fetchingPreviewLimitsRef.current.get(id) === limit) { + fetchingPreviewLimitsRef.current.delete(id) + } }) } }), diff --git a/web/src/pages/LibrariesPageSections.tsx b/web/src/pages/LibrariesPageSections.tsx index 5fa3cbd..c15d54a 100644 --- a/web/src/pages/LibrariesPageSections.tsx +++ b/web/src/pages/LibrariesPageSections.tsx @@ -112,10 +112,10 @@ export function LibrariesContent({ previews: LibraryPreview[] pinnedIds: string[] onTogglePin: (libraryId: string) => void - onNeedPreviews?: (ids: string[]) => void + onNeedPreviews?: (ids: string[], limit?: number) => void }) { const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length - const queuePreview = useLazyPreviewBatch(onNeedPreviews) + const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2)) // 下方媒体库货架支持向下滑动渐进流式加载:默认先展示前 3 个库货架, // 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库货架。 @@ -126,7 +126,7 @@ export function LibrariesContent({ useEffect(() => { const currentTargets = previews.slice(0, visibleCount).map((p) => p.library.id) - onNeedPreviews?.(currentTargets) + onNeedPreviews?.(currentTargets, 10) }, [previews, visibleCount, onNeedPreviews]) // 底部哨兵监听与滚动双保险(触底解锁后续媒体库货架) @@ -196,7 +196,7 @@ export function LibrariesContent({ useEffect(() => { const ids = pagedPreviews.filter((preview) => !preview.library.cover_url).map((preview) => preview.library.id) if (ids.length > 0) { - onNeedPreviews?.(ids) + onNeedPreviews?.(ids, 2) } }, [pagedPreviews, onNeedPreviews]) diff --git a/web/src/pages/LibrarySeriesEpisodes.tsx b/web/src/pages/LibrarySeriesEpisodes.tsx index aebf0eb..6b05456 100644 --- a/web/src/pages/LibrarySeriesEpisodes.tsx +++ b/web/src/pages/LibrarySeriesEpisodes.tsx @@ -87,8 +87,14 @@ export function LibrarySeriesEpisodes({ > {ep.backdrop_url || ep.poster_url ? ( diff --git a/web/src/pages/MediaDetailArtwork.tsx b/web/src/pages/MediaDetailArtwork.tsx index 35f17f8..dc118f6 100644 --- a/web/src/pages/MediaDetailArtwork.tsx +++ b/web/src/pages/MediaDetailArtwork.tsx @@ -14,8 +14,14 @@ export function MediaDetailBackdrop({ media }: MediaDetailArtworkProps) {
{media.backdrop_url || media.poster_url ? ( @@ -36,8 +42,14 @@ export function MediaDetailPoster({ media }: MediaDetailArtworkProps) { > {media.poster_url ? ( {media.title} diff --git a/web/src/pages/librariesPageModel.ts b/web/src/pages/librariesPageModel.ts index 30aec17..259f730 100644 --- a/web/src/pages/librariesPageModel.ts +++ b/web/src/pages/librariesPageModel.ts @@ -33,7 +33,7 @@ export function libraryArtworkItems(cards: SeriesCard[] = []): Array<{ src: stri version: card.rep.updated_at, })) .filter((item) => Boolean(item.src)) - .slice(0, 4) + .slice(0, 2) } export function getLibraryArtworks( diff --git a/web/src/pages/useMediaDetailPageState.ts b/web/src/pages/useMediaDetailPageState.ts index 68537ed..de26a53 100644 --- a/web/src/pages/useMediaDetailPageState.ts +++ b/web/src/pages/useMediaDetailPageState.ts @@ -148,7 +148,7 @@ function useMediaDetailRefresh({ // 三个请求并行发出;详情一到就解锁整页渲染,收藏状态与分集列表 // 到达后各自补齐(原先完全串行,首屏要排队等满三个往返)。 const nextMediaPromise = mediaAPI.get(id) - const favouritesPromise = playbackAPI.listFavourites().catch(() => []) + const favouritePromise = playbackAPI.favouriteStatus(id).catch(() => false) const episodesPromise = mediaAPI .getEpisodes(id) .then((r) => r.items ?? []) @@ -158,8 +158,7 @@ function useMediaDetailRefresh({ setMedia(nextMedia) setLoading(false) - const favourites = await favouritesPromise - setFavourite(favourites.some((item) => item.id === nextMedia.id)) + setFavourite(await favouritePromise) const episodes = await episodesPromise setEpisodes(episodes) diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index 0feb0ff..d36228b 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -33,7 +33,8 @@ import type { Media } from '../types' export type SeriesCard = { key: string rep: Media - linkMedia: Media + linkMedia?: Media + linkLibraryId?: string count: number last_added_at?: string } @@ -220,12 +221,13 @@ export function seasonLabel(season: number): string { } export function isSeriesCard(card: SeriesCard): boolean { + const linkMedia = card.linkMedia ?? card.rep return ( card.count > 1 || isEpisodeLike(card.rep) || - isEpisodeLike(card.linkMedia) || + isEpisodeLike(linkMedia) || pathLooksEpisodic(card.rep) || - pathLooksEpisodic(card.linkMedia) + pathLooksEpisodic(linkMedia) ) } @@ -474,10 +476,11 @@ export function groupSeries(items: Media[] = []): SeriesCard[] { } // Repeated movie IDs represent alternate locations/encodes, not // episodes. Fold the versions but keep the card in movie mode. - if (isEpisodeLike(m) || pathLooksEpisodic(m) || isEpisodeLike(g.linkMedia) || pathLooksEpisodic(g.linkMedia)) { + const currentLinkMedia = g.linkMedia ?? g.rep + if (isEpisodeLike(m) || pathLooksEpisodic(m) || isEpisodeLike(currentLinkMedia) || pathLooksEpisodic(currentLinkMedia)) { g.count += 1 } - if (betterSeriesLinkMedia(m, g.linkMedia)) { + if (betterSeriesLinkMedia(m, currentLinkMedia)) { g.linkMedia = m } if (betterSeriesRepresentative(m, g.rep)) { @@ -534,7 +537,8 @@ function mediaParentLooksLikeCollection(path?: string): boolean { export function seriesCardLink(card: SeriesCard): string { if (isSeriesCard(card)) { - return `/library/${targetLibraryID(card.linkMedia)}?series=${encodeURIComponent(card.key)}` + const targetLibrary = card.linkLibraryId || targetLibraryID(card.linkMedia ?? card.rep) + return `/library/${targetLibrary}?series=${encodeURIComponent(card.key)}` } return `/media/${card.rep.id}` }