import type { Locale } from "@app/i18n"; import { t } from "@app/i18n"; import { parseEpisodeFromFilename, type LibraryGroup } from "@app/types"; import { Badge, Button, Input, toast } from "@app/ui"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { cn } from "~/lib/utils"; import { go, queryParam, setQueryParams } from "~/lib/nav"; import { createTRPCReactClient, trpc } from "~/lib/trpc"; import { formatTrpcError } from "~/lib/trpc-error"; type ViewMode = "wall" | "list"; const PAGE_SIZE = 24; function useDebouncedValue(value: T, delayMs: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebounced(value), delayMs); return () => clearTimeout(timer); }, [value, delayMs]); return debounced; } function PosterBox(props: { posterUrl: string | null; locale: Locale; className?: string }) { return (
{props.posterUrl ? ( ) : ( t(props.locale, "library.noPoster") )}
); } function MountTabs(props: { mounts: { id: string; name: string }[]; value: string; onChange: (mountId: string) => void; }) { return (
{props.mounts.map((m) => ( ))}
); } /** 作品详情:剧集按需从 librarySeries 拉取(限当前挂载) */ function GroupDetail(props: { bangumiId: number; mountId: string; locale: Locale; onBack: () => void; onPlay: (mediaItemId: string, bangumiId: number) => void; onMatch?: ((mediaItemId: string, rawName?: string) => void) | undefined; onManage?: ((group: ManageTarget) => void) | undefined; }) { const { locale } = props; const seriesQuery = trpc.media.librarySeries.useQuery( { bangumiId: props.bangumiId, mountId: props.mountId || undefined }, { enabled: Boolean(props.mountId) }, ); const group = seriesQuery.data; return (
{seriesQuery.isPending && (

{t(locale, "browse.loading")}

)} {seriesQuery.isError && (

{t(locale, "browse.loadError")}

)} {group && (

{group.title}

{props.onManage && ( )}

{t(locale, "library.episodeCount", { count: group.episodes.length })}

{t(locale, "library.episodes")}
    {group.episodes.map((ep) => { // 落库集数优先,null 时用共享正则从文件名兜底解析 const epNo = ep.epNumber ?? parseEpisodeFromFilename(ep.rawName); return (
  • {epNo != null ? `EP${epNo}` : "—"} {ep.rawName} {props.onMatch && ( )}
  • ); })}
)}
); } function ContinueRow(props: { locale: Locale }) { const { locale } = props; const utils = trpc.useUtils(); const list = trpc.media.playbackContinue.useQuery({ limit: 6 }); const [confirmRemoveId, setConfirmRemoveId] = useState(null); const [confirmClear, setConfirmClear] = useState(false); const remove = trpc.media.playbackDelete.useMutation({ onSuccess: () => { setConfirmRemoveId(null); toast.success(t(locale, "library.removedToast")); void utils.media.playbackContinue.invalidate(); }, onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.removeError"))), }); const clear = trpc.media.playbackClear.useMutation({ onSuccess: () => { setConfirmClear(false); toast.success(t(locale, "library.clearedToast")); void utils.media.playbackContinue.invalidate(); }, onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.clearError"))), }); const items = list.data ?? []; if (list.isPending) return null; if (items.length === 0) return null; return (

{t(locale, "library.continueTitle")}

{items.map((item) => { const pct = item.durationMs > 0 ? Math.min(100, Math.round((item.positionMs / item.durationMs) * 100)) : 0; return (
); })}
); } /** * 「加载更多」分页累积:按 offset 缓存已取页。 * reqKey/refreshKey 变更时丢弃旧累积,避免 mutation 后旧页与新页混排。 */ type PagedAcc = { key: string; total: number; pages: Record; }; function usePagedAccumulator(params: { reqKey: string; refreshKey: number; data: { total: number; items: T[] } | undefined; offset: number; }): { items: T[]; total: number; hasMore: boolean } { const { reqKey, refreshKey, data, offset } = params; const [acc, setAcc] = useState | null>(null); const fullKey = `${reqKey}|r${refreshKey}`; const [prevFullKey, setPrevFullKey] = useState(fullKey); // 渲染期重置:条件或 refreshKey 变化后丢弃旧页 if (prevFullKey !== fullKey) { setPrevFullKey(fullKey); setAcc(null); } useEffect(() => { if (!data) return; setAcc((prev) => { if (!prev || prev.key !== fullKey || offset === 0) { return { key: fullKey, total: data.total, pages: { [offset]: data.items } }; } return { key: prev.key, total: data.total, pages: { ...prev.pages, [offset]: data.items }, }; }); }, [data, fullKey, offset]); const items: T[] = []; let total = 0; if (acc && acc.key === fullKey) { total = acc.total; for (const pageOffset of Object.keys(acc.pages) .map(Number) .sort((a, b) => a - b)) { const page = acc.pages[pageOffset]; if (page) items.push(...page); } } return { items, total, hasMore: items.length < total }; } type ManageTarget = { bangumiId: number; title: string; posterUrl: string | null }; /** 海报墙组管理弹窗:编辑标题/封面、重新匹配、取消匹配、删除记录 */ function GroupManageModal(props: { locale: Locale; mountId: string; group: ManageTarget; onClose: () => void; onChanged: () => void; onRemoved: (bangumiId: number) => void; }) { const { locale, mountId, group } = props; const [tab, setTab] = useState<"edit" | "rematch">("edit"); const [title, setTitle] = useState(group.title); const [poster, setPoster] = useState(group.posterUrl ?? ""); const [searchQ, setSearchQ] = useState(""); const [confirmUnbind, setConfirmUnbind] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const search = trpc.media.scrapeSearch.useQuery( { q: searchQ }, { enabled: tab === "rematch" && searchQ.length > 1 }, ); const save = trpc.media.libraryUpdateGroup.useMutation({ onSuccess: () => { toast.success(t(locale, "library.saved")); props.onChanged(); }, onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.groupOpError"))), }); const unbind = trpc.media.scrapeUnbindGroup.useMutation({ onSuccess: () => { toast.success(t(locale, "library.unbound")); props.onChanged(); props.onRemoved(group.bangumiId); props.onClose(); }, onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.groupOpError"))), }); const remove = trpc.media.libraryDeleteGroup.useMutation({ onSuccess: () => { toast.success(t(locale, "library.groupDeleted")); props.onChanged(); props.onRemoved(group.bangumiId); props.onClose(); }, onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.groupOpError"))), }); const rebind = trpc.media.scrapeBindGroup.useMutation({ onSuccess: () => { toast.success(t(locale, "library.matched")); props.onChanged(); setTab("edit"); setSearchQ(""); }, onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.groupOpError"))), }); const busy = save.isPending || unbind.isPending || remove.isPending || rebind.isPending; const canSave = title.trim().length > 0; return (

{t(locale, "library.groupManageTitle")}

{title}
{tab === "edit" ? (
setTitle(e.target.value)} />
setPoster(e.target.value)} />

{t(locale, "library.groupOpHint")}

) : (
setSearchQ(e.target.value)} />

{t(locale, "library.candidates")}

    {(search.data ?? []).map((hit) => (
  • {hit.nameCn || hit.name}
    {hit.nameCn && hit.name && (
    {hit.name}
    )}
  • ))}
)}
); } function WallView(props: { locale: Locale; mountId: string; q: string; refreshKey: number; selectedBangumiId: number | null; onSelect: (bangumiId: number | null) => void; onPlay: (mediaItemId: string, bangumiId: number) => void; onMatch?: ((mediaItemId: string, rawName?: string) => void) | undefined; onManage?: ((group: ManageTarget) => void) | undefined; onClearSearch?: (() => void) | undefined; hideEmpty?: boolean; mountsEmpty?: boolean; }) { const { locale, mountId, q } = props; const [offset, setOffset] = useState(0); const reqKey = `${mountId}|${q}`; const [prevReqKey, setPrevReqKey] = useState(reqKey); if (prevReqKey !== reqKey) { setPrevReqKey(reqKey); setOffset(0); } const [prevRefresh, setPrevRefresh] = useState(props.refreshKey); if (prevRefresh !== props.refreshKey) { setPrevRefresh(props.refreshKey); setOffset(0); } const groupsQuery = trpc.media.libraryGroups.useQuery( { mountId, q: q || undefined, limit: PAGE_SIZE, offset }, { enabled: Boolean(mountId) }, ); const paged = usePagedAccumulator({ reqKey, refreshKey: props.refreshKey, data: groupsQuery.data ? { total: groupsQuery.data.total, items: groupsQuery.data.groups } : undefined, offset, }); const groups = paged.items; const hasMore = paged.hasMore; if (props.selectedBangumiId != null) { return ( props.onSelect(null)} onPlay={props.onPlay} onMatch={props.onMatch} onManage={props.onManage} /> ); } const showEmpty = !props.hideEmpty && (props.mountsEmpty || (groups.length === 0 && !groupsQuery.isPending)); return (
{groups.map((group) => (
{props.onManage && ( )}
))} {showEmpty && (

{t(locale, "library.title")}

{!props.mountsEmpty && q ? t(locale, "library.noResults", { q }) : t(locale, "library.groupsEmpty")}

{!props.mountsEmpty && q && props.onClearSearch ? ( ) : ( <> )}
)}
{hasMore && (
)}
); } function ListView(props: { locale: Locale; mountId: string; q: string; filter: "" | "unmatched" | "ok"; refreshKey: number; onFilterChange: (f: "" | "unmatched" | "ok") => void; onMatch: (mediaItemId: string, rawName?: string) => void; onPlay: (mediaItemId: string, bangumiId?: number | null) => void; onRescrape?: ((mediaItemId: string) => void) | undefined; onClearSearch?: (() => void) | undefined; hideEmpty?: boolean; mountsEmpty?: boolean; }) { const { locale, mountId, q, filter } = props; const [offset, setOffset] = useState(0); const pageLimit = PAGE_SIZE; const reqKey = `${mountId}|${q}|${filter}`; const [prevReqKey, setPrevReqKey] = useState(reqKey); if (prevReqKey !== reqKey) { setPrevReqKey(reqKey); setOffset(0); } const [prevRefresh, setPrevRefresh] = useState(props.refreshKey); if (prevRefresh !== props.refreshKey) { setPrevRefresh(props.refreshKey); setOffset(0); } const list = trpc.media.libraryList.useQuery({ limit: pageLimit, offset, mountId: mountId || undefined, scrapeStatus: filter || undefined, q: q || undefined, }); // 与海报墙同一套累积逻辑:加载更多追加而不是顶掉上一页 type MediaListItem = { id: string; title: string; rawName: string; posterUrl: string | null; bangumiId: number | null; scrapeStatus: string; }; const paged = usePagedAccumulator({ reqKey, refreshKey: props.refreshKey, data: list.data ? { total: list.data.total, items: list.data.rows.map((r) => ({ id: r.id, title: r.title, rawName: r.rawName, posterUrl: r.posterUrl, bangumiId: r.bangumiId, scrapeStatus: r.scrapeStatus, })), } : undefined, offset, }); const rows = paged.items; const hasMore = paged.hasMore; const statusBadge = (status: string) => { if (status === "ok") return { variant: "secondary" as const, label: t(locale, "library.filterOk") }; if (status === "unmatched") return { variant: "default" as const, label: t(locale, "library.unmatched") }; if (status === "failed") return { variant: "destructive" as const, label: t(locale, "library.statusFailed") }; return { variant: "outline" as const, label: t(locale, "library.statusPending") }; }; return (
{rows.length > 0 && (
    {rows.map((item) => { const badge = statusBadge(item.scrapeStatus); return (
  • {item.title}

    {badge.label}

    {item.rawName}

  • ); })}
)} {!props.hideEmpty && (props.mountsEmpty || (rows.length === 0 && !list.isPending)) && (

{t(locale, "library.title")}

{!props.mountsEmpty && q ? t(locale, "library.noResults", { q }) : !props.mountsEmpty && filter ? t(locale, "library.filterEmpty") : t(locale, "library.empty")}

{!props.mountsEmpty && q && props.onClearSearch ? ( ) : !props.mountsEmpty && filter ? ( ) : ( <> )}
)} {hasMore && (
)}
); } function LibraryInner(props: { locale: Locale }) { const { locale } = props; const [view, setView] = useState(() => { try { return localStorage.getItem("media-library-view") === "list" ? "list" : "wall"; } catch { return "wall"; } }); const changeView = (v: ViewMode) => { setView(v); try { localStorage.setItem("media-library-view", v); } catch { /* noop */ } }; const [filter, setFilter] = useState<"" | "unmatched" | "ok">(""); const [q, setQ] = useState(""); const debouncedQ = useDebouncedValue(q, 300); const [matchId, setMatchId] = useState(null); const [manageGroup, setManageGroup] = useState(null); const [searchQ, setSearchQ] = useState(""); const [epInput, setEpInput] = useState(""); const [mountId, setMountId] = useState(() => queryParam("mount") ?? ""); const [selectedBangumiId, setSelectedBangumiId] = useState(() => { const raw = queryParam("g"); const parsed = raw ? Number(raw) : Number.NaN; return Number.isFinite(parsed) && parsed > 0 ? parsed : null; }); const search = trpc.media.scrapeSearch.useQuery( { q: searchQ }, { enabled: searchQ.length > 1 && matchId !== null }, ); const utils = trpc.useUtils(); const [refreshKey, setRefreshKey] = useState(0); const bumpRefresh = () => setRefreshKey((k) => k + 1); /** 组级操作后的统一刷新:分页游标 + 全部相关查询 */ const refreshAll = () => { bumpRefresh(); void utils.media.libraryList.invalidate(); void utils.media.libraryGroups.invalidate(); void utils.media.librarySeries.invalidate(); void utils.media.playbackContinue.invalidate(); }; const bind = trpc.media.scrapeBind.useMutation({ onSuccess: () => { setMatchId(null); toast.success(t(locale, "library.matched")); bumpRefresh(); void utils.media.libraryList.invalidate(); void utils.media.libraryGroups.invalidate(); void utils.media.librarySeries.invalidate(); void utils.media.playbackContinue.invalidate(); }, }); const rescrape = trpc.media.scrapeRescrape.useMutation({ onSuccess: () => { toast.success(t(locale, "library.reScrape")); bumpRefresh(); void utils.media.libraryList.invalidate(); void utils.media.libraryGroups.invalidate(); void utils.media.librarySeries.invalidate(); }, }); const bindWithEp = (bangumiId: number) => { if (!matchId) return; const epNum = epInput.trim() === "" ? undefined : Number(epInput); bind.mutate({ mediaItemId: matchId, bangumiId, ...(epNum != null && Number.isFinite(epNum) ? { epNumber: epNum } : {}), }); }; /** 打开匹配弹窗:集数用共享正则从文件名预填,用户可改 */ const openMatch = (mediaItemId: string, rawName?: string) => { setMatchId(mediaItemId); const parsed = rawName != null ? parseEpisodeFromFilename(rawName) : null; setEpInput(parsed != null ? String(parsed) : ""); }; const mounts = trpc.media.mountList.useQuery(); const probe = trpc.media.libraryList.useQuery({ limit: 1, offset: 0 }); const autoScan = trpc.media.libraryScan.useMutation(); const autoScanStarted = useRef(false); const [autoScanning, setAutoScanning] = useState(false); const mountList = mounts.data?.mounts ?? []; const mountsEmpty = !mounts.isPending && mountList.length === 0; useEffect(() => { setQueryParams({ mount: mountId || null }); }, [mountId]); // 挂载列表返回后:URL 里的 mount 有效则保留;否则自动选中第一个 useEffect(() => { const list = mounts.data?.mounts ?? []; if (list.length === 0) return; if (mountId && list.some((m) => m.id === mountId)) return; const first = list[0]; if (first) setMountId(first.id); }, [mounts.data, mountId]); // 空库且已有挂载时,自动把已挂载目录扫进媒体库(仅触发一次;顺序扫描避免并发打满 WebDAV) useEffect(() => { if (autoScanStarted.current) return; if (mounts.isPending || probe.isPending) return; const list = mounts.data?.mounts ?? []; if (list.length === 0) return; if ((probe.data?.total ?? 0) > 0) return; autoScanStarted.current = true; setAutoScanning(true); void (async () => { for (const m of list) { try { await autoScan.mutateAsync({ mountId: m.id }); } catch { // 单个挂载失败不阻断其余挂载 } } setAutoScanning(false); setRefreshKey((k) => k + 1); void utils.media.libraryList.invalidate(); void utils.media.libraryGroups.invalidate(); })(); }, [mounts.data, mounts.isPending, probe.data, probe.isPending, autoScan, utils]); useEffect(() => { setQueryParams({ g: selectedBangumiId != null ? String(selectedBangumiId) : null }); }, [selectedBangumiId]); const play = (id: string, bangumiId?: number | null) => { const g = bangumiId ?? selectedBangumiId; const back = g != null ? `&from=series&g=${g}` : ""; go(`/${locale}/watch?id=${id}${back}`); }; return (
{autoScanning && (

{t(locale, "library.autoScanning")}

)}

{t(locale, "library.title")}

setQ(e.target.value)} />
{mountList.length > 0 && ( { setMountId(id); setSelectedBangumiId(null); }} /> )} {view === "wall" ? ( setQ("")} hideEmpty={autoScanning} mountsEmpty={mountsEmpty} /> ) : ( rescrape.mutate({ mediaItemId: id })} onClearSearch={() => setQ("")} hideEmpty={autoScanning} mountsEmpty={mountsEmpty} /> )} {manageGroup && ( setManageGroup(null)} onChanged={refreshAll} onRemoved={(bangumiId) => { if (selectedBangumiId === bangumiId) setSelectedBangumiId(null); }} /> )} {matchId && (

{t(locale, "library.manualTitle")}

setSearchQ(e.target.value)} />
{t(locale, "library.epLabel")} setEpInput(e.target.value)} />

{t(locale, "library.candidates")}

    {(search.data ?? []).map((hit) => (
  • {hit.nameCn || hit.name}
    {hit.nameCn && hit.name && (
    {hit.name}
    )}
  • ))}
)}
); } const client = createTRPCReactClient(); export function LibraryBody(props: { locale: Locale }) { const [qc] = useState(() => new QueryClient()); return ( ); }