From a7eeb8fbebd6cb81a7a41315b99fa4a9acd47776 Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Thu, 3 Sep 2026 17:42:52 +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/service/media_library.go | 23 ++++--- internal/service/media_library_test.go | 63 ++++++++++++++++++ web/src/pages/HomePage.tsx | 85 ++++++++++++++++++++----- web/src/pages/LibrariesPageSections.tsx | 82 +++++++++++++++++++----- 4 files changed, 213 insertions(+), 40 deletions(-) diff --git a/internal/service/media_library.go b/internal/service/media_library.go index edead2d..05015b8 100644 --- a/internal/service/media_library.go +++ b/internal/service/media_library.go @@ -100,14 +100,21 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [ if len(items) == 0 { continue } - cards := groupMediaSeriesCards(items) - if len(cards) > cardLimit { - cards = cards[:cardLimit] - } - if cards == nil { - cards = []SeriesCard{} - } - out[i].Cards = cards + cards := groupMediaSeriesCards(items) + // 如果折叠后的作品部数不足 cardLimit,且该库总记录数大于当前提取的条目数, + // 说明多集剧集折叠占满了提取窗口,调用 ListLibrarySeriesCards 补齐完整的影视部数。 + if len(cards) < cardLimit && out[i].Total > int64(len(items)) { + if fullCards, _, err := s.ListLibrarySeriesCards(ctx, out[i].ID, visibility); err == nil && len(fullCards) > 0 { + cards = fullCards + } + } + if len(cards) > cardLimit { + cards = cards[:cardLimit] + } + if cards == nil { + cards = []SeriesCard{} + } + out[i].Cards = cards } if s.cache != nil { diff --git a/internal/service/media_library_test.go b/internal/service/media_library_test.go index ac7c9c0..d858b6a 100644 --- a/internal/service/media_library_test.go +++ b/internal/service/media_library_test.go @@ -92,3 +92,66 @@ func TestListLibrariesWithPreview(t *testing.T) { t.Errorf("preview[1].Cards[0].Count = %d, want 12", previews[1].Cards[0].Count) } } + +func TestListLibrariesWithPreviewLongSeriesCompletion(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + + lib := model.Library{Name: "剧集库", Path: "/media/tv", Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + var rows []model.Media + + // 1. 添加一部更早入库的短剧(2 集),如果按窗口 60 条限制,排在第 70 集之后,无法被初始窗口截取 + for i := 1; i <= 2; i++ { + rows = append(rows, model.Media{ + Base: model.Base{ID: fmt.Sprintf("short-ep-%02d", i), CreatedAt: now.Add(-time.Duration(100-i) * time.Minute)}, + LibraryID: lib.ID, + Title: fmt.Sprintf("经典短剧 第%d集", i), + Path: fmt.Sprintf("/media/tv/经典短剧/Season 01/经典短剧.S01E%02d.mp4", i), + SeasonNum: 1, + EpisodeNum: i, + }) + } + + // 2. 添加一部最新入库的 70 集长篇连续剧,这 70 篇全在新入库时间线上 + for i := 1; i <= 70; i++ { + rows = append(rows, model.Media{ + Base: model.Base{ID: fmt.Sprintf("long-ep-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Minute)}, + LibraryID: lib.ID, + Title: fmt.Sprintf("大长篇连续剧 第%d集", i), + Path: fmt.Sprintf("/media/tv/大长篇连续剧/Season 01/大长篇连续剧.S01E%02d.mp4", i), + SeasonNum: 1, + EpisodeNum: i, + }) + } + + if err := repos.DB.Create(&rows).Error; err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + + // 请求 10 部卡片 + previews, err := svc.ListLibrariesWithPreview(t.Context(), []model.Library{lib}, MediaVisibility{IncludeNSFW: true}, 10) + if err != nil { + t.Fatalf("ListLibrariesWithPreview failed: %v", err) + } + + if len(previews) != 1 { + t.Fatalf("got %d previews, want 1", len(previews)) + } + + // 总媒体行数是 72 行 + if previews[0].Total != 72 { + t.Errorf("previews[0].Total = %d, want 72", previews[0].Total) + } + + // 验证自适应补全机制:必须包含 2 部剧集(长篇 + 短剧),而不是因为长篇 70 集占满窗口只显示 1 部 + if len(previews[0].Cards) != 2 { + t.Fatalf("previews[0].Cards count = %d, want 2", len(previews[0].Cards)) + } +} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 4e9a896..6eccbbd 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { libraryAPI, type LibraryWithPreview } from '../api/library' import { historyAPI } from '../api/history' @@ -34,7 +34,7 @@ export function HomePage() { let cancelled = false libraryAPI - .list({ withPreview: true, previewLimit: 20 }) + .list({ withPreview: true, previewLimit: 10 }) .then((rows) => asArray(rows)) .catch(() => [] as LibraryWithPreview[]) .then((libs) => { @@ -140,6 +140,50 @@ export function HomePage() { 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]) + // 库列表还没回来先展示整页 loading;库为空时再等一下播放记录, // 以免在"空站点"和"有观看记录"两个终态之间闪空白。 if (librariesLoading || (libraries.length === 0 && historyLoading)) { @@ -174,20 +218,29 @@ export function HomePage() { /> )} - {/* 4. 各媒体库内容展示行 */} -
- {sortedLibraries.map((lib) => { - const cards = libraryData[lib.id]?.cards || [] - if (cards.length === 0) return null - return ( - - ) - })} -
+ {/* 4. 各媒体库内容展示行(向下滑动渐进流式加载) */} + {visibleLibraries.length > 0 && ( +
+ {visibleLibraries.map((lib) => { + const cards = libraryData[lib.id]?.cards || [] + return ( + + ) + })} + {visibleRowCount < activeLibraries.length && ( +
+
+
+ 加载更多媒体库… +
+
+ )} +
+ )}
) } diff --git a/web/src/pages/LibrariesPageSections.tsx b/web/src/pages/LibrariesPageSections.tsx index 6154720..1a9c7f5 100644 --- a/web/src/pages/LibrariesPageSections.tsx +++ b/web/src/pages/LibrariesPageSections.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react' +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' @@ -114,6 +114,45 @@ export function LibrariesContent({ }) { const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length + // 下方媒体库货架支持向下滑动渐进流式加载:默认先展示前 3 个库货架, + // 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库货架。 + const INITIAL_SHELVES = 3 + const STEP_SHELVES = 2 + const [visibleCount, setVisibleCount] = useState(INITIAL_SHELVES) + 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) { + setVisibleCount((prev) => { + if (prev >= previews.length) return prev + return Math.min(prev + STEP_SHELVES, previews.length) + }) + } + }, + { + root: scrollParent, + rootMargin: '400px 0px', + threshold: 0, + }, + ) + + observer.observe(sentinel) + return () => { + observer.disconnect() + } + }, [previews.length]) + + const visiblePreviews = useMemo(() => { + return previews.slice(0, visibleCount) + }, [previews, visibleCount]) + return ( <>
@@ -142,21 +181,32 @@ export function LibrariesContent({
-
- {previews.map((preview, index) => ( - - - - ))} -
+ {visiblePreviews.length > 0 && ( +
+ {visiblePreviews.map((preview, index) => ( + + + + ))} + + {visibleCount < previews.length && ( +
+
+
+ 加载更多媒体库货架… +
+
+ )} +
+ )} ) }