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