mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
优化
This commit is contained in:
@@ -123,7 +123,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
for i, l := range libs {
|
for i, l := range libs {
|
||||||
libIDs[i] = l.ID
|
libIDs[i] = l.ID
|
||||||
}
|
}
|
||||||
counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{
|
counts, _ := svc.Media.CountLibrariesCached(ctx, libIDs, repository.MediaQueryFilter{
|
||||||
IncludeNSFW: visibility.IncludeNSFW,
|
IncludeNSFW: visibility.IncludeNSFW,
|
||||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||||
|
|||||||
@@ -85,6 +85,18 @@ func toggleFavouriteHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func listFavouritesHandler(svc *service.Container) gin.HandlerFunc {
|
func listFavouritesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid, _ := c.Get(middleware.CtxUserID)
|
uid, _ := c.Get(middleware.CtxUserID)
|
||||||
|
if c.Query("ids") == "1" {
|
||||||
|
ids, err := svc.Playback.ListFavouriteIDs(c.Request.Context(), uid.(string))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ids == nil {
|
||||||
|
ids = []string{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ids": ids})
|
||||||
|
return
|
||||||
|
}
|
||||||
items, err := svc.Playback.ListFavourites(c.Request.Context(), uid.(string))
|
items, err := svc.Playback.ListFavourites(c.Request.Context(), uid.(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -388,6 +388,17 @@ func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model.
|
|||||||
// remoteMediaDetailRaw 拉取远程条目详情,同时返回原始载荷(ID 已伪装),
|
// remoteMediaDetailRaw 拉取远程条目详情,同时返回原始载荷(ID 已伪装),
|
||||||
// 供调用方免二次请求读取 Type / SeriesId 等字段。
|
// 供调用方免二次请求读取 Type / SeriesId 等字段。
|
||||||
func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (*model.Media, map[string]any, error) {
|
func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (*model.Media, map[string]any, error) {
|
||||||
|
cacheKey := ""
|
||||||
|
if r != nil && r.cache != nil && mount != nil && remoteID != "" {
|
||||||
|
cacheKey = r.remoteCacheKey("detail", mount.ID, remoteID)
|
||||||
|
var cached struct {
|
||||||
|
Media *model.Media `json:"media"`
|
||||||
|
Raw map[string]any `json:"raw"`
|
||||||
|
}
|
||||||
|
if r.cache.GetJSON(ctx, cacheKey, &cached) && cached.Media != nil {
|
||||||
|
return cached.Media, cached.Raw, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -400,6 +411,12 @@ func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *mod
|
|||||||
}
|
}
|
||||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, out)
|
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, out)
|
||||||
|
if cacheKey != "" {
|
||||||
|
r.cache.SetJSON(ctx, cacheKey, struct {
|
||||||
|
Media *model.Media `json:"media"`
|
||||||
|
Raw map[string]any `json:"raw"`
|
||||||
|
}{Media: &m, Raw: out}, r.remoteMediaCacheTTL())
|
||||||
|
}
|
||||||
return &m, out, nil
|
return &m, out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -241,6 +241,11 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
|
|||||||
if serveCachedImageFile(w, r, key, cachePath) {
|
if serveCachedImageFile(w, r, key, cachePath) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// 本地海报大多已经是一百多 KB 的 JPEG。先解码再缩放会把 2 核机器的
|
||||||
|
// 并发槽(2)堵成数秒队列。体积已经适合直接下发时,不要排队。
|
||||||
|
if serveCompactOriginal(w, r, srcPath, stat) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
release, ok := p.acquireResizeSlot(r.Context())
|
release, ok := p.acquireResizeSlot(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -273,6 +278,39 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compactImageSkipBytes 是“直接出原图”的体积上限。超过它的原图(多兆字节
|
||||||
|
// 的剧照、未压缩 sidecar)仍然走缩放,避免把大文件直接塞给电视端。
|
||||||
|
// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍排队缩放。
|
||||||
|
const compactImageSkipBytes = 200 * 1024
|
||||||
|
|
||||||
|
// serveCompactOriginal 在源文件已经很小且是浏览器可直接显示的 JPEG/WebP 时
|
||||||
|
// 跳过解码。返回 false 表示仍应走缩放路径。
|
||||||
|
func serveCompactOriginal(w http.ResponseWriter, r *http.Request, srcPath string, stat os.FileInfo) bool {
|
||||||
|
if stat == nil || stat.Size() <= 0 || stat.Size() > compactImageSkipBytes {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
file, err := os.Open(srcPath) // #nosec G304 -- srcPath is an allowed local path or a SHA-derived cache path.
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var header [12]byte
|
||||||
|
n, _ := file.Read(header[:])
|
||||||
|
_ = file.Close()
|
||||||
|
if !isCompactWebImage(header[:n]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return serveImageFile(w, r, filepath.Base(srcPath), srcPath, imageBrowserCacheControl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCompactWebImage(header []byte) bool {
|
||||||
|
if len(header) >= 3 && header[0] == 0xff && header[1] == 0xd8 && header[2] == 0xff {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return len(header) >= 12 &&
|
||||||
|
string(header[0:4]) == "RIFF" &&
|
||||||
|
string(header[8:12]) == "WEBP"
|
||||||
|
}
|
||||||
|
|
||||||
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
|
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
|
||||||
func (p *ImageProxy) writeResizeCache(cachePath string, data []byte) {
|
func (p *ImageProxy) writeResizeCache(cachePath string, data []byte) {
|
||||||
dir := filepath.Dir(cachePath)
|
dir := filepath.Dir(cachePath)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -274,3 +275,27 @@ func TestServeResizedFromFileUsesCacheBeforeDecodingSource(t *testing.T) {
|
|||||||
t.Fatal("expected cached thumbnail to be reused without decoding the source")
|
t.Fatal("expected cached thumbnail to be reused without decoding the source")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServeResizedFromFileSkipsDecodeForCompactJPEG(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
src := dir + string(os.PathSeparator) + "poster.jpg"
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 800, 1200)), &jpeg.Options{Quality: 40}); err != nil {
|
||||||
|
t.Fatalf("encode jpeg: %v", err)
|
||||||
|
}
|
||||||
|
if buf.Len() == 0 || buf.Len() > compactImageSkipBytes {
|
||||||
|
t.Fatalf("test jpeg is %d bytes, want a compact poster", buf.Len())
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(src, buf.Bytes(), 0o644); err != nil {
|
||||||
|
t.Fatalf("write source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
if !proxy.serveResizedFromFile(rec, httptest.NewRequest("GET", "/x?maxWidth=120", nil), src, imageResizeOptions{MaxWidth: 120}) {
|
||||||
|
t.Fatal("expected compact jpeg to be served")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(rec.Body.Bytes(), buf.Bytes()) {
|
||||||
|
t.Fatal("expected the original jpeg, not a decoded thumbnail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,6 +69,23 @@ func (s *MediaService) seriesCardsCacheKey(libraryID string, visibility MediaVis
|
|||||||
return "media:series-cards:" + hex.EncodeToString(sum[:])
|
return "media:series-cards:" + hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MediaService) libraryCountCacheKey(libraryIDs []string, filter repository.MediaQueryFilter) string {
|
||||||
|
ids := append([]string(nil), libraryIDs...)
|
||||||
|
allowed := append([]string(nil), filter.AllowedLibraryIDs...)
|
||||||
|
hidden := append([]string(nil), filter.HiddenLibraryIDs...)
|
||||||
|
sort.Strings(ids)
|
||||||
|
sort.Strings(allowed)
|
||||||
|
sort.Strings(hidden)
|
||||||
|
sum := sha1.Sum([]byte(strings.Join([]string{
|
||||||
|
"counts",
|
||||||
|
strings.Join(ids, ","),
|
||||||
|
fmt.Sprintf("%t", filter.IncludeNSFW),
|
||||||
|
strings.Join(allowed, ","),
|
||||||
|
strings.Join(hidden, ","),
|
||||||
|
}, "|")))
|
||||||
|
return "media:lib-counts:" + hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MediaService) mediaCacheTTLSeconds() int {
|
func (s *MediaService) mediaCacheTTLSeconds() int {
|
||||||
if s == nil || s.cfg == nil || s.cfg.Cache.MediaTTLSeconds < 1 {
|
if s == nil || s.cfg == nil || s.cfg.Cache.MediaTTLSeconds < 1 {
|
||||||
return 90
|
return 90
|
||||||
@@ -82,6 +99,18 @@ func (s *MediaService) mediaObjectTTL() time.Duration {
|
|||||||
return time.Duration(s.mediaCacheTTLSeconds()) * time.Second
|
return time.Duration(s.mediaCacheTTLSeconds()) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// derivedReadCacheTTL 给首页预览、剧集分组、库计数这类派生读缓存一个更长的
|
||||||
|
// 下限。扫描/刮削/删改都会走 invalidateMediaCache 清掉 media: 前缀,所以
|
||||||
|
// 拉长 TTL 不会在写入后继续吐旧卡片。配置里的 TTL 更长时仍以配置为准。
|
||||||
|
func (s *MediaService) derivedReadCacheTTL() time.Duration {
|
||||||
|
ttl := s.mediaObjectTTL()
|
||||||
|
const floor = 10 * time.Minute
|
||||||
|
if ttl < floor {
|
||||||
|
return floor
|
||||||
|
}
|
||||||
|
return ttl
|
||||||
|
}
|
||||||
|
|
||||||
func hashObjectCacheKey(parts []string) string {
|
func hashObjectCacheKey(parts []string) string {
|
||||||
sum := sha1.Sum([]byte(strings.Join(parts, "|")))
|
sum := sha1.Sum([]byte(strings.Join(parts, "|")))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
@@ -24,6 +23,31 @@ func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, erro
|
|||||||
return s.repo.Library.List(ctx)
|
return s.repo.Library.List(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountLibrariesCached returns per-library media totals. The homepage metadata
|
||||||
|
// request asks for every library, and the underlying COUNT is repeated on each
|
||||||
|
// refresh. Writes already drop the media: prefix, so a longer TTL is safe.
|
||||||
|
func (s *MediaService) CountLibrariesCached(ctx context.Context, libraryIDs []string, filter repository.MediaQueryFilter) (map[string]int64, error) {
|
||||||
|
if len(libraryIDs) == 0 {
|
||||||
|
return map[string]int64{}, nil
|
||||||
|
}
|
||||||
|
cacheKey := s.libraryCountCacheKey(libraryIDs, filter)
|
||||||
|
var cached map[string]int64
|
||||||
|
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) && cached != nil {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
counts, err := s.repo.Media.CountByLibraries(ctx, libraryIDs, filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if counts == nil {
|
||||||
|
counts = map[string]int64{}
|
||||||
|
}
|
||||||
|
if s.cache != nil {
|
||||||
|
s.cache.SetJSON(ctx, cacheKey, counts, s.derivedReadCacheTTL())
|
||||||
|
}
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListLibrariesWithPreview returns libraries populated with item counts and latest preview cards.
|
// 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) {
|
func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) {
|
||||||
return s.listLibrariesWithPreview(ctx, libraries, visibility, cardLimit, true)
|
return s.listLibrariesWithPreview(ctx, libraries, visibility, cardLimit, true)
|
||||||
@@ -120,7 +144,7 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.cache != nil {
|
if s.cache != nil {
|
||||||
s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, time.Duration(s.mediaCacheTTLSeconds())*time.Second)
|
s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, s.derivedReadCacheTTL())
|
||||||
}
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID strin
|
|||||||
cards := groupMediaSeriesCardsByKeys(rows, keys)
|
cards := groupMediaSeriesCardsByKeys(rows, keys)
|
||||||
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes, Cards: cards}
|
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes, Cards: cards}
|
||||||
if s.cache != nil {
|
if s.cache != nil {
|
||||||
s.cache.SetObject(cacheKey, value, s.mediaObjectTTL())
|
s.cache.SetObject(cacheKey, value, s.derivedReadCacheTTL())
|
||||||
}
|
}
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID str
|
|||||||
}
|
}
|
||||||
total := int64(len(cards))
|
total := int64(len(cards))
|
||||||
if s.cache != nil {
|
if s.cache != nil {
|
||||||
s.cache.SetObject(cacheKey, &seriesCardsCacheValue{Cards: cards, Total: total}, s.mediaObjectTTL())
|
s.cache.SetObject(cacheKey, &seriesCardsCacheValue{Cards: cards, Total: total}, s.derivedReadCacheTTL())
|
||||||
}
|
}
|
||||||
return cards, total, nil
|
return cards, total, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,6 +265,23 @@ func (p *PlaybackService) ListFavourites(ctx context.Context, userID string) ([]
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListFavouriteIDs returns the user's favourite media IDs without hydrating
|
||||||
|
// remote Emby items. Heart-icon state only needs the IDs; fetching each remote
|
||||||
|
// detail on every library open adds a few hundred milliseconds.
|
||||||
|
func (p *PlaybackService) ListFavouriteIDs(ctx context.Context, userID string) ([]string, error) {
|
||||||
|
favs, err := p.repo.Favorite.ListByUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(favs))
|
||||||
|
for _, fav := range favs {
|
||||||
|
if fav.MediaID != "" {
|
||||||
|
ids = append(ids, fav.MediaID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Playlists ──────────────────────────────────────────────────────────────
|
// ─── Playlists ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// CreatePlaylist persists a new playlist owned by userID.
|
// CreatePlaylist persists a new playlist owned by userID.
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export const playbackAPI = {
|
|||||||
listFavourites: () =>
|
listFavourites: () =>
|
||||||
api.get<{ items: Media[] }>('/favourites').then((r) => r.data.items),
|
api.get<{ items: Media[] }>('/favourites').then((r) => r.data.items),
|
||||||
|
|
||||||
|
listFavouriteIDs: () =>
|
||||||
|
api.get<{ ids: string[] }>('/favourites', { params: { ids: 1 } }).then((r) => r.data.ids ?? []),
|
||||||
|
|
||||||
listPlaylists: () =>
|
listPlaylists: () =>
|
||||||
api.get<{ items: Playlist[] }>('/playlists').then((r) => r.data.items),
|
api.get<{ items: Playlist[] }>('/playlists').then((r) => r.data.items),
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ export function useFavourites() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
playbackAPI
|
playbackAPI
|
||||||
.listFavourites()
|
.listFavouriteIDs()
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setFavouriteIDs(new Set((items ?? []).map((item) => item.id)))
|
setFavouriteIDs(new Set(items ?? []))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { SeriesCard } from '../utils/groupSeries'
|
|||||||
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
|
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
|
||||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||||
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
||||||
|
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||||
import {
|
import {
|
||||||
ContinueWatchingSection,
|
ContinueWatchingSection,
|
||||||
ContinueWatchingSkeleton,
|
ContinueWatchingSkeleton,
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
HomeLoadingState,
|
HomeLoadingState,
|
||||||
} from './HomePageSections'
|
} from './HomePageSections'
|
||||||
|
|
||||||
const PREVIEW_BATCH_SIZE = 4
|
|
||||||
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
@@ -102,12 +102,10 @@ export function HomePage() {
|
|||||||
if (targets.length === 0) return
|
if (targets.length === 0) return
|
||||||
targets.forEach((id) => fetchingPreviewLimitsRef.current.set(id, limit))
|
targets.forEach((id) => fetchingPreviewLimitsRef.current.set(id, limit))
|
||||||
|
|
||||||
const batches: string[][] = []
|
// 本地库一次拉完(同一条窗口查询、缓存键稳定)。远程库单独一批,
|
||||||
for (let i = 0; i < targets.length; i += PREVIEW_BATCH_SIZE) {
|
// 避免一个慢的 Emby 挂载拖住整页预览。
|
||||||
batches.push(targets.slice(i, i + PREVIEW_BATCH_SIZE))
|
const batches = partitionPreviewIDs(targets)
|
||||||
}
|
|
||||||
|
|
||||||
// 分批并发:一个慢库不再阻塞整页预览,先返回的批次立即渲染。
|
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
batches.map(async (batch) => {
|
batches.map(async (batch) => {
|
||||||
let loaded = false
|
let loaded = false
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { Library } from '../types'
|
|||||||
import type { SeriesCard } from '../utils/groupSeries'
|
import type { SeriesCard } from '../utils/groupSeries'
|
||||||
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
||||||
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
||||||
|
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||||
|
|
||||||
export function LibrariesPage() {
|
export function LibrariesPage() {
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
@@ -33,30 +34,33 @@ export function LibrariesPage() {
|
|||||||
if (targets.length === 0) return
|
if (targets.length === 0) return
|
||||||
targets.forEach((id) => fetchingRef.current.add(id))
|
targets.forEach((id) => fetchingRef.current.add(id))
|
||||||
|
|
||||||
let loaded = false
|
const batches = partitionPreviewIDs(targets)
|
||||||
try {
|
await Promise.allSettled(
|
||||||
const rows = await libraryAPI.listPreviews(targets, 10)
|
batches.map(async (batch) => {
|
||||||
loaded = true
|
let loaded = false
|
||||||
setLibraryData((prev) => {
|
try {
|
||||||
const next = { ...prev }
|
const rows = await libraryAPI.listPreviews(batch, 10)
|
||||||
for (const row of rows) {
|
loaded = true
|
||||||
next[row.id] = {
|
setLibraryData((prev) => {
|
||||||
cards: row.cards ?? [],
|
const next = { ...prev }
|
||||||
total: row.total ?? 0,
|
for (const row of rows) {
|
||||||
}
|
next[row.id] = {
|
||||||
|
cards: row.cards ?? [],
|
||||||
|
total: row.total ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 单个批次失败不影响其他批次。
|
||||||
|
} finally {
|
||||||
|
batch.forEach((id) => {
|
||||||
|
if (loaded) fetchedLibIdsRef.current.add(id)
|
||||||
|
fetchingRef.current.delete(id)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return next
|
}),
|
||||||
})
|
)
|
||||||
} catch {
|
|
||||||
// 容错
|
|
||||||
} finally {
|
|
||||||
targets.forEach((id) => {
|
|
||||||
if (loaded) {
|
|
||||||
fetchedLibIdsRef.current.add(id)
|
|
||||||
}
|
|
||||||
fetchingRef.current.delete(id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadLibraries = useCallback(async (options?: { force?: boolean }) => {
|
const loadLibraries = useCallback(async (options?: { force?: boolean }) => {
|
||||||
|
|||||||
@@ -3,4 +3,19 @@
|
|||||||
// embyremote~{accountID}~{remoteID}。
|
// embyremote~{accountID}~{remoteID}。
|
||||||
export function isRemoteEmbyID(id?: string | null): boolean {
|
export function isRemoteEmbyID(id?: string | null): boolean {
|
||||||
return Boolean(id && id.startsWith('embyremote~'))
|
return Boolean(id && id.startsWith('embyremote~'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// partitionPreviewIDs 把本地库和远程库拆开。混在一个预览请求里时,本地 SQL
|
||||||
|
// 会等最慢的远程 Emby 回来才一起返回。
|
||||||
|
export function partitionPreviewIDs(ids: string[]): string[][] {
|
||||||
|
const local: string[] = []
|
||||||
|
const remote: string[] = []
|
||||||
|
for (const id of ids) {
|
||||||
|
if (isRemoteEmbyID(id)) remote.push(id)
|
||||||
|
else local.push(id)
|
||||||
|
}
|
||||||
|
const batches: string[][] = []
|
||||||
|
if (local.length > 0) batches.push(local)
|
||||||
|
if (remote.length > 0) batches.push(remote)
|
||||||
|
return batches
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user