diff --git a/internal/handler/media.go b/internal/handler/media.go index ec11266..9306dff 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/gin-gonic/gin" @@ -60,37 +61,37 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { } role, _ := c.Get(middleware.CtxUserRole) includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1") - if !includeHidden { - libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs) - visibility := mediaVisibilityForRequest(c, svc) - filtered := libs[:0] - for _, lib := range libs { - if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) { - filtered = append(filtered, lib) - } - } - libs = filtered - } - rawIDs := strings.TrimSpace(c.Query("ids")) - var targetSet map[string]struct{} - if rawIDs != "" { - targetSet = make(map[string]struct{}) - for _, id := range strings.Split(rawIDs, ",") { - id = strings.TrimSpace(id) - if id != "" { - targetSet[id] = struct{}{} - } + if !includeHidden { + libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs) + visibility := mediaVisibilityForRequest(c, svc) + filtered := libs[:0] + for _, lib := range libs { + if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) { + filtered = append(filtered, lib) } } - if len(targetSet) > 0 { - filtered := libs[:0] - for _, lib := range libs { - if _, ok := targetSet[lib.ID]; ok { - filtered = append(filtered, lib) - } + libs = filtered + } + rawIDs := strings.TrimSpace(c.Query("ids")) + var targetSet map[string]struct{} + if rawIDs != "" { + targetSet = make(map[string]struct{}) + for _, id := range strings.Split(rawIDs, ",") { + id = strings.TrimSpace(id) + if id != "" { + targetSet[id] = struct{}{} } - libs = filtered } + } + if len(targetSet) > 0 { + filtered := libs[:0] + for _, lib := range libs { + if _, ok := targetSet[lib.ID]; ok { + filtered = append(filtered, lib) + } + } + libs = filtered + } withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true" limit := 10 if withPreview { @@ -103,7 +104,12 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { } out := make([]webLibraryPayload, 0, len(libs)+8) if withPreview { - previews, err := svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit) + var previews []service.LibraryPreviewItem + if c.Query("include_total") == "0" { + previews, err = svc.Media.ListLibraryPreviews(ctx, libs, mediaVisibilityForRequest(c, svc), limit) + } else { + previews, err = svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit) + } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -111,41 +117,55 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { for _, p := range previews { out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards}) } - } else { - visibility := mediaVisibilityForRequest(c, svc) - libIDs := make([]string, len(libs)) - for i, l := range libs { - libIDs[i] = l.ID + } else { + visibility := mediaVisibilityForRequest(c, svc) + libIDs := make([]string, len(libs)) + for i, l := range libs { + libIDs[i] = l.ID + } + counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{ + IncludeNSFW: visibility.IncludeNSFW, + AllowedLibraryIDs: visibility.AllowedLibraryIDs, + HiddenLibraryIDs: visibility.HiddenLibraryIDs, + }) + for _, l := range libs { + var total int64 + if counts != nil { + total = counts[l.ID] } - counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{ - IncludeNSFW: visibility.IncludeNSFW, - AllowedLibraryIDs: visibility.AllowedLibraryIDs, - HiddenLibraryIDs: visibility.HiddenLibraryIDs, - }) - for _, l := range libs { - var total int64 - if counts != nil { - total = counts[l.ID] - } - out = append(out, webLibraryPayload{Library: l, Total: total}) + out = append(out, webLibraryPayload{Library: l, Total: total}) + } + } + // 精确指定目标库时,如果目标全是本地库,就不必枚举远程挂载。 + // 首页预览会拆成多个小批次请求,跳过无关远程调用可以明显缩短 + // 每批次的尾延迟;未指定 ids 的完整库列表仍保持原行为。 + includeRemote := true + if len(targetSet) > 0 { + includeRemote = false + for id := range targetSet { + if service.IsEmbyRemoteID(id) { + includeRemote = true + break } } + } + // 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。 - if svc.EmbyRemote != nil { + if includeRemote && svc.EmbyRemote != nil { if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil { - visibility := mediaVisibilityForRequest(c, svc) - allowedViews := make([]service.RemoteLibraryView, 0, len(views)) - for _, v := range views { - if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) { + visibility := mediaVisibilityForRequest(c, svc) + allowedViews := make([]service.RemoteLibraryView, 0, len(views)) + for _, v := range views { + if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) { + continue + } + if len(targetSet) > 0 { + if _, ok := targetSet[v.Library.ID]; !ok { continue } - if len(targetSet) > 0 { - if _, ok := targetSet[v.Library.ID]; !ok { - continue - } - } - allowedViews = append(allowedViews, v) } + allowedViews = append(allowedViews, v) + } remotePayloads := make([]webLibraryPayload, len(allowedViews)) for i, v := range allowedViews { remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName} @@ -170,13 +190,13 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { if acct == nil { return } - tmpMount := &model.EmbyMount{ - Base: model.Base{ID: v.MountID}, - AccountID: v.AccountID, - RemoteViewID: v.RemoteID, - CollectionType: v.CollectionType, - Name: v.Library.Name, - } + tmpMount := &model.EmbyMount{ + Base: model.Base{ID: v.MountID}, + AccountID: v.AccountID, + RemoteViewID: v.RemoteID, + CollectionType: v.CollectionType, + Name: v.Library.Name, + } itemTypes := remoteLibraryItemTypes(v.CollectionType) if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil { remotePayloads[i].Total = total @@ -421,9 +441,14 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc { }) return } + sortSpec := parseMediaSort(c) + var history map[string]time.Time + if sortSpec.Field == "last_played" { + history = mediaHistoryMap(c, svc) + } groupVersions := c.DefaultQuery("group_versions", "1") != "0" if !groupVersions { - items, total, err := svc.Media.ListMediaVisible(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc)) + items, total, err := svc.Media.ListMediaVisible(ctx, id, page, size, mediaVisibilityForRequest(c, svc)) if err != nil { writeInternalOrCanceled(c, err) return @@ -439,17 +464,19 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc { }) return } - items, total, err := svc.Media.ListMediaVisibleGrouped(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc)) + grouped, err := svc.Media.GroupedMediaVisible(ctx, id, mediaVisibilityForRequest(c, svc)) if err != nil { writeInternalOrCanceled(c, err) return } + grouped = service.SortMediaItems(grouped, sortSpec.Field, sortSpec.Order, history) + items := service.PaginateMediaItems(grouped, page, size) if items == nil { items = []service.MediaItem{} } c.JSON(http.StatusOK, gin.H{ "items": items, - "total": total, + "total": len(grouped), "page": page, "page_size": size, }) @@ -699,25 +726,25 @@ func streamHandler(svc *service.Container) gin.HandlerFunc { } return } - target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - // 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。 - // 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断; - // 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。 - if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") { - if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil { - if !c.Writer.Written() { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - } - } - return - } - setRedirectNoStoreHeaders(c) - c.Redirect(http.StatusFound, target) + target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return + } + // 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。 + // 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断; + // 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。 + if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") { + if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil { + if !c.Writer.Written() { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + } + } + return + } + setRedirectNoStoreHeaders(c) + c.Redirect(http.StatusFound, target) + return } m, err := svc.Media.GetMedia(ctx, id) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { diff --git a/internal/handler/media_sort.go b/internal/handler/media_sort.go new file mode 100644 index 0000000..5ba7fc7 --- /dev/null +++ b/internal/handler/media_sort.go @@ -0,0 +1,50 @@ +package handler + +import ( + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" +) + +func parseMediaSort(c *gin.Context) service.MediaSortSpec { + if c == nil { + return service.NormalizeMediaSort("", "") + } + return service.NormalizeMediaSort(c.Query("sort"), c.Query("order")) +} + +// mediaHistoryMap loads the current user's latest playback time per media ID. +// It is only used for the explicit last_played sort so normal pagination keeps +// its single-query path. +func mediaHistoryMap(c *gin.Context, svc *service.Container) map[string]time.Time { + if c == nil || svc == nil || svc.Repo == nil || svc.Repo.DB == nil { + return nil + } + uid, _ := c.Get(middleware.CtxUserID) + userID := strings.TrimSpace(toString(uid)) + if userID == "" { + return nil + } + var rows []struct { + MediaID string `gorm:"column:media_id"` + WatchedAt time.Time `gorm:"column:watched_at"` + } + if err := svc.Repo.DB.WithContext(c.Request.Context()). + Model(&model.PlaybackHistory{}). + Select("media_id, MAX(watched_at) AS watched_at"). + Where("user_id = ?", userID). + Group("media_id"). + Scan(&rows).Error; err != nil { + return nil + } + out := make(map[string]time.Time, len(rows)) + for _, row := range rows { + out[row.MediaID] = row.WatchedAt + } + return out +} diff --git a/internal/handler/series.go b/internal/handler/series.go index 054f269..c9ac3fe 100644 --- a/internal/handler/series.go +++ b/internal/handler/series.go @@ -9,6 +9,7 @@ import ( "net/http" "sort" "strconv" + "time" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -66,6 +67,11 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { libID := c.Param("id") ctx := c.Request.Context() + sortSpec := parseMediaSort(c) + var history map[string]time.Time + if sortSpec.Field == "last_played" { + history = mediaHistoryMap(c, svc) + } // 远程剧集库:远程 Series 映射为系列卡片。 if svc.EmbyRemote != nil && service.IsEmbyRemoteID(libID) { mountID, remoteID, _ := service.DecodeEmbyRemoteID(libID) @@ -83,6 +89,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc { writeInternalOrCanceled(c, err) return } + cards = service.SortSeriesCards(cards, sortSpec.Field, sortSpec.Order, history) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500")) if page < 1 { @@ -122,6 +129,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc { writeInternalOrCanceled(c, err) return } + items = service.SortSeriesCards(items, sortSpec.Field, sortSpec.Order, history) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500")) if page < 1 { diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go index 62f99df..a0aaa0b 100644 --- a/internal/repository/media_repository.go +++ b/internal/repository/media_repository.go @@ -100,6 +100,26 @@ func (r *MediaRepository) ListByLibrariesFilteredNoCount(ctx context.Context, li return items, err } +// ListAllByLibrariesFilteredNoCount loads every matching row without issuing a +// COUNT query. Full-library consumers such as series grouping must scan the +// whole library anyway, so calling it once is both cheaper and more consistent +// than issuing paginated queries with repeated counts. +func (r *MediaRepository) ListAllByLibrariesFilteredNoCount(ctx context.Context, libraryIDs []string, filter MediaQueryFilter) ([]model.Media, error) { + items := make([]model.Media, 0) + if len(libraryIDs) == 0 { + return items, nil + } + 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) + } + q = applyMediaQueryFilter(q, filter) + err := q.Order("release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC").Find(&items).Error + return items, 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 diff --git a/internal/service/media_cache.go b/internal/service/media_cache.go index b590439..39f3927 100644 --- a/internal/service/media_cache.go +++ b/internal/service/media_cache.go @@ -35,7 +35,7 @@ func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string, return "media:list:" + hex.EncodeToString(sum[:]) } -func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLimit int, filter repository.MediaQueryFilter) string { +func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLimit int, filter repository.MediaQueryFilter, includeCounts bool) string { libIDs := make([]string, len(libraries)) for i, lib := range libraries { libIDs[i] = lib.ID @@ -48,7 +48,7 @@ func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLim sum := sha1.Sum([]byte(strings.Join([]string{ "preview", strings.Join(libIDs, ","), - fmt.Sprintf("%d:%t", cardLimit, filter.IncludeNSFW), + fmt.Sprintf("%d:%t:%t", cardLimit, filter.IncludeNSFW, includeCounts), strings.Join(allowed, ","), strings.Join(hidden, ","), }, "|"))) @@ -87,15 +87,19 @@ func hashObjectCacheKey(parts []string) string { return hex.EncodeToString(sum[:]) } -// mediaGroupedRowsCacheKey 版本分组源行(已挂库元数据)的对象缓存。 -func (s *MediaService) mediaGroupedRowsCacheKey(libraryID string, libraryIDs []string, filter repository.MediaQueryFilter) string { +// groupedItemsCacheKey caches the fully grouped version list. Version grouping +// requires scanning every row in the library, so recomputing it for each page +// request makes pagination O(pages × library size) instead of O(library size). +// Only the grouped result is cached; caching the ungrouped rows as well would +// duplicate large libraries in the process memory for no additional hit path. +func (s *MediaService) groupedItemsCacheKey(libraryID string, libraryIDs []string, filter repository.MediaQueryFilter) string { libs := append([]string(nil), libraryIDs...) sort.Strings(libs) allowed := append([]string(nil), filter.AllowedLibraryIDs...) hidden := append([]string(nil), filter.HiddenLibraryIDs...) sort.Strings(allowed) sort.Strings(hidden) - return "media:obj:grouped-rows:" + hashObjectCacheKey([]string{ + return "media:obj:grouped-items:" + hashObjectCacheKey([]string{ libraryID, strings.Join(libs, ","), fmt.Sprintf("%t", filter.IncludeNSFW), diff --git a/internal/service/media_display_library.go b/internal/service/media_display_library.go index 2b1c668..072a6aa 100644 --- a/internal/service/media_display_library.go +++ b/internal/service/media_display_library.go @@ -2,6 +2,7 @@ package service import ( "context" + "sort" "strings" "github.com/truewhile/MeBox/internal/model" @@ -12,14 +13,12 @@ func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model. if s == nil || s.repo == nil || s.repo.Library == nil || len(items) == 0 { return } - libs, err := s.repo.Library.List(ctx) + libs, err := s.displayLibraries(ctx) if err != nil { return } byID := make(map[string]model.Library, len(libs)) - for i := range libs { - libs[i] = normalizeLocalLibraryPathForDisplay(libs[i]) - lib := libs[i] + for _, lib := range libs { byID[lib.ID] = lib } resolver := newMediaDisplayLibraryResolver(ctx, s.repo, libs) @@ -44,11 +43,39 @@ func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model. } } +func (s *MediaService) displayLibraries(ctx context.Context) ([]model.Library, error) { + const cacheKey = "media:obj:library-metadata" + if s.cache != nil { + if cachedObj, ok := s.cache.GetObject(cacheKey); ok { + if cached, ok := cachedObj.([]model.Library); ok { + return cached, nil + } + } + } + libs, err := s.repo.Library.List(ctx) + if err != nil { + return nil, err + } + for i := range libs { + libs[i] = normalizeLocalLibraryPathForDisplay(libs[i]) + } + if s.cache != nil { + s.cache.SetObject(cacheKey, libs, s.mediaObjectTTL()) + } + return libs, nil +} + type mediaDisplayLibraryResolver struct { byID map[string]model.Library displayByID map[string]model.Library displayByMergeKey map[string]model.Library displayLibraries []model.Library + localDisplays []mediaLocalDisplayLibrary +} + +type mediaLocalDisplayLibrary struct { + library model.Library + path string } func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Container, libs []model.Library) mediaDisplayLibraryResolver { @@ -58,6 +85,7 @@ func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Contai displayByID: make(map[string]model.Library, len(displayLibraries)), displayByMergeKey: make(map[string]model.Library, len(displayLibraries)), displayLibraries: displayLibraries, + localDisplays: make([]mediaLocalDisplayLibrary, 0, len(displayLibraries)), } for _, lib := range libs { normalized := normalizeLocalLibraryPathForDisplay(lib) @@ -70,7 +98,23 @@ func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Contai resolver.displayByMergeKey[key] = lib } } + if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled { + continue + } + displayPath := cleanPathForVolumeMapping(resolveMappedDestinationPath(lib.Path)) + if displayPath == "" || displayPath == "." { + continue + } + resolver.localDisplays = append(resolver.localDisplays, mediaLocalDisplayLibrary{ + library: lib, + path: displayPath, + }) } + // 最长路径优先:一次命中就是原逻辑中最具体的媒体库,避免每条媒体都 + // 重新规范化全部库路径并扫描整个库列表。 + sort.SliceStable(resolver.localDisplays, func(i, j int) bool { + return len(resolver.localDisplays[i].path) > len(resolver.localDisplays[j].path) + }) return resolver } @@ -172,26 +216,10 @@ func (r mediaDisplayLibraryResolver) bestPathDisplayLibrary(media model.Media) ( if isRelativeVolumeMarkerPath(media.Path) { mediaPath = cleanPathForVolumeMapping(resolveMappedDestinationPath(media.Path)) } - var best model.Library - bestLen := 0 - for _, lib := range r.displayLibraries { - if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled { - continue + for _, display := range r.localDisplays { + if mediaPath == display.path || strings.HasPrefix(mediaPath, strings.TrimRight(display.path, "/")+"/") { + return display.library, true } - libPath := cleanPathForVolumeMapping(resolveMappedDestinationPath(lib.Path)) - if libPath == "" || libPath == "." { - continue - } - if mediaPath != libPath && !strings.HasPrefix(mediaPath, strings.TrimRight(libPath, "/")+"/") { - continue - } - if len(libPath) > bestLen { - best = lib - bestLen = len(libPath) - } - } - if bestLen > 0 { - return best, true } return model.Library{}, false } diff --git a/internal/service/media_display_library_test.go b/internal/service/media_display_library_test.go new file mode 100644 index 0000000..f94141b --- /dev/null +++ b/internal/service/media_display_library_test.go @@ -0,0 +1,43 @@ +package service + +import ( + "path/filepath" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +func TestMediaDisplayLibraryResolverUsesMostSpecificPath(t *testing.T) { + root := t.TempDir() + parentPath := filepath.Join(root, "media") + nestedPath := filepath.Join(parentPath, "anime") + parent := model.Library{ + Base: model.Base{ID: "parent"}, + Name: "媒体", + Path: parentPath, + Enabled: true, + } + nested := model.Library{ + Base: model.Base{ID: "nested"}, + Name: "动漫", + Path: nestedPath, + Enabled: true, + } + + resolver := newMediaDisplayLibraryResolver(t.Context(), nil, []model.Library{parent, nested}) + got, ok := resolver.DisplayLibraryForMedia(model.Media{ + LibraryID: parent.ID, + Path: filepath.Join(nestedPath, "Show", "S01E01.mkv"), + }) + if !ok || got.ID != nested.ID { + t.Fatalf("display library = %q, %t; want nested library", got.ID, ok) + } + + got, ok = resolver.DisplayLibraryForMedia(model.Media{ + LibraryID: parent.ID, + Path: filepath.Join(parentPath, "Movie", "Movie.mkv"), + }) + if !ok || got.ID != parent.ID { + t.Fatalf("display library = %q, %t; want parent library", got.ID, ok) + } +} diff --git a/internal/service/media_library.go b/internal/service/media_library.go index 05015b8..9baa960 100644 --- a/internal/service/media_library.go +++ b/internal/service/media_library.go @@ -26,6 +26,17 @@ func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, erro // ListLibrariesWithPreview returns libraries populated with item counts and latest preview cards. func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) { + return s.listLibrariesWithPreview(ctx, libraries, visibility, cardLimit, true) +} + +// ListLibraryPreviews returns only the latest preview cards. The metadata +// endpoint already returns totals, so preview batches used by the home and +// library pages can skip an otherwise repeated COUNT(*) over every library. +func (s *MediaService) ListLibraryPreviews(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) { + return s.listLibrariesWithPreview(ctx, libraries, visibility, cardLimit, false) +} + +func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int, includeCounts bool) ([]LibraryPreviewItem, error) { if cardLimit <= 0 { cardLimit = 10 } @@ -40,7 +51,7 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [ AllowedLibraryIDs: visibility.AllowedLibraryIDs, HiddenLibraryIDs: visibility.HiddenLibraryIDs, } - cacheKey := s.libraryPreviewCacheKey(libraries, cardLimit, filter) + cacheKey := s.libraryPreviewCacheKey(libraries, cardLimit, filter, includeCounts) var cached libraryPreviewCacheValue if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) { return cached.Items, nil @@ -56,22 +67,26 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [ libIDs = append(libIDs, lib.ID) } - counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter) - if err != nil { - return nil, err - } - - for i := range out { - if total, ok := counts[out[i].ID]; ok { - out[i].Total = total + if includeCounts { + counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter) + if err != nil { + return nil, err + } + for i := range out { + if total, ok := counts[out[i].ID]; ok { + out[i].Total = total + } } } - fetchCount := cardLimit * 4 - if fetchCount < 60 { - fetchCount = 60 - } else if fetchCount > 200 { - fetchCount = 200 + // Preview rows are only an internal candidate window. Keep it bounded so a + // 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 + } else if fetchCount > 400 { + fetchCount = 400 } recentByLibrary, err := s.repo.Media.ListRecentByLibraries(ctx, libIDs, fetchCount, filter) @@ -81,9 +96,6 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [ allPreviewItems := make([]model.Media, 0, len(libIDs)*fetchCount) for i := range out { - if out[i].Total == 0 { - continue - } items := recentByLibrary[out[i].ID] if len(items) == 0 { continue @@ -93,28 +105,18 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [ s.attachLibraryMetadata(ctx, allPreviewItems) for i := range out { - if out[i].Total == 0 { - continue - } items := recentByLibrary[out[i].ID] if len(items) == 0 { continue } - cards := groupMediaSeriesCards(items) - // 如果折叠后的作品部数不足 cardLimit,且该库总记录数大于当前提取的条目数, - // 说明多集剧集折叠占满了提取窗口,调用 ListLibrarySeriesCards 补齐完整的影视部数。 - if len(cards) < cardLimit && out[i].Total > int64(len(items)) { - if fullCards, _, err := s.ListLibrarySeriesCards(ctx, out[i].ID, visibility); err == nil && len(fullCards) > 0 { - cards = fullCards - } - } - if len(cards) > cardLimit { - cards = cards[:cardLimit] - } - if cards == nil { - cards = []SeriesCard{} - } - out[i].Cards = cards + cards := groupMediaSeriesCards(items) + if len(cards) > cardLimit { + cards = cards[:cardLimit] + } + if cards == nil { + cards = []SeriesCard{} + } + out[i].Cards = cards } if s.cache != nil { diff --git a/internal/service/media_library_test.go b/internal/service/media_library_test.go index d858b6a..02dd5a5 100644 --- a/internal/service/media_library_test.go +++ b/internal/service/media_library_test.go @@ -155,3 +155,28 @@ func TestListLibrariesWithPreviewLongSeriesCompletion(t *testing.T) { t.Fatalf("previews[0].Cards count = %d, want 2", len(previews[0].Cards)) } } + +func TestListLibraryPreviewsSkipsTotals(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) + previews, err := svc.ListLibraryPreviews(t.Context(), []model.Library{lib}, MediaVisibility{IncludeNSFW: true}, 10) + if err != nil { + t.Fatal(err) + } + if len(previews) != 1 || previews[0].Total != 0 || len(previews[0].Cards) != 1 { + t.Fatalf("preview-only result = %#v, want one card and no count", previews) + } +} diff --git a/internal/service/media_listing.go b/internal/service/media_listing.go index 32ba720..44bfadc 100644 --- a/internal/service/media_listing.go +++ b/internal/service/media_listing.go @@ -56,14 +56,45 @@ func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, p func (s *MediaService) ListMediaVisibleGrouped(ctx context.Context, libraryID string, page, pageSize int, visibility MediaVisibility) ([]MediaItem, int64, error) { page, pageSize = normalizeGroupedMediaPage(page, pageSize) - items, err := s.listMediaVisibleForGrouping(ctx, libraryID, visibility) + grouped, err := s.GroupedMediaVisible(ctx, libraryID, visibility) if err != nil { return nil, 0, err } - grouped := groupMediaVersions(items) return paginateMediaItems(grouped, page, pageSize), int64(len(grouped)), nil } +// GroupedMediaVisible returns the complete version-grouped media list before pagination. +// The result is cached as an immutable slice; sort/pagination callers must copy it before mutating. +func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]MediaItem, error) { + visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) + libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID) + if err != nil { + return nil, err + } + filter := repository.MediaQueryFilter{ + IncludeNSFW: visibility.IncludeNSFW, + AllowedLibraryIDs: visibility.AllowedLibraryIDs, + 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 + } + } + } + items, err := s.listMediaVisibleForGrouping(ctx, 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 +} + func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, error) { visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID) @@ -75,30 +106,19 @@ func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryI AllowedLibraryIDs: visibility.AllowedLibraryIDs, HiddenLibraryIDs: visibility.HiddenLibraryIDs, } - cacheKey := s.mediaGroupedRowsCacheKey(libraryID, libraryIDs, filter) - if s.cache != nil { - if cachedObj, ok := s.cache.GetObject(cacheKey); ok { - if cached, ok := cachedObj.([]model.Media); ok { - // 对象缓存中的切片视为不可变;attachLibraryMetadata 会在填充时 - // 执行过,命中路径直接返回副本即可(调用方只读)。 - return cached, nil - } - } - } - items, total, err := s.repo.Media.ListByLibrariesFiltered(ctx, libraryIDs, 0, maxMediaSearchLimit, filter) + // 版本分组的 URL 分页发生在 Go 进程内,响应里的 total 是分组后的数量, + // 不需要数据库再为原始行做一次 COUNT(*)。全量 COUNT 在超大媒体库上 + // 会重复扫描整个 library_id 范围,而这里只关心是否存在截断风险。 + items, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, maxMediaSearchLimit, filter) if err != nil { return nil, err } - if total > int64(len(items)) && s.log != nil { - s.log.Warn("media version grouping truncated by safety limit", + if len(items) >= maxMediaSearchLimit && s.log != nil { + s.log.Warn("media version grouping may be truncated by safety limit", zap.String("library_id", libraryID), - zap.Int64("total", total), zap.Int("limit", maxMediaSearchLimit)) } s.attachLibraryMetadata(ctx, items) - if s.cache != nil { - s.cache.SetObject(cacheKey, items, s.mediaObjectTTL()) - } return items, nil } diff --git a/internal/service/media_series.go b/internal/service/media_series.go index c29ee3a..d0c7e9b 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -10,6 +10,7 @@ import ( "time" "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) type seriesCardsCacheValue struct { @@ -24,6 +25,7 @@ type libraryRowsCacheValue struct { Rows []model.Media Resolver mediaSeriesKeyResolver Episodes map[string][]model.Media + Cards []SeriesCard } type SeriesCard struct { @@ -41,6 +43,7 @@ type seriesCardGroup struct { // libraryRowsWithIndex 返回整库行与预计算剧集索引(带对象缓存)。 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 { @@ -55,16 +58,17 @@ func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID strin } // listAllMediaVisible 走 ListMediaVisible,行已带库元数据(resolver 的 // key 计算依赖 DisplayLibraryPath/ID)。 - resolver := newMediaSeriesKeyResolver(rows) + resolver, keys := resolveMediaSeriesKeys(rows) episodes := make(map[string][]model.Media, len(rows)/4+1) - for _, row := range rows { - k := resolver.key(row) + for i, row := range rows { + k := keys[i] if k == "" { continue } episodes[k] = append(episodes[k], row) } - value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes} + cards := groupMediaSeriesCardsByKeys(rows, keys) + value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes, Cards: cards} if s.cache != nil { s.cache.SetObject(cacheKey, value, s.mediaObjectTTL()) } @@ -72,7 +76,6 @@ func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID strin } func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) { - visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) cacheKey := s.libraryCardsObjectKey(libraryID, visibility) if s.cache != nil { if obj, ok := s.cache.GetObject(cacheKey); ok { @@ -85,7 +88,12 @@ func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID str if err != nil { return nil, 0, err } - cards := groupMediaSeriesCards(rows.Rows) + cards := rows.Cards + if cards == nil && len(rows.Rows) > 0 { + // Fallback keeps rolling-cache compatibility if an older runtime cache + // value was created before Cards was added. + cards = groupMediaSeriesCards(rows.Rows) + } if cards == nil { cards = []SeriesCard{} } @@ -190,40 +198,40 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi } } - // 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影) - if len(out) <= 1 && target.Path != "" { - targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/")) - parentBase := filepath.Base(targetDir) - if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) { - targetTitleNorm := normalizeSeriesTitle(target.Title) - targetDirNorm := normalizeSeriesTitle(parentBase) - dirMatches := make([]model.Media, 0) - for _, row := range rows { - if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir { - continue - } - if row.ID == target.ID { - dirMatches = append(dirMatches, row) - continue - } - rowTitleNorm := normalizeSeriesTitle(row.Title) - allowMatch := false - if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) { - allowMatch = true - } else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm { - allowMatch = true - } else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm { - allowMatch = true - } - if allowMatch { - dirMatches = append(dirMatches, row) - } + // 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影) + if len(out) <= 1 && target.Path != "" { + targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/")) + parentBase := filepath.Base(targetDir) + if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) { + targetTitleNorm := normalizeSeriesTitle(target.Title) + targetDirNorm := normalizeSeriesTitle(parentBase) + dirMatches := make([]model.Media, 0) + for _, row := range rows { + if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir { + continue } - if len(dirMatches) > 1 { - out = dirMatches + if row.ID == target.ID { + dirMatches = append(dirMatches, row) + continue + } + rowTitleNorm := normalizeSeriesTitle(row.Title) + allowMatch := false + if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) { + allowMatch = true + } else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm { + allowMatch = true + } else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm { + allowMatch = true + } + if allowMatch { + dirMatches = append(dirMatches, row) } } + if len(dirMatches) > 1 { + out = dirMatches + } } + } if len(out) == 0 { out = []model.Media{*target} @@ -246,35 +254,47 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi } func (s *MediaService) listAllMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, int64, error) { - const pageSize = 2000 - var all []model.Media - var total int64 - for page := 1; ; page++ { - rows, n, err := s.ListMediaVisible(ctx, libraryID, page, pageSize, visibility) - if err != nil { - return nil, 0, err - } - if page == 1 { - total = n - all = make([]model.Media, 0, minInt64(n, pageSize)) - } - all = append(all, rows...) - if int64(len(all)) >= n || len(rows) < pageSize { - break - } + visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) + libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID) + if err != nil { + return nil, 0, err } - return all, total, nil + filter := repository.MediaQueryFilter{ + IncludeNSFW: visibility.IncludeNSFW, + AllowedLibraryIDs: visibility.AllowedLibraryIDs, + HiddenLibraryIDs: visibility.HiddenLibraryIDs, + } + rows, err := s.repo.Media.ListAllByLibrariesFilteredNoCount(ctx, libraryIDs, filter) + if err != nil { + return nil, 0, err + } + s.attachLibraryMetadata(ctx, rows) + return rows, int64(len(rows)), nil } func groupMediaSeriesCards(items []model.Media) []SeriesCard { if len(items) == 0 { return nil } + _, keys := resolveMediaSeriesKeys(items) + return groupMediaSeriesCardsByKeys(items, keys) +} + +// groupMediaSeriesCardsByKeys performs the card fold using already-resolved +// keys. Full-library caches need the same keys for the episode index and the +// series-card list; resolving them once avoids several regex-heavy passes over +// every episode row. +func groupMediaSeriesCardsByKeys(items []model.Media, keys []string) []SeriesCard { + if len(items) == 0 { + return nil + } + if len(keys) != len(items) { + return groupMediaSeriesCards(items) + } groups := make([]seriesCardGroup, 0) byKey := make(map[string]int, len(items)) - resolver := newMediaSeriesKeyResolver(items) - for _, item := range items { - key := resolver.key(item) + for i, item := range items { + key := keys[i] if key == "" { continue } diff --git a/internal/service/media_series_resolver.go b/internal/service/media_series_resolver.go index feae461..bfcb23c 100644 --- a/internal/service/media_series_resolver.go +++ b/internal/service/media_series_resolver.go @@ -14,75 +14,123 @@ type mediaSeriesKeyResolver struct { pathTitles map[string]string } +// mediaSeriesKeyInputs 保存单条媒体解析剧集 key 所需的派生字段。剧集卡片、 +// 剧集索引和分页分组需要反复读取同一批行;提前计算一次可以避免对每行执行 +// 多轮正则匹配、路径解析和标题规范化。 +type mediaSeriesKeyInputs struct { + episodic bool + pathKey string + externalKey string + titleKey string +} + +func analyzeMediaSeriesKey(item model.Media) mediaSeriesKeyInputs { + if !mediaLooksEpisodicForGrouping(item) { + return mediaSeriesKeyInputs{} + } + return mediaSeriesKeyInputs{ + episodic: true, + pathKey: mediaSeriesRawKey(item), + externalKey: repeatedSeriesExternalKey(item), + titleKey: repeatedSeriesTitleKey(item), + } +} + +func analyzeMediaSeriesKeys(items []model.Media) []mediaSeriesKeyInputs { + inputs := make([]mediaSeriesKeyInputs, len(items)) + for i := range items { + inputs[i] = analyzeMediaSeriesKey(items[i]) + } + return inputs +} + func newMediaSeriesKeyResolver(items []model.Media) mediaSeriesKeyResolver { + return newMediaSeriesKeyResolverFromInputs(analyzeMediaSeriesKeys(items)) +} + +func newMediaSeriesKeyResolverFromInputs(inputs []mediaSeriesKeyInputs) mediaSeriesKeyResolver { resolver := mediaSeriesKeyResolver{ pathCounts: make(map[string]int), externalCounts: make(map[string]int), titleCounts: make(map[string]int), pathTitles: make(map[string]string), } - for _, item := range items { - if !mediaLooksEpisodicForGrouping(item) { + pathTitleCandidates := make(map[string]map[string]struct{}) + for _, input := range inputs { + if !input.episodic { continue } - if key := mediaSeriesRawKey(item); strings.HasPrefix(key, "library-path") { - resolver.pathCounts[key]++ + if strings.HasPrefix(input.pathKey, "library-path") { + resolver.pathCounts[input.pathKey]++ + if input.titleKey != "" { + if pathTitleCandidates[input.pathKey] == nil { + pathTitleCandidates[input.pathKey] = make(map[string]struct{}) + } + pathTitleCandidates[input.pathKey][input.titleKey] = struct{}{} + } } - if key := repeatedSeriesExternalKey(item); key != "" { - resolver.externalCounts[key]++ + if input.externalKey != "" { + resolver.externalCounts[input.externalKey]++ } - if key := repeatedSeriesTitleKey(item); key != "" { - resolver.titleCounts[key]++ + if input.titleKey != "" { + resolver.titleCounts[input.titleKey]++ } } // A scraper can normalize the same show to one title while the source // release folders still contain different tags (1080p/2160p, uploader // names, etc.). Remember an unambiguous title alias for each path group so // those folders are bridged instead of rendered as separate collections. - pathTitleCandidates := make(map[string]map[string]struct{}) - for _, item := range items { - if !mediaLooksEpisodicForGrouping(item) { - continue - } - pathKey := mediaSeriesRawKey(item) - titleKey := repeatedSeriesTitleKey(item) - if !strings.HasPrefix(pathKey, "library-path") || titleKey == "" || resolver.titleCounts[titleKey] < 2 { - continue - } - if pathTitleCandidates[pathKey] == nil { - pathTitleCandidates[pathKey] = make(map[string]struct{}) - } - pathTitleCandidates[pathKey][titleKey] = struct{}{} - } for pathKey, candidates := range pathTitleCandidates { - if len(candidates) != 1 { - continue - } + repeatedTitle := "" + repeatedCount := 0 for titleKey := range candidates { - resolver.pathTitles[pathKey] = titleKey + if resolver.titleCounts[titleKey] < 2 { + continue + } + repeatedTitle = titleKey + repeatedCount++ + } + if repeatedCount == 1 { + resolver.pathTitles[pathKey] = repeatedTitle } } return resolver } +// resolveMediaSeriesKeys 对输入只做一轮派生字段计算,返回可供后续 O(1) 解析的 +// resolver 以及每条记录对应的 key。这是整库剧集加载的快速路径。 +func resolveMediaSeriesKeys(items []model.Media) (mediaSeriesKeyResolver, []string) { + inputs := analyzeMediaSeriesKeys(items) + resolver := newMediaSeriesKeyResolverFromInputs(inputs) + keys := make([]string, len(items)) + for i := range items { + keys[i] = resolver.keyFromInputs(items[i], inputs[i]) + } + return resolver, keys +} + func (r mediaSeriesKeyResolver) key(media model.Media) string { - if mediaLooksEpisodicForGrouping(media) { - if pathKey := mediaSeriesRawKey(media); strings.HasPrefix(pathKey, "library-path") { - if titleKey := r.pathTitles[pathKey]; titleKey != "" { + return r.keyFromInputs(media, analyzeMediaSeriesKey(media)) +} + +func (r mediaSeriesKeyResolver) keyFromInputs(media model.Media, input mediaSeriesKeyInputs) string { + if input.episodic { + if strings.HasPrefix(input.pathKey, "library-path") { + if titleKey := r.pathTitles[input.pathKey]; titleKey != "" { return compactSeriesKey(titleKey) } // A series directory is the strongest identity for mixed rows: // main episodes and specials (CM/NCOP/PV/OVA) may be scraped to // slightly different titles, but they still belong to one show. - if r.pathCounts[pathKey] > 1 { - return compactSeriesKey(pathKey) + if r.pathCounts[input.pathKey] > 1 { + return compactSeriesKey(input.pathKey) } } - if key := repeatedSeriesTitleKey(media); key != "" && r.titleCounts[key] > 1 { - return compactSeriesKey(key) + if input.titleKey != "" && r.titleCounts[input.titleKey] > 1 { + return compactSeriesKey(input.titleKey) } - if key := repeatedSeriesExternalKey(media); key != "" && r.externalCounts[key] > 1 { - return compactSeriesKey(key) + if input.externalKey != "" && r.externalCounts[input.externalKey] > 1 { + return compactSeriesKey(input.externalKey) } } return mediaSeriesKey(media) diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go index 1247352..387203a 100644 --- a/internal/service/media_series_test.go +++ b/internal/service/media_series_test.go @@ -593,3 +593,47 @@ func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) { t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps) } } + +func TestListLibrarySeriesCardsCachesPrecomputedCards(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + lib := model.Library{Name: "动画", Path: "/media/anime", Type: "anime", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + rows := []model.Media{ + {LibraryID: lib.ID, Title: "示例动画", Path: "/media/anime/示例动画/S01E01.mkv", SeasonNum: 1, EpisodeNum: 1}, + {LibraryID: lib.ID, Title: "示例动画", Path: "/media/anime/示例动画/S01E02.mkv", SeasonNum: 1, EpisodeNum: 2}, + } + 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())) + visibility := MediaVisibility{IncludeNSFW: true} + cards, total, err := svc.ListLibrarySeriesCards(t.Context(), lib.ID, visibility) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(cards) != 1 || cards[0].Count != 2 { + t.Fatalf("cold series cards = %#v, total = %d; want one two-episode card", cards, total) + } + + cachedObj, ok := svc.cache.GetObject(svc.libraryRowsCacheKey(lib.ID, visibility)) + if !ok { + t.Fatal("expected full library rows and precomputed cards to be cached") + } + cached, ok := cachedObj.(*libraryRowsCacheValue) + if !ok || cached.Cards == nil || len(cached.Cards) != 1 { + t.Fatalf("cached rows value = %#v, want one precomputed card", cachedObj) + } + + cards, total, err = svc.ListLibrarySeriesCards(t.Context(), lib.ID, visibility) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(cards) != 1 || cards[0].Count != 2 { + t.Fatalf("warm series cards = %#v, total = %d; want cached result", cards, total) + } +} diff --git a/internal/service/media_sort.go b/internal/service/media_sort.go new file mode 100644 index 0000000..6a10d27 --- /dev/null +++ b/internal/service/media_sort.go @@ -0,0 +1,256 @@ +package service + +import ( + "sort" + "strings" + "time" + + "github.com/truewhile/MeBox/internal/model" +) + +// MediaSortSpec is the normalized sort request shared by media and series endpoints. +type MediaSortSpec struct { + Field string + Order string +} + +// NormalizeMediaSort validates the request against the same fields the web UI +// exposes. Unknown values fall back to the historical release-date ordering. +func NormalizeMediaSort(field, order string) MediaSortSpec { + field = strings.ToLower(strings.TrimSpace(field)) + switch field { + case "title", "release_date", "year", "created_at", "updated_at", "rating", "duration", "bitrate", "last_played", "random": + case "imdb_rating": + field = "rating" + default: + field = "release_date" + } + + order = strings.ToLower(strings.TrimSpace(order)) + if order != "asc" && order != "desc" { + if field == "title" { + order = "asc" + } else { + order = "desc" + } + } + if field == "random" { + // The web client shuffles the complete result so pagination stays stable. + field = "release_date" + order = "desc" + } + return MediaSortSpec{Field: field, Order: order} +} + +// SortMediaItems returns a sorted copy of the grouped media list. The input is +// often backed by an immutable runtime cache, so callers must not rely on the +// returned slice aliasing it. +func SortMediaItems(items []MediaItem, field, order string, history map[string]time.Time) []MediaItem { + spec := NormalizeMediaSort(field, order) + out := append([]MediaItem(nil), items...) + if len(out) < 2 { + return out + } + sort.SliceStable(out, func(i, j int) bool { + cmp := compareMediaByField(out[i], out[j], spec, history) + if cmp == 0 { + cmp = compareID(out[i].ID, out[j].ID) + } + return cmp < 0 + }) + return out +} + +// SortSeriesCards returns a sorted copy of series cards. +func SortSeriesCards(cards []SeriesCard, field, order string, history map[string]time.Time) []SeriesCard { + spec := NormalizeMediaSort(field, order) + out := append([]SeriesCard(nil), cards...) + if len(out) < 2 { + return out + } + sort.SliceStable(out, func(i, j int) bool { + cmp := compareSeriesByField(out[i], out[j], spec, history) + if cmp == 0 { + cmp = compareID(out[i].Key, out[j].Key) + } + return cmp < 0 + }) + return out +} + +func compareMediaByField(a, b MediaItem, spec MediaSortSpec, history map[string]time.Time) int { + switch spec.Field { + case "title": + ta, tb := mediaSortTitle(a.Media), mediaSortTitle(b.Media) + return compareOrdered(strings.Compare(strings.ToLower(strings.TrimSpace(ta)), strings.ToLower(strings.TrimSpace(tb))), strings.TrimSpace(ta) != "", strings.TrimSpace(tb) != "", spec.Order) + case "release_date": + ta, oka := mediaReleaseTime(a.Media) + tb, okb := mediaReleaseTime(b.Media) + return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order) + case "year": + return compareOrdered(compareInt(a.Year, b.Year), true, true, spec.Order) + case "created_at": + return compareOrdered(compareTime(a.CreatedAt, b.CreatedAt), !a.CreatedAt.IsZero(), !b.CreatedAt.IsZero(), spec.Order) + case "updated_at": + ta, oka := mediaUpdatedTime(a.Media) + tb, okb := mediaUpdatedTime(b.Media) + return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order) + case "rating": + return compareOrdered(compareFloat(float64(a.Rating), float64(b.Rating)), true, true, spec.Order) + case "duration": + return compareOrdered(compareInt(a.DurationSec, b.DurationSec), true, true, spec.Order) + case "bitrate": + return compareOrdered(compareInt64(a.SizeBytes, b.SizeBytes), true, true, spec.Order) + case "last_played": + ta, oka := mediaItemLastPlayedTime(a, history) + tb, okb := mediaItemLastPlayedTime(b, history) + return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order) + default: + return 0 + } +} +func compareSeriesByField(a, b SeriesCard, spec MediaSortSpec, history map[string]time.Time) int { + switch spec.Field { + case "title": + return compareOrdered(strings.Compare(strings.ToLower(strings.TrimSpace(mediaSortTitle(a.Rep))), strings.ToLower(strings.TrimSpace(mediaSortTitle(b.Rep)))), strings.TrimSpace(mediaSortTitle(a.Rep)) != "", strings.TrimSpace(mediaSortTitle(b.Rep)) != "", spec.Order) + case "release_date": + ta, oka := mediaReleaseTime(a.Rep) + tb, okb := mediaReleaseTime(b.Rep) + return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order) + case "year": + return compareOrdered(compareInt(a.Rep.Year, b.Rep.Year), true, true, spec.Order) + case "created_at": + return compareOrdered(compareTime(a.Rep.CreatedAt, b.Rep.CreatedAt), !a.Rep.CreatedAt.IsZero(), !b.Rep.CreatedAt.IsZero(), spec.Order) + case "updated_at": + ta, oka := seriesUpdatedTime(a) + tb, okb := seriesUpdatedTime(b) + return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order) + case "rating": + return compareOrdered(compareFloat(float64(a.Rep.Rating), float64(b.Rep.Rating)), true, true, spec.Order) + case "duration": + return compareOrdered(compareInt(a.Rep.DurationSec, b.Rep.DurationSec), true, true, spec.Order) + case "bitrate": + return compareOrdered(compareInt64(a.Rep.SizeBytes, b.Rep.SizeBytes), true, true, spec.Order) + case "last_played": + ta, oka := seriesLastPlayed(a, history) + tb, okb := seriesLastPlayed(b, history) + return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order) + default: + return 0 + } +} + +func mediaSortTitle(m model.Media) string { + if strings.TrimSpace(m.Title) != "" { + return m.Title + } + return m.OriginalName +} + +func mediaReleaseTime(m model.Media) (time.Time, bool) { + if raw := strings.TrimSpace(m.ReleaseDate); raw != "" { + if parsed, err := time.Parse("2006-01-02", raw); err == nil { + return parsed, true + } + } + if m.Year > 0 { + return time.Date(m.Year, time.January, 1, 0, 0, 0, 0, time.UTC), true + } + return time.Time{}, false +} + +func mediaUpdatedTime(m model.Media) (time.Time, bool) { + if !m.UpdatedAt.IsZero() { + return m.UpdatedAt, true + } + if !m.CreatedAt.IsZero() { + return m.CreatedAt, true + } + return time.Time{}, false +} + +func seriesUpdatedTime(card SeriesCard) (time.Time, bool) { + if card.LastAddedAt != nil && !card.LastAddedAt.IsZero() { + return *card.LastAddedAt, true + } + return mediaUpdatedTime(card.Rep) +} + +func seriesLastPlayed(card SeriesCard, history map[string]time.Time) (time.Time, bool) { + for _, id := range []string{card.Rep.ID, card.LinkMedia.ID} { + if played, ok := history[id]; ok { + return played, true + } + } + return time.Time{}, false +} + +func mediaItemLastPlayedTime(item MediaItem, history map[string]time.Time) (time.Time, bool) { + best, found := history[item.ID] + for _, version := range item.Versions { + if played, ok := history[version.ID]; ok && (!found || played.After(best)) { + best = played + found = true + } + } + return best, found +} +func compareOrdered(cmp int, aValid, bValid bool, order string) int { + if !aValid && !bValid { + return 0 + } + if !aValid { + return 1 + } + if !bValid { + return -1 + } + if order == "desc" { + return -cmp + } + return cmp +} + +func compareTime(a, b time.Time) int { + if a.Before(b) { + return -1 + } + if a.After(b) { + return 1 + } + return 0 +} + +func compareInt(a, b int) int { + if a < b { + return -1 + } + if a > b { + return 1 + } + return 0 +} + +func compareInt64(a, b int64) int { + if a < b { + return -1 + } + if a > b { + return 1 + } + return 0 +} + +func compareFloat(a, b float64) int { + if a < b { + return -1 + } + if a > b { + return 1 + } + return 0 +} + +func compareID(a, b string) int { + return strings.Compare(a, b) +} diff --git a/internal/service/media_sort_test.go b/internal/service/media_sort_test.go new file mode 100644 index 0000000..9cafa49 --- /dev/null +++ b/internal/service/media_sort_test.go @@ -0,0 +1,42 @@ +package service + +import ( + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +func TestSortMediaItemsTitleAscEmptyLast(t *testing.T) { + items := []MediaItem{ + {Media: model.Media{Base: model.Base{ID: "b"}, Title: "Beta"}}, + {Media: model.Media{Base: model.Base{ID: "empty"}}}, + {Media: model.Media{Base: model.Base{ID: "a"}, Title: "alpha"}}, + } + got := SortMediaItems(items, "title", "asc", nil) + if got[0].ID != "a" || got[1].ID != "b" || got[2].ID != "empty" { + t.Fatalf("title order = %q, %q, %q", got[0].ID, got[1].ID, got[2].ID) + } +} + +func TestSortMediaItemsReleaseDescEmptyLast(t *testing.T) { + items := []MediaItem{ + {Media: model.Media{Base: model.Base{ID: "old"}, ReleaseDate: "2020-01-01"}}, + {Media: model.Media{Base: model.Base{ID: "empty"}}}, + {Media: model.Media{Base: model.Base{ID: "new"}, ReleaseDate: "2024-01-01"}}, + } + got := SortMediaItems(items, "release_date", "desc", nil) + if got[0].ID != "new" || got[1].ID != "old" || got[2].ID != "empty" { + t.Fatalf("release order = %q, %q, %q", got[0].ID, got[1].ID, got[2].ID) + } +} + +func TestSortSeriesCardsTitleAsc(t *testing.T) { + cards := []SeriesCard{ + {Key: "b", Rep: model.Media{Base: model.Base{ID: "b"}, Title: "Beta"}}, + {Key: "a", Rep: model.Media{Base: model.Base{ID: "a"}, Title: "alpha"}}, + } + got := SortSeriesCards(cards, "title", "asc", nil) + if got[0].Key != "a" || got[1].Key != "b" { + t.Fatalf("series order = %q, %q", got[0].Key, got[1].Key) + } +} diff --git a/internal/service/media_versions_test.go b/internal/service/media_versions_test.go index f74fe5d..86f6465 100644 --- a/internal/service/media_versions_test.go +++ b/internal/service/media_versions_test.go @@ -281,7 +281,8 @@ func TestListMediaVisibleGroupedPaginatesAfterVersionGrouping(t *testing.T) { t.Fatal(err) } - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos). + SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop())) page, total, err := svc.ListMediaVisibleGrouped(t.Context(), lib.ID, 1, 1, MediaVisibility{IncludeNSFW: true}) if err != nil { t.Fatal(err) @@ -295,6 +296,17 @@ func TestListMediaVisibleGroupedPaginatesAfterVersionGrouping(t *testing.T) { if page[0].Media.Path != rows[0].Path { t.Fatalf("primary version = %q, want %q", page[0].Media.Path, rows[0].Path) } + cacheKey := svc.groupedItemsCacheKey(lib.ID, []string{lib.ID}, repository.MediaQueryFilter{IncludeNSFW: true}) + if _, ok := svc.cache.GetObject(cacheKey); !ok { + t.Fatal("expected grouped media items to be cached after the first page request") + } + secondPage, secondTotal, err := svc.ListMediaVisibleGrouped(t.Context(), lib.ID, 2, 1, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if secondTotal != total || len(secondPage) != 1 || secondPage[0].Media.Path != rows[2].Path { + t.Fatalf("cached second page = %#v, total = %d; want Matrix item and total %d", secondPage, secondTotal, total) + } } func TestSearchMediaVisiblePageGroupedPaginatesAfterVersionGrouping(t *testing.T) { diff --git a/web/public/artwork-cache-sw.js b/web/public/artwork-cache-sw.js index 85333a8..92d4e0e 100644 --- a/web/public/artwork-cache-sw.js +++ b/web/public/artwork-cache-sw.js @@ -102,11 +102,21 @@ async function deleteOldArtworkVariants(cache, currentRequest) { } function artworkIdentity(url) { + // 尺寸/质量属于同一源图的不同派生资源,必须参与身份计算; + // 否则 400px 卡片图会删掉 1600px Hero 图的缓存。 + const variant = ['maxWidth', 'maxHeight', 'quality'] + .map((key) => { + const value = url.searchParams.get(key) + return value ? `${key}=${value}` : '' + }) + .filter(Boolean) + .join('&') + const variantSuffix = variant ? `&${variant}` : '' if (url.pathname === '/api/img') { - return `${url.origin}${url.pathname}?url=${url.searchParams.get('url') || ''}` + return `${url.origin}${url.pathname}?url=${url.searchParams.get('url') || ''}${variantSuffix}` } if (url.pathname.startsWith('/api/cloud/play/')) { - return `${url.origin}${url.pathname}?ref=${url.searchParams.get('ref') || ''}` + return `${url.origin}${url.pathname}?ref=${url.searchParams.get('ref') || ''}${variantSuffix}` } return '' } diff --git a/web/src/App.tsx b/web/src/App.tsx index a6e23e2..bef1f1a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,10 +1,10 @@ -import { Component, Suspense, type ErrorInfo, type ReactNode } from 'react' +import { Component, Suspense, lazy, type ErrorInfo, type ReactNode } from 'react' import { Navigate, Route, Routes } from 'react-router-dom' import { appRoutes, type AppRoute } from './appRoutes' import { Layout } from './components/Layout' import { RequireAdmin, RequireAuth } from './components/RequireAuth' -import { LoginPage } from './pages/LoginPage' +const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage }))) const Loading = () =>

加载中…

diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 203c60c..2db6843 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -210,22 +210,36 @@ export type ImageURLOptions = | { refreshCache?: boolean retryFailed?: boolean + maxWidth?: number + maxHeight?: number + quality?: number } export function imageURL(remote?: string, version?: string, options: ImageURLOptions = false): string { if (!remote) return '' const versionQuery = version ? `v=${encodeURIComponent(version)}` : '' - const retryFailed = typeof options === 'boolean' ? options : Boolean(options.retryFailed) - const refreshCache = typeof options === 'boolean' ? false : Boolean(options.refreshCache) + const opts: Exclude = typeof options === 'boolean' ? {} : options + const retryFailed = typeof options === 'boolean' ? options : Boolean(opts.retryFailed) + const refreshCache = typeof options === 'boolean' ? false : Boolean(opts.refreshCache) const retryQuery = retryFailed ? 'retry=1' : '' const refreshQuery = refreshCache ? 'refresh=1' : '' - const imageQuery = [versionQuery, retryQuery, refreshQuery].filter(Boolean).join('&') + const resizeQuery = [ + positiveDimensionQuery('maxWidth', opts.maxWidth), + positiveDimensionQuery('maxHeight', opts.maxHeight), + positiveDimensionQuery('quality', opts.quality, 100), + ].filter(Boolean).join('&') + const imageQuery = [versionQuery, retryQuery, refreshQuery, resizeQuery].filter(Boolean).join('&') if (remote.startsWith('/api/img')) return withQuery(withoutAuthQuery(remote), imageQuery) if (remote.startsWith('/api/cloud/play/')) return withQuery(withoutAuthQuery(remote), imageQuery) if (remote.startsWith('/api/')) return withQuery(withQuery(remote, tokenQuery()), imageQuery) return withQuery(`/api/img?url=${encodeURIComponent(remote)}`, imageQuery) } +function positiveDimensionQuery(name: string, value?: number, max = 10_000): string { + if (!Number.isFinite(value) || !value || value <= 0) return '' + return `${name}=${Math.min(Math.round(value), max)}` +} + function withQuery(url: string, query: string): string { if (!query) return url return `${url}${url.includes('?') ? '&' : '?'}${query}` diff --git a/web/src/api/library.ts b/web/src/api/library.ts index 8280d26..3217208 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -105,6 +105,7 @@ export const libraryAPI = { .get('/libraries', { params: { with_preview: 1, + include_total: 0, preview_limit: previewLimit, ids: ids.join(','), }, @@ -160,22 +161,34 @@ export const libraryAPI = { scrape: (id: string, options?: ScrapeOptions) => api.post(`/libraries/${id}/scrape`, options ?? null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data), - listMedia: (id: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean }) => + listMedia: ( + id: string, + page = 1, + pageSize = 50, + options?: { groupVersions?: boolean; sort?: string; order?: 'asc' | 'desc' }, + ) => api .get(`/libraries/${id}/media`, { params: { page, page_size: pageSize, group_versions: options?.groupVersions === false ? 0 : undefined, + sort: options?.sort, + order: options?.order, }, timeout: LONG_REQUEST_TIMEOUT, }) .then((r) => r.data), - listSeries: (id: string, page = 1, pageSize = 500) => + listSeries: ( + id: string, + page = 1, + pageSize = 500, + options?: { sort?: string; order?: 'asc' | 'desc' }, + ) => api .get(`/libraries/${id}/series`, { - params: { page, page_size: pageSize }, + params: { page, page_size: pageSize, sort: options?.sort, order: options?.order }, timeout: LONG_REQUEST_TIMEOUT, }) .then((r) => r.data), diff --git a/web/src/appRoutes.tsx b/web/src/appRoutes.tsx index 1fea31d..9815a12 100644 --- a/web/src/appRoutes.tsx +++ b/web/src/appRoutes.tsx @@ -47,17 +47,29 @@ const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m) const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage }))) // 常用页面的路由 chunk 空闲预取:应用加载完成后浏览器一空闲就把浏览路径 -// (库列表/库详情/媒体详情/播放/收藏/历史等)的 chunk 拉下来, +// (库列表/库详情/媒体详情/收藏/历史等)的 chunk 拉下来, // 首次点击进入时不再出现"加载中…"等 chunk 下载。失败静默(导航时会重试)。 let prefetchStarted = false export function prefetchCommonRouteChunks() { if (prefetchStarted || typeof window === 'undefined') return + + // 弱网/省流模式不做空闲预取:首屏预览和海报加载优先占用带宽。 + const connection = (navigator as Navigator & { + connection?: { saveData?: boolean; effectiveType?: string } + }).connection + if ( + connection?.saveData || + connection?.effectiveType === 'slow-2g' || + connection?.effectiveType === '2g' + ) { + return + } + prefetchStarted = true const loaders = [ LibrariesPageLoader, LibraryPageLoader, MediaDetailPageLoader, - PlayerPageLoader, FavouritesPageLoader, WatchHistoryPageLoader, PlaylistsPageLoader, @@ -68,9 +80,9 @@ export function prefetchCommonRouteChunks() { } } if (typeof requestIdleCallback !== 'undefined') { - requestIdleCallback(run, { timeout: 5000 }) + requestIdleCallback(run, { timeout: 8000 }) } else { - window.setTimeout(run, 2000) + window.setTimeout(run, 2500) } } diff --git a/web/src/components/LayoutHeaderSections.tsx b/web/src/components/LayoutHeaderSections.tsx index c9efec4..e9204c3 100644 --- a/web/src/components/LayoutHeaderSections.tsx +++ b/web/src/components/LayoutHeaderSections.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Link, useLocation, useNavigate } from 'react-router-dom' import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react' @@ -132,12 +132,12 @@ function LayoutHeaderSearch() { const locationKey = `${location.pathname}${location.search}` const prevLocationKeyRef = useRef(locationKey) - const setSearchOpen = (open: boolean) => { + const setSearchOpen = useCallback((open: boolean) => { isOpenRef.current = open setIsOpen(open) - } + }, []) - const dismissSearch = (clearQuery: boolean) => { + const dismissSearch = useCallback((clearQuery: boolean) => { suppressInputRef.current = true setSearchOpen(false) if (clearQuery) { @@ -156,9 +156,11 @@ function LayoutHeaderSearch() { window.setTimeout(() => { suppressInputRef.current = false }, 0) - } + }, [setSearchOpen]) const dismissSearchRef = useRef(dismissSearch) - dismissSearchRef.current = dismissSearch + useEffect(() => { + dismissSearchRef.current = dismissSearch + }, [dismissSearch]) useEffect(() => { const trimmed = query.trim() diff --git a/web/src/components/LayoutSections.tsx b/web/src/components/LayoutSections.tsx index 6514d24..02ed330 100644 --- a/web/src/components/LayoutSections.tsx +++ b/web/src/components/LayoutSections.tsx @@ -1,5 +1,4 @@ import { Outlet } from 'react-router-dom' -import { AnimatePresence, motion } from 'framer-motion' import clsx from 'clsx' import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent' @@ -50,29 +49,18 @@ export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarP } export function LayoutMobileSidebar({ children, isOpen, onClose }: LayoutMobileSidebarProps) { + if (!isOpen) return null + return ( - - {isOpen && ( -
- - - {children} - -
- )} -
+
+
+
+ {children} +
+
) } @@ -139,19 +127,11 @@ export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: Layou return (
- - - - - - - +
+ + + +
) diff --git a/web/src/components/LayoutSidebarContent.tsx b/web/src/components/LayoutSidebarContent.tsx index b9e760d..dc447b9 100644 --- a/web/src/components/LayoutSidebarContent.tsx +++ b/web/src/components/LayoutSidebarContent.tsx @@ -1,5 +1,4 @@ import { Link } from 'react-router-dom' -import { motion } from 'framer-motion' import { ArrowLeft, Menu, X } from 'lucide-react' import clsx from 'clsx' @@ -77,13 +76,9 @@ function LayoutSidebarHeader({ className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm" /> {sidebarExpanded && ( - + MeBox - + )} diff --git a/web/src/components/LayoutSidebarNav.tsx b/web/src/components/LayoutSidebarNav.tsx index 4838230..c3ab25e 100644 --- a/web/src/components/LayoutSidebarNav.tsx +++ b/web/src/components/LayoutSidebarNav.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react' import { Link, useLocation } from 'react-router-dom' -import { AnimatePresence, motion } from 'framer-motion' import { ChevronDown } from 'lucide-react' import clsx from 'clsx' @@ -52,21 +51,13 @@ export function SidebarGroup({ id, icon, label, children, collapsed, open, activ
)} - - {!collapsed && open && ( - -
- {children} -
-
- )} -
+ {!collapsed && open && ( +
+
+ {children} +
+
+ )} ) } @@ -103,13 +94,9 @@ export function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarL {icon} {!collapsed && ( - + {label} - + )} {collapsed && (
diff --git a/web/src/components/LayoutUserMenu.tsx b/web/src/components/LayoutUserMenu.tsx index b674a1d..926b58d 100644 --- a/web/src/components/LayoutUserMenu.tsx +++ b/web/src/components/LayoutUserMenu.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { Link, useLocation } from 'react-router-dom' -import { AnimatePresence, motion } from 'framer-motion' import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, Tv, UserCog } from 'lucide-react' import clsx from 'clsx' @@ -103,26 +102,18 @@ export function LayoutUserMenu({ const menuPortal = isOpen && menuPosition && typeof document !== 'undefined' ? createPortal( - - + + , document.body, ) : null diff --git a/web/src/components/MediaCard.tsx b/web/src/components/MediaCard.tsx index 08fbd85..fa1f2b1 100644 --- a/web/src/components/MediaCard.tsx +++ b/web/src/components/MediaCard.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef, useState, type ReactNode } from 'react' +import { memo, useEffect, useRef, useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' import { Film, Play, Layers, Star } from 'lucide-react' import { imageURL } from '../api/client' @@ -22,7 +22,12 @@ export const MediaCard = memo(function MediaCard({ const ref = useRef(null) const href = linkTo ?? `/media/${media.id}` const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover') - const posterSrc = imageURL(media.poster_url, media.updated_at) + const posterSrc = imageURL(media.poster_url, media.updated_at, { + maxWidth: compact ? 320 : 480, + maxHeight: compact ? 480 : 600, + quality: 82, + }) + const blurredPosterSrc = imageURL(media.poster_url, media.updated_at, { maxWidth: 160, quality: 60 }) const displayRating = rating ?? media.rating const versionCount = media.versions?.length ?? 0 // renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效 @@ -43,7 +48,7 @@ export const MediaCard = memo(function MediaCard({ <> {posterFit === 'contain' && ( ( + onVisible: () => void, + rootMargin = DEFAULT_ROOT_MARGIN, +): RefObject { + const ref = useRef(null) + const callbackRef = useRef(onVisible) + + useEffect(() => { + callbackRef.current = onVisible + }, [onVisible]) + + useEffect(() => { + const element = ref.current + if (!element) return + + if (typeof IntersectionObserver === 'undefined') { + callbackRef.current() + return + } + + const root = document.getElementById('app-main-scroll') + let fired = false + const observer = new IntersectionObserver( + (entries) => { + if (fired || !entries.some((entry) => entry.isIntersecting)) return + fired = true + observer.disconnect() + callbackRef.current() + }, + { root, rootMargin, threshold: 0.01 }, + ) + observer.observe(element) + return () => observer.disconnect() + }, [rootMargin]) + + return ref +} diff --git a/web/src/hooks/useLazyPreviewBatch.ts b/web/src/hooks/useLazyPreviewBatch.ts new file mode 100644 index 0000000..4f69b2b --- /dev/null +++ b/web/src/hooks/useLazyPreviewBatch.ts @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useRef } from 'react' + +/** + * 合并短时间内进入视口的预览请求,避免每个卡片各发一次 HTTP 请求。 + * 回调放在 ref 中,调用方每次渲染传新的闭包也不会重建定时器。 + */ +export function useLazyPreviewBatch( + onNeedPreviews?: (ids: string[]) => void, + delayMs = 48, +) { + const pendingRef = useRef>(new Set()) + const timerRef = useRef(null) + const callbackRef = useRef(onNeedPreviews) + + useEffect(() => { + callbackRef.current = onNeedPreviews + }, [onNeedPreviews]) + + useEffect(() => { + return () => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current) + } + } + }, []) + + return useCallback((id: string) => { + if (!id || !callbackRef.current) return + pendingRef.current.add(id) + if (timerRef.current !== null) return + timerRef.current = window.setTimeout(() => { + timerRef.current = null + const ids = Array.from(pendingRef.current) + pendingRef.current.clear() + if (ids.length > 0) { + callbackRef.current?.(ids) + } + }, delayMs) + }, [delayMs]) +} diff --git a/web/src/index.css b/web/src/index.css index fe8ce3a..1bcc267 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -418,3 +418,42 @@ video::cue(*) { 0 0 8px rgba(0, 0, 0, 0.8), 0 0 16px rgba(0, 0, 0, 0.6) !important; } + + +/* ── Entrance animations (replaces framer-motion on the critical shell) ── */ +@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } } +@keyframes drawer-in { from { transform: translateX(-100%); } to { transform: translateX(0); } } +@keyframes page-in { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } +@keyframes accordion-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } +@keyframes fade-slide-in { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } } +@keyframes menu-in { from { opacity: 0; transform: translateY(10px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } + +.animate-overlay-in { animation: overlay-in 0.15s ease-out both; } +.animate-drawer-in { animation: drawer-in 0.22s ease-out both; } +.animate-page-in { animation: page-in 0.25s ease-out both; } +.animate-accordion-in { animation: accordion-in 0.18s ease-out both; } +.animate-fade-slide-in { animation: fade-slide-in 0.2s ease-out both; } +.animate-menu-in { animation: menu-in 0.15s ease-out both; } + +@media (prefers-reduced-motion: reduce) { + .animate-overlay-in, + .animate-drawer-in, + .animate-page-in, + .animate-accordion-in, + .animate-fade-slide-in, + .animate-menu-in { animation: none !important; } +} + + +@keyframes pulse-soft { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } +@keyframes hero-in { from { opacity: 0; transform: scale(1.08); } to { opacity: 0.38; transform: scale(1.02); } } +@keyframes title-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +.animate-pulse-soft { animation: pulse-soft 1.5s ease-in-out infinite; } +.animate-hero-in { animation: hero-in 0.8s ease-out both; } +.animate-title-in { animation: title-in 0.3s ease-out both; } + +@media (prefers-reduced-motion: reduce) { + .animate-pulse-soft, + .animate-hero-in, + .animate-title-in { animation: none !important; } +} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 44dec41..6840e42 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -18,6 +18,7 @@ import { HomeLoadingState, } from './HomePageSections' +const PREVIEW_BATCH_SIZE = 4 const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url) export function HomePage() { @@ -86,48 +87,72 @@ export function HomePage() { const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds]) - // 按需拉取卡片预览管理 - const fetchedLibIdsRef = useRef>(new Set()) - const fetchingRef = useRef>(new Set()) + // 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格, + // 稍后需要 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 uniqueIDs = Array.from(new Set(ids.filter(Boolean))) + const targets = uniqueIDs.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)) - try { - const rows = await libraryAPI.listPreviews(targets, 10) - setLibraryData((prev) => { - const next = { ...prev } - for (const row of rows) { - next[row.id] = { - cards: row.cards ?? [], - items: [], - total: row.total ?? 0, - } - } - return next - }) - } catch { - // 容错 - } finally { - targets.forEach((id) => { - fetchedLibIdsRef.current.add(id) - fetchingRef.current.delete(id) - }) + const batches: string[][] = [] + for (let i = 0; i < targets.length; i += PREVIEW_BATCH_SIZE) { + batches.push(targets.slice(i, i + PREVIEW_BATCH_SIZE)) } + + // 分批并发:一个慢库不再阻塞整页预览,先返回的批次立即渲染。 + await Promise.allSettled( + batches.map(async (batch) => { + let loaded = false + try { + const rows = await libraryAPI.listPreviews(batch, limit) + loaded = true + setLibraryData((prev) => { + const next = { ...prev } + for (const row of rows) { + next[row.id] = { + cards: row.cards ?? [], + items: [], + total: row.total ?? 0, + } + } + return next + }) + } catch { + // 单个批次失败不影响其他批次;导航回来时会重试。 + } finally { + batch.forEach((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) + } + }) + } + }), + ) }, []) - // 3. 首屏优先加载:轮播图库 + 媒体库卡片区前 20 个库 + 首屏前 3 个内容行 + // 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后再按批加载。 useEffect(() => { if (sortedLibraries.length === 0) return const carouselLibIds = sortedLibraries .filter((l) => l.carousel_enabled === true) .map((l) => l.id) - const topGridLibIds = sortedLibraries.slice(0, 20).map((l) => l.id) const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id) - const initialTargets = Array.from(new Set([...carouselLibIds, ...topGridLibIds, ...topRowLibIds])) - void fetchPreviews(initialTargets) + const initialTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds])) + void fetchPreviews(initialTargets, 10) }, [sortedLibraries, fetchPreviews]) // 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库 @@ -140,7 +165,7 @@ export function HomePage() { useEffect(() => { if (sortedLibraries.length === 0) return const currentTargets = sortedLibraries.slice(0, visibleTargetCount).map((l) => l.id) - void fetchPreviews(currentTargets) + void fetchPreviews(currentTargets, 10) }, [sortedLibraries, visibleTargetCount, fetchPreviews]) // 当前已拉取并确认有内容的媒体库行 @@ -214,7 +239,7 @@ export function HomePage() { const libraryCounts = useMemo(() => { const counts: Record = {} for (const lib of libraries) { - counts[lib.id] = libraryData[lib.id]?.total ?? 0 + counts[lib.id] = lib.total ?? libraryData[lib.id]?.total ?? 0 } return counts }, [libraries, libraryData]) diff --git a/web/src/pages/HomePageSections.tsx b/web/src/pages/HomePageSections.tsx index 82eaf04..d91ceaa 100644 --- a/web/src/pages/HomePageSections.tsx +++ b/web/src/pages/HomePageSections.tsx @@ -1,6 +1,5 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' -import { AnimatePresence, motion } from 'framer-motion' import { ArrowRight, ChevronLeft, @@ -18,6 +17,8 @@ import { } from 'lucide-react' import { imageURL } from '../api/client' +import { useInViewOnce } from '../hooks/useInViewOnce' +import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch' import { MediaCard } from '../components/MediaCard' import type { HistoryItem } from '../api/playback' import type { Library, Media } from '../types' @@ -37,7 +38,6 @@ const TYPE_ICONS: Record = { music: , adult: , } - const TYPE_LABELS: Record = { movie: '电影', movies: '电影', @@ -53,11 +53,7 @@ const TYPE_LABELS: Record = { export function HomeLoadingState() { return (
- +
@@ -65,7 +61,7 @@ export function HomeLoadingState() { 首页内容准备中… - +
) } @@ -170,24 +166,20 @@ export function HomeCarouselSection({ {/* Background Backdrop Image with Crossfade */}
- - {visual && ( - { - e.currentTarget.style.display = 'none' - }} - /> - )} - + {visual && ( + { + e.currentTarget.style.display = 'none' + }} + /> + )}
@@ -212,18 +204,12 @@ export function HomeCarouselSection({ {/* Title */}
- - - {currentItem.title} - - +

+ {currentItem.title} +

{currentItem.original_name && currentItem.original_name !== currentItem.title && (

{currentItem.original_name} @@ -295,8 +281,10 @@ export function HomeCarouselSection({

{poster && ( {currentItem.title} { @@ -377,17 +365,13 @@ export function HomeLibrariesSection({ 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 pagedLibraries = useMemo(() => { const start = (effectivePage - 1) * PAGE_SIZE return libraries.slice(start, start + PAGE_SIZE) }, [libraries, effectivePage]) - useEffect(() => { - const ids = pagedLibraries.map((l) => l.id) - onNeedPreviews?.(ids) - }, [pagedLibraries, onNeedPreviews]) - return (
@@ -449,72 +433,91 @@ export function HomeLibrariesSection({
- {/* Libraries Grid */}
- {pagedLibraries.map((lib) => { - const count = libraryCounts[lib.id] ?? 0 - const cards = libraryData?.[lib.id]?.cards || [] - const artwork = getLibraryArtworks(lib, cards) - - return ( - - {/* 封面图片展示区:和媒体库页面一样,显示设置好的或生成的图片 */} -
1 ? 'grid grid-cols-2 gap-0.5' : '' - }`} - > - {artwork.length > 0 ? ( - artwork.map(({ src, version }, index) => ( - { - event.currentTarget.style.visibility = 'hidden' - }} - /> - )) - ) : ( -
- {TYPE_ICONS[lib.type] || } -
- )} - - {/* 浮动类型标签 */} -
- {TYPE_LABELS[lib.type] || '自定义'} -
-
- - {/* 媒体库信息 */} -
-

- {lib.name} -

-

- {count > 0 ? `${count} 部媒体` : '暂无条目'} -

-
- - ) - })} + {pagedLibraries.map((lib) => ( + { + if (!lib.cover_url) { + queuePreview(lib.id) + } + }} + /> + ))}
) } +function HomeLibraryCard({ + library, + count, + cards, + onVisible, +}: { + library: Library + count: number + cards: SeriesCard[] + onVisible: () => void +}) { + const ref = useInViewOnce(onVisible) + const artwork = getLibraryArtworks(library, cards) + + return ( + +
1 ? 'grid grid-cols-2 gap-0.5' : '' + }`} + > + {artwork.length > 0 ? ( + artwork.map(({ src, version }, index) => ( + { + event.currentTarget.style.visibility = 'hidden' + }} + /> + )) + ) : ( +
+ {TYPE_ICONS[library.type] || } +
+ )} + +
+ {TYPE_LABELS[library.type] || '自定义'} +
+
+ +
+

+ {library.name} +

+

+ {count > 0 ? `${count} 部媒体` : '暂无条目'} +

+
+ + ) +} + /* ========================================================================= 3. 单个媒体库内容行 (Horizontal Scroll Row) ========================================================================= */ @@ -694,7 +697,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
{media.poster_url ? ( fetchingRef.current.add(id)) + let loaded = false try { const rows = await libraryAPI.listPreviews(targets, 10) + loaded = true setLibraryData((prev) => { const next = { ...prev } for (const row of rows) { @@ -49,7 +51,9 @@ export function LibrariesPage() { // 容错 } finally { targets.forEach((id) => { - fetchedLibIdsRef.current.add(id) + if (loaded) { + fetchedLibIdsRef.current.add(id) + } fetchingRef.current.delete(id) }) } @@ -68,13 +72,10 @@ export function LibrariesPage() { try { const libs = await fetchLibraries() setLibraries(libs) - // 优先拉取入口卡片网格当前页(前 20 个库)的预览 - const topIds = libs.slice(0, 20).map((l) => l.id) - void fetchPreviews(topIds) } finally { setLoading(false) } - }, [fetchPreviews]) + }, []) async function handleRepairRescrape() { if (repairing) return @@ -105,7 +106,7 @@ export function LibrariesPage() { return { library, items: [], - total: data?.total ?? 0, + total: library.total ?? data?.total ?? 0, cards: data?.cards ?? [], } }) diff --git a/web/src/pages/LibrariesPageSections.tsx b/web/src/pages/LibrariesPageSections.tsx index e0a9bb4..2487c5b 100644 --- a/web/src/pages/LibrariesPageSections.tsx +++ b/web/src/pages/LibrariesPageSections.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' -import { motion } from 'framer-motion' import { ArrowRight, ChevronLeft, ChevronRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react' import { imageURL } from '../api/client' import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle' import { MediaCard } from '../components/MediaCard' +import { useInViewOnce } from '../hooks/useInViewOnce' +import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch' import { seriesCardLink } from '../utils/groupSeries' import { libraryDisplayPath } from './libraryDisplayModel' import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel' @@ -19,7 +20,6 @@ const TYPE_ICONS: Record = { music: , adult: , } - const TYPE_LABELS: Record = { movie: '电影', tv: '剧集', @@ -115,6 +115,7 @@ export function LibrariesContent({ onNeedPreviews?: (ids: string[]) => void }) { const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length + const queuePreview = useLazyPreviewBatch(onNeedPreviews) // 下方媒体库货架支持向下滑动渐进流式加载:默认先展示前 3 个库货架, // 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库货架。 @@ -185,15 +186,12 @@ export function LibrariesContent({ const [entryPage, setEntryPage] = useState(1) const totalEntryPages = Math.max(1, Math.ceil(previews.length / ENTRY_PAGE_SIZE)) const effectiveEntryPage = Math.min(entryPage, totalEntryPages) + const pagedPreviews = useMemo(() => { const start = (effectiveEntryPage - 1) * ENTRY_PAGE_SIZE return previews.slice(start, start + ENTRY_PAGE_SIZE) }, [previews, effectiveEntryPage]) - useEffect(() => { - const ids = pagedPreviews.map((p) => p.library.id) - onNeedPreviews?.(ids) - }, [pagedPreviews, onNeedPreviews]) return ( <> @@ -241,37 +239,38 @@ export function LibrariesContent({ )}
- {pagedPreviews.map((preview, index) => ( - ( +
onTogglePin(preview.library.id)} + onVisible={() => { + if (!preview.library.cover_url) { + queuePreview(preview.library.id) + } + }} /> - +
))}
{visiblePreviews.length > 0 && (
- {visiblePreviews.map((preview, index) => ( - ( +
- +
))} {visibleCount < previews.length && ( @@ -292,12 +291,15 @@ function LibraryEntryCard({ preview, pinned, onTogglePin, + onVisible, }: { preview: LibraryPreview pinned: boolean onTogglePin: () => void + onVisible?: () => void }) { const library = preview.library + const ref = useInViewOnce(onVisible ?? (() => {})) const artwork = library.cover_url ? [{ src: library.cover_url, version: library.updated_at }] : libraryArtworkItems(preview.cards) @@ -305,6 +307,7 @@ function LibraryEntryCard({ return (
( void cardActions: (media: Media) => ReactNode onSeriesClick: (series: SeriesCard) => void } @@ -25,6 +28,9 @@ export function LibraryMediaSections({ seriesCards, selectedSeries, loading, + hasMore, + loadingMore, + onLoadMore, cardActions, onSeriesClick, }: LibraryMediaSectionsProps) { @@ -60,10 +66,58 @@ export function LibraryMediaSections({ {isSeries && seriesCards.length === 0 && !loading && ( )} + + ) } +function LoadMoreSentinel({ + hasMore, + loadingMore, + onLoadMore, +}: { + hasMore: boolean + loadingMore: boolean + onLoadMore: () => void +}) { + const ref = useRef(null) + + useEffect(() => { + const element = ref.current + if (!element || !hasMore) return + const root = document.getElementById('app-main-scroll') + const observer = new IntersectionObserver( + (entries) => { + if (!loadingMore && entries.some((entry) => entry.isIntersecting)) { + onLoadMore() + } + }, + { root, rootMargin: '600px 0px', threshold: 0 }, + ) + observer.observe(element) + return () => observer.disconnect() + }, [hasMore, loadingMore, onLoadMore]) + + if (!hasMore) return null + return ( +
+ +
+ ) +} + // 独立 memo 组件:onClick 闭包在其内部创建,props 均为稳定引用, // 父级重渲染不会穿透到每张剧集卡片。 const SeriesCardItem = memo(function SeriesCardItem({ @@ -99,4 +153,4 @@ function LibraryEmptyState({ message }: { message: string }) {

{message}

) -} +} \ No newline at end of file diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index c211dba..48a5573 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -1,8 +1,7 @@ -import { useCallback, useEffect, useMemo, useState, Fragment, type ReactNode } from 'react' +import { useCallback, useMemo, useState, Fragment, type ReactNode } from 'react' import { useLocation, useParams, useSearchParams } from 'react-router-dom' import { motion } from 'framer-motion' -import { historyAPI } from '../api/history' import type { Media } from '../types' import { useAuthStore } from '../stores/auth' import { isTheatricalFeature, type SeriesCard } from '../utils/groupSeries' @@ -49,48 +48,10 @@ export function LibraryPage() { return (saved as SortOrder) || 'asc' }) const [randomSeed, setRandomSeed] = useState(() => Date.now()) - const [historyMap, setHistoryMap] = useState>(new Map()) - - useEffect(() => { - if (sortField !== 'last_played') return - let cancelled = false - historyAPI - .list(1000) - .then((historyItems) => { - if (cancelled) return - const map = new Map() - for (const item of historyItems ?? []) { - if (item.media_id && item.watched_at) { - if (!map.has(item.media_id) || new Date(item.watched_at) > new Date(map.get(item.media_id)!)) { - map.set(item.media_id, item.watched_at) - } - } - } - setHistoryMap(map) - }) - .catch(() => {}) - return () => { - cancelled = true - } - }, [sortField]) - - const handleSortChange = (field: SortField, order: SortOrder) => { - setSortField(field) - setSortOrder(order) - if (id) { - localStorage.setItem(`mebox_lib_sort_field_${id}`, field) - localStorage.setItem(`mebox_lib_sort_order_${id}`, order) - } - localStorage.setItem('mebox_lib_sort_field', field) - localStorage.setItem('mebox_lib_sort_order', order) - if (field === 'random') { - setRandomSeed(Date.now()) - } - } - - const handleReshuffle = () => { - setRandomSeed(Date.now()) - } + const [lastNonRandomSort, setLastNonRandomSort] = useState(sortField === 'random' ? 'release_date' : sortField) + const [lastNonRandomOrder, setLastNonRandomOrder] = useState(sortOrder) + const serverSortField = sortField === 'random' ? lastNonRandomSort : sortField + const serverSortOrder = sortField === 'random' ? lastNonRandomOrder : sortOrder // 剧集模式:选中某个剧集后展开详情 const [selectedSeries, setSelectedSeries] = useState(null) @@ -102,22 +63,56 @@ export function LibraryPage() { seriesEpisodeItems, total, loading, + loadingMore, + hasMore, + loadMore, + loadAll, loadingSeriesEpisodes, isSeriesLibrary, isSeries, seriesCards, loadingAllText, reloadCurrentLibrary, - } = useLibraryData(id, selectedSeries) + } = useLibraryData(id, selectedSeries, serverSortField, serverSortOrder) - const sortedItems = useMemo(() => { - return sortMediaList(items, sortField, sortOrder, randomSeed, historyMap) - }, [items, sortField, sortOrder, randomSeed, historyMap]) + // 常规排序由服务端全局完成;只有 random 模式才在客户端洗牌。 + const displayedItems = useMemo(() => { + return sortField === 'random' ? sortMediaList(items, 'random', sortOrder, randomSeed) : items + }, [items, sortField, sortOrder, randomSeed]) - const sortedSeriesCards = useMemo(() => { - return sortSeriesList(seriesCards, sortField, sortOrder, randomSeed, historyMap) - }, [seriesCards, sortField, sortOrder, randomSeed, historyMap]) + const displayedSeriesCards = useMemo(() => { + return sortField === 'random' ? sortSeriesList(seriesCards, 'random', sortOrder, randomSeed) : seriesCards + }, [seriesCards, sortField, sortOrder, randomSeed]) + const handleSortChange = useCallback(async (field: SortField, order: SortOrder) => { + if (field === 'random') { + if (sortField !== 'random') { + setLastNonRandomSort(sortField) + setLastNonRandomOrder(sortOrder) + await loadAll() + } + setRandomSeed(Date.now()) + } else { + setLastNonRandomSort(field) + setLastNonRandomOrder(order) + } + setSortField(field) + setSortOrder(order) + if (id) { + localStorage.setItem(`mebox_lib_sort_field_${id}`, field) + localStorage.setItem(`mebox_lib_sort_order_${id}`, order) + } + localStorage.setItem('mebox_lib_sort_field', field) + localStorage.setItem('mebox_lib_sort_order', order) + }, [id, loadAll, sortField, sortOrder]) + + const handleLoadMore = useCallback(() => { + void loadMore() + }, [loadMore]) + + const handleReshuffle = () => { + setRandomSeed(Date.now()) + } const { scanning, scanProgress, @@ -142,7 +137,7 @@ export function LibraryPage() { isSeriesLibrary, isSeries, loading, - seriesCards: sortedSeriesCards, + seriesCards: displayedSeriesCards, searchParams, setSearchParams, selectedSeries, @@ -236,7 +231,7 @@ export function LibraryPage() { {!selectedSeries && ( diff --git a/web/src/pages/useLibraryData.ts b/web/src/pages/useLibraryData.ts index bd86410..7e04cd5 100644 --- a/web/src/pages/useLibraryData.ts +++ b/web/src/pages/useLibraryData.ts @@ -1,19 +1,26 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import toast from 'react-hot-toast' import { libraryAPI } from '../api/library' import type { Library, Media } from '../types' import { peekLibrary, resolveLibrary } from '../utils/libraryCache' import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries' +import type { SortField, SortOrder } from '../utils/mediaSort' -export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | null) { +export function useLibraryData( + libraryID: string, + selectedSeries: SeriesCard | null, + sortField: SortField, + sortOrder: SortOrder, +) { const [library, setLibrary] = useState(null) const [items, setItems] = useState([]) const [serverSeriesCards, setServerSeriesCards] = useState([]) const [seriesEpisodeItems, setSeriesEpisodeItems] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) - const [loadingAll, setLoadingAll] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + const [hasMore, setHasMore] = useState(false) const [loadingSeriesEpisodes, setLoadingSeriesEpisodes] = useState(false) const isSeriesLibrary = isSeriesLibraryType(library?.type) @@ -26,79 +33,96 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n return groupSeries(items) }, [isSeries, isSeriesLibrary, items, serverSeriesCards]) - // reloadCurrentLibrary 通过自增 tick 重跑加载(原实现靠克隆 library 对象 - // 触发第二个 effect,这里合并成单个 bootstrap effect 后改用显式信号)。 const [reloadTick, setReloadTick] = useState(0) + const requestSeqRef = useRef(0) + const nextPageRef = useRef(2) + const loadedCountRef = useRef(0) + const totalRef = useRef(0) + const hasMoreRef = useRef(false) + const libraryRef = useRef(null) + const modeRef = useRef<'media' | 'series'>('media') + const moreInFlightRef = useRef(false) useEffect(() => { if (!libraryID) return + const seq = ++requestSeqRef.current let cancelled = false + nextPageRef.current = 2 + loadedCountRef.current = 0 + totalRef.current = 0 + hasMoreRef.current = false + moreInFlightRef.current = false setLoading(true) - setLoadingAll(false) + setLoadingMore(false) + setHasMore(false) setLibrary(null) setItems([]) setServerSeriesCards([]) setSeriesEpisodeItems([]) + setTotal(0) const bootstrap = async () => { - // 库信息先查会话缓存(首页/全部媒体库页已拉过全量列表),命中则 - // 同步就绪,内容请求在挂载当帧即发出;未命中才退回单独请求, - // 消除原先"先等库信息、再等内容"的两段串行首屏等待。 - let lib = peekLibrary(libraryID) - if (lib) setLibrary(lib) + let lib = peekLibrary(libraryID) ?? null + if (lib) { + libraryRef.current = lib + setLibrary(lib) + } try { const resolved = await resolveLibrary(libraryID) - if (cancelled) return + if (cancelled || seq !== requestSeqRef.current) return if (!lib) { lib = resolved + libraryRef.current = lib setLibrary(lib) } } catch { - if (!cancelled) { + if (!cancelled && seq === requestSeqRef.current) { setLoading(false) toast.error('媒体库不存在或无权限') } return } + if (!lib || cancelled || seq !== requestSeqRef.current) return - const seriesLibrary = isSeriesLibraryType(lib.type) - setLoadingAll(true) + const seriesMode = isSeriesLibraryType(lib.type) + modeRef.current = seriesMode ? 'series' : 'media' + const pageSize = pageSizeFor(lib, seriesMode) try { - if (seriesLibrary) { - const collected = await loadAllSeriesCards(libraryID, lib.is_remote_emby, (next) => { - if (cancelled) return - setTotal(next.total) - if (next.firstPage) { - setServerSeriesCards(next.items) - setLoading(false) - } - }, () => cancelled) - if (!cancelled) setServerSeriesCards(collected.items) - return + if (seriesMode) { + const data = await libraryAPI.listSeries(libraryID, 1, pageSize, { sort: sortField, order: sortOrder }) + if (cancelled || seq !== requestSeqRef.current) return + const pageItems = data.items ?? [] + setServerSeriesCards(pageItems) + loadedCountRef.current = pageItems.length + totalRef.current = data.total ?? pageItems.length + } else { + const data = await libraryAPI.listMedia(libraryID, 1, pageSize, { sort: sortField, order: sortOrder }) + if (cancelled || seq !== requestSeqRef.current) return + const pageItems = data.items ?? [] + setItems(pageItems) + loadedCountRef.current = pageItems.length + totalRef.current = data.total ?? pageItems.length } - - const collected = await loadAllMedia(libraryID, lib.is_remote_emby, (next) => { - if (cancelled) return - setTotal(next.total) - if (next.firstPage) { - setItems(next.items) - setLoading(false) - } - }, () => cancelled) - if (!cancelled) setItems(collected.items) + setTotal(totalRef.current) + hasMoreRef.current = loadedCountRef.current < totalRef.current + setHasMore(hasMoreRef.current) } catch { - if (!cancelled) toast.error('媒体库加载失败') + if (!cancelled && seq === requestSeqRef.current) { + toast.error('媒体库加载失败') + } } finally { - if (!cancelled) { + if (!cancelled && seq === requestSeqRef.current) { setLoading(false) - setLoadingAll(false) } } } void bootstrap() - return () => { cancelled = true } - }, [libraryID, reloadTick]) + return () => { + cancelled = true + requestSeqRef.current += 1 + } + }, [libraryID, reloadTick, sortField, sortOrder]) useEffect(() => { if (!libraryID || !isSeriesLibrary || !selectedSeries) { @@ -122,15 +146,74 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n return () => { cancelled = true } }, [libraryID, isSeriesLibrary, selectedSeries]) + const loadMore = useCallback(async () => { + const lib = libraryRef.current + if (!lib || moreInFlightRef.current || !hasMoreRef.current) return + const seq = requestSeqRef.current + moreInFlightRef.current = true + setLoadingMore(true) + try { + const page = nextPageRef.current + if (modeRef.current === 'series') { + const data = await libraryAPI.listSeries(libraryID, page, pageSizeFor(lib, true), { + sort: sortField, + order: sortOrder, + }) + if (seq !== requestSeqRef.current) return + const pageItems = data.items ?? [] + setServerSeriesCards((prev) => [...prev, ...pageItems]) + loadedCountRef.current += pageItems.length + totalRef.current = data.total ?? totalRef.current + nextPageRef.current = page + 1 + hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current + } else { + const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib, false), { + sort: sortField, + order: sortOrder, + }) + if (seq !== requestSeqRef.current) return + const pageItems = data.items ?? [] + setItems((prev) => [...prev, ...pageItems]) + loadedCountRef.current += pageItems.length + totalRef.current = data.total ?? totalRef.current + nextPageRef.current = page + 1 + hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current + } + setTotal(totalRef.current) + setHasMore(hasMoreRef.current) + } catch { + toast.error('媒体库加载失败') + hasMoreRef.current = false + setHasMore(false) + } finally { + moreInFlightRef.current = false + setLoadingMore(false) + } + }, [libraryID, sortField, sortOrder]) + + const loadAll = useCallback(async () => { + const seq = requestSeqRef.current + setLoadingMore(true) + try { + while (seq === requestSeqRef.current && hasMoreRef.current) { + await loadMore() + if (seq !== requestSeqRef.current || !hasMoreRef.current) break + await yieldToBrowser() + } + } finally { + if (seq === requestSeqRef.current) setLoadingMore(false) + } + }, [loadMore]) + const reloadCurrentLibrary = useCallback(() => { setReloadTick((tick) => tick + 1) }, []) - const loadingAllText = loadingAll && !loading && (isSeriesLibrary ? total > serverSeriesCards.length : total > items.length) - ? (isSeriesLibrary - ? `正在继续加载剧集卡片:${serverSeriesCards.length} / ${total}` - : `正在继续加载全部条目:${items.length} / ${total}`) - : '' + const loadedCount = isSeriesLibrary ? serverSeriesCards.length : items.length + const loadingAllText = + !loading && hasMore + ? `已加载 ${loadedCount} / ${total},向下滚动继续加载` + : '' return { library, @@ -138,6 +221,10 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n seriesEpisodeItems, total, loading, + loadingMore, + hasMore, + loadMore, + loadAll, loadingSeriesEpisodes, isSeriesLibrary, isSeries, @@ -151,6 +238,11 @@ function isSeriesLibraryType(type?: string) { return type === 'tv' || type === 'anime' || type === 'variety' } +function pageSizeFor(lib: Library, seriesMode: boolean): number { + if (lib.is_remote_emby) return 100 + return seriesMode ? 500 : 2000 +} + function yieldToBrowser(): Promise { return new Promise((resolve) => { if (typeof requestIdleCallback !== 'undefined') { @@ -159,54 +251,4 @@ function yieldToBrowser(): Promise { setTimeout(resolve, 0) } }) -} - -async function loadAllSeriesCards( - libraryID: string, - isRemoteEmby: boolean | undefined, - onPage: (state: { items: SeriesCard[]; total: number; firstPage: boolean }) => void, - isCancelled: () => boolean, -) { - const pageSize = isRemoteEmby ? 100 : 500 - let page = 1 - let collected: SeriesCard[] = [] - for (;;) { - // 切库/卸载后取消标志置位,立即停止继续拉取剩余页 - if (isCancelled()) return { items: collected } - const data = await libraryAPI.listSeries(libraryID, page, pageSize) - if (isCancelled()) return { items: collected } - // 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。 - const pageItems = data.items ?? [] - collected = collected.concat(pageItems) - onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 }) - if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break - page += 1 - await yieldToBrowser() - } - return { items: collected } -} - -async function loadAllMedia( - libraryID: string, - isRemoteEmby: boolean | undefined, - onPage: (state: { items: Media[]; total: number; firstPage: boolean }) => void, - isCancelled: () => boolean, -) { - const pageSize = isRemoteEmby ? 100 : 2000 - let page = 1 - let collected: Media[] = [] - for (;;) { - // 切库/卸载后取消标志置位,立即停止继续拉取剩余页 - if (isCancelled()) return { items: collected } - const data = await libraryAPI.listMedia(libraryID, page, pageSize) - if (isCancelled()) return { items: collected } - // 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。 - const pageItems = data.items ?? [] - collected = collected.concat(pageItems) - onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 }) - if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break - page += 1 - await yieldToBrowser() - } - return { items: collected } -} +} \ No newline at end of file diff --git a/web/src/types/library.ts b/web/src/types/library.ts index 5b1fa4e..4eb8f1c 100644 --- a/web/src/types/library.ts +++ b/web/src/types/library.ts @@ -24,6 +24,8 @@ export interface Library { /** 远程 Emby 挂载库(只读,不支持扫描/刮削/编辑) */ is_remote_emby?: boolean remote_source?: string + /** 媒体条目总数;`/api/libraries` 已在元数据请求中一并返回。 */ + total?: number } export interface ScanResult {