From e422ecce53312559b649dbc00639479f18610579 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:37:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/media.go | 57 +++++--- internal/handler/media_test.go | 13 +- web/src/api/library.ts | 16 ++- web/src/pages/HomePage.tsx | 170 +++++++++++++++--------- web/src/pages/HomePageSections.tsx | 71 ++++++++-- web/src/pages/LibrariesPage.tsx | 76 +++++++++-- web/src/pages/LibrariesPageSections.tsx | 67 ++++++++-- 7 files changed, 353 insertions(+), 117 deletions(-) diff --git a/internal/handler/media.go b/internal/handler/media.go index fef7929..1aebcdb 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -58,17 +58,37 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { } role, _ := c.Get(middleware.CtxUserRole) includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1") - if !includeHidden { - libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs) - visibility := mediaVisibilityForRequest(c, svc) - filtered := libs[:0] - for _, lib := range libs { - if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) { - filtered = append(filtered, lib) + if !includeHidden { + libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs) + visibility := mediaVisibilityForRequest(c, svc) + filtered := libs[:0] + for _, lib := range libs { + if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) { + filtered = append(filtered, lib) + } + } + libs = filtered + } + rawIDs := strings.TrimSpace(c.Query("ids")) + var targetSet map[string]struct{} + if rawIDs != "" { + targetSet = make(map[string]struct{}) + for _, id := range strings.Split(rawIDs, ",") { + id = strings.TrimSpace(id) + if id != "" { + targetSet[id] = struct{}{} + } } } - libs = filtered - } + if len(targetSet) > 0 { + filtered := libs[:0] + for _, lib := range libs { + if _, ok := targetSet[lib.ID]; ok { + filtered = append(filtered, lib) + } + } + libs = filtered + } withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true" limit := 10 if withPreview { @@ -97,14 +117,19 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { // 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。 if svc.EmbyRemote != nil { if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil { - visibility := mediaVisibilityForRequest(c, svc) - allowedViews := make([]service.RemoteLibraryView, 0, len(views)) - for _, v := range views { - if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) { - continue + visibility := mediaVisibilityForRequest(c, svc) + allowedViews := make([]service.RemoteLibraryView, 0, len(views)) + for _, v := range views { + if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) { + continue + } + if len(targetSet) > 0 { + if _, ok := targetSet[v.Library.ID]; !ok { + continue + } + } + allowedViews = append(allowedViews, v) } - allowedViews = append(allowedViews, v) - } remotePayloads := make([]webLibraryPayload, len(allowedViews)) for i, v := range allowedViews { remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName} diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go index 032c119..ae52dbd 100644 --- a/internal/handler/media_test.go +++ b/internal/handler/media_test.go @@ -104,11 +104,16 @@ func TestListLibrariesHidesAdultDirectoriesUnlessAdminRequestsAll(t *testing.T) t.Fatalf("watching library list should hide adult directories, got %#v", visible) } - all := requestLibraries(t, svc, viewer.ID, "admin", "/api/libraries?include_hidden=1") - if len(all) != 2 { - t.Fatalf("admin include_hidden list should keep management access, got %#v", all) + all := requestLibraries(t, svc, viewer.ID, "admin", "/api/libraries?include_hidden=1") + if len(all) != 2 { + t.Fatalf("admin include_hidden list should keep management access, got %#v", all) + } + + filtered := requestLibraries(t, svc, viewer.ID, "admin", "/api/libraries?include_hidden=1&ids="+safe.ID) + if len(filtered) != 1 || filtered[0].ID != safe.ID { + t.Fatalf("ids filter should return only requested library, got %#v", filtered) + } } -} func TestGetLibraryAllowsEmptyLibrary(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/web/src/api/library.ts b/web/src/api/library.ts index 4185398..ed113c4 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -87,17 +87,31 @@ export interface LibraryWithPreview extends Library { } export const libraryAPI = { - list: (options?: { includeHidden?: boolean; withPreview?: boolean; previewLimit?: number }) => + list: (options?: { includeHidden?: boolean; withPreview?: boolean; previewLimit?: number; ids?: string[] }) => api .get('/libraries', { params: { ...(options?.includeHidden ? { include_hidden: 1 } : {}), ...(options?.withPreview ? { with_preview: 1 } : {}), ...(options?.previewLimit ? { preview_limit: options.previewLimit } : {}), + ...(options?.ids && options.ids.length > 0 ? { ids: options.ids.join(',') } : {}), }, }) .then((r) => r.data), + listPreviews: (ids: string[], previewLimit = 10) => { + if (ids.length === 0) return Promise.resolve([]) + return api + .get('/libraries', { + params: { + with_preview: 1, + preview_limit: previewLimit, + ids: ids.join(','), + }, + }) + .then((r) => r.data) + }, + get: (id: string, options?: { includeHidden?: boolean }) => api .get(`/libraries/${id}`, { diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 6eccbbd..656fd93 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { libraryAPI, type LibraryWithPreview } from '../api/library' +import { libraryAPI } from '../api/library' import { historyAPI } from '../api/history' import type { HistoryItem } from '../api/playback' import type { Library, Media } from '../types' @@ -30,26 +30,17 @@ export function HomePage() { const [historyLoading, setHistoryLoading] = useState(true) const { pinnedIds } = usePinnedLibraries() + // 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏) useEffect(() => { let cancelled = false libraryAPI - .list({ withPreview: true, previewLimit: 10 }) - .then((rows) => asArray(rows)) - .catch(() => [] as LibraryWithPreview[]) + .list() + .then((rows) => asArray(rows)) + .catch(() => [] as Library[]) .then((libs) => { if (cancelled) return setLibraries(libs) - - const mapData: Record = {} - for (const lib of libs) { - mapData[lib.id] = { - cards: lib.cards ?? [], - items: [], - total: lib.total ?? 0, - } - } - setLibraryData(mapData) setLibrariesLoading(false) }) @@ -58,6 +49,7 @@ export function HomePage() { } }, []) + // 2. 继续观看记录独立渐进加载 useEffect(() => { let cancelled = false @@ -85,6 +77,97 @@ export function HomePage() { } }, []) + const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds]) + + // 按需拉取卡片预览管理 + const [fetchedLibIds, setFetchedLibIds] = useState>(new Set()) + const fetchingRef = useRef>(new Set()) + + const fetchPreviews = useCallback(async (ids: string[]) => { + const targets = ids.filter((id) => !fetchedLibIds.has(id) && !fetchingRef.current.has(id)) + if (targets.length === 0) return + targets.forEach((id) => fetchingRef.current.add(id)) + + try { + const rows = await libraryAPI.listPreviews(targets, 10) + setLibraryData((prev) => { + const next = { ...prev } + for (const row of rows) { + next[row.id] = { + cards: row.cards ?? [], + items: [], + total: row.total ?? 0, + } + } + return next + }) + } catch { + // 容错 + } finally { + setFetchedLibIds((prev) => { + const next = new Set(prev) + targets.forEach((id) => { + next.add(id) + fetchingRef.current.delete(id) + }) + return next + }) + } + }, [fetchedLibIds]) + + // 3. 首屏视口媒体库:优先加载轮播图指定的库和前 3 个库 + useEffect(() => { + if (sortedLibraries.length === 0) return + const carouselLibIds = sortedLibraries + .filter((l) => l.carousel_enabled === true) + .map((l) => l.id) + const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id) + const initialTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds])) + void fetchPreviews(initialTargets) + }, [sortedLibraries, fetchPreviews]) + + // 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库 + const INITIAL_ROWS = 3 + const STEP_ROWS = 2 + const [visibleTargetCount, setVisibleTargetCount] = useState(INITIAL_ROWS) + const sentinelRef = useRef(null) + + // 随 visibleTargetCount 增加,按需触发后续库的预览加载 + useEffect(() => { + if (sortedLibraries.length === 0) return + const currentTargets = sortedLibraries.slice(0, visibleTargetCount).map((l) => l.id) + void fetchPreviews(currentTargets) + }, [sortedLibraries, visibleTargetCount, fetchPreviews]) + + // 底部哨兵监听(触底解锁后续媒体库行) + useEffect(() => { + const sentinel = sentinelRef.current + if (!sentinel) return + + const scrollParent = document.getElementById('app-main-scroll') + const observer = new IntersectionObserver( + (entries) => { + const [entry] = entries + if (entry?.isIntersecting) { + setVisibleTargetCount((prev) => { + if (prev >= sortedLibraries.length) return prev + return Math.min(prev + STEP_ROWS, sortedLibraries.length) + }) + } + }, + { + root: scrollParent, + rootMargin: '400px 0px', + threshold: 0, + }, + ) + + observer.observe(sentinel) + return () => { + observer.disconnect() + } + }, [sortedLibraries.length]) + // Quick lookup map for libraries const libraryMap = useMemo(() => { const map = new Map() @@ -138,51 +221,14 @@ export function HomePage() { return candidateMedia.slice(0, 10) }, [libraries, libraryData]) - const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds]) - - // 过滤出有内容的媒体库,避免空库空占名额 - const activeLibraries = useMemo(() => { - return sortedLibraries.filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0) - }, [sortedLibraries, libraryData]) - - // 媒体库展示行支持向下滑动渐进流式加载:默认先展示前 3 个库, - // 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库。 - const INITIAL_ROWS = 3 - const STEP_ROWS = 2 - const [visibleRowCount, setVisibleRowCount] = useState(INITIAL_ROWS) - const sentinelRef = useRef(null) - - useEffect(() => { - const sentinel = sentinelRef.current - if (!sentinel) return - - const scrollParent = document.getElementById('app-main-scroll') - const observer = new IntersectionObserver( - (entries) => { - const [entry] = entries - if (entry?.isIntersecting) { - setVisibleRowCount((prev) => { - if (prev >= activeLibraries.length) return prev - return Math.min(prev + STEP_ROWS, activeLibraries.length) - }) - } - }, - { - root: scrollParent, - rootMargin: '400px 0px', - threshold: 0, - }, - ) - - observer.observe(sentinel) - return () => { - observer.disconnect() - } - }, [activeLibraries.length]) - + // 当前已拉取并确认有内容的媒体库行 const visibleLibraries = useMemo(() => { - return activeLibraries.slice(0, visibleRowCount) - }, [activeLibraries, visibleRowCount]) + return sortedLibraries + .slice(0, visibleTargetCount) + .filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0) + }, [sortedLibraries, visibleTargetCount, libraryData]) + + const hasMoreLibraries = visibleTargetCount < sortedLibraries.length // 库列表还没回来先展示整页 loading;库为空时再等一下播放记录, // 以免在"空站点"和"有观看记录"两个终态之间闪空白。 @@ -209,7 +255,7 @@ export function HomePage() { {historyLoading && } {!historyLoading && history.length > 0 && } - {/* 3. 媒体库卡片区 */} + {/* 3. 媒体库卡片区(每页展示 20 个媒体库) */} {sortedLibraries.length > 0 && ( )} - {/* 4. 各媒体库内容展示行(向下滑动渐进流式加载) */} + {/* 4. 各媒体库内容展示行(向下滑动渐进流式加载,不受上方20个分页限制) */} {visibleLibraries.length > 0 && (
{visibleLibraries.map((lib) => { @@ -231,7 +277,7 @@ export function HomePage() { /> ) })} - {visibleRowCount < activeLibraries.length && ( + {hasMoreLibraries && (
diff --git a/web/src/pages/HomePageSections.tsx b/web/src/pages/HomePageSections.tsx index e5de800..0a88ff0 100644 --- a/web/src/pages/HomePageSections.tsx +++ b/web/src/pages/HomePageSections.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' import { @@ -371,6 +371,16 @@ export function HomeLibrariesSection({ libraryData?: Record libraryCounts: Record }) { + const PAGE_SIZE = 20 + const [currentPage, setCurrentPage] = useState(1) + const totalPages = Math.max(1, Math.ceil(libraries.length / PAGE_SIZE)) + const effectivePage = Math.min(currentPage, totalPages) + + const pagedLibraries = useMemo(() => { + const start = (effectivePage - 1) * PAGE_SIZE + return libraries.slice(start, start + PAGE_SIZE) + }, [libraries, effectivePage]) + return (
@@ -379,27 +389,62 @@ export function HomeLibrariesSection({
-

- 媒体库 -

+
+

+ 媒体库 +

+ {libraries.length > PAGE_SIZE && ( + + 共 {libraries.length} 个 · 每页 {PAGE_SIZE} 个 + + )} +

点击卡片浏览对应媒体库精选内容

- - 全部媒体库 - - +
+ {totalPages > 1 && ( +
+ + + {effectivePage} / {totalPages} + + +
+ )} + + + 全部媒体库 + + +
{/* Libraries Grid */} -
- {libraries.map((lib) => { +
+ {pagedLibraries.map((lib) => { const count = libraryCounts[lib.id] ?? 0 const cards = libraryData?.[lib.id]?.cards || [] const artwork = getLibraryArtworks(lib, cards) diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index f7dee75..b059b3a 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { libraryAPI } from '../api/library' import { toolsAPI } from '../api/tools' @@ -10,32 +10,65 @@ import { LibrariesHeader, } from './LibrariesPageSections' import type { LibraryPreview } from './librariesPageModel' +import type { Library } from '../types' +import type { SeriesCard } from '../utils/groupSeries' import { sortLibraryPreviews } from '../utils/pinnedLibraries' export function LibrariesPage() { - const [previews, setPreviews] = useState([]) + const [libraries, setLibraries] = useState([]) + const [libraryData, setLibraryData] = useState>({}) const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries() const [loading, setLoading] = useState(true) const [repairing, setRepairing] = useState(false) const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false) const [repairMsg, setRepairMsg] = useState('') + const [fetchedLibIds, setFetchedLibIds] = useState>(new Set()) + const fetchingRef = useRef>(new Set()) + + const fetchPreviews = useCallback(async (ids: string[]) => { + const targets = ids.filter((id) => !fetchedLibIds.has(id) && !fetchingRef.current.has(id)) + if (targets.length === 0) return + targets.forEach((id) => fetchingRef.current.add(id)) + + try { + const rows = await libraryAPI.listPreviews(targets, 10) + setLibraryData((prev) => { + const next = { ...prev } + for (const row of rows) { + next[row.id] = { + cards: row.cards ?? [], + total: row.total ?? 0, + } + } + return next + }) + } catch { + // 容错 + } finally { + setFetchedLibIds((prev) => { + const next = new Set(prev) + targets.forEach((id) => { + next.add(id) + fetchingRef.current.delete(id) + }) + return next + }) + } + }, [fetchedLibIds]) + const loadLibraries = useCallback(async () => { setLoading(true) try { - const libs = await libraryAPI.list({ withPreview: true }) - setPreviews( - libs.map((library) => ({ - library, - items: [], - total: library.total ?? 0, - cards: library.cards ?? [], - })), - ) + const libs = await libraryAPI.list() + setLibraries(libs) + // 优先拉取前 3 个库的卡片预览 + const topIds = libs.slice(0, 3).map((l) => l.id) + void fetchPreviews(topIds) } finally { setLoading(false) } - }, []) + }, [fetchPreviews]) async function handleRepairRescrape() { if (repairing) return @@ -60,6 +93,18 @@ export function LibrariesPage() { loadLibraries().catch(() => undefined) }, [loadLibraries]) + const previews: LibraryPreview[] = useMemo(() => { + return libraries.map((library) => { + const data = libraryData[library.id] + return { + library, + items: [], + total: data?.total ?? 0, + cards: data?.cards ?? [], + } + }) + }, [libraries, libraryData]) + const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds]) const handleTogglePin = useCallback((libraryId: string) => { @@ -88,7 +133,12 @@ export function LibrariesPage() { {previews.length === 0 ? ( ) : ( - + )}
) diff --git a/web/src/pages/LibrariesPageSections.tsx b/web/src/pages/LibrariesPageSections.tsx index 1a9c7f5..e07b7fb 100644 --- a/web/src/pages/LibrariesPageSections.tsx +++ b/web/src/pages/LibrariesPageSections.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' import { motion } from 'framer-motion' -import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react' +import { ArrowRight, ChevronLeft, ChevronRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react' import { imageURL } from '../api/client' import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle' @@ -107,10 +107,12 @@ export function LibrariesContent({ previews, pinnedIds, onTogglePin, + onNeedPreviews, }: { previews: LibraryPreview[] pinnedIds: string[] onTogglePin: (libraryId: string) => void + onNeedPreviews?: (ids: string[]) => void }) { const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length @@ -121,6 +123,11 @@ export function LibrariesContent({ const [visibleCount, setVisibleCount] = useState(INITIAL_SHELVES) const sentinelRef = useRef(null) + useEffect(() => { + const currentTargets = previews.slice(0, visibleCount).map((p) => p.library.id) + onNeedPreviews?.(currentTargets) + }, [previews, visibleCount, onNeedPreviews]) + useEffect(() => { const sentinel = sentinelRef.current if (!sentinel) return @@ -153,18 +160,62 @@ export function LibrariesContent({ return previews.slice(0, visibleCount) }, [previews, visibleCount]) + const ENTRY_PAGE_SIZE = 20 + const [entryPage, setEntryPage] = useState(1) + const totalEntryPages = Math.max(1, Math.ceil(previews.length / ENTRY_PAGE_SIZE)) + const effectiveEntryPage = Math.min(entryPage, totalEntryPages) + const pagedPreviews = useMemo(() => { + const start = (effectiveEntryPage - 1) * ENTRY_PAGE_SIZE + return previews.slice(start, start + ENTRY_PAGE_SIZE) + }, [previews, effectiveEntryPage]) + return ( <>
-
-

媒体库入口

-

- 按目录进入完整媒体库;下方每个目录也会直接展示最新内容。 - {pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'} -

+
+
+
+

媒体库入口

+ {previews.length > ENTRY_PAGE_SIZE && ( + + 共 {previews.length} 个 · 每页 {ENTRY_PAGE_SIZE} 个 + + )} +
+

+ 按目录进入完整媒体库;下方每个目录也会直接展示最新内容。 + {pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'} +

+
+ + {totalEntryPages > 1 && ( +
+ + + {effectiveEntryPage} / {totalEntryPages} + + +
+ )}
- {previews.map((preview, index) => ( + {pagedPreviews.map((preview, index) => (