Files
MeBox/web/src/pages/useLibraryData.ts
T
Cursor Agent ba621cfc4a Optimize home/library loading and SQLite query performance
- Replace home preview N+1 queries with a single window-function batch query
- Cache library previews and series card lists (15s TTL, shared with media cache)
- Tune SQLite: larger page cache, mmap, wal_autocheckpoint, ANALYZE on migrate
- Defer history fetch on library page until last_played sort is selected
- Yield to browser between paginated library loads to keep UI responsive

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 01:39:28 +00:00

199 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import { libraryAPI } from '../api/library'
import type { Library, Media } from '../types'
import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries'
export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | null) {
const [library, setLibrary] = useState<Library | null>(null)
const [items, setItems] = useState<Media[]>([])
const [serverSeriesCards, setServerSeriesCards] = useState<SeriesCard[]>([])
const [seriesEpisodeItems, setSeriesEpisodeItems] = useState<Media[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [loadingAll, setLoadingAll] = useState(false)
const [loadingSeriesEpisodes, setLoadingSeriesEpisodes] = useState(false)
const isSeriesLibrary = isSeriesLibraryType(library?.type)
const hasEpisodicItems = useMemo(() => items.some(isEpisodeLike), [items])
const isSeries = isSeriesLibrary || serverSeriesCards.length > 0 || hasEpisodicItems
const seriesCards = useMemo(() => {
if (isSeriesLibrary) return serverSeriesCards
if (!isSeries || items.length === 0) return []
return groupSeries(items)
}, [isSeries, isSeriesLibrary, items, serverSeriesCards])
useEffect(() => {
if (!libraryID) return
let cancelled = false
setLoading(true)
setLibrary(null)
setItems([])
setServerSeriesCards([])
setSeriesEpisodeItems([])
libraryAPI.get(libraryID)
.then((lib) => {
if (!cancelled) setLibrary(lib)
})
.catch(() => {
if (!cancelled) {
setLibrary(null)
setLoading(false)
toast.error('媒体库不存在或无权限')
}
})
return () => { cancelled = true }
}, [libraryID])
useEffect(() => {
if (!libraryID || !library) return
let cancelled = false
setLoading(true)
setLoadingAll(true)
setItems([])
setServerSeriesCards([])
setSeriesEpisodeItems([])
const loadAll = async () => {
if (isSeriesLibrary) {
const collected = await loadAllSeriesCards(libraryID, library.is_remote_emby, (next) => {
if (cancelled) return
setTotal(next.total)
if (next.firstPage) {
setServerSeriesCards(next.items)
setLoading(false)
}
})
if (!cancelled) setServerSeriesCards(collected.items)
return
}
const collected = await loadAllMedia(libraryID, library.is_remote_emby, (next) => {
if (cancelled) return
setTotal(next.total)
if (next.firstPage) {
setItems(next.items)
setLoading(false)
}
})
if (!cancelled) setItems(collected.items)
}
loadAll()
.catch(() => {
if (!cancelled) toast.error('媒体库加载失败')
})
.finally(() => {
if (!cancelled) {
setLoading(false)
setLoadingAll(false)
}
})
return () => { cancelled = true }
}, [libraryID, library, isSeriesLibrary])
useEffect(() => {
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
setSeriesEpisodeItems([])
setLoadingSeriesEpisodes(false)
return
}
let cancelled = false
setLoadingSeriesEpisodes(true)
setSeriesEpisodeItems([])
libraryAPI.listSeriesEpisodes(libraryID, selectedSeries.key)
.then((r) => {
if (!cancelled) setSeriesEpisodeItems(r.items ?? [])
})
.catch(() => {
if (!cancelled) toast.error('剧集列表加载失败')
})
.finally(() => {
if (!cancelled) setLoadingSeriesEpisodes(false)
})
return () => { cancelled = true }
}, [libraryID, isSeriesLibrary, selectedSeries])
const reloadCurrentLibrary = useCallback(() => {
setLibrary((current) => (current ? { ...current } : current))
}, [])
const loadingAllText = loadingAll && !loading && (isSeriesLibrary ? total > serverSeriesCards.length : total > items.length)
? (isSeriesLibrary
? `正在继续加载剧集卡片:${serverSeriesCards.length} / ${total}`
: `正在继续加载全部条目:${items.length} / ${total}`)
: ''
return {
library,
items,
seriesEpisodeItems,
total,
loading,
loadingSeriesEpisodes,
isSeriesLibrary,
isSeries,
seriesCards,
loadingAllText,
reloadCurrentLibrary,
}
}
function isSeriesLibraryType(type?: string) {
return type === 'tv' || type === 'anime' || type === 'variety'
}
function yieldToBrowser(): Promise<void> {
return new Promise((resolve) => {
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(() => resolve(), { timeout: 48 })
} else {
setTimeout(resolve, 0)
}
})
}
async function loadAllSeriesCards(
libraryID: string,
isRemoteEmby: boolean | undefined,
onPage: (state: { items: SeriesCard[]; total: number; firstPage: boolean }) => void,
) {
const pageSize = isRemoteEmby ? 100 : 500
let page = 1
let collected: SeriesCard[] = []
for (;;) {
const data = await libraryAPI.listSeries(libraryID, page, pageSize)
// 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。
const pageItems = data.items ?? []
collected = collected.concat(pageItems)
onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 })
if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break
page += 1
await yieldToBrowser()
}
return { items: collected }
}
async function loadAllMedia(
libraryID: string,
isRemoteEmby: boolean | undefined,
onPage: (state: { items: Media[]; total: number; firstPage: boolean }) => void,
) {
const pageSize = isRemoteEmby ? 100 : 2000
let page = 1
let collected: Media[] = []
for (;;) {
const data = await libraryAPI.listMedia(libraryID, page, pageSize)
// 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。
const pageItems = data.items ?? []
collected = collected.concat(pageItems)
onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 })
if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break
page += 1
await yieldToBrowser()
}
return { items: collected }
}