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