mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
重构核心模块并同步更新项目配置与相关实现
This commit is contained in:
+110
-83
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+12
-2
@@ -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 ''
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 = () => <p className="px-6 py-8 text-sand-500">加载中…</p>
|
||||
|
||||
|
||||
+17
-3
@@ -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<ImageURLOptions, boolean> = typeof options === 'boolean' ? {} : options
|
||||
const retryFailed = typeof options === 'boolean' ? options : Boolean(opts.retryFailed)
|
||||
const refreshCache = typeof options === 'boolean' ? false : Boolean(opts.refreshCache)
|
||||
const retryQuery = retryFailed ? 'retry=1' : ''
|
||||
const refreshQuery = refreshCache ? 'refresh=1' : ''
|
||||
const imageQuery = [versionQuery, retryQuery, refreshQuery].filter(Boolean).join('&')
|
||||
const resizeQuery = [
|
||||
positiveDimensionQuery('maxWidth', opts.maxWidth),
|
||||
positiveDimensionQuery('maxHeight', opts.maxHeight),
|
||||
positiveDimensionQuery('quality', opts.quality, 100),
|
||||
].filter(Boolean).join('&')
|
||||
const imageQuery = [versionQuery, retryQuery, refreshQuery, resizeQuery].filter(Boolean).join('&')
|
||||
if (remote.startsWith('/api/img')) return withQuery(withoutAuthQuery(remote), imageQuery)
|
||||
if (remote.startsWith('/api/cloud/play/')) return withQuery(withoutAuthQuery(remote), imageQuery)
|
||||
if (remote.startsWith('/api/')) return withQuery(withQuery(remote, tokenQuery()), imageQuery)
|
||||
return withQuery(`/api/img?url=${encodeURIComponent(remote)}`, imageQuery)
|
||||
}
|
||||
|
||||
function positiveDimensionQuery(name: string, value?: number, max = 10_000): string {
|
||||
if (!Number.isFinite(value) || !value || value <= 0) return ''
|
||||
return `${name}=${Math.min(Math.round(value), max)}`
|
||||
}
|
||||
|
||||
function withQuery(url: string, query: string): string {
|
||||
if (!query) return url
|
||||
return `${url}${url.includes('?') ? '&' : '?'}${query}`
|
||||
|
||||
+16
-3
@@ -105,6 +105,7 @@ export const libraryAPI = {
|
||||
.get<LibraryWithPreview[]>('/libraries', {
|
||||
params: {
|
||||
with_preview: 1,
|
||||
include_total: 0,
|
||||
preview_limit: previewLimit,
|
||||
ids: ids.join(','),
|
||||
},
|
||||
@@ -160,22 +161,34 @@ export const libraryAPI = {
|
||||
scrape: (id: string, options?: ScrapeOptions) =>
|
||||
api.post(`/libraries/${id}/scrape`, options ?? null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
|
||||
|
||||
listMedia: (id: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean }) =>
|
||||
listMedia: (
|
||||
id: string,
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
options?: { groupVersions?: boolean; sort?: string; order?: 'asc' | 'desc' },
|
||||
) =>
|
||||
api
|
||||
.get<MediaPage>(`/libraries/${id}/media`, {
|
||||
params: {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
group_versions: options?.groupVersions === false ? 0 : undefined,
|
||||
sort: options?.sort,
|
||||
order: options?.order,
|
||||
},
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
listSeries: (id: string, page = 1, pageSize = 500) =>
|
||||
listSeries: (
|
||||
id: string,
|
||||
page = 1,
|
||||
pageSize = 500,
|
||||
options?: { sort?: string; order?: 'asc' | 'desc' },
|
||||
) =>
|
||||
api
|
||||
.get<SeriesPage>(`/libraries/${id}/series`, {
|
||||
params: { page, page_size: pageSize },
|
||||
params: { page, page_size: pageSize, sort: options?.sort, order: options?.order },
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
+16
-4
@@ -47,17 +47,29 @@ const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m)
|
||||
const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage })))
|
||||
|
||||
// 常用页面的路由 chunk 空闲预取:应用加载完成后浏览器一空闲就把浏览路径
|
||||
// (库列表/库详情/媒体详情/播放/收藏/历史等)的 chunk 拉下来,
|
||||
// (库列表/库详情/媒体详情/收藏/历史等)的 chunk 拉下来,
|
||||
// 首次点击进入时不再出现"加载中…"等 chunk 下载。失败静默(导航时会重试)。
|
||||
let prefetchStarted = false
|
||||
export function prefetchCommonRouteChunks() {
|
||||
if (prefetchStarted || typeof window === 'undefined') return
|
||||
|
||||
// 弱网/省流模式不做空闲预取:首屏预览和海报加载优先占用带宽。
|
||||
const connection = (navigator as Navigator & {
|
||||
connection?: { saveData?: boolean; effectiveType?: string }
|
||||
}).connection
|
||||
if (
|
||||
connection?.saveData ||
|
||||
connection?.effectiveType === 'slow-2g' ||
|
||||
connection?.effectiveType === '2g'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
prefetchStarted = true
|
||||
const loaders = [
|
||||
LibrariesPageLoader,
|
||||
LibraryPageLoader,
|
||||
MediaDetailPageLoader,
|
||||
PlayerPageLoader,
|
||||
FavouritesPageLoader,
|
||||
WatchHistoryPageLoader,
|
||||
PlaylistsPageLoader,
|
||||
@@ -68,9 +80,9 @@ export function prefetchCommonRouteChunks() {
|
||||
}
|
||||
}
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(run, { timeout: 5000 })
|
||||
requestIdleCallback(run, { timeout: 8000 })
|
||||
} else {
|
||||
window.setTimeout(run, 2000)
|
||||
window.setTimeout(run, 2500)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
|
||||
@@ -132,12 +132,12 @@ function LayoutHeaderSearch() {
|
||||
const locationKey = `${location.pathname}${location.search}`
|
||||
const prevLocationKeyRef = useRef(locationKey)
|
||||
|
||||
const setSearchOpen = (open: boolean) => {
|
||||
const setSearchOpen = useCallback((open: boolean) => {
|
||||
isOpenRef.current = open
|
||||
setIsOpen(open)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dismissSearch = (clearQuery: boolean) => {
|
||||
const dismissSearch = useCallback((clearQuery: boolean) => {
|
||||
suppressInputRef.current = true
|
||||
setSearchOpen(false)
|
||||
if (clearQuery) {
|
||||
@@ -156,9 +156,11 @@ function LayoutHeaderSearch() {
|
||||
window.setTimeout(() => {
|
||||
suppressInputRef.current = false
|
||||
}, 0)
|
||||
}
|
||||
}, [setSearchOpen])
|
||||
const dismissSearchRef = useRef(dismissSearch)
|
||||
dismissSearchRef.current = dismissSearch
|
||||
useEffect(() => {
|
||||
dismissSearchRef.current = dismissSearch
|
||||
}, [dismissSearch])
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
||||
@@ -50,29 +49,18 @@ export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarP
|
||||
}
|
||||
|
||||
export function LayoutMobileSidebar({ children, isOpen, onClose }: LayoutMobileSidebarProps) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex lg:hidden">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/15 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '-100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
|
||||
className="relative z-10 flex h-full min-h-0 w-64 max-w-xs flex-col overflow-hidden shadow-xl"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="fixed inset-0 z-50 flex lg:hidden">
|
||||
<div
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/15 backdrop-blur-sm animate-overlay-in"
|
||||
/>
|
||||
<div className="relative z-10 flex h-full min-h-0 w-64 max-w-xs flex-col overflow-hidden shadow-xl animate-drawer-in">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,19 +127,11 @@ export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: Layou
|
||||
return (
|
||||
<main id="app-main-scroll" className={clsx('flex-1 overflow-y-auto [overflow-anchor:none] px-4 py-6 md:px-8 md:py-10', bottomPad)}>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={routeKey}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
>
|
||||
<RouteErrorBoundary>
|
||||
<Outlet />
|
||||
</RouteErrorBoundary>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<div key={routeKey} className="animate-page-in">
|
||||
<RouteErrorBoundary>
|
||||
<Outlet />
|
||||
</RouteErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowLeft, Menu, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@@ -77,13 +76,9 @@ function LayoutSidebarHeader({
|
||||
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
|
||||
/>
|
||||
{sidebarExpanded && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="font-display text-lg font-extrabold tracking-tight text-[var(--app-text)]"
|
||||
>
|
||||
<span className="font-display text-lg font-extrabold tracking-tight text-[var(--app-text)] animate-fade-slide-in">
|
||||
MeBox
|
||||
</motion.span>
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<SidebarIconButton className="hidden lg:block" onClick={onToggleSidebar}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@@ -52,21 +51,13 @@ export function SidebarGroup({ id, icon, label, children, collapsed, open, activ
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{!collapsed && open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="space-y-1 pb-1 pl-3">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{!collapsed && open && (
|
||||
<div className="overflow-hidden animate-accordion-in">
|
||||
<div className="space-y-1 pb-1 pl-3">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -103,13 +94,9 @@ export function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarL
|
||||
{icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="truncate whitespace-nowrap"
|
||||
>
|
||||
<span className="truncate whitespace-nowrap">
|
||||
{label}
|
||||
</motion.span>
|
||||
</span>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="pointer-events-none absolute left-full z-50 ml-3 whitespace-nowrap rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, Tv, UserCog } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@@ -103,26 +102,18 @@ export function LayoutUserMenu({
|
||||
|
||||
const menuPortal = isOpen && menuPosition && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
<>
|
||||
<div
|
||||
key="layout-user-menu-backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="fixed inset-0 z-[120]"
|
||||
className="fixed inset-0 z-[120] animate-overlay-in"
|
||||
aria-hidden="true"
|
||||
onPointerDown={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
<div
|
||||
key="layout-user-menu-panel"
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
role="menu"
|
||||
style={{ top: menuPosition.top, right: menuPosition.right }}
|
||||
className="fixed z-[121] w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl"
|
||||
className="fixed z-[121] w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl animate-menu-in"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onNavigate={onClose} />
|
||||
@@ -191,8 +182,8 @@ export function LayoutUserMenu({
|
||||
<LogOut size={16} />
|
||||
<span>安全登出系统</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</AnimatePresence>,
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Film, Play, Layers, Star } from 'lucide-react'
|
||||
import { imageURL } from '../api/client'
|
||||
@@ -22,7 +22,12 @@ export const MediaCard = memo(function MediaCard({
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const href = linkTo ?? `/media/${media.id}`
|
||||
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
|
||||
const posterSrc = imageURL(media.poster_url, media.updated_at)
|
||||
const posterSrc = imageURL(media.poster_url, media.updated_at, {
|
||||
maxWidth: compact ? 320 : 480,
|
||||
maxHeight: compact ? 480 : 600,
|
||||
quality: 82,
|
||||
})
|
||||
const blurredPosterSrc = imageURL(media.poster_url, media.updated_at, { maxWidth: 160, quality: 60 })
|
||||
const displayRating = rating ?? media.rating
|
||||
const versionCount = media.versions?.length ?? 0
|
||||
// renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效
|
||||
@@ -43,7 +48,7 @@ export const MediaCard = memo(function MediaCard({
|
||||
<>
|
||||
{posterFit === 'contain' && (
|
||||
<img
|
||||
src={posterSrc}
|
||||
src={blurredPosterSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
const DEFAULT_ROOT_MARGIN = '480px 0px'
|
||||
|
||||
/**
|
||||
* 元素首次进入滚动容器附近时触发一次。用于媒体库预览的按需加载。
|
||||
*/
|
||||
export function useInViewOnce<T extends Element>(
|
||||
onVisible: () => void,
|
||||
rootMargin = DEFAULT_ROOT_MARGIN,
|
||||
): RefObject<T> {
|
||||
const ref = useRef<T>(null)
|
||||
const callbackRef = useRef(onVisible)
|
||||
|
||||
useEffect(() => {
|
||||
callbackRef.current = onVisible
|
||||
}, [onVisible])
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element) return
|
||||
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
callbackRef.current()
|
||||
return
|
||||
}
|
||||
|
||||
const root = document.getElementById('app-main-scroll')
|
||||
let fired = false
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (fired || !entries.some((entry) => entry.isIntersecting)) return
|
||||
fired = true
|
||||
observer.disconnect()
|
||||
callbackRef.current()
|
||||
},
|
||||
{ root, rootMargin, threshold: 0.01 },
|
||||
)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [rootMargin])
|
||||
|
||||
return ref
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* 合并短时间内进入视口的预览请求,避免每个卡片各发一次 HTTP 请求。
|
||||
* 回调放在 ref 中,调用方每次渲染传新的闭包也不会重建定时器。
|
||||
*/
|
||||
export function useLazyPreviewBatch(
|
||||
onNeedPreviews?: (ids: string[]) => void,
|
||||
delayMs = 48,
|
||||
) {
|
||||
const pendingRef = useRef<Set<string>>(new Set())
|
||||
const timerRef = useRef<number | null>(null)
|
||||
const callbackRef = useRef(onNeedPreviews)
|
||||
|
||||
useEffect(() => {
|
||||
callbackRef.current = onNeedPreviews
|
||||
}, [onNeedPreviews])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useCallback((id: string) => {
|
||||
if (!id || !callbackRef.current) return
|
||||
pendingRef.current.add(id)
|
||||
if (timerRef.current !== null) return
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null
|
||||
const ids = Array.from(pendingRef.current)
|
||||
pendingRef.current.clear()
|
||||
if (ids.length > 0) {
|
||||
callbackRef.current?.(ids)
|
||||
}
|
||||
}, delayMs)
|
||||
}, [delayMs])
|
||||
}
|
||||
Vendored
+39
@@ -418,3 +418,42 @@ video::cue(*) {
|
||||
0 0 8px rgba(0, 0, 0, 0.8),
|
||||
0 0 16px rgba(0, 0, 0, 0.6) !important;
|
||||
}
|
||||
|
||||
|
||||
/* ── Entrance animations (replaces framer-motion on the critical shell) ── */
|
||||
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes drawer-in { from { transform: translateX(-100%); } to { transform: translateX(0); } }
|
||||
@keyframes page-in { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes accordion-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes fade-slide-in { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes menu-in { from { opacity: 0; transform: translateY(10px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
|
||||
.animate-overlay-in { animation: overlay-in 0.15s ease-out both; }
|
||||
.animate-drawer-in { animation: drawer-in 0.22s ease-out both; }
|
||||
.animate-page-in { animation: page-in 0.25s ease-out both; }
|
||||
.animate-accordion-in { animation: accordion-in 0.18s ease-out both; }
|
||||
.animate-fade-slide-in { animation: fade-slide-in 0.2s ease-out both; }
|
||||
.animate-menu-in { animation: menu-in 0.15s ease-out both; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-overlay-in,
|
||||
.animate-drawer-in,
|
||||
.animate-page-in,
|
||||
.animate-accordion-in,
|
||||
.animate-fade-slide-in,
|
||||
.animate-menu-in { animation: none !important; }
|
||||
}
|
||||
|
||||
|
||||
@keyframes pulse-soft { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
@keyframes hero-in { from { opacity: 0; transform: scale(1.08); } to { opacity: 0.38; transform: scale(1.02); } }
|
||||
@keyframes title-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.animate-pulse-soft { animation: pulse-soft 1.5s ease-in-out infinite; }
|
||||
.animate-hero-in { animation: hero-in 0.8s ease-out both; }
|
||||
.animate-title-in { animation: title-in 0.3s ease-out both; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-pulse-soft,
|
||||
.animate-hero-in,
|
||||
.animate-title-in { animation: none !important; }
|
||||
}
|
||||
|
||||
+57
-32
@@ -18,6 +18,7 @@ import {
|
||||
HomeLoadingState,
|
||||
} from './HomePageSections'
|
||||
|
||||
const PREVIEW_BATCH_SIZE = 4
|
||||
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||
|
||||
export function HomePage() {
|
||||
@@ -86,48 +87,72 @@ export function HomePage() {
|
||||
|
||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
||||
|
||||
// 按需拉取卡片预览管理
|
||||
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
|
||||
const fetchingRef = useRef<Set<string>>(new Set())
|
||||
// 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格,
|
||||
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
|
||||
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
||||
const fetchingPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
||||
|
||||
const fetchPreviews = useCallback(async (ids: string[]) => {
|
||||
const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id))
|
||||
const fetchPreviews = useCallback(async (ids: string[], limit = 10) => {
|
||||
const uniqueIDs = Array.from(new Set(ids.filter(Boolean)))
|
||||
const targets = uniqueIDs.filter(
|
||||
(id) =>
|
||||
(fetchedPreviewLimitsRef.current.get(id) ?? 0) < limit &&
|
||||
(fetchingPreviewLimitsRef.current.get(id) ?? 0) < limit,
|
||||
)
|
||||
if (targets.length === 0) return
|
||||
targets.forEach((id) => fetchingRef.current.add(id))
|
||||
targets.forEach((id) => fetchingPreviewLimitsRef.current.set(id, limit))
|
||||
|
||||
try {
|
||||
const rows = await libraryAPI.listPreviews(targets, 10)
|
||||
setLibraryData((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of rows) {
|
||||
next[row.id] = {
|
||||
cards: row.cards ?? [],
|
||||
items: [],
|
||||
total: row.total ?? 0,
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch {
|
||||
// 容错
|
||||
} finally {
|
||||
targets.forEach((id) => {
|
||||
fetchedLibIdsRef.current.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
const batches: string[][] = []
|
||||
for (let i = 0; i < targets.length; i += PREVIEW_BATCH_SIZE) {
|
||||
batches.push(targets.slice(i, i + PREVIEW_BATCH_SIZE))
|
||||
}
|
||||
|
||||
// 分批并发:一个慢库不再阻塞整页预览,先返回的批次立即渲染。
|
||||
await Promise.allSettled(
|
||||
batches.map(async (batch) => {
|
||||
let loaded = false
|
||||
try {
|
||||
const rows = await libraryAPI.listPreviews(batch, limit)
|
||||
loaded = true
|
||||
setLibraryData((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of rows) {
|
||||
next[row.id] = {
|
||||
cards: row.cards ?? [],
|
||||
items: [],
|
||||
total: row.total ?? 0,
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch {
|
||||
// 单个批次失败不影响其他批次;导航回来时会重试。
|
||||
} finally {
|
||||
batch.forEach((id) => {
|
||||
if (loaded) {
|
||||
fetchedPreviewLimitsRef.current.set(
|
||||
id,
|
||||
Math.max(fetchedPreviewLimitsRef.current.get(id) ?? 0, limit),
|
||||
)
|
||||
}
|
||||
if (fetchingPreviewLimitsRef.current.get(id) === limit) {
|
||||
fetchingPreviewLimitsRef.current.delete(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
}, [])
|
||||
|
||||
// 3. 首屏优先加载:轮播图库 + 媒体库卡片区前 20 个库 + 首屏前 3 个内容行
|
||||
// 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后再按批加载。
|
||||
useEffect(() => {
|
||||
if (sortedLibraries.length === 0) return
|
||||
const carouselLibIds = sortedLibraries
|
||||
.filter((l) => l.carousel_enabled === true)
|
||||
.map((l) => l.id)
|
||||
const topGridLibIds = sortedLibraries.slice(0, 20).map((l) => l.id)
|
||||
const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id)
|
||||
const initialTargets = Array.from(new Set([...carouselLibIds, ...topGridLibIds, ...topRowLibIds]))
|
||||
void fetchPreviews(initialTargets)
|
||||
const initialTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds]))
|
||||
void fetchPreviews(initialTargets, 10)
|
||||
}, [sortedLibraries, fetchPreviews])
|
||||
|
||||
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
|
||||
@@ -140,7 +165,7 @@ export function HomePage() {
|
||||
useEffect(() => {
|
||||
if (sortedLibraries.length === 0) return
|
||||
const currentTargets = sortedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
|
||||
void fetchPreviews(currentTargets)
|
||||
void fetchPreviews(currentTargets, 10)
|
||||
}, [sortedLibraries, visibleTargetCount, fetchPreviews])
|
||||
|
||||
// 当前已拉取并确认有内容的媒体库行
|
||||
@@ -214,7 +239,7 @@ export function HomePage() {
|
||||
const libraryCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const lib of libraries) {
|
||||
counts[lib.id] = libraryData[lib.id]?.total ?? 0
|
||||
counts[lib.id] = lib.total ?? libraryData[lib.id]?.total ?? 0
|
||||
}
|
||||
return counts
|
||||
}, [libraries, libraryData])
|
||||
|
||||
+108
-105
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
@@ -18,6 +17,8 @@ import {
|
||||
} from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { useInViewOnce } from '../hooks/useInViewOnce'
|
||||
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { HistoryItem } from '../api/playback'
|
||||
import type { Library, Media } from '../types'
|
||||
@@ -37,7 +38,6 @@ const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
music: <Music size={18} />,
|
||||
adult: <Film size={18} />,
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
movies: '电影',
|
||||
@@ -53,11 +53,7 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
export function HomeLoadingState() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-48">
|
||||
<motion.div
|
||||
animate={{ opacity: [0.4, 1, 0.4] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 animate-pulse-soft">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-2 border-[var(--app-border)] border-t-[var(--app-active-bg)]" />
|
||||
<Film className="absolute h-4 w-4 text-brand-500" />
|
||||
@@ -65,7 +61,7 @@ export function HomeLoadingState() {
|
||||
<span className="text-sm font-semibold uppercase tracking-widest text-[var(--app-muted)]">
|
||||
首页内容准备中…
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -170,24 +166,20 @@ export function HomeCarouselSection({
|
||||
{/* Background Backdrop Image with Crossfade */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="theme-hero-bg h-full w-full" />
|
||||
<AnimatePresence mode="wait">
|
||||
{visual && (
|
||||
<motion.img
|
||||
key={visual + currentItem.id}
|
||||
initial={{ opacity: 0, scale: 1.08 }}
|
||||
animate={{ opacity: 0.38, scale: 1.02 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
src={imageURL(visual, currentItem.updated_at)}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover object-center blur-[1px]"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{visual && (
|
||||
<img
|
||||
key={visual + currentItem.id}
|
||||
src={imageURL(visual, currentItem.updated_at, { maxWidth: 1920, maxHeight: 1080, quality: 78 })}
|
||||
alt=""
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
className="absolute inset-0 h-full w-full object-cover object-center blur-[1px] animate-hero-in"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="theme-hero-overlay absolute inset-0" />
|
||||
<div className="theme-hero-fade absolute inset-x-0 bottom-0 h-36" />
|
||||
</div>
|
||||
@@ -212,18 +204,12 @@ export function HomeCarouselSection({
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-2">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.h1
|
||||
key={currentItem.title + currentItem.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="font-display text-2xl font-extrabold leading-tight tracking-tight text-[var(--app-text)] sm:text-3xl md:text-4xl lg:text-5xl"
|
||||
>
|
||||
{currentItem.title}
|
||||
</motion.h1>
|
||||
</AnimatePresence>
|
||||
<h1
|
||||
key={currentItem.title + currentItem.id}
|
||||
className="font-display text-2xl font-extrabold leading-tight tracking-tight text-[var(--app-text)] sm:text-3xl md:text-4xl lg:text-5xl animate-title-in"
|
||||
>
|
||||
{currentItem.title}
|
||||
</h1>
|
||||
{currentItem.original_name && currentItem.original_name !== currentItem.title && (
|
||||
<p className="text-xs font-semibold text-[var(--app-muted)] tracking-wide">
|
||||
{currentItem.original_name}
|
||||
@@ -295,8 +281,10 @@ export function HomeCarouselSection({
|
||||
</div>
|
||||
{poster && (
|
||||
<img
|
||||
src={imageURL(poster, currentItem.updated_at)}
|
||||
src={imageURL(poster, currentItem.updated_at, { maxWidth: 560, quality: 84 })}
|
||||
alt={currentItem.title}
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
className="absolute inset-2 h-[calc(100%-1rem)] w-[calc(100%-1rem)] rounded-[1.25rem] object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
@@ -377,17 +365,13 @@ export function HomeLibrariesSection({
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const totalPages = Math.max(1, Math.ceil(libraries.length / PAGE_SIZE))
|
||||
const effectivePage = Math.min(currentPage, totalPages)
|
||||
const queuePreview = useLazyPreviewBatch(onNeedPreviews)
|
||||
|
||||
const pagedLibraries = useMemo<Library[]>(() => {
|
||||
const start = (effectivePage - 1) * PAGE_SIZE
|
||||
return libraries.slice(start, start + PAGE_SIZE)
|
||||
}, [libraries, effectivePage])
|
||||
|
||||
useEffect(() => {
|
||||
const ids = pagedLibraries.map((l) => l.id)
|
||||
onNeedPreviews?.(ids)
|
||||
}, [pagedLibraries, onNeedPreviews])
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--app-border)] pb-3">
|
||||
@@ -449,72 +433,91 @@ export function HomeLibrariesSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Libraries Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-6">
|
||||
{pagedLibraries.map((lib) => {
|
||||
const count = libraryCounts[lib.id] ?? 0
|
||||
const cards = libraryData?.[lib.id]?.cards || []
|
||||
const artwork = getLibraryArtworks(lib, cards)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={lib.id}
|
||||
to={`/library/${lib.id}`}
|
||||
className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10"
|
||||
title={lib.name}
|
||||
>
|
||||
{/* 封面图片展示区:和媒体库页面一样,显示设置好的或生成的图片 */}
|
||||
<div
|
||||
className={`relative h-28 w-full overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--app-panel-soft),var(--app-panel))] shadow-inner ${
|
||||
artwork.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''
|
||||
}`}
|
||||
>
|
||||
{artwork.length > 0 ? (
|
||||
artwork.map(({ src, version }, index) => (
|
||||
<img
|
||||
key={`${src}-${index}`}
|
||||
src={imageURL(src, version)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.visibility = 'hidden'
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-brand-500">
|
||||
{TYPE_ICONS[lib.type] || <FolderOpen size={28} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 浮动类型标签 */}
|
||||
<div className="absolute top-2 right-2 rounded-lg border border-white/20 bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white backdrop-blur-md shadow-sm">
|
||||
{TYPE_LABELS[lib.type] || '自定义'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 媒体库信息 */}
|
||||
<div className="mt-3 flex flex-col justify-between">
|
||||
<h3
|
||||
className="line-clamp-2 break-words font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500"
|
||||
title={lib.name}
|
||||
>
|
||||
{lib.name}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
|
||||
{count > 0 ? `${count} 部媒体` : '暂无条目'}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{pagedLibraries.map((lib) => (
|
||||
<HomeLibraryCard
|
||||
key={lib.id}
|
||||
library={lib}
|
||||
count={libraryCounts[lib.id] ?? 0}
|
||||
cards={libraryData?.[lib.id]?.cards ?? []}
|
||||
onVisible={() => {
|
||||
if (!lib.cover_url) {
|
||||
queuePreview(lib.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeLibraryCard({
|
||||
library,
|
||||
count,
|
||||
cards,
|
||||
onVisible,
|
||||
}: {
|
||||
library: Library
|
||||
count: number
|
||||
cards: SeriesCard[]
|
||||
onVisible: () => void
|
||||
}) {
|
||||
const ref = useInViewOnce<HTMLAnchorElement>(onVisible)
|
||||
const artwork = getLibraryArtworks(library, cards)
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
to={`/library/${library.id}`}
|
||||
className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10"
|
||||
title={library.name}
|
||||
>
|
||||
<div
|
||||
className={`relative h-28 w-full overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--app-panel-soft),var(--app-panel))] shadow-inner ${
|
||||
artwork.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''
|
||||
}`}
|
||||
>
|
||||
{artwork.length > 0 ? (
|
||||
artwork.map(({ src, version }, index) => (
|
||||
<img
|
||||
key={`${src}-${index}`}
|
||||
src={imageURL(src, version, { maxWidth: 480, maxHeight: 320, quality: 78 })}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.visibility = 'hidden'
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-brand-500">
|
||||
{TYPE_ICONS[library.type] || <FolderOpen size={28} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-2 right-2 rounded-lg border border-white/20 bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white backdrop-blur-md shadow-sm">
|
||||
{TYPE_LABELS[library.type] || '自定义'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col justify-between">
|
||||
<h3
|
||||
className="line-clamp-2 break-words font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500"
|
||||
title={library.name}
|
||||
>
|
||||
{library.name}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
|
||||
{count > 0 ? `${count} 部媒体` : '暂无条目'}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
3. 单个媒体库内容行 (Horizontal Scroll Row)
|
||||
========================================================================= */
|
||||
@@ -694,7 +697,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
|
||||
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-xl bg-[var(--app-panel-soft)]">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url, media.updated_at)}
|
||||
src={imageURL(media.poster_url, media.updated_at, { maxWidth: 180, maxHeight: 240, quality: 78 })}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
|
||||
@@ -33,8 +33,10 @@ export function LibrariesPage() {
|
||||
if (targets.length === 0) return
|
||||
targets.forEach((id) => fetchingRef.current.add(id))
|
||||
|
||||
let loaded = false
|
||||
try {
|
||||
const rows = await libraryAPI.listPreviews(targets, 10)
|
||||
loaded = true
|
||||
setLibraryData((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of rows) {
|
||||
@@ -49,7 +51,9 @@ export function LibrariesPage() {
|
||||
// 容错
|
||||
} finally {
|
||||
targets.forEach((id) => {
|
||||
fetchedLibIdsRef.current.add(id)
|
||||
if (loaded) {
|
||||
fetchedLibIdsRef.current.add(id)
|
||||
}
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
}
|
||||
@@ -68,13 +72,10 @@ export function LibrariesPage() {
|
||||
try {
|
||||
const libs = await fetchLibraries()
|
||||
setLibraries(libs)
|
||||
// 优先拉取入口卡片网格当前页(前 20 个库)的预览
|
||||
const topIds = libs.slice(0, 20).map((l) => l.id)
|
||||
void fetchPreviews(topIds)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [fetchPreviews])
|
||||
}, [])
|
||||
|
||||
async function handleRepairRescrape() {
|
||||
if (repairing) return
|
||||
@@ -105,7 +106,7 @@ export function LibrariesPage() {
|
||||
return {
|
||||
library,
|
||||
items: [],
|
||||
total: data?.total ?? 0,
|
||||
total: library.total ?? data?.total ?? 0,
|
||||
cards: data?.cards ?? [],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, ChevronLeft, ChevronRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { useInViewOnce } from '../hooks/useInViewOnce'
|
||||
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
|
||||
import { seriesCardLink } from '../utils/groupSeries'
|
||||
import { libraryDisplayPath } from './libraryDisplayModel'
|
||||
import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel'
|
||||
@@ -19,7 +20,6 @@ const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
music: <Music size={18} />,
|
||||
adult: <Film size={18} />,
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
tv: '剧集',
|
||||
@@ -115,6 +115,7 @@ export function LibrariesContent({
|
||||
onNeedPreviews?: (ids: string[]) => void
|
||||
}) {
|
||||
const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length
|
||||
const queuePreview = useLazyPreviewBatch(onNeedPreviews)
|
||||
|
||||
// 下方媒体库货架支持向下滑动渐进流式加载:默认先展示前 3 个库货架,
|
||||
// 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库货架。
|
||||
@@ -185,15 +186,12 @@ export function LibrariesContent({
|
||||
const [entryPage, setEntryPage] = useState(1)
|
||||
const totalEntryPages = Math.max(1, Math.ceil(previews.length / ENTRY_PAGE_SIZE))
|
||||
const effectiveEntryPage = Math.min(entryPage, totalEntryPages)
|
||||
|
||||
const pagedPreviews = useMemo<LibraryPreview[]>(() => {
|
||||
const start = (effectiveEntryPage - 1) * ENTRY_PAGE_SIZE
|
||||
return previews.slice(start, start + ENTRY_PAGE_SIZE)
|
||||
}, [previews, effectiveEntryPage])
|
||||
|
||||
useEffect(() => {
|
||||
const ids = pagedPreviews.map((p) => p.library.id)
|
||||
onNeedPreviews?.(ids)
|
||||
}, [pagedPreviews, onNeedPreviews])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -241,37 +239,38 @@ export function LibrariesContent({
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
|
||||
{pagedPreviews.map((preview, index) => (
|
||||
<motion.div
|
||||
{pagedPreviews.map((preview) => (
|
||||
<div
|
||||
key={preview.library.id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
className="animate-page-in"
|
||||
>
|
||||
<LibraryEntryCard
|
||||
preview={preview}
|
||||
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
|
||||
onTogglePin={() => onTogglePin(preview.library.id)}
|
||||
onVisible={() => {
|
||||
if (!preview.library.cover_url) {
|
||||
queuePreview(preview.library.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{visiblePreviews.length > 0 && (
|
||||
<section className="space-y-6">
|
||||
{visiblePreviews.map((preview, index) => (
|
||||
<motion.div
|
||||
{visiblePreviews.map((preview) => (
|
||||
<div
|
||||
key={preview.library.id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index, 3) * 0.03 }}
|
||||
className="animate-page-in"
|
||||
>
|
||||
<LibraryShelf
|
||||
preview={preview}
|
||||
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{visibleCount < previews.length && (
|
||||
@@ -292,12 +291,15 @@ function LibraryEntryCard({
|
||||
preview,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
onVisible,
|
||||
}: {
|
||||
preview: LibraryPreview
|
||||
pinned: boolean
|
||||
onTogglePin: () => void
|
||||
onVisible?: () => void
|
||||
}) {
|
||||
const library = preview.library
|
||||
const ref = useInViewOnce<HTMLDivElement>(onVisible ?? (() => {}))
|
||||
const artwork = library.cover_url
|
||||
? [{ src: library.cover_url, version: library.updated_at }]
|
||||
: libraryArtworkItems(preview.cards)
|
||||
@@ -305,6 +307,7 @@ function LibraryEntryCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={
|
||||
'group relative flex overflow-hidden rounded-2xl sm:rounded-3xl border bg-white p-2.5 sm:p-3 shadow-card transition-all hover:-translate-y-0.5 hover:shadow-card-hover ' +
|
||||
(pinned ? 'border-brand-300 ring-1 ring-brand-100' : 'border-sand-200 hover:border-brand-200')
|
||||
@@ -339,7 +342,7 @@ function LibraryEntryCard({
|
||||
artwork.map(({ src, version }, index) => (
|
||||
<img
|
||||
key={`${src}-${index}`}
|
||||
src={imageURL(src, version)}
|
||||
src={imageURL(src, version, { maxWidth: 400, maxHeight: 300, quality: 78 })}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, type ReactNode } from 'react'
|
||||
import { memo, useEffect, useRef, type ReactNode } from 'react'
|
||||
import { Film } from 'lucide-react'
|
||||
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
@@ -15,6 +15,9 @@ type LibraryMediaSectionsProps = {
|
||||
seriesCards: SeriesCard[]
|
||||
selectedSeries: SeriesCard | null
|
||||
loading: boolean
|
||||
hasMore: boolean
|
||||
loadingMore: boolean
|
||||
onLoadMore: () => void
|
||||
cardActions: (media: Media) => ReactNode
|
||||
onSeriesClick: (series: SeriesCard) => void
|
||||
}
|
||||
@@ -25,6 +28,9 @@ export function LibraryMediaSections({
|
||||
seriesCards,
|
||||
selectedSeries,
|
||||
loading,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
cardActions,
|
||||
onSeriesClick,
|
||||
}: LibraryMediaSectionsProps) {
|
||||
@@ -60,10 +66,58 @@ export function LibraryMediaSections({
|
||||
{isSeries && seriesCards.length === 0 && !loading && (
|
||||
<LibraryEmptyState message="该库尚未发现任何剧集,触发一次扫描后再来看看" />
|
||||
)}
|
||||
|
||||
<LoadMoreSentinel
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadMoreSentinel({
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
}: {
|
||||
hasMore: boolean
|
||||
loadingMore: boolean
|
||||
onLoadMore: () => void
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element || !hasMore) return
|
||||
const root = document.getElementById('app-main-scroll')
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!loadingMore && entries.some((entry) => entry.isIntersecting)) {
|
||||
onLoadMore()
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '600px 0px', threshold: 0 },
|
||||
)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, loadingMore, onLoadMore])
|
||||
|
||||
if (!hasMore) return null
|
||||
return (
|
||||
<div ref={ref} className="flex justify-center py-8">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingMore}
|
||||
onClick={onLoadMore}
|
||||
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-2 text-sm font-bold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{loadingMore ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 独立 memo 组件:onClick 闭包在其内部创建,props 均为稳定引用,
|
||||
// 父级重渲染不会穿透到每张剧集卡片。
|
||||
const SeriesCardItem = memo(function SeriesCardItem({
|
||||
@@ -99,4 +153,4 @@ function LibraryEmptyState({ message }: { message: string }) {
|
||||
<p className="text-ink-50">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState, Fragment, type ReactNode } from 'react'
|
||||
import { useCallback, useMemo, useState, Fragment, type ReactNode } from 'react'
|
||||
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
import { historyAPI } from '../api/history'
|
||||
import type { Media } from '../types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { isTheatricalFeature, type SeriesCard } from '../utils/groupSeries'
|
||||
@@ -49,48 +48,10 @@ export function LibraryPage() {
|
||||
return (saved as SortOrder) || 'asc'
|
||||
})
|
||||
const [randomSeed, setRandomSeed] = useState(() => Date.now())
|
||||
const [historyMap, setHistoryMap] = useState<Map<string, string>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
if (sortField !== 'last_played') return
|
||||
let cancelled = false
|
||||
historyAPI
|
||||
.list(1000)
|
||||
.then((historyItems) => {
|
||||
if (cancelled) return
|
||||
const map = new Map<string, string>()
|
||||
for (const item of historyItems ?? []) {
|
||||
if (item.media_id && item.watched_at) {
|
||||
if (!map.has(item.media_id) || new Date(item.watched_at) > new Date(map.get(item.media_id)!)) {
|
||||
map.set(item.media_id, item.watched_at)
|
||||
}
|
||||
}
|
||||
}
|
||||
setHistoryMap(map)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [sortField])
|
||||
|
||||
const handleSortChange = (field: SortField, order: SortOrder) => {
|
||||
setSortField(field)
|
||||
setSortOrder(order)
|
||||
if (id) {
|
||||
localStorage.setItem(`mebox_lib_sort_field_${id}`, field)
|
||||
localStorage.setItem(`mebox_lib_sort_order_${id}`, order)
|
||||
}
|
||||
localStorage.setItem('mebox_lib_sort_field', field)
|
||||
localStorage.setItem('mebox_lib_sort_order', order)
|
||||
if (field === 'random') {
|
||||
setRandomSeed(Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
const handleReshuffle = () => {
|
||||
setRandomSeed(Date.now())
|
||||
}
|
||||
const [lastNonRandomSort, setLastNonRandomSort] = useState<SortField>(sortField === 'random' ? 'release_date' : sortField)
|
||||
const [lastNonRandomOrder, setLastNonRandomOrder] = useState<SortOrder>(sortOrder)
|
||||
const serverSortField = sortField === 'random' ? lastNonRandomSort : sortField
|
||||
const serverSortOrder = sortField === 'random' ? lastNonRandomOrder : sortOrder
|
||||
|
||||
// 剧集模式:选中某个剧集后展开详情
|
||||
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
|
||||
@@ -102,22 +63,56 @@ export function LibraryPage() {
|
||||
seriesEpisodeItems,
|
||||
total,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
loadAll,
|
||||
loadingSeriesEpisodes,
|
||||
isSeriesLibrary,
|
||||
isSeries,
|
||||
seriesCards,
|
||||
loadingAllText,
|
||||
reloadCurrentLibrary,
|
||||
} = useLibraryData(id, selectedSeries)
|
||||
} = useLibraryData(id, selectedSeries, serverSortField, serverSortOrder)
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
return sortMediaList(items, sortField, sortOrder, randomSeed, historyMap)
|
||||
}, [items, sortField, sortOrder, randomSeed, historyMap])
|
||||
// 常规排序由服务端全局完成;只有 random 模式才在客户端洗牌。
|
||||
const displayedItems = useMemo(() => {
|
||||
return sortField === 'random' ? sortMediaList(items, 'random', sortOrder, randomSeed) : items
|
||||
}, [items, sortField, sortOrder, randomSeed])
|
||||
|
||||
const sortedSeriesCards = useMemo(() => {
|
||||
return sortSeriesList(seriesCards, sortField, sortOrder, randomSeed, historyMap)
|
||||
}, [seriesCards, sortField, sortOrder, randomSeed, historyMap])
|
||||
const displayedSeriesCards = useMemo(() => {
|
||||
return sortField === 'random' ? sortSeriesList(seriesCards, 'random', sortOrder, randomSeed) : seriesCards
|
||||
}, [seriesCards, sortField, sortOrder, randomSeed])
|
||||
|
||||
const handleSortChange = useCallback(async (field: SortField, order: SortOrder) => {
|
||||
if (field === 'random') {
|
||||
if (sortField !== 'random') {
|
||||
setLastNonRandomSort(sortField)
|
||||
setLastNonRandomOrder(sortOrder)
|
||||
await loadAll()
|
||||
}
|
||||
setRandomSeed(Date.now())
|
||||
} else {
|
||||
setLastNonRandomSort(field)
|
||||
setLastNonRandomOrder(order)
|
||||
}
|
||||
setSortField(field)
|
||||
setSortOrder(order)
|
||||
if (id) {
|
||||
localStorage.setItem(`mebox_lib_sort_field_${id}`, field)
|
||||
localStorage.setItem(`mebox_lib_sort_order_${id}`, order)
|
||||
}
|
||||
localStorage.setItem('mebox_lib_sort_field', field)
|
||||
localStorage.setItem('mebox_lib_sort_order', order)
|
||||
}, [id, loadAll, sortField, sortOrder])
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
void loadMore()
|
||||
}, [loadMore])
|
||||
|
||||
const handleReshuffle = () => {
|
||||
setRandomSeed(Date.now())
|
||||
}
|
||||
const {
|
||||
scanning,
|
||||
scanProgress,
|
||||
@@ -142,7 +137,7 @@ export function LibraryPage() {
|
||||
isSeriesLibrary,
|
||||
isSeries,
|
||||
loading,
|
||||
seriesCards: sortedSeriesCards,
|
||||
seriesCards: displayedSeriesCards,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
selectedSeries,
|
||||
@@ -236,7 +231,7 @@ export function LibraryPage() {
|
||||
{!selectedSeries && (
|
||||
<LibraryPageHeader
|
||||
library={library}
|
||||
itemCount={isSeries ? sortedSeriesCards.length : total}
|
||||
itemCount={isSeries ? displayedSeriesCards.length : total}
|
||||
loadingAllText={loadingAllText}
|
||||
scanProgress={scanProgress}
|
||||
isAdmin={role === 'admin'}
|
||||
@@ -257,10 +252,13 @@ export function LibraryPage() {
|
||||
|
||||
<LibraryMediaSections
|
||||
isSeries={isSeries}
|
||||
items={sortedItems}
|
||||
seriesCards={sortedSeriesCards}
|
||||
items={displayedItems}
|
||||
seriesCards={displayedSeriesCards}
|
||||
selectedSeries={selectedSeries}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
cardActions={cardActions}
|
||||
onSeriesClick={handleSeriesClick}
|
||||
/>
|
||||
|
||||
+139
-97
@@ -1,19 +1,26 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, Media } from '../types'
|
||||
import { peekLibrary, resolveLibrary } from '../utils/libraryCache'
|
||||
import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries'
|
||||
import type { SortField, SortOrder } from '../utils/mediaSort'
|
||||
|
||||
export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | null) {
|
||||
export function useLibraryData(
|
||||
libraryID: string,
|
||||
selectedSeries: SeriesCard | null,
|
||||
sortField: SortField,
|
||||
sortOrder: SortOrder,
|
||||
) {
|
||||
const [library, setLibrary] = useState<Library | null>(null)
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [serverSeriesCards, setServerSeriesCards] = useState<SeriesCard[]>([])
|
||||
const [seriesEpisodeItems, setSeriesEpisodeItems] = useState<Media[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingAll, setLoadingAll] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loadingSeriesEpisodes, setLoadingSeriesEpisodes] = useState(false)
|
||||
|
||||
const isSeriesLibrary = isSeriesLibraryType(library?.type)
|
||||
@@ -26,79 +33,96 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
|
||||
return groupSeries(items)
|
||||
}, [isSeries, isSeriesLibrary, items, serverSeriesCards])
|
||||
|
||||
// reloadCurrentLibrary 通过自增 tick 重跑加载(原实现靠克隆 library 对象
|
||||
// 触发第二个 effect,这里合并成单个 bootstrap effect 后改用显式信号)。
|
||||
const [reloadTick, setReloadTick] = useState(0)
|
||||
const requestSeqRef = useRef(0)
|
||||
const nextPageRef = useRef(2)
|
||||
const loadedCountRef = useRef(0)
|
||||
const totalRef = useRef(0)
|
||||
const hasMoreRef = useRef(false)
|
||||
const libraryRef = useRef<Library | null>(null)
|
||||
const modeRef = useRef<'media' | 'series'>('media')
|
||||
const moreInFlightRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryID) return
|
||||
const seq = ++requestSeqRef.current
|
||||
let cancelled = false
|
||||
nextPageRef.current = 2
|
||||
loadedCountRef.current = 0
|
||||
totalRef.current = 0
|
||||
hasMoreRef.current = false
|
||||
moreInFlightRef.current = false
|
||||
setLoading(true)
|
||||
setLoadingAll(false)
|
||||
setLoadingMore(false)
|
||||
setHasMore(false)
|
||||
setLibrary(null)
|
||||
setItems([])
|
||||
setServerSeriesCards([])
|
||||
setSeriesEpisodeItems([])
|
||||
setTotal(0)
|
||||
|
||||
const bootstrap = async () => {
|
||||
// 库信息先查会话缓存(首页/全部媒体库页已拉过全量列表),命中则
|
||||
// 同步就绪,内容请求在挂载当帧即发出;未命中才退回单独请求,
|
||||
// 消除原先"先等库信息、再等内容"的两段串行首屏等待。
|
||||
let lib = peekLibrary(libraryID)
|
||||
if (lib) setLibrary(lib)
|
||||
let lib = peekLibrary(libraryID) ?? null
|
||||
if (lib) {
|
||||
libraryRef.current = lib
|
||||
setLibrary(lib)
|
||||
}
|
||||
try {
|
||||
const resolved = await resolveLibrary(libraryID)
|
||||
if (cancelled) return
|
||||
if (cancelled || seq !== requestSeqRef.current) return
|
||||
if (!lib) {
|
||||
lib = resolved
|
||||
libraryRef.current = lib
|
||||
setLibrary(lib)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
if (!cancelled && seq === requestSeqRef.current) {
|
||||
setLoading(false)
|
||||
toast.error('媒体库不存在或无权限')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!lib || cancelled || seq !== requestSeqRef.current) return
|
||||
|
||||
const seriesLibrary = isSeriesLibraryType(lib.type)
|
||||
setLoadingAll(true)
|
||||
const seriesMode = isSeriesLibraryType(lib.type)
|
||||
modeRef.current = seriesMode ? 'series' : 'media'
|
||||
const pageSize = pageSizeFor(lib, seriesMode)
|
||||
try {
|
||||
if (seriesLibrary) {
|
||||
const collected = await loadAllSeriesCards(libraryID, lib.is_remote_emby, (next) => {
|
||||
if (cancelled) return
|
||||
setTotal(next.total)
|
||||
if (next.firstPage) {
|
||||
setServerSeriesCards(next.items)
|
||||
setLoading(false)
|
||||
}
|
||||
}, () => cancelled)
|
||||
if (!cancelled) setServerSeriesCards(collected.items)
|
||||
return
|
||||
if (seriesMode) {
|
||||
const data = await libraryAPI.listSeries(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
|
||||
if (cancelled || seq !== requestSeqRef.current) return
|
||||
const pageItems = data.items ?? []
|
||||
setServerSeriesCards(pageItems)
|
||||
loadedCountRef.current = pageItems.length
|
||||
totalRef.current = data.total ?? pageItems.length
|
||||
} else {
|
||||
const data = await libraryAPI.listMedia(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
|
||||
if (cancelled || seq !== requestSeqRef.current) return
|
||||
const pageItems = data.items ?? []
|
||||
setItems(pageItems)
|
||||
loadedCountRef.current = pageItems.length
|
||||
totalRef.current = data.total ?? pageItems.length
|
||||
}
|
||||
|
||||
const collected = await loadAllMedia(libraryID, lib.is_remote_emby, (next) => {
|
||||
if (cancelled) return
|
||||
setTotal(next.total)
|
||||
if (next.firstPage) {
|
||||
setItems(next.items)
|
||||
setLoading(false)
|
||||
}
|
||||
}, () => cancelled)
|
||||
if (!cancelled) setItems(collected.items)
|
||||
setTotal(totalRef.current)
|
||||
hasMoreRef.current = loadedCountRef.current < totalRef.current
|
||||
setHasMore(hasMoreRef.current)
|
||||
} catch {
|
||||
if (!cancelled) toast.error('媒体库加载失败')
|
||||
if (!cancelled && seq === requestSeqRef.current) {
|
||||
toast.error('媒体库加载失败')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
if (!cancelled && seq === requestSeqRef.current) {
|
||||
setLoading(false)
|
||||
setLoadingAll(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
return () => { cancelled = true }
|
||||
}, [libraryID, reloadTick])
|
||||
return () => {
|
||||
cancelled = true
|
||||
requestSeqRef.current += 1
|
||||
}
|
||||
}, [libraryID, reloadTick, sortField, sortOrder])
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
|
||||
@@ -122,15 +146,74 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
|
||||
return () => { cancelled = true }
|
||||
}, [libraryID, isSeriesLibrary, selectedSeries])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
const lib = libraryRef.current
|
||||
if (!lib || moreInFlightRef.current || !hasMoreRef.current) return
|
||||
const seq = requestSeqRef.current
|
||||
moreInFlightRef.current = true
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const page = nextPageRef.current
|
||||
if (modeRef.current === 'series') {
|
||||
const data = await libraryAPI.listSeries(libraryID, page, pageSizeFor(lib, true), {
|
||||
sort: sortField,
|
||||
order: sortOrder,
|
||||
})
|
||||
if (seq !== requestSeqRef.current) return
|
||||
const pageItems = data.items ?? []
|
||||
setServerSeriesCards((prev) => [...prev, ...pageItems])
|
||||
loadedCountRef.current += pageItems.length
|
||||
totalRef.current = data.total ?? totalRef.current
|
||||
nextPageRef.current = page + 1
|
||||
hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current
|
||||
} else {
|
||||
const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib, false), {
|
||||
sort: sortField,
|
||||
order: sortOrder,
|
||||
})
|
||||
if (seq !== requestSeqRef.current) return
|
||||
const pageItems = data.items ?? []
|
||||
setItems((prev) => [...prev, ...pageItems])
|
||||
loadedCountRef.current += pageItems.length
|
||||
totalRef.current = data.total ?? totalRef.current
|
||||
nextPageRef.current = page + 1
|
||||
hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current
|
||||
}
|
||||
setTotal(totalRef.current)
|
||||
setHasMore(hasMoreRef.current)
|
||||
} catch {
|
||||
toast.error('媒体库加载失败')
|
||||
hasMoreRef.current = false
|
||||
setHasMore(false)
|
||||
} finally {
|
||||
moreInFlightRef.current = false
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [libraryID, sortField, sortOrder])
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
const seq = requestSeqRef.current
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
while (seq === requestSeqRef.current && hasMoreRef.current) {
|
||||
await loadMore()
|
||||
if (seq !== requestSeqRef.current || !hasMoreRef.current) break
|
||||
await yieldToBrowser()
|
||||
}
|
||||
} finally {
|
||||
if (seq === requestSeqRef.current) setLoadingMore(false)
|
||||
}
|
||||
}, [loadMore])
|
||||
|
||||
const reloadCurrentLibrary = useCallback(() => {
|
||||
setReloadTick((tick) => tick + 1)
|
||||
}, [])
|
||||
|
||||
const loadingAllText = loadingAll && !loading && (isSeriesLibrary ? total > serverSeriesCards.length : total > items.length)
|
||||
? (isSeriesLibrary
|
||||
? `正在继续加载剧集卡片:${serverSeriesCards.length} / ${total}`
|
||||
: `正在继续加载全部条目:${items.length} / ${total}`)
|
||||
: ''
|
||||
const loadedCount = isSeriesLibrary ? serverSeriesCards.length : items.length
|
||||
const loadingAllText =
|
||||
!loading && hasMore
|
||||
? `已加载 ${loadedCount} / ${total},向下滚动继续加载`
|
||||
: ''
|
||||
|
||||
return {
|
||||
library,
|
||||
@@ -138,6 +221,10 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
|
||||
seriesEpisodeItems,
|
||||
total,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
loadAll,
|
||||
loadingSeriesEpisodes,
|
||||
isSeriesLibrary,
|
||||
isSeries,
|
||||
@@ -151,6 +238,11 @@ function isSeriesLibraryType(type?: string) {
|
||||
return type === 'tv' || type === 'anime' || type === 'variety'
|
||||
}
|
||||
|
||||
function pageSizeFor(lib: Library, seriesMode: boolean): number {
|
||||
if (lib.is_remote_emby) return 100
|
||||
return seriesMode ? 500 : 2000
|
||||
}
|
||||
|
||||
function yieldToBrowser(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
@@ -159,54 +251,4 @@ function yieldToBrowser(): Promise<void> {
|
||||
setTimeout(resolve, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function loadAllSeriesCards(
|
||||
libraryID: string,
|
||||
isRemoteEmby: boolean | undefined,
|
||||
onPage: (state: { items: SeriesCard[]; total: number; firstPage: boolean }) => void,
|
||||
isCancelled: () => boolean,
|
||||
) {
|
||||
const pageSize = isRemoteEmby ? 100 : 500
|
||||
let page = 1
|
||||
let collected: SeriesCard[] = []
|
||||
for (;;) {
|
||||
// 切库/卸载后取消标志置位,立即停止继续拉取剩余页
|
||||
if (isCancelled()) return { items: collected }
|
||||
const data = await libraryAPI.listSeries(libraryID, page, pageSize)
|
||||
if (isCancelled()) return { items: collected }
|
||||
// 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。
|
||||
const pageItems = data.items ?? []
|
||||
collected = collected.concat(pageItems)
|
||||
onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 })
|
||||
if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break
|
||||
page += 1
|
||||
await yieldToBrowser()
|
||||
}
|
||||
return { items: collected }
|
||||
}
|
||||
|
||||
async function loadAllMedia(
|
||||
libraryID: string,
|
||||
isRemoteEmby: boolean | undefined,
|
||||
onPage: (state: { items: Media[]; total: number; firstPage: boolean }) => void,
|
||||
isCancelled: () => boolean,
|
||||
) {
|
||||
const pageSize = isRemoteEmby ? 100 : 2000
|
||||
let page = 1
|
||||
let collected: Media[] = []
|
||||
for (;;) {
|
||||
// 切库/卸载后取消标志置位,立即停止继续拉取剩余页
|
||||
if (isCancelled()) return { items: collected }
|
||||
const data = await libraryAPI.listMedia(libraryID, page, pageSize)
|
||||
if (isCancelled()) return { items: collected }
|
||||
// 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。
|
||||
const pageItems = data.items ?? []
|
||||
collected = collected.concat(pageItems)
|
||||
onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 })
|
||||
if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break
|
||||
page += 1
|
||||
await yieldToBrowser()
|
||||
}
|
||||
return { items: collected }
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ export interface Library {
|
||||
/** 远程 Emby 挂载库(只读,不支持扫描/刮削/编辑) */
|
||||
is_remote_emby?: boolean
|
||||
remote_source?: string
|
||||
/** 媒体条目总数;`/api/libraries` 已在元数据请求中一并返回。 */
|
||||
total?: number
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
|
||||
Reference in New Issue
Block a user