chore(product): WIP checkpoint library/episode work before merging danmaku branch

Unrelated in-progress changes found on main: LibraryBody expansion, episode helpers, history-guard, nav/e2e. Committed as checkpoint to unblock merge.
This commit is contained in:
noelorin 2026-09-27 02:13:45 +08:00
parent 63e760ba36
commit da6378e421
20 changed files with 1261 additions and 181 deletions

View File

@ -1,12 +1,13 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import type { LibraryGroup } from "@app/types";
import { Button, Input, toast } from "@app/ui";
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";
@ -78,7 +79,8 @@ function GroupDetail(props: {
locale: Locale;
onBack: () => void;
onPlay: (mediaItemId: string, bangumiId: number) => void;
onMatch?: ((mediaItemId: string) => void) | undefined;
onMatch?: ((mediaItemId: string, rawName?: string) => void) | undefined;
onManage?: ((group: ManageTarget) => void) | undefined;
}) {
const { locale } = props;
const seriesQuery = trpc.media.librarySeries.useQuery(
@ -103,9 +105,29 @@ function GroupDetail(props: {
<PosterBox posterUrl={group.posterUrl} locale={locale} />
</div>
<div className="min-w-0 flex-1 space-y-2">
<h2 className="truncate text-lg font-semibold" title={group.title}>
{group.title}
</h2>
<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>
@ -114,38 +136,45 @@ function GroupDetail(props: {
{t(locale, "library.episodes")}
</div>
<ul className="divide-y divide-border rounded-lg border border-border">
{group.episodes.map((ep) => (
<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">
{ep.epNumber != null ? `EP${ep.epNumber}` : "—"}
</span>
<span
className="min-w-0 flex-1 truncate"
title={ep.rawName}
{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"
>
{ep.rawName}
</span>
<Button
size="sm"
variant="secondary"
onClick={() => props.onPlay(ep.id, props.bangumiId)}
>
{t(locale, "library.play")}
</Button>
{props.onMatch && (
<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="outline"
onClick={() => props.onMatch?.(ep.id)}
variant="secondary"
onClick={() => props.onPlay(ep.id, props.bangumiId)}
>
{t(locale, "library.match")}
{t(locale, "library.play")}
</Button>
)}
</li>
))}
{props.onMatch && (
<Button
size="sm"
variant="outline"
onClick={() =>
props.onMatch?.(ep.id, ep.rawName)
}
>
{t(locale, "library.match")}
</Button>
)}
</li>
);
})}
</ul>
</div>
</div>
@ -157,7 +186,26 @@ function GroupDetail(props: {
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;
@ -166,6 +214,20 @@ function ContinueRow(props: { locale: Locale }) {
<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) => {
@ -174,28 +236,56 @@ function ContinueRow(props: { locale: Locale }) {
? Math.min(100, Math.round((item.positionMs / item.durationMs) * 100))
: 0;
return (
<button
<div
key={item.mediaItemId}
type="button"
className="w-40 shrink-0 overflow-hidden rounded-lg border border-border bg-card text-left transition-colors hover:border-primary/60"
onClick={() => go(`/${locale}/watch?id=${item.mediaItemId}`)}
className="relative w-40 shrink-0 overflow-hidden rounded-lg border border-border bg-card transition-colors hover:border-primary/60"
>
<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">
<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="h-full bg-primary"
style={{ width: `${pct}%` }}
/>
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>
<div className="mt-1 font-mono text-[11px] text-muted-foreground">
{t(locale, "library.continue")} · {pct}%
</div>
</div>
</button>
</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>
@ -257,6 +347,245 @@ function usePagedAccumulator<T>(params: {
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;
@ -265,7 +594,8 @@ function WallView(props: {
selectedBangumiId: number | null;
onSelect: (bangumiId: number | null) => void;
onPlay: (mediaItemId: string, bangumiId: number) => void;
onMatch?: ((mediaItemId: string) => void) | undefined;
onMatch?: ((mediaItemId: string, rawName?: string) => void) | undefined;
onManage?: ((group: ManageTarget) => void) | undefined;
onClearSearch?: (() => void) | undefined;
hideEmpty?: boolean;
mountsEmpty?: boolean;
@ -307,6 +637,7 @@ function WallView(props: {
onBack={() => props.onSelect(null)}
onPlay={props.onPlay}
onMatch={props.onMatch}
onManage={props.onManage}
/>
);
}
@ -318,22 +649,40 @@ function WallView(props: {
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
{groups.map((group) => (
<button
key={group.bangumiId}
type="button"
className="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 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>
<div className="mt-0.5 font-mono text-xs text-muted-foreground">
{t(locale, "library.episodeCount", { count: group.episodeCount })}
</div>
</div>
</button>
</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">
@ -388,7 +737,7 @@ function ListView(props: {
filter: "" | "unmatched" | "ok";
refreshKey: number;
onFilterChange: (f: "" | "unmatched" | "ok") => void;
onMatch: (mediaItemId: string) => void;
onMatch: (mediaItemId: string, rawName?: string) => void;
onPlay: (mediaItemId: string, bangumiId?: number | null) => void;
onRescrape?: ((mediaItemId: string) => void) | undefined;
onClearSearch?: (() => void) | undefined;
@ -420,6 +769,7 @@ function ListView(props: {
type MediaListItem = {
id: string;
title: string;
rawName: string;
posterUrl: string | null;
bangumiId: number | null;
scrapeStatus: string;
@ -433,6 +783,7 @@ function ListView(props: {
items: list.data.rows.map((r) => ({
id: r.id,
title: r.title,
rawName: r.rawName,
posterUrl: r.posterUrl,
bangumiId: r.bangumiId,
scrapeStatus: r.scrapeStatus,
@ -444,6 +795,16 @@ function ListView(props: {
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">
@ -461,89 +822,112 @@ function ListView(props: {
</select>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
{rows.map((item) => (
<div
key={item.id}
className="overflow-hidden rounded-lg border border-border bg-card"
>
<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 flex gap-1">
<Button
size="sm"
variant="secondary"
onClick={() => props.onPlay(item.id, item.bangumiId)}
{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"
>
{t(locale, "library.play")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => props.onMatch(item.id)}
>
{t(locale, "library.match")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => props.onRescrape?.(item.id)}
>
{t(locale, "library.reScrape")}
</Button>
</div>
</div>
</div>
))}
{!props.hideEmpty &&
(props.mountsEmpty || (rows.length === 0 && !list.isPending)) && (
<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 })
: !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")}
<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={() => go(`/${locale}/mounts`)}
onClick={() => props.onMatch(item.id, item.rawName)}
>
{t(locale, "library.ctaMounts")}
{t(locale, "library.match")}
</Button>
</>
)}
</div>
</div>
)}
</div>
<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
@ -580,6 +964,7 @@ function LibraryInner(props: { locale: Locale }) {
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") ?? "");
@ -595,6 +980,14 @@ function LibraryInner(props: { locale: Locale }) {
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);
@ -626,6 +1019,13 @@ function LibraryInner(props: { locale: Locale }) {
});
};
/** 打开匹配弹窗:集数用共享正则从文件名预填,用户可改 */
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();
@ -751,7 +1151,8 @@ function LibraryInner(props: { locale: Locale }) {
selectedBangumiId={selectedBangumiId}
onSelect={setSelectedBangumiId}
onPlay={play}
onMatch={setMatchId}
onMatch={openMatch}
onManage={setManageGroup}
onClearSearch={() => setQ("")}
hideEmpty={autoScanning}
mountsEmpty={mountsEmpty}
@ -764,7 +1165,7 @@ function LibraryInner(props: { locale: Locale }) {
filter={filter}
refreshKey={refreshKey}
onFilterChange={setFilter}
onMatch={setMatchId}
onMatch={openMatch}
onPlay={play}
onRescrape={(id) => rescrape.mutate({ mediaItemId: id })}
onClearSearch={() => setQ("")}
@ -773,6 +1174,19 @@ function LibraryInner(props: { locale: Locale }) {
/>
)}
{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">

View File

@ -1,5 +1,6 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { parseEpisodeFromFilename } from "@app/types";
import { Button, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Artplayer from "artplayer";
@ -33,6 +34,11 @@ type ProgressInput = {
durationMs: number;
};
/** 集数:落库值优先,null 时用共享正则从文件名解析(与后端同一套) */
function epNumberOf(ep: { epNumber: number | null; rawName: string }): number | null {
return ep.epNumber ?? parseEpisodeFromFilename(ep.rawName);
}
function readDanmakuPrefs(): DanmakuPrefs {
const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8, density: 1 };
try {
@ -144,7 +150,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
);
const episodes = useMemo(() => {
const list = series.data?.episodes ?? [];
return [...list].sort((a, b) => (a.epNumber ?? 0) - (b.epNumber ?? 0));
return [...list].sort((a, b) => (epNumberOf(a) ?? 0) - (epNumberOf(b) ?? 0));
}, [series.data]);
const epIndex = episodes.findIndex((ep) => ep.id === mediaItemId);
const prevEp = epIndex > 0 ? episodes[epIndex - 1] : null;
@ -518,6 +524,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
<ul className="flex flex-wrap gap-2">
{episodes.map((ep) => {
const current = ep.id === mediaItemId;
const epNo = epNumberOf(ep);
return (
<li key={ep.id}>
<button
@ -530,8 +537,8 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
)}
onClick={() => !current && openEpisode(ep.id)}
>
{ep.epNumber != null
? t(locale, "watch.episodeN", { n: ep.epNumber })
{epNo != null
? t(locale, "watch.episodeN", { n: epNo })
: ep.rawName}
{current && (
<span className="ml-2 text-[11px]">
@ -550,7 +557,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
<div className="flex items-center gap-3 rounded-lg border border-primary/40 bg-primary/5 p-4">
<p className="text-sm">
{t(locale, "watch.episodeN", {
n: nextEp.epNumber ?? episodes.indexOf(nextEp) + 1,
n: epNumberOf(nextEp) ?? episodes.indexOf(nextEp) + 1,
})}
</p>
<Button size="sm" className="ml-auto" onClick={() => openEpisode(nextEp.id)}>

View File

@ -84,6 +84,10 @@ const nav = [
<Fragment set:html={headHtml} />
<Fragment set:html={jsonLdHtml} />
<ClientRouter />
{/* ClientRouter onPopState 在 history.state 为 null 时会吞掉前进/后退,见 history-guard */}
<script>
import "@/lib/history-guard";
</script>
<script is:inline define:vars={{ cookieName: COOKIE_LANG, maxAge: COOKIE_LANG_MAX_AGE, nextLocale: locale === "zh-CN" ? "en" : "zh-CN" }}>
(function () {
function apply(mode) {

View File

@ -0,0 +1,77 @@
import { navigate } from "astro:transitions/client";
/**
* Astro ClientRouter 的 onPopState 在 `ev.state === null` 时直接 return,
* 结果是地址栏已变、页面不更新。本模块做两层兜底:
* 1. 禁止 history API 把 state 写成 null / 缺 index 的形状;
* 2. popstate 仍遇到 null state 时,按当前 URL 补一次软导航(失败则整页加载)。
*/
type HistoryStateShape = {
index?: number | undefined;
scrollX?: number | undefined;
scrollY?: number | undefined;
};
function currentState(): HistoryStateShape | null {
const s = window.history.state as HistoryStateShape | null | undefined;
return s && typeof s === "object" ? s : null;
}
function withRouterState(state: unknown): unknown {
const prev = currentState();
const index = prev?.index ?? 0;
const scrollX = prev?.scrollX ?? window.scrollX;
const scrollY = prev?.scrollY ?? window.scrollY;
if (state == null) return { index, scrollX, scrollY };
if (typeof state !== "object") return state;
const obj = state as Record<string, unknown>;
return {
...obj,
index: typeof obj["index"] === "number" ? obj["index"] : index,
scrollX: typeof obj["scrollX"] === "number" ? obj["scrollX"] : scrollX,
scrollY: typeof obj["scrollY"] === "number" ? obj["scrollY"] : scrollY,
};
}
function patchHistory(): void {
const historyProto = window.history;
const rawReplace = historyProto.replaceState.bind(historyProto);
const rawPush = historyProto.pushState.bind(historyProto);
historyProto.replaceState = (data: unknown, unused: string, url?: string | URL | null) => {
rawReplace(withRouterState(data), unused, url ?? undefined);
};
historyProto.pushState = (data: unknown, unused: string, url?: string | URL | null) => {
rawPush(withRouterState(data), unused, url ?? undefined);
};
}
function onPopState(ev: PopStateEvent): void {
if (ev.state != null) return;
// ClientRouter 已在此刻 return;补 state 后按当前 URL 软导航,恢复换页
const href = window.location.href;
rawEnsureState();
void navigate(href).catch(() => {
window.location.reload();
});
}
function rawEnsureState(): void {
const prev = currentState();
if (prev?.index != null && prev.scrollX != null && prev.scrollY != null) return;
window.history.replaceState(withRouterState(prev), "", window.location.href);
}
let installed = false;
/** 幂等安装 history 守卫(模块副作用入口)。 */
export function installHistoryGuard(): void {
if (installed || typeof window === "undefined") return;
installed = true;
patchHistory();
window.addEventListener("popstate", onPopState);
rawEnsureState();
}
installHistoryGuard();

View File

@ -1,4 +1,5 @@
import { navigate } from "astro:transitions/client";
import "@/lib/history-guard";
/** 站内软导航(ClientRouter),避免整页 GET。 */
export function go(href: string): void {
@ -11,7 +12,11 @@ export function queryParam(name: string): string | null {
return new URLSearchParams(window.location.search).get(name);
}
/** 更新查询参数(不刷新,便于刷新/分享后停留在同一选集)。 */
/**
* 更新查询参数(不刷新,便于刷新/分享后停留在同一选集)。
* history-guard 会保证 replaceState 不把 ClientRouter 的 state 抹成 null,
* 否则 onPopState 直接 return,浏览器前进/后退只变地址栏、不换页。
*/
export function setQueryParams(params: Record<string, string | null>): void {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
@ -19,5 +24,6 @@ export function setQueryParams(params: Record<string, string | null>): void {
if (value == null) url.searchParams.delete(key);
else url.searchParams.set(key, value);
}
window.history.replaceState(null, "", url.toString());
if (url.toString() === window.location.href) return;
window.history.replaceState(window.history.state, "", url.toString());
}

80
e2e/nav.spec.ts Normal file
View File

@ -0,0 +1,80 @@
import { expect, test } from "@playwright/test";
const homeH1 = (page: import("@playwright/test").Page) => page.getByRole("heading", { level: 1 });
const loginHeading = (page: import("@playwright/test").Page) =>
page.getByRole("heading", { name: "登录" });
test.describe("浏览器前进 / 后退触发页面更新", () => {
test("软导航后 goBack / goForward 换页", async ({ page }) => {
await page.goto("/zh-CN");
await expect(homeH1(page)).toContainText("私有影音库");
await page.getByRole("link", { name: "登录 / 注册" }).first().click();
await expect(page).toHaveURL(/\/zh-CN\/auth\/login/);
await expect(loginHeading(page)).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(/\/zh-CN$/);
await expect(homeH1(page)).toContainText("私有影音库");
await page.goForward();
await expect(page).toHaveURL(/\/zh-CN\/auth\/login/);
await expect(loginHeading(page)).toBeVisible();
});
test("history.state 被抹成 null 后仍能后退换页", async ({ page }) => {
await page.goto("/zh-CN");
await expect(homeH1(page)).toContainText("私有影音库");
// 复现旧 bug:replaceState(null) 让 ClientRouter onPopState 直接 return
await page.evaluate(() => {
window.history.replaceState(null, "", window.location.href);
});
await page.locator('a[href="/zh-CN/auth/login"]').first().click();
await expect(page).toHaveURL(/\/zh-CN\/auth\/login/);
await page.goBack();
await expect(page).toHaveURL(/\/zh-CN$/);
await expect(homeH1(page)).toContainText("私有影音库");
});
test("setQueryParams 式 replaceState 后跨页后退仍换页", async ({ page }) => {
await page.goto("/zh-CN/auth/login");
await expect(loginHeading(page)).toBeVisible();
await page.evaluate(() => {
const url = new URL(window.location.href);
url.searchParams.set("next", "/zh-CN/library");
window.history.replaceState(window.history.state, "", url.toString());
});
await expect(page).toHaveURL(/next=/);
await page.locator('a[href="/zh-CN"]').first().click();
await expect(page).toHaveURL(/\/zh-CN$/);
await expect(homeH1(page)).toContainText("私有影音库");
await page.goBack();
await expect(page).toHaveURL(/\/zh-CN\/auth\/login/);
await expect(loginHeading(page)).toBeVisible();
});
test("绕过守卫写入 null state 后,popstate 兜底仍换页", async ({ page }) => {
await page.goto("/zh-CN");
await expect(homeH1(page)).toContainText("私有影音库");
// 用原生 replaceState 直接抹掉 state,验证 popstate 兜底(非补丁路径)
await page.evaluate(() => {
const proto = History.prototype as History;
proto.replaceState.call(window.history, null, "", window.location.href);
});
await page.locator('a[href="/zh-CN/auth/login"]').first().click();
await expect(page).toHaveURL(/\/zh-CN\/auth\/login/);
await page.goBack();
await expect(page).toHaveURL(/\/zh-CN$/);
await expect(homeH1(page)).toContainText("私有影音库");
});
});

View File

@ -196,6 +196,22 @@ export const mediaItemDao = {
.where(and(...conds))
.orderBy(asc(mediaItems.epNumber), asc(mediaItems.rawName));
},
/** 同作品组内任一非空封面(单集无封面时回退继承父级封面) */
async findGroupPoster(userId: string, bangumiId: number): Promise<string | null> {
const [row] = await db
.select({ posterUrl: mediaItems.posterUrl })
.from(mediaItems)
.where(
and(
eq(mediaItems.userId, userId),
eq(mediaItems.bangumiId, bangumiId),
isNotNull(mediaItems.posterUrl),
),
)
.orderBy(asc(mediaItems.epNumber), asc(mediaItems.id))
.limit(1);
return row?.posterUrl ?? null;
},
async getByUserPath(
userId: string,
mountId: string,
@ -301,6 +317,40 @@ export const mediaItemDao = {
.returning({ id: mediaItems.id });
return rows.map((r) => r.id);
},
/** 删除挂载下某作品组的全部条目(海报墙组删除),返回被删条目 id 供清理进度 */
async deleteByBangumi(userId: string, mountId: string, bangumiId: number): Promise<string[]> {
const rows = await db
.delete(mediaItems)
.where(
and(
eq(mediaItems.userId, userId),
eq(mediaItems.mountId, mountId),
eq(mediaItems.bangumiId, bangumiId),
),
)
.returning({ id: mediaItems.id });
return rows.map((r) => r.id);
},
/** 批量更新组内显示元数据(标题/封面),返回更新行数 */
async updateGroupMeta(
userId: string,
mountId: string,
bangumiId: number,
data: { title?: string | undefined; posterUrl?: string | null | undefined },
): Promise<number> {
const rows = await db
.update(mediaItems)
.set({ ...data, updatedAt: new Date() })
.where(
and(
eq(mediaItems.userId, userId),
eq(mediaItems.mountId, mountId),
eq(mediaItems.bangumiId, bangumiId),
),
)
.returning({ id: mediaItems.id });
return rows.length;
},
/** 清理挂载已不存在的孤儿条目(历史脏数据兜底),返回删除数量 */
async cleanupOrphans(): Promise<number> {
const rows = await db

View File

@ -67,6 +67,27 @@ export const playbackProgressDao = {
.delete(playbackProgress)
.where(inArray(playbackProgress.mediaItemId, mediaItemIds));
},
/** 移除单条观看记录(用户维度),返回删除数量 */
async remove(userId: string, mediaItemId: string): Promise<number> {
const rows = await db
.delete(playbackProgress)
.where(
and(
eq(playbackProgress.userId, userId),
eq(playbackProgress.mediaItemId, mediaItemId),
),
)
.returning({ id: playbackProgress.id });
return rows.length;
},
/** 清除用户全部观看记录,返回删除数量 */
async clearByUser(userId: string): Promise<number> {
const rows = await db
.delete(playbackProgress)
.where(eq(playbackProgress.userId, userId))
.returning({ id: playbackProgress.id });
return rows.length;
},
/** 清理媒体条目已不存在的孤儿进度(历史脏数据兜底) */
async cleanupOrphans(): Promise<void> {
await db

View File

@ -79,11 +79,39 @@ export const en: Record<MessageKey, string> = {
"Search and pick a title; all videos under this folder will bind to it, episodes parsed from filenames.",
"library.backToList": "Back to library",
"library.continueTitle": "Continue watching",
"library.removeContinue": "Remove",
"library.confirmRemoveContinue": "Confirm remove",
"library.clearContinue": "Clear all",
"library.confirmClearContinue": "Confirm clear",
"library.removedToast": "Record removed",
"library.clearedToast": "All records cleared",
"library.removeError": "Failed to remove record, please retry",
"library.clearError": "Failed to clear records, please retry",
"library.manageGroup": "Manage",
"library.groupManageTitle": "Manage title",
"library.editInfo": "Edit info",
"library.editTitleLabel": "Title",
"library.posterLabel": "Poster URL",
"library.posterPlaceholder": "Paste poster image URL; empty clears it",
"library.save": "Save",
"library.saved": "Saved",
"library.unbind": "Unmatch",
"library.confirmUnbind": "Confirm unmatch",
"library.unbound": "Unmatched; entries kept in library",
"library.deleteGroup": "Delete records",
"library.confirmDeleteGroup": "Confirm delete",
"library.groupDeleted": "Title records deleted",
"library.rematch": "Rematch",
"library.groupOpError": "Action failed, please retry",
"library.groupOpHint":
"Unmatch removes the title from the poster wall but keeps entries; delete removes all entries and watch progress (files on the drive stay).",
"library.matched": "Matched",
"library.autoScanning": "Auto-scanning mounted directories into the library…",
"library.noResults": "No results matching “{q}”",
"library.filterEmpty": "Nothing under the current filter — try another filter",
"library.clearSearch": "Clear search",
"library.statusPending": "Pending scrape",
"library.statusFailed": "Scrape failed",
"browse.title": "File browser",
"browse.play": "Play",

View File

@ -76,11 +76,39 @@ export const zhCN = {
"搜索并选定作品后,该文件夹下所有视频将绑定为同一作品,集数按文件名自动解析。",
"library.backToList": "返回媒体库",
"library.continueTitle": "继续观看",
"library.removeContinue": "移除",
"library.confirmRemoveContinue": "确认移除",
"library.clearContinue": "清除全部",
"library.confirmClearContinue": "确认清除",
"library.removedToast": "已移除观看记录",
"library.clearedToast": "已清除全部观看记录",
"library.removeError": "移除记录失败,请重试",
"library.clearError": "清除记录失败,请重试",
"library.manageGroup": "管理",
"library.groupManageTitle": "作品管理",
"library.editInfo": "编辑信息",
"library.editTitleLabel": "标题",
"library.posterLabel": "封面链接",
"library.posterPlaceholder": "粘贴封面图片 URL,留空即清除",
"library.save": "保存",
"library.saved": "已保存",
"library.unbind": "取消匹配",
"library.confirmUnbind": "确认取消匹配",
"library.unbound": "已取消匹配,条目保留在库中",
"library.deleteGroup": "删除记录",
"library.confirmDeleteGroup": "确认删除",
"library.groupDeleted": "已删除作品记录",
"library.rematch": "重新匹配",
"library.groupOpError": "操作失败,请重试",
"library.groupOpHint":
"取消匹配会把该作品移出海报墙但保留条目;删除会移除组内全部条目与观看进度(不删网盘文件)。",
"library.matched": "已匹配成功",
"library.autoScanning": "正在自动扫描已挂载目录并入库…",
"library.noResults": "没有匹配「{q}」的结果",
"library.filterEmpty": "当前筛选下没有条目,试试切换筛选条件",
"library.clearSearch": "清除搜索",
"library.statusPending": "待刮削",
"library.statusFailed": "刮削失败",
"browse.title": "文件浏览",
"browse.play": "播放",

View File

@ -55,6 +55,19 @@ export const mediaRouter = t.router({
.query(({ ctx, input }) =>
libraryService.series(ctx.userId, input.bangumiId, input.mountId),
),
libraryDeleteGroup: protectedProcedure
.input(librarySchemas.deleteGroup)
.mutation(({ ctx, input }) =>
libraryService.deleteGroup(ctx.userId, input.mountId, input.bangumiId),
),
libraryUpdateGroup: protectedProcedure
.input(librarySchemas.updateGroup)
.mutation(({ ctx, input }) =>
libraryService.updateGroup(ctx.userId, input.mountId, input.bangumiId, {
title: input.title,
posterUrl: input.posterUrl,
}),
),
scrapeSearch: protectedProcedure
.input(scrapeSchemas.search)
@ -84,6 +97,16 @@ export const mediaRouter = t.router({
.mutation(({ ctx, input }) =>
scrapeService.rescrapePath(ctx.userId, input.mountId, input.path),
),
scrapeUnbindGroup: protectedProcedure
.input(scrapeSchemas.unbindGroup)
.mutation(({ ctx, input }) =>
scrapeService.unbindGroup(ctx.userId, input.mountId, input.bangumiId),
),
scrapeBindGroup: protectedProcedure
.input(scrapeSchemas.bindGroup)
.mutation(({ ctx, input }) =>
scrapeService.bindGroup(ctx.userId, input.mountId, input.bangumiId, input.newBangumiId),
),
playbackReport: protectedProcedure
.input(playbackSchemas.report)
@ -94,6 +117,12 @@ export const mediaRouter = t.router({
playbackContinue: protectedProcedure
.input(playbackSchemas.continueList)
.query(({ ctx, input }) => playbackService.continueList(ctx.userId, input.limit)),
playbackDelete: protectedProcedure
.input(playbackSchemas.remove)
.mutation(({ ctx, input }) => playbackService.remove(ctx.userId, input.mediaItemId)),
playbackClear: protectedProcedure
.input(playbackSchemas.clear)
.mutation(({ ctx }) => playbackService.clearAll(ctx.userId)),
danmakuFetch: protectedProcedure
.input(danmakuSchemas.fetch)

View File

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { dedupeSameFileRows } from "./library.service.js";
function ep(path: string, epNumber: number | null, rawName: string) {
return { path, epNumber, rawName };
}
describe("dedupeSameFileRows", () => {
it("网盘别名双路径(一条是另一条的路径后缀子树)只保留短路径", () => {
const rows = [
ep("/外接存储-TOSHIBA MQ01ABF050/QB/剧A/Season 1/E01.mp4", 1, "E01.mp4"),
ep("/剧A/Season 1/E01.mp4", 1, "E01.mp4"),
];
const out = dedupeSameFileRows(rows);
expect(out).toHaveLength(1);
expect(out[0]?.path).toBe("/剧A/Season 1/E01.mp4");
});
it("不同子树的同名文件不去重", () => {
const rows = [ep("/剧A/01.mp4", 1, "01.mp4"), ep("/剧B/01.mp4", 1, "01.mp4")];
expect(dedupeSameFileRows(rows)).toHaveLength(2);
});
it("同 path 重复行只留一条", () => {
const rows = [ep("/剧A/01.mp4", 1, "01.mp4"), ep("/剧A/01.mp4", 1, "01.mp4")];
expect(dedupeSameFileRows(rows)).toHaveLength(1);
});
it("按集数升序返回,未标集数排最后", () => {
const rows = [
ep("/剧A/E03.mp4", 3, "E03.mp4"),
ep("/剧A/E01.mp4", 1, "E01.mp4"),
ep("/剧A/SP.mp4", null, "SP.mp4"),
];
const out = dedupeSameFileRows(rows);
expect(out.map((r) => r.epNumber)).toEqual([1, 3, null]);
});
it("多集双路径:每集只留一条", () => {
const rows = [
ep("/QB/剧A/E01.mp4", 1, "E01.mp4"),
ep("/剧A/E01.mp4", 1, "E01.mp4"),
ep("/QB/剧A/E02.mp4", 2, "E02.mp4"),
ep("/剧A/E02.mp4", 2, "E02.mp4"),
];
const out = dedupeSameFileRows(rows);
expect(out).toHaveLength(2);
expect(out.map((r) => r.path)).toEqual(["/剧A/E01.mp4", "/剧A/E02.mp4"]);
});
});

View File

@ -1,6 +1,6 @@
import { TRPCError } from "@trpc/server";
import { mediaItemDao, mountDao, playbackProgressDao, type MediaItemRow } from "@app/dao";
import type { LibraryGroupPage } from "@app/types";
import { parseEpisodeFromFilename, type LibraryGroupPage } from "@app/types";
import { decryptSecret } from "./secret.js";
import { createWebdav, isVideoFilename, joinWebdavPath, listDirectory } from "./webdav-client.js";
import { scrapeService } from "./scrape.service.js";
@ -58,6 +58,33 @@ async function walkVideos(
}
}
/**
* 同一物理文件被多个路径入口扫描入库时(网盘别名/快捷方式:一条 path 是另一条的
* 路径后缀子树,或跨挂载同 path),剧集列表会一行一路径地重复展示。
* 按路径包含关系去重:保留更靠近根的短路径,另一入口不再重复出现。
*/
export function dedupeSameFileRows<
T extends { path: string; epNumber: number | null; rawName: string },
>(rows: readonly T[]): T[] {
const byPathLen = [...rows].sort((a, b) => a.path.length - b.path.length);
const kept: T[] = [];
for (const row of byPathLen) {
const isDup = kept.some(
(k) =>
k.path === row.path ||
(row.path.length > k.path.length
? row.path.endsWith(k.path)
: k.path.endsWith(row.path)),
);
if (!isDup) kept.push(row);
}
return kept.sort(
(a, b) =>
(a.epNumber ?? Number.MAX_SAFE_INTEGER) - (b.epNumber ?? Number.MAX_SAFE_INTEGER) ||
a.rawName.localeCompare(b.rawName),
);
}
export const libraryService = {
/** 惰性兜底:挂载删除后残留的孤儿条目/进度(级联为主,这里清历史脏数据) */
async cleanupOrphans(): Promise<void> {
@ -76,7 +103,16 @@ export const libraryService = {
},
) {
await this.cleanupOrphans();
return mediaItemDao.list(userId, params);
const page = await mediaItemDao.list(userId, params);
// 集数正则兜底:历史 null 行读时补齐,展示/排序不再出现无集数
return {
...page,
rows: page.rows.map((row) =>
row.epNumber == null
? { ...row, epNumber: parseEpisodeFromFilename(row.rawName) }
: row,
),
};
},
/** 海报墙:指定挂载下已刮削条目按 bangumiId 分组的分页摘要(剧集详情按需走 series) */
@ -115,21 +151,57 @@ export const libraryService = {
/** 同作品剧集(按集数升序);mountId 可选:库内详情只看该挂载,播放页不传跨挂载 */
async series(userId: string, bangumiId: number, mountId?: string | undefined) {
await this.cleanupOrphans();
const rows = await mediaItemDao.listByBangumiId(userId, bangumiId, mountId);
const rows = dedupeSameFileRows(
await mediaItemDao.listByBangumiId(userId, bangumiId, mountId),
);
const first = rows[0];
// 单集无封面时回退继承组内任一非空封面
const posterUrl =
first?.posterUrl ?? rows.find((row) => row.posterUrl != null)?.posterUrl ?? null;
return {
bangumiId,
title: first?.title ?? "",
posterUrl: first?.posterUrl ?? null,
posterUrl,
episodes: rows.map((row) => ({
id: row.id,
title: row.title,
epNumber: row.epNumber,
// 落库值优先,null 时读时用同一套正则从文件名解析
epNumber: row.epNumber ?? parseEpisodeFromFilename(row.rawName),
rawName: row.rawName,
})),
};
},
/** 海报墙组级删除:物理删除该挂载下组内全部条目,级联清观看进度(不碰挂载内实际文件) */
async deleteGroup(
userId: string,
mountId: string,
bangumiId: number,
): Promise<{ removed: number }> {
const mount = await mountDao.getByIdForUser(mountId, userId);
if (!mount) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" });
const removedIds = await mediaItemDao.deleteByBangumi(userId, mountId, bangumiId);
if (removedIds.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "作品组不存在" });
}
await playbackProgressDao.deleteByMediaItems(removedIds);
return { removed: removedIds.length };
},
/** 海报墙组级编辑:批量更新组内显示标题 / 封面 */
async updateGroup(
userId: string,
mountId: string,
bangumiId: number,
input: { title?: string | undefined; posterUrl?: string | null | undefined },
): Promise<{ updated: number }> {
const mount = await mountDao.getByIdForUser(mountId, userId);
if (!mount) throw new TRPCError({ code: "NOT_FOUND", message: "挂载不存在" });
const updated = await mediaItemDao.updateGroupMeta(userId, mountId, bangumiId, input);
if (updated === 0) throw new TRPCError({ code: "NOT_FOUND", message: "作品组不存在" });
return { updated };
},
async scan(
userId: string,
mountId: string,

View File

@ -32,14 +32,26 @@ export const playbackService = {
/** 未看完的最近条目,供首页/媒体库「继续观看」入口 */
async continueList(userId: string, limit: number): Promise<ContinueItem[]> {
const rows = await playbackProgressDao.listUnfinished(userId, limit);
// 单集无封面时回退继承父级(同 bangumiId 组)封面;同组只查一次
const groupPoster = new Map<number, string | null>();
const out: ContinueItem[] = [];
for (const row of rows) {
const item = await mediaItemDao.getByIdForUser(row.mediaItemId, userId);
if (!item) continue;
let posterUrl = item.posterUrl;
if (!posterUrl && item.bangumiId != null) {
if (!groupPoster.has(item.bangumiId)) {
groupPoster.set(
item.bangumiId,
await mediaItemDao.findGroupPoster(userId, item.bangumiId),
);
}
posterUrl = groupPoster.get(item.bangumiId) ?? null;
}
out.push({
mediaItemId: item.id,
title: item.title,
posterUrl: item.posterUrl,
posterUrl,
positionMs: row.positionMs,
durationMs: row.durationMs,
updatedAt: row.updatedAt.toISOString(),
@ -47,4 +59,14 @@ export const playbackService = {
}
return out;
},
/** 移除单条观看记录 */
async remove(userId: string, mediaItemId: string) {
const removed = await playbackProgressDao.remove(userId, mediaItemId);
return { mediaItemId, removed };
},
/** 清除当前用户全部观看记录 */
async clearAll(userId: string) {
const cleared = await playbackProgressDao.clearByUser(userId);
return { cleared };
},
};

View File

@ -1,6 +1,6 @@
import { TRPCError } from "@trpc/server";
import { mediaItemDao } from "@app/dao";
import type { BatchResult, DanmakuMatchOutput } from "@app/types";
import { parseEpisodeFromFilename, type BatchResult, type DanmakuMatchOutput } from "@app/types";
import {
hasOpenCredentials,
matchFileName,
@ -9,25 +9,8 @@ import {
remoteFileHash,
} from "./danmaku.service.js";
/** 从文件名提取集数:SxxExx / - NN / EPxx / 第N集·话 / [NN] / Exx */
export function parseEpisodeFromFilename(name: string): number | null {
const s = name.replace(/\.[a-z0-9]+$/i, "");
const patterns: RegExp[] = [
/S\d{1,2}\s*E(\d{1,3})/i,
/(?:^|[\s\-_[·])EP?(\d{1,4})(?:[\s\-_\]·]|$)/i,
/第\s*(\d{1,4})\s*[集话話]/,
/\[(\d{1,3})\]/,
/\s-\s(\d{2,3})(?:\s|$|v\d)/i,
];
for (const re of patterns) {
const m = s.match(re);
if (m?.[1]) {
const n = Number.parseInt(m[1], 10);
if (Number.isFinite(n)) return n;
}
}
return null;
}
/** 集数解析统一走 @app/types(前后端共用同一套正则);此处再导出保持旧入口可用 */
export { parseEpisodeFromFilename };
/** 从文件名剥出作品检索词:去扩展名/字幕组/集数/分辨率等噪声 */
export function buildSearchQuery(filename: string): string {
@ -337,10 +320,22 @@ export const scrapeService = {
/** 扫描后自动刮削:择优候选,低置信留 unmatched */
async autoScrape(userId: string, mediaItemId: string): Promise<void> {
const row = await mediaItemDao.getByIdForUser(mediaItemId, userId);
if (!row || row.scrapeStatus === "ok") return;
if (!row) return;
// 集数始终用正则兜底补齐(含历史 null 行),无论刮削结果如何都尝试落库
const epNumber = row.epNumber ?? parseEpisodeFromFilename(row.rawName);
if (row.scrapeStatus === "ok") {
if (epNumber !== row.epNumber) {
await mediaItemDao.updateScrape(mediaItemId, userId, {
epNumber,
scrapeStatus: row.scrapeStatus,
});
}
return;
}
const q = buildSearchQuery(row.rawName);
if (!q) {
await mediaItemDao.updateScrape(mediaItemId, userId, {
epNumber,
scrapeStatus: "unmatched",
scrapedAt: new Date(),
});
@ -352,6 +347,7 @@ export const scrapeService = {
hits = await bangumiSearch(q);
} catch {
await mediaItemDao.updateScrape(mediaItemId, userId, {
epNumber,
scrapeStatus: "failed",
scrapedAt: new Date(),
});
@ -369,6 +365,7 @@ export const scrapeService = {
}
if (!best || bestScore < AUTO_BIND_THRESHOLD) {
await mediaItemDao.updateScrape(mediaItemId, userId, {
epNumber,
scrapeStatus: "unmatched",
scrapedAt: new Date(),
});
@ -380,7 +377,7 @@ export const scrapeService = {
scrapedAt: new Date(),
posterUrl: best.image || null,
title: best.nameCn || best.name,
epNumber: parseEpisodeFromFilename(row.rawName),
epNumber,
});
},
@ -405,4 +402,48 @@ export const scrapeService = {
message: `批量刮削完成:成功 ${bound},跳过已绑定 ${skipped}`,
};
},
/** 组级取消匹配:清作品绑定与弹弹play 身份、改回 unmatched(保留入库条目、标题与封面) */
async unbindGroup(
userId: string,
mountId: string,
bangumiId: number,
): Promise<{ updated: number }> {
const rows = await mediaItemDao.listByBangumiId(userId, bangumiId, mountId);
if (rows.length === 0) throw new TRPCError({ code: "NOT_FOUND", message: "作品组不存在" });
for (const row of rows) {
await mediaItemDao.updateScrape(row.id, userId, {
bangumiId: null,
epNumber: null,
dandanplayEpisodeId: null,
matchedHash: null,
scrapeStatus: "unmatched",
scrapedAt: null,
});
}
return { updated: rows.length };
},
/** 组级重新匹配:整组改绑另一作品,标题封面按新作品元数据刷新,集数沿用文件解析值 */
async bindGroup(
userId: string,
mountId: string,
bangumiId: number,
newBangumiId: number,
): Promise<{ updated: number; title: string | null; posterUrl: string | null }> {
const rows = await mediaItemDao.listByBangumiId(userId, bangumiId, mountId);
if (rows.length === 0) throw new TRPCError({ code: "NOT_FOUND", message: "作品组不存在" });
const meta = await fetchSubjectMeta(newBangumiId);
for (const row of rows) {
await mediaItemDao.updateScrape(row.id, userId, {
bangumiId: newBangumiId,
scrapeStatus: "ok",
scrapedAt: new Date(),
posterUrl: meta.posterUrl ?? row.posterUrl,
title: meta.title ?? row.title,
epNumber: row.epNumber,
});
}
return { updated: rows.length, title: meta.title, posterUrl: meta.posterUrl };
},
};

View File

@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { parseEpisodeFromFilename } from "./episode.js";
describe("parseEpisodeFromFilename", () => {
it("S01E03", () => {
expect(parseEpisodeFromFilename("TRIGUN STARGAZE S01E03.mp4")).toBe(3);
expect(parseEpisodeFromFilename("Show.S01E02.1080p.mkv")).toBe(2);
});
it("字幕组前缀 + ' - 01'", () => {
expect(
parseEpisodeFromFilename("[Ani] TRIGUN STARGAZE - 01 [1080P][Baha][WEB-DL].mp4"),
).toBe(1);
expect(parseEpisodeFromFilename("[Group] Title - 05 [1080p].mkv")).toBe(5);
});
it("方括号集数", () => {
expect(
parseEpisodeFromFilename("[orion origin] Trigun Stargaze [03] [1080p][H265].mp4"),
).toBe(3);
expect(parseEpisodeFromFilename("[Sub] Title [01][1080p].mkv")).toBe(1);
});
it("EPxx / Exx", () => {
expect(parseEpisodeFromFilename("Title EP03.mkv")).toBe(3);
expect(parseEpisodeFromFilename("Title E03.mkv")).toBe(3);
});
it("第N集·话", () => {
expect(parseEpisodeFromFilename("进击的巨人 第12集.mkv")).toBe(12);
expect(parseEpisodeFromFilename("进击的巨人 第12话.mkv")).toBe(12);
});
it("裸数字", () => {
expect(parseEpisodeFromFilename("Title 12.mkv")).toBe(12);
expect(parseEpisodeFromFilename("Title.12.1080p.mkv")).toBe(12);
expect(parseEpisodeFromFilename("Title 12v2.mkv")).toBe(12);
});
it("不误判分辨率/年份/编码", () => {
expect(parseEpisodeFromFilename("Movie.2023.1080p.x264.mkv")).toBeNull();
expect(parseEpisodeFromFilename("Movie 1080p.mkv")).toBeNull();
expect(parseEpisodeFromFilename("[Group] Title [1080p].mkv")).toBeNull();
});
it("无集数信息返回 null", () => {
expect(parseEpisodeFromFilename("Movie.mkv")).toBeNull();
});
});

View File

@ -0,0 +1,25 @@
/**
* 从文件名提取集数(前后端共用的唯一正则源)。
* 模式优先级:SxxExx → EPxx/Exx → 第N集·话 → [NN] → " - NN" → 裸数字。
*/
export function parseEpisodeFromFilename(name: string): number | null {
const s = name.replace(/\.[a-z0-9]+$/i, "");
const patterns: RegExp[] = [
/S\d{1,2}\s*E(\d{1,3})/i,
/(?:^|[\s\-_[·])EP?(\d{1,4})(?:[\s\-_\]·]|$)/i,
/第\s*(\d{1,4})\s*[集话話]/,
/\[(\d{1,3})\]/,
/\s-\s(\d{2,3})(?:\s|$|v\d)/i,
// 裸数字("Title 12" / "Title.12.1080p"):数字串后必须跟分隔符或结尾,
// 避免吞掉 1080/2023/264 这类 4 位以上数字串的中段
/(?:^|[\s.-])(\d{1,3})(?:v\d)?(?=[\s.-]|$)/,
];
for (const re of patterns) {
const m = s.match(re);
if (m?.[1]) {
const n = Number.parseInt(m[1], 10);
if (Number.isFinite(n)) return n;
}
}
return null;
}

View File

@ -2,3 +2,4 @@
export * from "./schemas.js";
export * from "./media-schemas.js";
export * from "./media-fs.js";
export * from "./episode.js";

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { mountSchemas, playbackSchemas, scrapeSchemas } from "./media-schemas.js";
import { librarySchemas, mountSchemas, playbackSchemas, scrapeSchemas } from "./media-schemas.js";
describe("mountSchemas.create", () => {
it("拒绝缺 baseUrl", () => {
@ -38,3 +38,55 @@ describe("playbackSchemas.report", () => {
).toBe(false);
});
});
describe("playbackSchemas.remove / clear", () => {
it("remove 需要 mediaItemId", () => {
expect(playbackSchemas.remove.safeParse({ mediaItemId: "m1" }).success).toBe(true);
expect(playbackSchemas.remove.safeParse({}).success).toBe(false);
});
it("clear 空对象即可", () => {
expect(playbackSchemas.clear.safeParse({}).success).toBe(true);
});
});
describe("海报墙组级操作 schema", () => {
it("deleteGroup 需要 mountId + bangumiId", () => {
const ok = librarySchemas.deleteGroup.safeParse({ mountId: "m", bangumiId: 1 });
expect(ok.success).toBe(true);
expect(librarySchemas.deleteGroup.safeParse({ mountId: "m" }).success).toBe(false);
});
it("updateGroup 至少改一项", () => {
expect(librarySchemas.updateGroup.safeParse({ mountId: "m", bangumiId: 1 }).success).toBe(
false,
);
expect(
librarySchemas.updateGroup.safeParse({ mountId: "m", bangumiId: 1, title: "新标题" })
.success,
).toBe(true);
expect(
librarySchemas.updateGroup.safeParse({
mountId: "m",
bangumiId: 1,
posterUrl: null,
}).success,
).toBe(true);
expect(
librarySchemas.updateGroup.safeParse({ mountId: "m", bangumiId: 1, title: "" }).success,
).toBe(false);
});
it("unbindGroup / bindGroup 参数校验", () => {
expect(scrapeSchemas.unbindGroup.safeParse({ mountId: "m", bangumiId: 1 }).success).toBe(
true,
);
expect(
scrapeSchemas.bindGroup.safeParse({
mountId: "m",
bangumiId: 1,
newBangumiId: 2,
}).success,
).toBe(true);
expect(scrapeSchemas.bindGroup.safeParse({ mountId: "m", bangumiId: 1 }).success).toBe(
false,
);
});
});

View File

@ -65,6 +65,22 @@ export const librarySchemas = {
bangumiId: z.number().int().positive(),
mountId: id.optional(),
}),
/** 海报墙组级删除:物理删除该挂载下组内全部条目(含观看进度级联清理) */
deleteGroup: z.object({
mountId: id,
bangumiId: z.number().int().positive(),
}),
/** 海报墙组级编辑:批量改显示标题 / 封面(至少改一项) */
updateGroup: z
.object({
mountId: id,
bangumiId: z.number().int().positive(),
title: z.string().min(1, "标题不能为空").max(200).optional(),
posterUrl: z.string().max(2000).nullable().optional(),
})
.refine((v) => v.title !== undefined || v.posterUrl !== undefined, {
message: "没有需要更新的字段",
}),
};
export type LibraryGroup = {
@ -119,6 +135,10 @@ export const playbackSchemas = {
continueList: z.object({
limit: z.number().int().min(1).max(20).default(6),
}),
/** 移除单条观看记录 */
remove: z.object({ mediaItemId: id }),
/** 清除当前用户全部观看记录 */
clear: z.object({}),
};
export const scrapeSchemas = {
@ -143,6 +163,17 @@ export const scrapeSchemas = {
mountId: id,
path: z.string().min(1).max(2000),
}),
/** 海报墙组级取消匹配:改回未匹配、移出海报墙(保留入库条目) */
unbindGroup: z.object({
mountId: id,
bangumiId: z.number().int().positive(),
}),
/** 海报墙组级重新匹配:整组改绑另一 Bangumi 作品 */
bindGroup: z.object({
mountId: id,
bangumiId: z.number().int().positive(),
newBangumiId: z.number().int().positive(),
}),
};
export const danmakuSchemas = {