app-template/app/web/src/components/pages/LibraryBody.tsx

1258 lines
55 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 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<T>(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 (
<div
className={cn(
"flex aspect-[3/4] items-center justify-center bg-muted text-xs text-muted-foreground",
props.className,
)}
>
{props.posterUrl ? (
<img
src={props.posterUrl}
alt=""
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
t(props.locale, "library.noPoster")
)}
</div>
);
}
function MountTabs(props: {
mounts: { id: string; name: string }[];
value: string;
onChange: (mountId: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1 rounded-md border border-border p-0.5" role="tablist">
{props.mounts.map((m) => (
<button
key={m.id}
type="button"
role="tab"
aria-selected={m.id === props.value}
className={cn(
"rounded px-3 py-1.5 text-sm transition-colors",
m.id === props.value
? "bg-primary font-medium text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => props.onChange(m.id)}
>
{m.name}
</button>
))}
</div>
);
}
/** 作品详情:剧集按需从 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 (
<div className="space-y-4">
<Button variant="outline" size="sm" onClick={props.onBack}>
← {t(locale, "library.backToList")}
</Button>
{seriesQuery.isPending && (
<p className="text-sm text-muted-foreground">{t(locale, "browse.loading")}</p>
)}
{seriesQuery.isError && (
<p className="text-sm text-red-600">{t(locale, "browse.loadError")}</p>
)}
{group && (
<div className="flex flex-col gap-4 sm:flex-row">
<div className="w-full max-w-40 overflow-hidden rounded-lg border border-border sm:w-40">
<PosterBox posterUrl={group.posterUrl} locale={locale} />
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex items-start gap-2">
<h2
className="min-w-0 flex-1 truncate text-lg font-semibold"
title={group.title}
>
{group.title}
</h2>
{props.onManage && (
<Button
size="sm"
variant="outline"
onClick={() =>
props.onManage?.({
bangumiId: props.bangumiId,
title: group.title,
posterUrl: group.posterUrl,
})
}
>
{t(locale, "library.manageGroup")}
</Button>
)}
</div>
<p className="text-sm text-muted-foreground">
{t(locale, "library.episodeCount", { count: group.episodes.length })}
</p>
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t(locale, "library.episodes")}
</div>
<ul className="divide-y divide-border rounded-lg border border-border">
{group.episodes.map((ep) => {
// 落库集数优先,null 时用共享正则从文件名兜底解析
const epNo =
ep.epNumber ?? parseEpisodeFromFilename(ep.rawName);
return (
<li
key={ep.id}
className="flex items-center gap-3 px-3 py-2 text-sm"
>
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground">
{epNo != null ? `EP${epNo}` : "—"}
</span>
<span
className="min-w-0 flex-1 truncate"
title={ep.rawName}
>
{ep.rawName}
</span>
<Button
size="sm"
variant="secondary"
onClick={() => props.onPlay(ep.id, props.bangumiId)}
>
{t(locale, "library.play")}
</Button>
{props.onMatch && (
<Button
size="sm"
variant="outline"
onClick={() =>
props.onMatch?.(ep.id, ep.rawName)
}
>
{t(locale, "library.match")}
</Button>
)}
</li>
);
})}
</ul>
</div>
</div>
</div>
)}
</div>
);
}
function ContinueRow(props: { locale: Locale }) {
const { locale } = props;
const utils = trpc.useUtils();
const list = trpc.media.playbackContinue.useQuery({ limit: 6 });
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(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 (
<section className="space-y-2">
<div className="flex items-center gap-2">
<p className="u-label">{t(locale, "library.continueTitle")}</p>
<span className="u-dotline flex-1" aria-hidden="true" />
<button
type="button"
className="shrink-0 text-xs text-muted-foreground transition-colors hover:text-destructive"
disabled={clear.isPending}
onClick={() => {
setConfirmRemoveId(null);
if (confirmClear) clear.mutate({});
else setConfirmClear(true);
}}
>
{confirmClear
? t(locale, "library.confirmClearContinue")
: t(locale, "library.clearContinue")}
</button>
</div>
<div className="flex gap-3 overflow-x-auto pb-1">
{items.map((item) => {
const pct =
item.durationMs > 0
? Math.min(100, Math.round((item.positionMs / item.durationMs) * 100))
: 0;
return (
<div
key={item.mediaItemId}
className="relative w-40 shrink-0 overflow-hidden rounded-lg border border-border bg-card transition-colors hover:border-primary/60"
>
<button
type="button"
className="block w-full text-left"
onClick={() => {
setConfirmRemoveId(null);
setConfirmClear(false);
go(`/${locale}/watch?id=${item.mediaItemId}`);
}}
>
<PosterBox posterUrl={item.posterUrl} locale={locale} />
<div className="p-2">
<div
className="truncate text-sm font-medium"
title={item.title}
>
{item.title}
</div>
<div className="mt-1 h-1 w-full overflow-hidden rounded bg-muted">
<div
className="h-full bg-primary"
style={{ width: `${pct}%` }}
/>
</div>
<div className="mt-1 font-mono text-[11px] text-muted-foreground">
{t(locale, "library.continue")} · {pct}%
</div>
</div>
</button>
<button
type="button"
className="absolute top-1.5 right-1.5 z-10 rounded-md border border-border bg-background/90 px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:text-destructive"
disabled={remove.isPending}
onClick={() => {
setConfirmClear(false);
if (confirmRemoveId === item.mediaItemId) {
remove.mutate({ mediaItemId: item.mediaItemId });
} else {
setConfirmRemoveId(item.mediaItemId);
}
}}
>
{confirmRemoveId === item.mediaItemId
? t(locale, "library.confirmRemoveContinue")
: t(locale, "library.removeContinue")}
</button>
</div>
);
})}
</div>
</section>
);
}
/**
* 「加载更多」分页累积:按 offset 缓存已取页。
* reqKey/refreshKey 变更时丢弃旧累积,避免 mutation 后旧页与新页混排。
*/
type PagedAcc<T> = {
key: string;
total: number;
pages: Record<number, T[]>;
};
function usePagedAccumulator<T>(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<PagedAcc<T> | 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg space-y-3 rounded-lg border border-border bg-background p-4">
<div className="flex items-center gap-2">
<h2 className="shrink-0 font-medium">
{t(locale, "library.groupManageTitle")}
</h2>
<span className="truncate text-xs text-muted-foreground" title={title}>
{title}
</span>
</div>
<div className="flex rounded-md border border-border p-0.5">
<button
type="button"
className={cn(
"flex-1 rounded px-3 py-1.5 text-sm transition-colors",
tab === "edit"
? "bg-primary font-medium text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => setTab("edit")}
>
{t(locale, "library.editInfo")}
</button>
<button
type="button"
className={cn(
"flex-1 rounded px-3 py-1.5 text-sm transition-colors",
tab === "rematch"
? "bg-primary font-medium text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => setTab("rematch")}
>
{t(locale, "library.rematch")}
</button>
</div>
{tab === "edit" ? (
<div className="space-y-3">
<div className="space-y-1 text-sm">
<label htmlFor="group-manage-title" className="text-muted-foreground">
{t(locale, "library.editTitleLabel")}
</label>
<Input
id="group-manage-title"
value={title}
disabled={busy}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="space-y-1 text-sm">
<label htmlFor="group-manage-poster" className="text-muted-foreground">
{t(locale, "library.posterLabel")}
</label>
<Input
id="group-manage-poster"
value={poster}
disabled={busy}
placeholder={t(locale, "library.posterPlaceholder")}
onChange={(e) => setPoster(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button
size="sm"
disabled={!canSave || busy}
onClick={() =>
save.mutate({
mountId,
bangumiId: group.bangumiId,
title: title.trim(),
posterUrl: poster.trim() === "" ? null : poster.trim(),
})
}
>
{t(locale, "library.save")}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t(locale, "library.groupOpHint")}
</p>
<div className="flex flex-wrap gap-2 border-t border-border pt-3">
<Button
size="sm"
variant="destructive"
disabled={busy}
onClick={() => {
setConfirmDelete(false);
if (confirmUnbind) {
unbind.mutate({
mountId,
bangumiId: group.bangumiId,
});
} else {
setConfirmUnbind(true);
}
}}
>
{confirmUnbind
? t(locale, "library.confirmUnbind")
: t(locale, "library.unbind")}
</Button>
<Button
size="sm"
variant="destructive"
disabled={busy}
onClick={() => {
setConfirmUnbind(false);
if (confirmDelete) {
remove.mutate({
mountId,
bangumiId: group.bangumiId,
});
} else {
setConfirmDelete(true);
}
}}
>
{confirmDelete
? t(locale, "library.confirmDeleteGroup")
: t(locale, "library.deleteGroup")}
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<Input
placeholder={t(locale, "library.searchWork")}
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t(locale, "library.candidates")}
</p>
<ul className="max-h-64 divide-y divide-border overflow-auto rounded border border-border">
{(search.data ?? []).map((hit) => (
<li
key={hit.id}
className="flex items-center justify-between gap-2 p-2 text-sm"
>
<div className="min-w-0">
<div className="truncate">{hit.nameCn || hit.name}</div>
{hit.nameCn && hit.name && (
<div className="truncate text-xs text-muted-foreground">
{hit.name}
</div>
)}
</div>
<Button
size="sm"
disabled={rebind.isPending}
onClick={() =>
rebind.mutate({
mountId,
bangumiId: group.bangumiId,
newBangumiId: hit.id,
})
}
>
{t(locale, "library.bind")}
</Button>
</li>
))}
</ul>
</div>
)}
<div className="flex justify-end gap-2 border-t border-border pt-3">
<Button variant="outline" size="sm" disabled={busy} onClick={props.onClose}>
{t(locale, "library.cancel")}
</Button>
</div>
</div>
</div>
);
}
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<LibraryGroup>({
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 (
<GroupDetail
bangumiId={props.selectedBangumiId}
mountId={mountId}
locale={locale}
onBack={() => props.onSelect(null)}
onPlay={props.onPlay}
onMatch={props.onMatch}
onManage={props.onManage}
/>
);
}
const showEmpty =
!props.hideEmpty && (props.mountsEmpty || (groups.length === 0 && !groupsQuery.isPending));
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
{groups.map((group) => (
<div key={group.bangumiId} className="relative">
<button
type="button"
className="w-full overflow-hidden rounded-lg border border-border bg-card text-left transition-colors hover:border-primary/60"
onClick={() => props.onSelect(group.bangumiId)}
>
<PosterBox posterUrl={group.posterUrl} locale={locale} />
<div className="p-2">
<div className="truncate text-sm font-medium" title={group.title}>
{group.title}
</div>
<div className="mt-0.5 font-mono text-xs text-muted-foreground">
{t(locale, "library.episodeCount", {
count: group.episodeCount,
})}
</div>
</div>
</button>
{props.onManage && (
<button
type="button"
className="absolute top-1.5 right-1.5 z-10 rounded-md border border-border bg-background/90 px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:text-primary"
onClick={() =>
props.onManage?.({
bangumiId: group.bangumiId,
title: group.title,
posterUrl: group.posterUrl,
})
}
>
{t(locale, "library.manageGroup")}
</button>
)}
</div>
))}
{showEmpty && (
<div className="u-empty col-span-full">
<p className="u-empty__title">{t(locale, "library.title")}</p>
<p>
{!props.mountsEmpty && q
? t(locale, "library.noResults", { q })
: t(locale, "library.groupsEmpty")}
</p>
<div className="u-empty__actions">
{!props.mountsEmpty && q && props.onClearSearch ? (
<Button size="sm" variant="outline" onClick={props.onClearSearch}>
{t(locale, "library.clearSearch")}
</Button>
) : (
<>
<Button size="sm" onClick={() => go(`/${locale}/browse`)}>
{t(locale, "library.ctaBrowse")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => go(`/${locale}/mounts`)}
>
{t(locale, "library.ctaMounts")}
</Button>
</>
)}
</div>
</div>
)}
</div>
{hasMore && (
<div className="flex justify-center">
<Button
variant="outline"
disabled={groupsQuery.isPending}
onClick={() => setOffset(groups.length)}
>
{t(locale, "library.loadMore")}
</Button>
</div>
)}
</div>
);
}
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<MediaListItem>({
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 (
<div className="space-y-4">
<div className="flex justify-end">
<select
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
value={filter}
onChange={(e) => {
props.onFilterChange(e.target.value as typeof filter);
setOffset(0);
}}
>
<option value="">{t(locale, "library.filterAll")}</option>
<option value="ok">{t(locale, "library.filterOk")}</option>
<option value="unmatched">{t(locale, "library.unmatched")}</option>
</select>
</div>
{rows.length > 0 && (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<ul className="divide-y divide-border">
{rows.map((item) => {
const badge = statusBadge(item.scrapeStatus);
return (
<li
key={item.id}
className="flex flex-wrap items-center gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"
>
<PosterBox
posterUrl={item.posterUrl}
locale={locale}
className="h-14 w-[42px] shrink-0 overflow-hidden rounded-md border border-border"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p
className="truncate text-sm font-medium"
title={item.title}
>
{item.title}
</p>
<Badge
variant={badge.variant}
className="shrink-0 font-normal"
>
{badge.label}
</Badge>
</div>
<p
className="truncate font-mono text-xs text-muted-foreground"
title={item.rawName}
>
{item.rawName}
</p>
</div>
<div className="ml-auto flex shrink-0 gap-1">
<Button
size="sm"
variant="secondary"
onClick={() => props.onPlay(item.id, item.bangumiId)}
>
{t(locale, "library.play")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => props.onMatch(item.id, item.rawName)}
>
{t(locale, "library.match")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => props.onRescrape?.(item.id)}
>
{t(locale, "library.reScrape")}
</Button>
</div>
</li>
);
})}
</ul>
</div>
)}
{!props.hideEmpty && (props.mountsEmpty || (rows.length === 0 && !list.isPending)) && (
<div className="u-empty">
<p className="u-empty__title">{t(locale, "library.title")}</p>
<p>
{!props.mountsEmpty && q
? t(locale, "library.noResults", { q })
: !props.mountsEmpty && filter
? t(locale, "library.filterEmpty")
: t(locale, "library.empty")}
</p>
<div className="u-empty__actions">
{!props.mountsEmpty && q && props.onClearSearch ? (
<Button size="sm" variant="outline" onClick={props.onClearSearch}>
{t(locale, "library.clearSearch")}
</Button>
) : !props.mountsEmpty && filter ? (
<Button
size="sm"
variant="outline"
onClick={() => props.onFilterChange("")}
>
{t(locale, "library.filterAll")}
</Button>
) : (
<>
<Button size="sm" onClick={() => go(`/${locale}/browse`)}>
{t(locale, "library.ctaBrowse")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => go(`/${locale}/mounts`)}
>
{t(locale, "library.ctaMounts")}
</Button>
</>
)}
</div>
</div>
)}
{hasMore && (
<div className="flex justify-center">
<Button
variant="outline"
disabled={list.isPending}
onClick={() => setOffset((o) => o + pageLimit)}
>
{t(locale, "library.loadMore")}
</Button>
</div>
)}
</div>
);
}
function LibraryInner(props: { locale: Locale }) {
const { locale } = props;
const [view, setView] = useState<ViewMode>(() => {
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<string | null>(null);
const [manageGroup, setManageGroup] = useState<ManageTarget | null>(null);
const [searchQ, setSearchQ] = useState("");
const [epInput, setEpInput] = useState("");
const [mountId, setMountId] = useState(() => queryParam("mount") ?? "");
const [selectedBangumiId, setSelectedBangumiId] = useState<number | null>(() => {
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 (
<div className="space-y-6">
<ContinueRow locale={locale} />
{autoScanning && (
<p className="text-sm text-muted-foreground">{t(locale, "library.autoScanning")}</p>
)}
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold">{t(locale, "library.title")}</h1>
<div className="ml-auto flex flex-wrap items-center gap-2">
<div className="flex rounded-md border border-border p-0.5">
<button
type="button"
className={cn(
"rounded px-3 py-1.5 text-sm transition-colors",
view === "wall"
? "bg-primary font-medium text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => {
// 海报墙只展示已刮削分组,切过去时列表对齐同一集合
changeView("wall");
}}
>
{t(locale, "library.viewWall")}
</button>
<button
type="button"
className={cn(
"rounded px-3 py-1.5 text-sm transition-colors",
view === "list"
? "bg-primary font-medium text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => {
changeView("list");
if (filter === "") setFilter("ok");
}}
>
{t(locale, "library.viewList")}
</button>
</div>
<Input
className="w-40"
placeholder={t(locale, "library.searchPlaceholder")}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
</div>
{mountList.length > 0 && (
<MountTabs
mounts={mountList}
value={mountId}
onChange={(id) => {
setMountId(id);
setSelectedBangumiId(null);
}}
/>
)}
{view === "wall" ? (
<WallView
locale={locale}
mountId={mountId}
q={debouncedQ}
refreshKey={refreshKey}
selectedBangumiId={selectedBangumiId}
onSelect={setSelectedBangumiId}
onPlay={play}
onMatch={openMatch}
onManage={setManageGroup}
onClearSearch={() => setQ("")}
hideEmpty={autoScanning}
mountsEmpty={mountsEmpty}
/>
) : (
<ListView
locale={locale}
mountId={mountId}
q={debouncedQ}
filter={filter}
refreshKey={refreshKey}
onFilterChange={setFilter}
onMatch={openMatch}
onPlay={play}
onRescrape={(id) => rescrape.mutate({ mediaItemId: id })}
onClearSearch={() => setQ("")}
hideEmpty={autoScanning}
mountsEmpty={mountsEmpty}
/>
)}
{manageGroup && (
<GroupManageModal
locale={locale}
mountId={mountId}
group={manageGroup}
onClose={() => setManageGroup(null)}
onChanged={refreshAll}
onRemoved={(bangumiId) => {
if (selectedBangumiId === bangumiId) setSelectedBangumiId(null);
}}
/>
)}
{matchId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg rounded-lg border border-border bg-background p-4 space-y-3">
<h2 className="font-medium">{t(locale, "library.manualTitle")}</h2>
<Input
placeholder={t(locale, "library.searchWork")}
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
/>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">
{t(locale, "library.epLabel")}
</span>
<Input
className="w-28"
inputMode="numeric"
placeholder={t(locale, "library.epPlaceholder")}
value={epInput}
onChange={(e) => setEpInput(e.target.value)}
/>
</div>
<p className="text-xs text-muted-foreground">
{t(locale, "library.candidates")}
</p>
<ul className="max-h-64 divide-y divide-border overflow-auto rounded border border-border">
{(search.data ?? []).map((hit) => (
<li
key={hit.id}
className="flex items-center justify-between gap-2 p-2 text-sm"
>
<div className="min-w-0">
<div className="truncate">{hit.nameCn || hit.name}</div>
{hit.nameCn && hit.name && (
<div className="truncate text-xs text-muted-foreground">
{hit.name}
</div>
)}
</div>
<Button size="sm" onClick={() => bindWithEp(hit.id)}>
{t(locale, "library.bind")}
</Button>
</li>
))}
</ul>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setMatchId(null)}>
{t(locale, "library.cancel")}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
const client = createTRPCReactClient();
export function LibraryBody(props: { locale: Locale }) {
const [qc] = useState(() => new QueryClient());
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<LibraryInner locale={props.locale} />
</trpc.Provider>
</QueryClientProvider>
);
}