diff --git a/internal/handler/media.go b/internal/handler/media.go index 9306dff..b9db987 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -123,7 +123,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { for i, l := range libs { libIDs[i] = l.ID } - counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{ + counts, _ := svc.Media.CountLibrariesCached(ctx, libIDs, repository.MediaQueryFilter{ IncludeNSFW: visibility.IncludeNSFW, AllowedLibraryIDs: visibility.AllowedLibraryIDs, HiddenLibraryIDs: visibility.HiddenLibraryIDs, diff --git a/internal/handler/playback.go b/internal/handler/playback.go index 0c414f0..7face4c 100644 --- a/internal/handler/playback.go +++ b/internal/handler/playback.go @@ -85,6 +85,18 @@ func toggleFavouriteHandler(svc *service.Container) gin.HandlerFunc { func listFavouritesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { 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)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go index 346e5d7..ae3f741 100644 --- a/internal/service/emby_remote_web.go +++ b/internal/service/emby_remote_web.go @@ -388,6 +388,17 @@ func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model. // remoteMediaDetailRaw 拉取远程条目详情,同时返回原始载荷(ID 已伪装), // 供调用方免二次请求读取 Type / SeriesId 等字段。 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) if err != nil { return nil, nil, err @@ -400,6 +411,12 @@ func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *mod } RewriteEmbyRemoteIDs(out, mount.ID) 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 } diff --git a/internal/service/image_resize.go b/internal/service/image_resize.go index 2ac19e9..e620ba4 100644 --- a/internal/service/image_resize.go +++ b/internal/service/image_resize.go @@ -241,6 +241,11 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request if serveCachedImageFile(w, r, key, cachePath) { return true } + // 本地海报大多已经是一百多 KB 的 JPEG。先解码再缩放会把 2 核机器的 + // 并发槽(2)堵成数秒队列。体积已经适合直接下发时,不要排队。 + if serveCompactOriginal(w, r, srcPath, stat) { + return true + } release, ok := p.acquireResizeSlot(r.Context()) if !ok { @@ -273,6 +278,39 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request 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 原子写入缩放结果;失败只记日志,不影响本次响应。 func (p *ImageProxy) writeResizeCache(cachePath string, data []byte) { dir := filepath.Dir(cachePath) diff --git a/internal/service/image_resize_test.go b/internal/service/image_resize_test.go index f2f187b..a570caf 100644 --- a/internal/service/image_resize_test.go +++ b/internal/service/image_resize_test.go @@ -4,6 +4,7 @@ import ( "bytes" "image" "image/color" + "image/jpeg" "image/png" "net/http/httptest" "os" @@ -274,3 +275,27 @@ func TestServeResizedFromFileUsesCacheBeforeDecodingSource(t *testing.T) { 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") + } +} diff --git a/internal/service/media_cache.go b/internal/service/media_cache.go index 39f3927..10ad7a8 100644 --- a/internal/service/media_cache.go +++ b/internal/service/media_cache.go @@ -69,6 +69,23 @@ func (s *MediaService) seriesCardsCacheKey(libraryID string, visibility MediaVis 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 { if s == nil || s.cfg == nil || s.cfg.Cache.MediaTTLSeconds < 1 { return 90 @@ -82,6 +99,18 @@ func (s *MediaService) mediaObjectTTL() time.Duration { 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 { sum := sha1.Sum([]byte(strings.Join(parts, "|"))) return hex.EncodeToString(sum[:]) diff --git a/internal/service/media_library.go b/internal/service/media_library.go index 9baa960..fa959f2 100644 --- a/internal/service/media_library.go +++ b/internal/service/media_library.go @@ -2,7 +2,6 @@ package service import ( "context" - "time" "github.com/truewhile/MeBox/internal/model" "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) } +// 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. func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) { return s.listLibrariesWithPreview(ctx, libraries, visibility, cardLimit, true) @@ -120,7 +144,7 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [ } 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 diff --git a/internal/service/media_series.go b/internal/service/media_series.go index d0c7e9b..7d1311c 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -70,7 +70,7 @@ func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID strin 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()) + s.cache.SetObject(cacheKey, value, s.derivedReadCacheTTL()) } return value, nil } @@ -99,7 +99,7 @@ func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID str } total := int64(len(cards)) 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 } diff --git a/internal/service/playback.go b/internal/service/playback.go index c0f9ec3..486a8cc 100644 --- a/internal/service/playback.go +++ b/internal/service/playback.go @@ -265,6 +265,23 @@ func (p *PlaybackService) ListFavourites(ctx context.Context, userID string) ([] 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 ────────────────────────────────────────────────────────────── // CreatePlaylist persists a new playlist owned by userID. diff --git a/web/src/api/playback.ts b/web/src/api/playback.ts index f3abbc6..3be394d 100644 --- a/web/src/api/playback.ts +++ b/web/src/api/playback.ts @@ -61,6 +61,9 @@ export const playbackAPI = { listFavourites: () => 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: () => api.get<{ items: Playlist[] }>('/playlists').then((r) => r.data.items), diff --git a/web/src/hooks/useFavourites.ts b/web/src/hooks/useFavourites.ts index 9bfd95d..820823c 100644 --- a/web/src/hooks/useFavourites.ts +++ b/web/src/hooks/useFavourites.ts @@ -10,10 +10,10 @@ export function useFavourites() { useEffect(() => { let cancelled = false playbackAPI - .listFavourites() + .listFavouriteIDs() .then((items) => { if (!cancelled) { - setFavouriteIDs(new Set((items ?? []).map((item) => item.id))) + setFavouriteIDs(new Set(items ?? [])) } }) .catch(() => { diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index f8f6561..7e91859 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -8,6 +8,7 @@ import type { SeriesCard } from '../utils/groupSeries' import { fetchLibraries, peekLibraries } from '../utils/libraryCache' import { usePinnedLibraries } from '../hooks/usePinnedLibraries' import { sortByPinnedIds } from '../utils/pinnedLibraries' +import { partitionPreviewIDs } from '../utils/remoteEmby' import { ContinueWatchingSection, ContinueWatchingSkeleton, @@ -18,7 +19,6 @@ import { HomeLoadingState, } from './HomePageSections' -const PREVIEW_BATCH_SIZE = 4 const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url) export function HomePage() { @@ -102,12 +102,10 @@ export function HomePage() { if (targets.length === 0) return targets.forEach((id) => fetchingPreviewLimitsRef.current.set(id, limit)) - const batches: string[][] = [] - for (let i = 0; i < targets.length; i += PREVIEW_BATCH_SIZE) { - batches.push(targets.slice(i, i + PREVIEW_BATCH_SIZE)) - } + // 本地库一次拉完(同一条窗口查询、缓存键稳定)。远程库单独一批, + // 避免一个慢的 Emby 挂载拖住整页预览。 + const batches = partitionPreviewIDs(targets) - // 分批并发:一个慢库不再阻塞整页预览,先返回的批次立即渲染。 await Promise.allSettled( batches.map(async (batch) => { let loaded = false diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index 0b6046d..299beba 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -15,6 +15,7 @@ import type { Library } from '../types' import type { SeriesCard } from '../utils/groupSeries' import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache' import { sortLibraryPreviews } from '../utils/pinnedLibraries' +import { partitionPreviewIDs } from '../utils/remoteEmby' export function LibrariesPage() { const [libraries, setLibraries] = useState([]) @@ -33,30 +34,33 @@ 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) { - next[row.id] = { - cards: row.cards ?? [], - total: row.total ?? 0, - } + const batches = partitionPreviewIDs(targets) + await Promise.allSettled( + batches.map(async (batch) => { + let loaded = false + try { + const rows = await libraryAPI.listPreviews(batch, 10) + loaded = true + setLibraryData((prev) => { + const next = { ...prev } + 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 }) => { diff --git a/web/src/utils/remoteEmby.ts b/web/src/utils/remoteEmby.ts index 7a39805..2c44a64 100644 --- a/web/src/utils/remoteEmby.ts +++ b/web/src/utils/remoteEmby.ts @@ -3,4 +3,19 @@ // embyremote~{accountID}~{remoteID}。 export function isRemoteEmbyID(id?: string | null): boolean { 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 } \ No newline at end of file