Compare commits

...

12 Commits

Author SHA1 Message Date
github-actions[bot] fc84291346 chore: bump version to 0.0.104 [skip ci] 2026-09-03 15:12:00 +00:00
truewhile c37e936f48 bug 2026-09-03 23:11:42 +08:00
github-actions[bot] 389cb99bcf chore: bump version to 0.0.103 [skip ci] 2026-09-03 14:48:19 +00:00
truewhile 1b611a6181 bug处理 2026-09-03 22:48:02 +08:00
github-actions[bot] 25c03f2b0d chore: bump version to 0.0.102 [skip ci] 2026-09-03 13:18:35 +00:00
truewhile a56b1801f9 bug处理 2026-09-03 21:18:20 +08:00
github-actions[bot] 99c755dc29 chore: bump version to 0.0.101 [skip ci] 2026-09-03 12:51:41 +00:00
truewhile 086c0307c3 bug处理 2026-09-03 20:51:24 +08:00
github-actions[bot] 51d0f5010e chore: bump version to 0.0.100 [skip ci] 2026-09-03 12:20:20 +00:00
truewhile b9dd09a5d2 bug处理 2026-09-03 20:19:31 +08:00
github-actions[bot] e79f393969 chore: bump version to 0.0.99 [skip ci] 2026-09-03 11:37:58 +00:00
truewhile e422ecce53 优化 2026-09-03 19:37:29 +08:00
14 changed files with 577 additions and 168 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.98
0.0.104
+60 -20
View File
@@ -13,6 +13,7 @@ import (
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
@@ -58,17 +59,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 {
@@ -89,22 +110,41 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
for _, p := range previews {
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
}
} else {
for _, l := range libs {
out = append(out, webLibraryPayload{Library: l})
} else {
visibility := mediaVisibilityForRequest(c, svc)
libIDs := make([]string, len(libs))
for i, l := range libs {
libIDs[i] = l.ID
}
counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
})
for _, l := range libs {
var total int64
if counts != nil {
total = counts[l.ID]
}
out = append(out, webLibraryPayload{Library: l, Total: total})
}
}
}
// 远程 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}
+9 -4
View File
@@ -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)
+32 -11
View File
@@ -189,19 +189,40 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
}
}
// 如果没有聚合到多集,尝试同父目录匹配
if len(out) <= 1 && target.Path != "" {
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
dirMatches := make([]model.Media, 0)
for _, row := range rows {
if row.Path != "" && filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) == targetDir {
dirMatches = append(dirMatches, row)
// 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影)
if len(out) <= 1 && target.Path != "" {
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
parentBase := filepath.Base(targetDir)
if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) {
targetTitleNorm := normalizeSeriesTitle(target.Title)
targetDirNorm := normalizeSeriesTitle(parentBase)
dirMatches := make([]model.Media, 0)
for _, row := range rows {
if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir {
continue
}
if row.ID == target.ID {
dirMatches = append(dirMatches, row)
continue
}
rowTitleNorm := normalizeSeriesTitle(row.Title)
allowMatch := false
if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) {
allowMatch = true
} else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm {
allowMatch = true
} else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm {
allowMatch = true
}
if allowMatch {
dirMatches = append(dirMatches, row)
}
}
if len(dirMatches) > 1 {
out = dirMatches
}
}
}
if len(dirMatches) > 1 {
out = dirMatches
}
}
if len(out) == 0 {
out = []model.Media{*target}
+15 -1
View File
@@ -11,6 +11,16 @@ import (
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`)
var genericMovieTitleRE = regexp.MustCompile(`(?i)^(?:cd\s*\d+|part\s*\d+|disc\s*\d+|disk\s*\d+|dvd\s*\d+|movie|film|video|main|feature|track\s*\d+|preview|sample|trailer|\d{3,4}p|4k|2160p|1080p|720p)$`)
func isGenericMovieTitle(title string) bool {
title = strings.TrimSpace(title)
if title == "" {
return true
}
return genericMovieTitleRE.MatchString(title)
}
func mediaSeriesKey(media model.Media) string {
return compactSeriesKey(mediaSeriesRawKey(media))
}
@@ -54,7 +64,11 @@ func mediaSeriesRawKey(media model.Media) string {
return seriesFingerprint("movie-external", fmt.Sprintf("bgm:%d", media.BangumiID))
}
if fromPath != "" && !mediaParentLooksLikeCollection(media.Path) {
return seriesFingerprint("library-path", media.LibraryID, fromPath)
titleNorm := normalizeSeriesTitle(media.Title)
fromPathNorm := normalizeSeriesTitle(fromPath)
if titleNorm == "" || isGenericMovieTitle(titleNorm) || titleNorm == fromPathNorm {
return seriesFingerprint("library-path", media.LibraryID, fromPath)
}
}
return seriesFingerprint("library-title", media.LibraryID, normalizeSeriesTitle(media.Title))
}
+64
View File
@@ -445,3 +445,67 @@ func TestGroupMediaSeriesCardsBridgesReleaseFoldersByMatchedSeriesTitle(t *testi
t.Fatalf("cards=%#v, want one series bridged by matched title", cards)
}
}
func TestGroupMediaSeriesCardsKeepsIndependentMoviesSeparateInSharedSubdirectory(t *testing.T) {
// 同一分类子目录下存放多部不同标题的独立电影,不应被强制折叠成 1 部
items := []model.Media{
{LibraryID: "movies", Title: "老师2024偷窥篇", Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`},
{LibraryID: "movies", Title: "紫光灯下的肉体诱惑", Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`},
{LibraryID: "movies", Title: "修洗衣机", Path: `/media/小姐姐/国产/nana/修洗衣机.strm`},
}
cards := groupMediaSeriesCards(items)
if len(cards) != 3 {
t.Fatalf("got %d cards, want 3 independent movie cards", len(cards))
}
// 但同一部电影的 CD1 和 CD2 仍应正确折叠为 1 部
cdItems := []model.Media{
{LibraryID: "movies", Title: "cd1", Path: `/media/电影/指环王 (2001)/cd1.mkv`},
{LibraryID: "movies", Title: "cd2", Path: `/media/电影/指环王 (2001)/cd2.mkv`},
}
cdCards := groupMediaSeriesCards(cdItems)
if len(cdCards) != 1 {
t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards))
}
}
func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Base: model.Base{ID: "lib-movies"}, Name: "电影", Type: "movies", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
m1 := model.Media{
Base: model.Base{ID: "m1"},
LibraryID: lib.ID,
Title: "老师2024偷窥篇",
Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`,
}
m2 := model.Media{
Base: model.Base{ID: "m2"},
LibraryID: lib.ID,
Title: "紫光灯下的肉体诱惑",
Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`,
}
m3 := model.Media{
Base: model.Base{ID: "m3"},
LibraryID: lib.ID,
Title: "修洗衣机",
Path: `/media/小姐姐/国产/nana/修洗衣机.strm`,
}
if err := repos.DB.Create(&[]model.Media{m1, m2, m3}).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
eps, err := svc.ListMediaEpisodes(t.Context(), "m1", MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(eps) != 1 || eps[0].ID != "m1" {
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
}
}
+15 -1
View File
@@ -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<LibraryWithPreview[]>('/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<LibraryWithPreview[]>([])
return api
.get<LibraryWithPreview[]>('/libraries', {
params: {
with_preview: 1,
preview_limit: previewLimit,
ids: ids.join(','),
},
})
.then((r) => r.data)
},
get: (id: string, options?: { includeHidden?: boolean }) =>
api
.get<Library>(`/libraries/${id}`, {
+5 -4
View File
@@ -6,6 +6,7 @@ import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-rea
import { imageURL } from '../api/client'
import { mediaAPI } from '../api/library'
import type { Media, PlayProfile, User } from '../types'
import { favouriteMediaLink } from '../utils/mediaNavigation'
import { resolveHeaderBack } from './layoutNavigation'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { LayoutUserMenu } from './LayoutUserMenu'
@@ -149,17 +150,17 @@ function LayoutHeaderSearch() {
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSelect = (mediaId: string) => {
const handleSelect = (item: Media) => {
setIsOpen(false)
setQuery('')
navigate(`/media/${mediaId}`)
navigate(favouriteMediaLink(item))
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false)
} else if (e.key === 'Enter' && results.length > 0) {
handleSelect(results[0].id)
handleSelect(results[0])
}
}
@@ -220,7 +221,7 @@ function LayoutHeaderSearch() {
{results.map((item) => (
<button
key={item.id}
onClick={() => handleSelect(item.id)}
onClick={() => handleSelect(item)}
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
>
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
+128 -64
View File
@@ -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<LibraryWithPreview>(rows))
.catch(() => [] as LibraryWithPreview[])
.list()
.then((rows) => asArray<Library>(rows))
.catch(() => [] as Library[])
.then((libs) => {
if (cancelled) return
setLibraries(libs)
const mapData: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }> = {}
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,123 @@ export function HomePage() {
}
}, [])
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
// 按需拉取卡片预览管理
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
const fetchingRef = useRef<Set<string>>(new Set())
const fetchPreviews = useCallback(async (ids: string[]) => {
const targets = ids.filter((id) => !fetchedLibIdsRef.current.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 {
targets.forEach((id) => {
fetchedLibIdsRef.current.add(id)
fetchingRef.current.delete(id)
})
}
}, [])
// 3. 首屏优先加载:轮播图库 + 媒体库卡片区前 20 个库 + 首屏前 3 个内容行
useEffect(() => {
if (sortedLibraries.length === 0) return
const carouselLibIds = sortedLibraries
.filter((l) => l.carousel_enabled === true)
.map((l) => l.id)
const topGridLibIds = sortedLibraries.slice(0, 20).map((l) => l.id)
const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id)
const initialTargets = Array.from(new Set([...carouselLibIds, ...topGridLibIds, ...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<HTMLDivElement | null>(null)
// 随 visibleTargetCount 增加,按需触发后续库的预览加载
useEffect(() => {
if (sortedLibraries.length === 0) return
const currentTargets = sortedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
void fetchPreviews(currentTargets)
}, [sortedLibraries, visibleTargetCount, fetchPreviews])
// 当前已拉取并确认有内容的媒体库行
const visibleLibraries = useMemo(() => {
return sortedLibraries
.slice(0, visibleTargetCount)
.filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0)
}, [sortedLibraries, visibleTargetCount, libraryData])
const hasMoreLibraries = visibleTargetCount < sortedLibraries.length
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库行)
useEffect(() => {
const scrollParent = document.getElementById('app-main-scroll')
if (!scrollParent) return
const handleCheckBottom = () => {
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
if (remaining < 600) {
setVisibleTargetCount((prev) => {
if (prev >= sortedLibraries.length) return prev
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
})
}
}
scrollParent.addEventListener('scroll', handleCheckBottom, { passive: true })
const sentinel = sentinelRef.current
let observer: IntersectionObserver | null = null
if (sentinel) {
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: '600px 0px',
threshold: 0,
},
)
observer.observe(sentinel)
}
handleCheckBottom()
return () => {
scrollParent.removeEventListener('scroll', handleCheckBottom)
if (observer) {
observer.disconnect()
}
}
}, [visibleLibraries.length, hasMoreLibraries, sortedLibraries.length])
// Quick lookup map for libraries
const libraryMap = useMemo(() => {
const map = new Map<string, Library>()
@@ -138,52 +247,6 @@ 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<HTMLDivElement | null>(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)) {
@@ -209,16 +272,17 @@ export function HomePage() {
{historyLoading && <ContinueWatchingSkeleton />}
{!historyLoading && history.length > 0 && <ContinueWatchingSection history={history} />}
{/* 3. 媒体库卡片区 */}
{/* 3. 媒体库卡片区(每页展示 20 个媒体库) */}
{sortedLibraries.length > 0 && (
<HomeLibrariesSection
libraries={sortedLibraries}
libraryData={libraryData}
libraryCounts={libraryCounts}
onNeedPreviews={fetchPreviews}
/>
)}
{/* 4. 各媒体库内容展示行(向下滑动渐进流式加载) */}
{/* 4. 各媒体库内容展示行(向下滑动渐进流式加载,不受上方20个分页限制) */}
{visibleLibraries.length > 0 && (
<div className="space-y-10">
{visibleLibraries.map((lib) => {
@@ -231,7 +295,7 @@ export function HomePage() {
/>
)
})}
{visibleRowCount < activeLibraries.length && (
{hasMoreLibraries && (
<div ref={sentinelRef} className="flex h-10 w-full items-center justify-center py-2 opacity-60">
<div className="flex items-center gap-2 text-xs text-[var(--app-muted)]">
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
+71 -15
View File
@@ -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 {
@@ -366,11 +366,28 @@ export function HomeLibrariesSection({
libraries,
libraryData,
libraryCounts,
onNeedPreviews,
}: {
libraries: Library[]
libraryData?: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>
libraryCounts: Record<string, number>
onNeedPreviews?: (ids: string[]) => void
}) {
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<Library[]>(() => {
const start = (effectivePage - 1) * PAGE_SIZE
return libraries.slice(start, start + PAGE_SIZE)
}, [libraries, effectivePage])
useEffect(() => {
const ids = pagedLibraries.map((l) => l.id)
onNeedPreviews?.(ids)
}, [pagedLibraries, onNeedPreviews])
return (
<section className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--app-border)] pb-3">
@@ -379,27 +396,62 @@ export function HomeLibrariesSection({
<LibraryIcon size={18} />
</span>
<div>
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
媒体库
</h2>
<div className="flex items-center gap-2">
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
媒体库
</h2>
{libraries.length > PAGE_SIZE && (
<span className="rounded-md border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[11px] font-semibold text-[var(--app-muted)]">
共 {libraries.length} 个 · 每页 {PAGE_SIZE} 个
</span>
)}
</div>
<p className="text-xs text-[var(--app-muted)]">
点击卡片浏览对应媒体库精选内容
</p>
</div>
</div>
<Link
to="/libraries"
className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500"
>
<span>全部媒体库</span>
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
</Link>
<div className="flex items-center gap-3">
{totalPages > 1 && (
<div className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 py-1 text-xs">
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={effectivePage <= 1}
className="rounded-lg p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--app-muted)] transition-colors"
title="上一页"
>
<ChevronLeft size={14} />
</button>
<span className="font-mono text-xs font-semibold text-[var(--app-subtle)]">
{effectivePage} / {totalPages}
</span>
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={effectivePage >= totalPages}
className="rounded-lg p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--app-muted)] transition-colors"
title="下一页"
>
<ChevronRight size={14} />
</button>
</div>
)}
<Link
to="/libraries"
className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500"
>
<span>全部媒体库</span>
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
</Link>
</div>
</div>
{/* Libraries Grid */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6">
{libraries.map((lib) => {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-6">
{pagedLibraries.map((lib) => {
const count = libraryCounts[lib.id] ?? 0
const cards = libraryData?.[lib.id]?.cards || []
const artwork = getLibraryArtworks(lib, cards)
@@ -409,6 +461,7 @@ export function HomeLibrariesSection({
key={lib.id}
to={`/library/${lib.id}`}
className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10"
title={lib.name}
>
{/* 封面图片展示区:和媒体库页面一样,显示设置好的或生成的图片 */}
<div
@@ -444,7 +497,10 @@ export function HomeLibrariesSection({
{/* 媒体库信息 */}
<div className="mt-3 flex flex-col justify-between">
<h3 className="truncate font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500">
<h3
className="line-clamp-2 break-words font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500"
title={lib.name}
>
{lib.name}
</h3>
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
@@ -490,7 +546,7 @@ export function HomeLibraryRowSection({
{TYPE_ICONS[library.type] || <FolderOpen size={18} />}
</span>
<div>
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]" title={library.name}>
{library.name}
</h2>
<span className="text-xs text-[var(--app-muted)]">
+59 -13
View File
@@ -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,61 @@ 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<LibraryPreview[]>([])
const [libraries, setLibraries] = useState<Library[]>([])
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
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 fetchedLibIdsRef = useRef<Set<string>>(new Set())
const fetchingRef = useRef<Set<string>>(new Set())
const fetchPreviews = useCallback(async (ids: string[]) => {
const targets = ids.filter((id) => !fetchedLibIdsRef.current.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 {
targets.forEach((id) => {
fetchedLibIdsRef.current.add(id)
fetchingRef.current.delete(id)
})
}
}, [])
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)
// 优先拉取入口卡片网格当前页(前 20 个库)的预览
const topIds = libs.slice(0, 20).map((l) => l.id)
void fetchPreviews(topIds)
} finally {
setLoading(false)
}
}, [])
}, [fetchPreviews])
async function handleRepairRescrape() {
if (repairing) return
@@ -60,6 +89,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 +129,12 @@ export function LibrariesPage() {
{previews.length === 0 ? (
<LibrariesEmptyState />
) : (
<LibrariesContent previews={sortedPreviews} pinnedIds={pinnedIds} onTogglePin={handleTogglePin} />
<LibrariesContent
previews={sortedPreviews}
pinnedIds={pinnedIds}
onTogglePin={handleTogglePin}
onNeedPreviews={fetchPreviews}
/>
)}
</div>
)
+116 -32
View File
@@ -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
@@ -122,49 +124,124 @@ export function LibrariesContent({
const sentinelRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
const currentTargets = previews.slice(0, visibleCount).map((p) => p.library.id)
onNeedPreviews?.(currentTargets)
}, [previews, visibleCount, onNeedPreviews])
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库货架)
useEffect(() => {
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,
},
)
if (!scrollParent) return
observer.observe(sentinel)
return () => {
observer.disconnect()
const handleCheckBottom = () => {
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
if (remaining < 600) {
setVisibleCount((prev) => {
if (prev >= previews.length) return prev
return Math.min(prev + STEP_SHELVES, previews.length)
})
}
}
}, [previews.length])
scrollParent.addEventListener('scroll', handleCheckBottom, { passive: true })
const sentinel = sentinelRef.current
let observer: IntersectionObserver | null = null
if (sentinel) {
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: '600px 0px',
threshold: 0,
},
)
observer.observe(sentinel)
}
handleCheckBottom()
return () => {
scrollParent.removeEventListener('scroll', handleCheckBottom)
if (observer) {
observer.disconnect()
}
}
}, [visibleCount, previews.length])
const visiblePreviews = useMemo(() => {
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<LibraryPreview[]>(() => {
const start = (effectiveEntryPage - 1) * ENTRY_PAGE_SIZE
return previews.slice(start, start + ENTRY_PAGE_SIZE)
}, [previews, effectiveEntryPage])
useEffect(() => {
const ids = pagedPreviews.map((p) => p.library.id)
onNeedPreviews?.(ids)
}, [pagedPreviews, onNeedPreviews])
return (
<>
<section className="space-y-4">
<div>
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
<p className="text-sm text-ink-50">
按目录进入完整媒体库;下方每个目录也会直接展示最新内容。
{pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'}
</p>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
{previews.length > ENTRY_PAGE_SIZE && (
<span className="rounded-md border border-sand-200 bg-sand-50 px-2 py-0.5 text-xs font-semibold text-sand-700">
共 {previews.length} 个 · 每页 {ENTRY_PAGE_SIZE} 个
</span>
)}
</div>
<p className="text-sm text-ink-50">
按目录进入完整媒体库;下方每个目录也会直接展示最新内容。
{pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'}
</p>
</div>
{totalEntryPages > 1 && (
<div className="flex items-center gap-2 rounded-xl border border-sand-200 bg-white px-2.5 py-1 text-xs shadow-sm">
<button
type="button"
onClick={() => setEntryPage((p) => Math.max(1, p - 1))}
disabled={effectiveEntryPage <= 1}
className="rounded-lg p-1 text-ink-50 hover:bg-sand-100 hover:text-ink-600 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink-50 transition-colors"
title="上一页"
>
<ChevronLeft size={14} />
</button>
<span className="font-mono text-xs font-semibold text-ink-600">
{effectiveEntryPage} / {totalEntryPages}
</span>
<button
type="button"
onClick={() => setEntryPage((p) => Math.min(totalEntryPages, p + 1))}
disabled={effectiveEntryPage >= totalEntryPages}
className="rounded-lg p-1 text-ink-50 hover:bg-sand-100 hover:text-ink-600 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink-50 transition-colors"
title="下一页"
>
<ChevronRight size={14} />
</button>
</div>
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
{previews.map((preview, index) => (
{pagedPreviews.map((preview, index) => (
<motion.div
key={preview.library.id}
initial={{ opacity: 0, y: 12 }}
@@ -255,6 +332,7 @@ function LibraryEntryCard({
<Link
to={`/library/${library.id}`}
className="flex min-w-0 flex-1"
title={library.name}
>
<div className={`grid h-20 w-24 sm:h-24 sm:w-36 shrink-0 gap-1 overflow-hidden rounded-xl sm:rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
{artwork.length > 0 ? (
@@ -285,7 +363,10 @@ function LibraryEntryCard({
{preview.total.toLocaleString()} 个条目
</span>
</div>
<h2 className="line-clamp-2 font-display text-sm font-bold leading-tight text-ink-600 group-hover:text-brand-600 sm:truncate sm:text-xl sm:font-black">
<h2
className="line-clamp-2 break-words font-display text-sm font-bold leading-snug text-ink-600 group-hover:text-brand-600 sm:text-base lg:text-lg sm:font-black"
title={library.name}
>
{library.name}
{pinned ? <span className="ml-1.5 align-middle text-[10px] font-bold text-brand-600">置顶</span> : null}
</h2>
@@ -318,7 +399,10 @@ function LibraryShelf({ preview, pinned }: { preview: LibraryPreview; pinned?: b
{TYPE_ICONS[library.type] ?? <LibraryIcon size={14} />}
{TYPE_LABELS[library.type] ?? library.type}
</div>
<h2 className="line-clamp-2 font-display text-xl sm:text-2xl font-black text-ink-600 sm:truncate">
<h2
className="line-clamp-2 break-words font-display text-xl sm:text-2xl font-black text-ink-600"
title={library.name}
>
{library.name}
{pinned ? <span className="ml-2 align-middle text-xs font-bold text-brand-600">置顶</span> : null}
</h2>
+1 -1
View File
@@ -32,7 +32,7 @@ export function MediaDetailPage() {
media={media}
isAdmin={role === 'admin'}
favourite={detail.favourite}
playTargetId={detail.firstPlayableEpisode?.id}
playTargetId={media.id || detail.firstPlayableEpisode?.id}
scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork}
onToggleFavourite={detail.toggleFavourite}
onScrapeEpisodeArtworkChange={detail.setScrapeEpisodeArtwork}
+1 -1
View File
@@ -96,7 +96,7 @@ export function MediaDetailPlaybackActions({
onToggleFavourite,
playTargetId,
}: MediaDetailPlaybackActionsProps) {
const targetId = playTargetId || media.id
const targetId = media.id || playTargetId || ''
return (
<div className="flex flex-wrap gap-3">
<Link to={`/play/${targetId}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm">