重构核心模块并同步更新项目配置与相关实现

This commit is contained in:
truewhile
2026-09-13 20:36:14 +08:00
parent 386fc358d1
commit 9a68ef701f
38 changed files with 1587 additions and 678 deletions
+110 -83
View File
@@ -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) {
+50
View File
@@ -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
}
+8
View File
@@ -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 {
+20
View File
@@ -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
+9 -5
View File
@@ -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),
+51 -23
View File
@@ -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
}
@@ -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)
}
}
+37 -35
View File
@@ -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 {
+25
View File
@@ -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)
}
}
+39 -19
View File
@@ -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
}
+76 -56
View File
@@ -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
}
+84 -36
View File
@@ -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)
+44
View File
@@ -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)
}
}
+256
View File
@@ -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)
}
+42
View File
@@ -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)
}
}
+13 -1
View File
@@ -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) {