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:
parent
63e760ba36
commit
da6378e421
|
|
@ -1,12 +1,13 @@
|
||||||
import type { Locale } from "@app/i18n";
|
import type { Locale } from "@app/i18n";
|
||||||
import { t } from "@app/i18n";
|
import { t } from "@app/i18n";
|
||||||
import type { LibraryGroup } from "@app/types";
|
import { parseEpisodeFromFilename, type LibraryGroup } from "@app/types";
|
||||||
import { Button, Input, toast } from "@app/ui";
|
import { Badge, Button, Input, toast } from "@app/ui";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { go, queryParam, setQueryParams } from "~/lib/nav";
|
import { go, queryParam, setQueryParams } from "~/lib/nav";
|
||||||
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||||||
|
import { formatTrpcError } from "~/lib/trpc-error";
|
||||||
|
|
||||||
type ViewMode = "wall" | "list";
|
type ViewMode = "wall" | "list";
|
||||||
|
|
||||||
|
|
@ -78,7 +79,8 @@ function GroupDetail(props: {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onPlay: (mediaItemId: string, bangumiId: number) => 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 { locale } = props;
|
||||||
const seriesQuery = trpc.media.librarySeries.useQuery(
|
const seriesQuery = trpc.media.librarySeries.useQuery(
|
||||||
|
|
@ -103,9 +105,29 @@ function GroupDetail(props: {
|
||||||
<PosterBox posterUrl={group.posterUrl} locale={locale} />
|
<PosterBox posterUrl={group.posterUrl} locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<h2 className="truncate text-lg font-semibold" title={group.title}>
|
<div className="flex items-start gap-2">
|
||||||
{group.title}
|
<h2
|
||||||
</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">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t(locale, "library.episodeCount", { count: group.episodes.length })}
|
{t(locale, "library.episodeCount", { count: group.episodes.length })}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -114,38 +136,45 @@ function GroupDetail(props: {
|
||||||
{t(locale, "library.episodes")}
|
{t(locale, "library.episodes")}
|
||||||
</div>
|
</div>
|
||||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||||
{group.episodes.map((ep) => (
|
{group.episodes.map((ep) => {
|
||||||
<li
|
// 落库集数优先,null 时用共享正则从文件名兜底解析
|
||||||
key={ep.id}
|
const epNo =
|
||||||
className="flex items-center gap-3 px-3 py-2 text-sm"
|
ep.epNumber ?? parseEpisodeFromFilename(ep.rawName);
|
||||||
>
|
return (
|
||||||
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground">
|
<li
|
||||||
{ep.epNumber != null ? `EP${ep.epNumber}` : "—"}
|
key={ep.id}
|
||||||
</span>
|
className="flex items-center gap-3 px-3 py-2 text-sm"
|
||||||
<span
|
|
||||||
className="min-w-0 flex-1 truncate"
|
|
||||||
title={ep.rawName}
|
|
||||||
>
|
>
|
||||||
{ep.rawName}
|
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
</span>
|
{epNo != null ? `EP${epNo}` : "—"}
|
||||||
<Button
|
</span>
|
||||||
size="sm"
|
<span
|
||||||
variant="secondary"
|
className="min-w-0 flex-1 truncate"
|
||||||
onClick={() => props.onPlay(ep.id, props.bangumiId)}
|
title={ep.rawName}
|
||||||
>
|
>
|
||||||
{t(locale, "library.play")}
|
{ep.rawName}
|
||||||
</Button>
|
</span>
|
||||||
{props.onMatch && (
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
onClick={() => props.onMatch?.(ep.id)}
|
onClick={() => props.onPlay(ep.id, props.bangumiId)}
|
||||||
>
|
>
|
||||||
{t(locale, "library.match")}
|
{t(locale, "library.play")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{props.onMatch && (
|
||||||
</li>
|
<Button
|
||||||
))}
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
props.onMatch?.(ep.id, ep.rawName)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(locale, "library.match")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -157,7 +186,26 @@ function GroupDetail(props: {
|
||||||
|
|
||||||
function ContinueRow(props: { locale: Locale }) {
|
function ContinueRow(props: { locale: Locale }) {
|
||||||
const { locale } = props;
|
const { locale } = props;
|
||||||
|
const utils = trpc.useUtils();
|
||||||
const list = trpc.media.playbackContinue.useQuery({ limit: 6 });
|
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 ?? [];
|
const items = list.data ?? [];
|
||||||
if (list.isPending) return null;
|
if (list.isPending) return null;
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
@ -166,6 +214,20 @@ function ContinueRow(props: { locale: Locale }) {
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="u-label">{t(locale, "library.continueTitle")}</p>
|
<p className="u-label">{t(locale, "library.continueTitle")}</p>
|
||||||
<span className="u-dotline flex-1" aria-hidden="true" />
|
<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>
|
||||||
<div className="flex gap-3 overflow-x-auto pb-1">
|
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
|
|
@ -174,28 +236,56 @@ function ContinueRow(props: { locale: Locale }) {
|
||||||
? Math.min(100, Math.round((item.positionMs / item.durationMs) * 100))
|
? Math.min(100, Math.round((item.positionMs / item.durationMs) * 100))
|
||||||
: 0;
|
: 0;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={item.mediaItemId}
|
key={item.mediaItemId}
|
||||||
type="button"
|
className="relative w-40 shrink-0 overflow-hidden rounded-lg border border-border bg-card transition-colors hover:border-primary/60"
|
||||||
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}`)}
|
|
||||||
>
|
>
|
||||||
<PosterBox posterUrl={item.posterUrl} locale={locale} />
|
<button
|
||||||
<div className="p-2">
|
type="button"
|
||||||
<div className="truncate text-sm font-medium" title={item.title}>
|
className="block w-full text-left"
|
||||||
{item.title}
|
onClick={() => {
|
||||||
</div>
|
setConfirmRemoveId(null);
|
||||||
<div className="mt-1 h-1 w-full overflow-hidden rounded bg-muted">
|
setConfirmClear(false);
|
||||||
|
go(`/${locale}/watch?id=${item.mediaItemId}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PosterBox posterUrl={item.posterUrl} locale={locale} />
|
||||||
|
<div className="p-2">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary"
|
className="truncate text-sm font-medium"
|
||||||
style={{ width: `${pct}%` }}
|
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>
|
||||||
<div className="mt-1 font-mono text-[11px] text-muted-foreground">
|
</button>
|
||||||
{t(locale, "library.continue")} · {pct}%
|
<button
|
||||||
</div>
|
type="button"
|
||||||
</div>
|
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"
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
@ -257,6 +347,245 @@ function usePagedAccumulator<T>(params: {
|
||||||
return { items, total, hasMore: items.length < total };
|
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: {
|
function WallView(props: {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
mountId: string;
|
mountId: string;
|
||||||
|
|
@ -265,7 +594,8 @@ function WallView(props: {
|
||||||
selectedBangumiId: number | null;
|
selectedBangumiId: number | null;
|
||||||
onSelect: (bangumiId: number | null) => void;
|
onSelect: (bangumiId: number | null) => void;
|
||||||
onPlay: (mediaItemId: string, bangumiId: number) => 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;
|
onClearSearch?: (() => void) | undefined;
|
||||||
hideEmpty?: boolean;
|
hideEmpty?: boolean;
|
||||||
mountsEmpty?: boolean;
|
mountsEmpty?: boolean;
|
||||||
|
|
@ -307,6 +637,7 @@ function WallView(props: {
|
||||||
onBack={() => props.onSelect(null)}
|
onBack={() => props.onSelect(null)}
|
||||||
onPlay={props.onPlay}
|
onPlay={props.onPlay}
|
||||||
onMatch={props.onMatch}
|
onMatch={props.onMatch}
|
||||||
|
onManage={props.onManage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -318,22 +649,40 @@ function WallView(props: {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<button
|
<div key={group.bangumiId} className="relative">
|
||||||
key={group.bangumiId}
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="overflow-hidden rounded-lg border border-border bg-card text-left transition-colors hover:border-primary/60"
|
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)}
|
onClick={() => props.onSelect(group.bangumiId)}
|
||||||
>
|
>
|
||||||
<PosterBox posterUrl={group.posterUrl} locale={locale} />
|
<PosterBox posterUrl={group.posterUrl} locale={locale} />
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<div className="truncate text-sm font-medium" title={group.title}>
|
<div className="truncate text-sm font-medium" title={group.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>
|
||||||
<div className="mt-0.5 font-mono text-xs text-muted-foreground">
|
</button>
|
||||||
{t(locale, "library.episodeCount", { count: group.episodeCount })}
|
{props.onManage && (
|
||||||
</div>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
</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 && (
|
{showEmpty && (
|
||||||
<div className="u-empty col-span-full">
|
<div className="u-empty col-span-full">
|
||||||
|
|
@ -388,7 +737,7 @@ function ListView(props: {
|
||||||
filter: "" | "unmatched" | "ok";
|
filter: "" | "unmatched" | "ok";
|
||||||
refreshKey: number;
|
refreshKey: number;
|
||||||
onFilterChange: (f: "" | "unmatched" | "ok") => void;
|
onFilterChange: (f: "" | "unmatched" | "ok") => void;
|
||||||
onMatch: (mediaItemId: string) => void;
|
onMatch: (mediaItemId: string, rawName?: string) => void;
|
||||||
onPlay: (mediaItemId: string, bangumiId?: number | null) => void;
|
onPlay: (mediaItemId: string, bangumiId?: number | null) => void;
|
||||||
onRescrape?: ((mediaItemId: string) => void) | undefined;
|
onRescrape?: ((mediaItemId: string) => void) | undefined;
|
||||||
onClearSearch?: (() => void) | undefined;
|
onClearSearch?: (() => void) | undefined;
|
||||||
|
|
@ -420,6 +769,7 @@ function ListView(props: {
|
||||||
type MediaListItem = {
|
type MediaListItem = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
rawName: string;
|
||||||
posterUrl: string | null;
|
posterUrl: string | null;
|
||||||
bangumiId: number | null;
|
bangumiId: number | null;
|
||||||
scrapeStatus: string;
|
scrapeStatus: string;
|
||||||
|
|
@ -433,6 +783,7 @@ function ListView(props: {
|
||||||
items: list.data.rows.map((r) => ({
|
items: list.data.rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
|
rawName: r.rawName,
|
||||||
posterUrl: r.posterUrl,
|
posterUrl: r.posterUrl,
|
||||||
bangumiId: r.bangumiId,
|
bangumiId: r.bangumiId,
|
||||||
scrapeStatus: r.scrapeStatus,
|
scrapeStatus: r.scrapeStatus,
|
||||||
|
|
@ -444,6 +795,16 @@ function ListView(props: {
|
||||||
const rows = paged.items;
|
const rows = paged.items;
|
||||||
const hasMore = paged.hasMore;
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|
@ -461,89 +822,112 @@ function ListView(props: {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
|
{rows.length > 0 && (
|
||||||
{rows.map((item) => (
|
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||||
<div
|
<ul className="divide-y divide-border">
|
||||||
key={item.id}
|
{rows.map((item) => {
|
||||||
className="overflow-hidden rounded-lg border border-border bg-card"
|
const badge = statusBadge(item.scrapeStatus);
|
||||||
>
|
return (
|
||||||
<PosterBox posterUrl={item.posterUrl} locale={locale} />
|
<li
|
||||||
<div className="p-2">
|
key={item.id}
|
||||||
<div className="truncate text-sm font-medium" title={item.title}>
|
className="flex flex-wrap items-center gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex gap-1">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => props.onPlay(item.id, item.bangumiId)}
|
|
||||||
>
|
>
|
||||||
{t(locale, "library.play")}
|
<PosterBox
|
||||||
</Button>
|
posterUrl={item.posterUrl}
|
||||||
<Button
|
locale={locale}
|
||||||
size="sm"
|
className="h-14 w-[42px] shrink-0 overflow-hidden rounded-md border border-border"
|
||||||
variant="outline"
|
/>
|
||||||
onClick={() => props.onMatch(item.id)}
|
<div className="min-w-0 flex-1">
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
{t(locale, "library.match")}
|
<p
|
||||||
</Button>
|
className="truncate text-sm font-medium"
|
||||||
<Button
|
title={item.title}
|
||||||
size="sm"
|
>
|
||||||
variant="ghost"
|
{item.title}
|
||||||
onClick={() => props.onRescrape?.(item.id)}
|
</p>
|
||||||
>
|
<Badge
|
||||||
{t(locale, "library.reScrape")}
|
variant={badge.variant}
|
||||||
</Button>
|
className="shrink-0 font-normal"
|
||||||
</div>
|
>
|
||||||
</div>
|
{badge.label}
|
||||||
</div>
|
</Badge>
|
||||||
))}
|
</div>
|
||||||
{!props.hideEmpty &&
|
<p
|
||||||
(props.mountsEmpty || (rows.length === 0 && !list.isPending)) && (
|
className="truncate font-mono text-xs text-muted-foreground"
|
||||||
<div className="u-empty col-span-full">
|
title={item.rawName}
|
||||||
<p className="u-empty__title">{t(locale, "library.title")}</p>
|
>
|
||||||
<p>
|
{item.rawName}
|
||||||
{!props.mountsEmpty && q
|
</p>
|
||||||
? t(locale, "library.noResults", { q })
|
</div>
|
||||||
: !props.mountsEmpty && filter
|
<div className="ml-auto flex shrink-0 gap-1">
|
||||||
? t(locale, "library.filterEmpty")
|
<Button
|
||||||
: t(locale, "library.empty")}
|
size="sm"
|
||||||
</p>
|
variant="secondary"
|
||||||
<div className="u-empty__actions">
|
onClick={() => props.onPlay(item.id, item.bangumiId)}
|
||||||
{!props.mountsEmpty && q && props.onClearSearch ? (
|
>
|
||||||
<Button
|
{t(locale, "library.play")}
|
||||||
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>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => go(`/${locale}/mounts`)}
|
onClick={() => props.onMatch(item.id, item.rawName)}
|
||||||
>
|
>
|
||||||
{t(locale, "library.ctaMounts")}
|
{t(locale, "library.match")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
<Button
|
||||||
)}
|
size="sm"
|
||||||
</div>
|
variant="ghost"
|
||||||
</div>
|
onClick={() => props.onRescrape?.(item.id)}
|
||||||
)}
|
>
|
||||||
</div>
|
{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 && (
|
{hasMore && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -580,6 +964,7 @@ function LibraryInner(props: { locale: Locale }) {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const debouncedQ = useDebouncedValue(q, 300);
|
const debouncedQ = useDebouncedValue(q, 300);
|
||||||
const [matchId, setMatchId] = useState<string | null>(null);
|
const [matchId, setMatchId] = useState<string | null>(null);
|
||||||
|
const [manageGroup, setManageGroup] = useState<ManageTarget | null>(null);
|
||||||
const [searchQ, setSearchQ] = useState("");
|
const [searchQ, setSearchQ] = useState("");
|
||||||
const [epInput, setEpInput] = useState("");
|
const [epInput, setEpInput] = useState("");
|
||||||
const [mountId, setMountId] = useState(() => queryParam("mount") ?? "");
|
const [mountId, setMountId] = useState(() => queryParam("mount") ?? "");
|
||||||
|
|
@ -595,6 +980,14 @@ function LibraryInner(props: { locale: Locale }) {
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const bumpRefresh = () => setRefreshKey((k) => k + 1);
|
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({
|
const bind = trpc.media.scrapeBind.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setMatchId(null);
|
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 mounts = trpc.media.mountList.useQuery();
|
||||||
const probe = trpc.media.libraryList.useQuery({ limit: 1, offset: 0 });
|
const probe = trpc.media.libraryList.useQuery({ limit: 1, offset: 0 });
|
||||||
const autoScan = trpc.media.libraryScan.useMutation();
|
const autoScan = trpc.media.libraryScan.useMutation();
|
||||||
|
|
@ -751,7 +1151,8 @@ function LibraryInner(props: { locale: Locale }) {
|
||||||
selectedBangumiId={selectedBangumiId}
|
selectedBangumiId={selectedBangumiId}
|
||||||
onSelect={setSelectedBangumiId}
|
onSelect={setSelectedBangumiId}
|
||||||
onPlay={play}
|
onPlay={play}
|
||||||
onMatch={setMatchId}
|
onMatch={openMatch}
|
||||||
|
onManage={setManageGroup}
|
||||||
onClearSearch={() => setQ("")}
|
onClearSearch={() => setQ("")}
|
||||||
hideEmpty={autoScanning}
|
hideEmpty={autoScanning}
|
||||||
mountsEmpty={mountsEmpty}
|
mountsEmpty={mountsEmpty}
|
||||||
|
|
@ -764,7 +1165,7 @@ function LibraryInner(props: { locale: Locale }) {
|
||||||
filter={filter}
|
filter={filter}
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
onFilterChange={setFilter}
|
onFilterChange={setFilter}
|
||||||
onMatch={setMatchId}
|
onMatch={openMatch}
|
||||||
onPlay={play}
|
onPlay={play}
|
||||||
onRescrape={(id) => rescrape.mutate({ mediaItemId: id })}
|
onRescrape={(id) => rescrape.mutate({ mediaItemId: id })}
|
||||||
onClearSearch={() => setQ("")}
|
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 && (
|
{matchId && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<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">
|
<div className="w-full max-w-lg rounded-lg border border-border bg-background p-4 space-y-3">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Locale } from "@app/i18n";
|
import type { Locale } from "@app/i18n";
|
||||||
import { t } from "@app/i18n";
|
import { t } from "@app/i18n";
|
||||||
|
import { parseEpisodeFromFilename } from "@app/types";
|
||||||
import { Button, toast } from "@app/ui";
|
import { Button, toast } from "@app/ui";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import Artplayer from "artplayer";
|
import Artplayer from "artplayer";
|
||||||
|
|
@ -33,6 +34,11 @@ type ProgressInput = {
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 集数:落库值优先,null 时用共享正则从文件名解析(与后端同一套) */
|
||||||
|
function epNumberOf(ep: { epNumber: number | null; rawName: string }): number | null {
|
||||||
|
return ep.epNumber ?? parseEpisodeFromFilename(ep.rawName);
|
||||||
|
}
|
||||||
|
|
||||||
function readDanmakuPrefs(): DanmakuPrefs {
|
function readDanmakuPrefs(): DanmakuPrefs {
|
||||||
const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8, density: 1 };
|
const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8, density: 1 };
|
||||||
try {
|
try {
|
||||||
|
|
@ -144,7 +150,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
);
|
);
|
||||||
const episodes = useMemo(() => {
|
const episodes = useMemo(() => {
|
||||||
const list = series.data?.episodes ?? [];
|
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]);
|
}, [series.data]);
|
||||||
const epIndex = episodes.findIndex((ep) => ep.id === mediaItemId);
|
const epIndex = episodes.findIndex((ep) => ep.id === mediaItemId);
|
||||||
const prevEp = epIndex > 0 ? episodes[epIndex - 1] : null;
|
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">
|
<ul className="flex flex-wrap gap-2">
|
||||||
{episodes.map((ep) => {
|
{episodes.map((ep) => {
|
||||||
const current = ep.id === mediaItemId;
|
const current = ep.id === mediaItemId;
|
||||||
|
const epNo = epNumberOf(ep);
|
||||||
return (
|
return (
|
||||||
<li key={ep.id}>
|
<li key={ep.id}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -530,8 +537,8 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
)}
|
)}
|
||||||
onClick={() => !current && openEpisode(ep.id)}
|
onClick={() => !current && openEpisode(ep.id)}
|
||||||
>
|
>
|
||||||
{ep.epNumber != null
|
{epNo != null
|
||||||
? t(locale, "watch.episodeN", { n: ep.epNumber })
|
? t(locale, "watch.episodeN", { n: epNo })
|
||||||
: ep.rawName}
|
: ep.rawName}
|
||||||
{current && (
|
{current && (
|
||||||
<span className="ml-2 text-[11px]">
|
<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">
|
<div className="flex items-center gap-3 rounded-lg border border-primary/40 bg-primary/5 p-4">
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{t(locale, "watch.episodeN", {
|
{t(locale, "watch.episodeN", {
|
||||||
n: nextEp.epNumber ?? episodes.indexOf(nextEp) + 1,
|
n: epNumberOf(nextEp) ?? episodes.indexOf(nextEp) + 1,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<Button size="sm" className="ml-auto" onClick={() => openEpisode(nextEp.id)}>
|
<Button size="sm" className="ml-auto" onClick={() => openEpisode(nextEp.id)}>
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,10 @@ const nav = [
|
||||||
<Fragment set:html={headHtml} />
|
<Fragment set:html={headHtml} />
|
||||||
<Fragment set:html={jsonLdHtml} />
|
<Fragment set:html={jsonLdHtml} />
|
||||||
<ClientRouter />
|
<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" }}>
|
<script is:inline define:vars={{ cookieName: COOKIE_LANG, maxAge: COOKIE_LANG_MAX_AGE, nextLocale: locale === "zh-CN" ? "en" : "zh-CN" }}>
|
||||||
(function () {
|
(function () {
|
||||||
function apply(mode) {
|
function apply(mode) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { navigate } from "astro:transitions/client";
|
import { navigate } from "astro:transitions/client";
|
||||||
|
import "@/lib/history-guard";
|
||||||
|
|
||||||
/** 站内软导航(ClientRouter),避免整页 GET。 */
|
/** 站内软导航(ClientRouter),避免整页 GET。 */
|
||||||
export function go(href: string): void {
|
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);
|
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 {
|
export function setQueryParams(params: Record<string, string | null>): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const url = new URL(window.location.href);
|
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);
|
if (value == null) url.searchParams.delete(key);
|
||||||
else url.searchParams.set(key, value);
|
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());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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("私有影音库");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -196,6 +196,22 @@ export const mediaItemDao = {
|
||||||
.where(and(...conds))
|
.where(and(...conds))
|
||||||
.orderBy(asc(mediaItems.epNumber), asc(mediaItems.rawName));
|
.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(
|
async getByUserPath(
|
||||||
userId: string,
|
userId: string,
|
||||||
mountId: string,
|
mountId: string,
|
||||||
|
|
@ -301,6 +317,40 @@ export const mediaItemDao = {
|
||||||
.returning({ id: mediaItems.id });
|
.returning({ id: mediaItems.id });
|
||||||
return rows.map((r) => r.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> {
|
async cleanupOrphans(): Promise<number> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,27 @@ export const playbackProgressDao = {
|
||||||
.delete(playbackProgress)
|
.delete(playbackProgress)
|
||||||
.where(inArray(playbackProgress.mediaItemId, mediaItemIds));
|
.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> {
|
async cleanupOrphans(): Promise<void> {
|
||||||
await db
|
await db
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
"Search and pick a title; all videos under this folder will bind to it, episodes parsed from filenames.",
|
||||||
"library.backToList": "Back to library",
|
"library.backToList": "Back to library",
|
||||||
"library.continueTitle": "Continue watching",
|
"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.matched": "Matched",
|
||||||
"library.autoScanning": "Auto-scanning mounted directories into the library…",
|
"library.autoScanning": "Auto-scanning mounted directories into the library…",
|
||||||
"library.noResults": "No results matching “{q}”",
|
"library.noResults": "No results matching “{q}”",
|
||||||
"library.filterEmpty": "Nothing under the current filter — try another filter",
|
"library.filterEmpty": "Nothing under the current filter — try another filter",
|
||||||
"library.clearSearch": "Clear search",
|
"library.clearSearch": "Clear search",
|
||||||
|
"library.statusPending": "Pending scrape",
|
||||||
|
"library.statusFailed": "Scrape failed",
|
||||||
|
|
||||||
"browse.title": "File browser",
|
"browse.title": "File browser",
|
||||||
"browse.play": "Play",
|
"browse.play": "Play",
|
||||||
|
|
|
||||||
|
|
@ -76,11 +76,39 @@ export const zhCN = {
|
||||||
"搜索并选定作品后,该文件夹下所有视频将绑定为同一作品,集数按文件名自动解析。",
|
"搜索并选定作品后,该文件夹下所有视频将绑定为同一作品,集数按文件名自动解析。",
|
||||||
"library.backToList": "返回媒体库",
|
"library.backToList": "返回媒体库",
|
||||||
"library.continueTitle": "继续观看",
|
"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.matched": "已匹配成功",
|
||||||
"library.autoScanning": "正在自动扫描已挂载目录并入库…",
|
"library.autoScanning": "正在自动扫描已挂载目录并入库…",
|
||||||
"library.noResults": "没有匹配「{q}」的结果",
|
"library.noResults": "没有匹配「{q}」的结果",
|
||||||
"library.filterEmpty": "当前筛选下没有条目,试试切换筛选条件",
|
"library.filterEmpty": "当前筛选下没有条目,试试切换筛选条件",
|
||||||
"library.clearSearch": "清除搜索",
|
"library.clearSearch": "清除搜索",
|
||||||
|
"library.statusPending": "待刮削",
|
||||||
|
"library.statusFailed": "刮削失败",
|
||||||
|
|
||||||
"browse.title": "文件浏览",
|
"browse.title": "文件浏览",
|
||||||
"browse.play": "播放",
|
"browse.play": "播放",
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,19 @@ export const mediaRouter = t.router({
|
||||||
.query(({ ctx, input }) =>
|
.query(({ ctx, input }) =>
|
||||||
libraryService.series(ctx.userId, input.bangumiId, input.mountId),
|
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
|
scrapeSearch: protectedProcedure
|
||||||
.input(scrapeSchemas.search)
|
.input(scrapeSchemas.search)
|
||||||
|
|
@ -84,6 +97,16 @@ export const mediaRouter = t.router({
|
||||||
.mutation(({ ctx, input }) =>
|
.mutation(({ ctx, input }) =>
|
||||||
scrapeService.rescrapePath(ctx.userId, input.mountId, input.path),
|
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
|
playbackReport: protectedProcedure
|
||||||
.input(playbackSchemas.report)
|
.input(playbackSchemas.report)
|
||||||
|
|
@ -94,6 +117,12 @@ export const mediaRouter = t.router({
|
||||||
playbackContinue: protectedProcedure
|
playbackContinue: protectedProcedure
|
||||||
.input(playbackSchemas.continueList)
|
.input(playbackSchemas.continueList)
|
||||||
.query(({ ctx, input }) => playbackService.continueList(ctx.userId, input.limit)),
|
.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
|
danmakuFetch: protectedProcedure
|
||||||
.input(danmakuSchemas.fetch)
|
.input(danmakuSchemas.fetch)
|
||||||
|
|
|
||||||
|
|
@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { mediaItemDao, mountDao, playbackProgressDao, type MediaItemRow } from "@app/dao";
|
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 { decryptSecret } from "./secret.js";
|
||||||
import { createWebdav, isVideoFilename, joinWebdavPath, listDirectory } from "./webdav-client.js";
|
import { createWebdav, isVideoFilename, joinWebdavPath, listDirectory } from "./webdav-client.js";
|
||||||
import { scrapeService } from "./scrape.service.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 = {
|
export const libraryService = {
|
||||||
/** 惰性兜底:挂载删除后残留的孤儿条目/进度(级联为主,这里清历史脏数据) */
|
/** 惰性兜底:挂载删除后残留的孤儿条目/进度(级联为主,这里清历史脏数据) */
|
||||||
async cleanupOrphans(): Promise<void> {
|
async cleanupOrphans(): Promise<void> {
|
||||||
|
|
@ -76,7 +103,16 @@ export const libraryService = {
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
await this.cleanupOrphans();
|
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) */
|
/** 海报墙:指定挂载下已刮削条目按 bangumiId 分组的分页摘要(剧集详情按需走 series) */
|
||||||
|
|
@ -115,21 +151,57 @@ export const libraryService = {
|
||||||
/** 同作品剧集(按集数升序);mountId 可选:库内详情只看该挂载,播放页不传跨挂载 */
|
/** 同作品剧集(按集数升序);mountId 可选:库内详情只看该挂载,播放页不传跨挂载 */
|
||||||
async series(userId: string, bangumiId: number, mountId?: string | undefined) {
|
async series(userId: string, bangumiId: number, mountId?: string | undefined) {
|
||||||
await this.cleanupOrphans();
|
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 first = rows[0];
|
||||||
|
// 单集无封面时回退继承组内任一非空封面
|
||||||
|
const posterUrl =
|
||||||
|
first?.posterUrl ?? rows.find((row) => row.posterUrl != null)?.posterUrl ?? null;
|
||||||
return {
|
return {
|
||||||
bangumiId,
|
bangumiId,
|
||||||
title: first?.title ?? "",
|
title: first?.title ?? "",
|
||||||
posterUrl: first?.posterUrl ?? null,
|
posterUrl,
|
||||||
episodes: rows.map((row) => ({
|
episodes: rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
epNumber: row.epNumber,
|
// 落库值优先,null 时读时用同一套正则从文件名解析
|
||||||
|
epNumber: row.epNumber ?? parseEpisodeFromFilename(row.rawName),
|
||||||
rawName: 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(
|
async scan(
|
||||||
userId: string,
|
userId: string,
|
||||||
mountId: string,
|
mountId: string,
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,26 @@ export const playbackService = {
|
||||||
/** 未看完的最近条目,供首页/媒体库「继续观看」入口 */
|
/** 未看完的最近条目,供首页/媒体库「继续观看」入口 */
|
||||||
async continueList(userId: string, limit: number): Promise<ContinueItem[]> {
|
async continueList(userId: string, limit: number): Promise<ContinueItem[]> {
|
||||||
const rows = await playbackProgressDao.listUnfinished(userId, limit);
|
const rows = await playbackProgressDao.listUnfinished(userId, limit);
|
||||||
|
// 单集无封面时回退继承父级(同 bangumiId 组)封面;同组只查一次
|
||||||
|
const groupPoster = new Map<number, string | null>();
|
||||||
const out: ContinueItem[] = [];
|
const out: ContinueItem[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const item = await mediaItemDao.getByIdForUser(row.mediaItemId, userId);
|
const item = await mediaItemDao.getByIdForUser(row.mediaItemId, userId);
|
||||||
if (!item) continue;
|
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({
|
out.push({
|
||||||
mediaItemId: item.id,
|
mediaItemId: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
posterUrl: item.posterUrl,
|
posterUrl,
|
||||||
positionMs: row.positionMs,
|
positionMs: row.positionMs,
|
||||||
durationMs: row.durationMs,
|
durationMs: row.durationMs,
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
|
@ -47,4 +59,14 @@ export const playbackService = {
|
||||||
}
|
}
|
||||||
return out;
|
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 };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { mediaItemDao } from "@app/dao";
|
import { mediaItemDao } from "@app/dao";
|
||||||
import type { BatchResult, DanmakuMatchOutput } from "@app/types";
|
import { parseEpisodeFromFilename, type BatchResult, type DanmakuMatchOutput } from "@app/types";
|
||||||
import {
|
import {
|
||||||
hasOpenCredentials,
|
hasOpenCredentials,
|
||||||
matchFileName,
|
matchFileName,
|
||||||
|
|
@ -9,25 +9,8 @@ import {
|
||||||
remoteFileHash,
|
remoteFileHash,
|
||||||
} from "./danmaku.service.js";
|
} from "./danmaku.service.js";
|
||||||
|
|
||||||
/** 从文件名提取集数:SxxExx / - NN / EPxx / 第N集·话 / [NN] / Exx */
|
/** 集数解析统一走 @app/types(前后端共用同一套正则);此处再导出保持旧入口可用 */
|
||||||
export function parseEpisodeFromFilename(name: string): number | null {
|
export { parseEpisodeFromFilename };
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从文件名剥出作品检索词:去扩展名/字幕组/集数/分辨率等噪声 */
|
/** 从文件名剥出作品检索词:去扩展名/字幕组/集数/分辨率等噪声 */
|
||||||
export function buildSearchQuery(filename: string): string {
|
export function buildSearchQuery(filename: string): string {
|
||||||
|
|
@ -337,10 +320,22 @@ export const scrapeService = {
|
||||||
/** 扫描后自动刮削:择优候选,低置信留 unmatched */
|
/** 扫描后自动刮削:择优候选,低置信留 unmatched */
|
||||||
async autoScrape(userId: string, mediaItemId: string): Promise<void> {
|
async autoScrape(userId: string, mediaItemId: string): Promise<void> {
|
||||||
const row = await mediaItemDao.getByIdForUser(mediaItemId, userId);
|
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);
|
const q = buildSearchQuery(row.rawName);
|
||||||
if (!q) {
|
if (!q) {
|
||||||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||||||
|
epNumber,
|
||||||
scrapeStatus: "unmatched",
|
scrapeStatus: "unmatched",
|
||||||
scrapedAt: new Date(),
|
scrapedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
@ -352,6 +347,7 @@ export const scrapeService = {
|
||||||
hits = await bangumiSearch(q);
|
hits = await bangumiSearch(q);
|
||||||
} catch {
|
} catch {
|
||||||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||||||
|
epNumber,
|
||||||
scrapeStatus: "failed",
|
scrapeStatus: "failed",
|
||||||
scrapedAt: new Date(),
|
scrapedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
@ -369,6 +365,7 @@ export const scrapeService = {
|
||||||
}
|
}
|
||||||
if (!best || bestScore < AUTO_BIND_THRESHOLD) {
|
if (!best || bestScore < AUTO_BIND_THRESHOLD) {
|
||||||
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
await mediaItemDao.updateScrape(mediaItemId, userId, {
|
||||||
|
epNumber,
|
||||||
scrapeStatus: "unmatched",
|
scrapeStatus: "unmatched",
|
||||||
scrapedAt: new Date(),
|
scrapedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
@ -380,7 +377,7 @@ export const scrapeService = {
|
||||||
scrapedAt: new Date(),
|
scrapedAt: new Date(),
|
||||||
posterUrl: best.image || null,
|
posterUrl: best.image || null,
|
||||||
title: best.nameCn || best.name,
|
title: best.nameCn || best.name,
|
||||||
epNumber: parseEpisodeFromFilename(row.rawName),
|
epNumber,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -405,4 +402,48 @@ export const scrapeService = {
|
||||||
message: `批量刮削完成:成功 ${bound},跳过已绑定 ${skipped}`,
|
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 };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -2,3 +2,4 @@
|
||||||
export * from "./schemas.js";
|
export * from "./schemas.js";
|
||||||
export * from "./media-schemas.js";
|
export * from "./media-schemas.js";
|
||||||
export * from "./media-fs.js";
|
export * from "./media-fs.js";
|
||||||
|
export * from "./episode.js";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("mountSchemas.create", () => {
|
||||||
it("拒绝缺 baseUrl", () => {
|
it("拒绝缺 baseUrl", () => {
|
||||||
|
|
@ -38,3 +38,55 @@ describe("playbackSchemas.report", () => {
|
||||||
).toBe(false);
|
).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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,22 @@ export const librarySchemas = {
|
||||||
bangumiId: z.number().int().positive(),
|
bangumiId: z.number().int().positive(),
|
||||||
mountId: id.optional(),
|
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 = {
|
export type LibraryGroup = {
|
||||||
|
|
@ -119,6 +135,10 @@ export const playbackSchemas = {
|
||||||
continueList: z.object({
|
continueList: z.object({
|
||||||
limit: z.number().int().min(1).max(20).default(6),
|
limit: z.number().int().min(1).max(20).default(6),
|
||||||
}),
|
}),
|
||||||
|
/** 移除单条观看记录 */
|
||||||
|
remove: z.object({ mediaItemId: id }),
|
||||||
|
/** 清除当前用户全部观看记录 */
|
||||||
|
clear: z.object({}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const scrapeSchemas = {
|
export const scrapeSchemas = {
|
||||||
|
|
@ -143,6 +163,17 @@ export const scrapeSchemas = {
|
||||||
mountId: id,
|
mountId: id,
|
||||||
path: z.string().min(1).max(2000),
|
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 = {
|
export const danmakuSchemas = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue