diff --git a/app/web/src/components/pages/LibraryBody.tsx b/app/web/src/components/pages/LibraryBody.tsx
index fcd3031..c253bdb 100644
--- a/app/web/src/components/pages/LibraryBody.tsx
+++ b/app/web/src/components/pages/LibraryBody.tsx
@@ -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: {
-
- {group.title}
-
+
+
+ {group.title}
+
+ {props.onManage && (
+
+ )}
+
{t(locale, "library.episodeCount", { count: group.episodes.length })}
@@ -114,38 +136,45 @@ function GroupDetail(props: {
{t(locale, "library.episodes")}
- {group.episodes.map((ep) => (
- -
-
- {ep.epNumber != null ? `EP${ep.epNumber}` : "—"}
-
- {
+ // 落库集数优先,null 时用共享正则从文件名兜底解析
+ const epNo =
+ ep.epNumber ?? parseEpisodeFromFilename(ep.rawName);
+ return (
+
-
- {ep.rawName}
-
-
- {props.onMatch && (
+
+ {epNo != null ? `EP${epNo}` : "—"}
+
+
+ {ep.rawName}
+
- )}
-
- ))}
+ {props.onMatch && (
+
+ )}
+
+ );
+ })}
@@ -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(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 }) {
{t(locale, "library.continueTitle")}
+
{items.map((item) => {
@@ -174,28 +236,56 @@ function ContinueRow(props: { locale: Locale }) {
? Math.min(100, Math.round((item.positionMs / item.durationMs) * 100))
: 0;
return (
-
+
+
{
+ 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")}
+
+
);
})}
@@ -257,6 +347,245 @@ function usePagedAccumulator(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 (
+
+
+
+
+ {t(locale, "library.groupManageTitle")}
+
+
+ {title}
+
+
+
+ setTab("edit")}
+ >
+ {t(locale, "library.editInfo")}
+
+ setTab("rematch")}
+ >
+ {t(locale, "library.rematch")}
+
+
+
+ {tab === "edit" ? (
+
+
+
+ setTitle(e.target.value)}
+ />
+
+
+
+ setPoster(e.target.value)}
+ />
+
+
+
+ save.mutate({
+ mountId,
+ bangumiId: group.bangumiId,
+ title: title.trim(),
+ posterUrl: poster.trim() === "" ? null : poster.trim(),
+ })
+ }
+ >
+ {t(locale, "library.save")}
+
+
+
+ {t(locale, "library.groupOpHint")}
+
+
+ {
+ setConfirmDelete(false);
+ if (confirmUnbind) {
+ unbind.mutate({
+ mountId,
+ bangumiId: group.bangumiId,
+ });
+ } else {
+ setConfirmUnbind(true);
+ }
+ }}
+ >
+ {confirmUnbind
+ ? t(locale, "library.confirmUnbind")
+ : t(locale, "library.unbind")}
+
+ {
+ setConfirmUnbind(false);
+ if (confirmDelete) {
+ remove.mutate({
+ mountId,
+ bangumiId: group.bangumiId,
+ });
+ } else {
+ setConfirmDelete(true);
+ }
+ }}
+ >
+ {confirmDelete
+ ? t(locale, "library.confirmDeleteGroup")
+ : t(locale, "library.deleteGroup")}
+
+
+
+ ) : (
+
+
setSearchQ(e.target.value)}
+ />
+
+ {t(locale, "library.candidates")}
+
+
+ {(search.data ?? []).map((hit) => (
+ -
+
+
{hit.nameCn || hit.name}
+ {hit.nameCn && hit.name && (
+
+ {hit.name}
+
+ )}
+
+
+ rebind.mutate({
+ mountId,
+ bangumiId: group.bangumiId,
+ newBangumiId: hit.id,
+ })
+ }
+ >
+ {t(locale, "library.bind")}
+
+
+ ))}
+
+
+ )}
+
+
+
+ {t(locale, "library.cancel")}
+
+
+
+
+ );
+}
+
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: {
{groups.map((group) => (
-
props.onSelect(group.bangumiId)}
- >
-
-
-
- {group.title}
+
+
props.onSelect(group.bangumiId)}
+ >
+
+
+
+ {group.title}
+
+
+ {t(locale, "library.episodeCount", {
+ count: group.episodeCount,
+ })}
+
-
- {t(locale, "library.episodeCount", { count: group.episodeCount })}
-
-
-
+
+ {props.onManage && (
+
+ props.onManage?.({
+ bangumiId: group.bangumiId,
+ title: group.title,
+ posterUrl: group.posterUrl,
+ })
+ }
+ >
+ {t(locale, "library.manageGroup")}
+
+ )}
+
))}
{showEmpty && (
@@ -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 (
@@ -461,89 +822,112 @@ function ListView(props: {
-
- {rows.map((item) => (
-
-
-
-
- {item.title}
-
-
-
props.onPlay(item.id, item.bangumiId)}
+ {rows.length > 0 && (
+
+
+ {rows.map((item) => {
+ const badge = statusBadge(item.scrapeStatus);
+ return (
+ -
- {t(locale, "library.play")}
-
- props.onMatch(item.id)}
- >
- {t(locale, "library.match")}
-
- props.onRescrape?.(item.id)}
- >
- {t(locale, "library.reScrape")}
-
-
-
-
- ))}
- {!props.hideEmpty &&
- (props.mountsEmpty || (rows.length === 0 && !list.isPending)) && (
-
-
{t(locale, "library.title")}
-
- {!props.mountsEmpty && q
- ? t(locale, "library.noResults", { q })
- : !props.mountsEmpty && filter
- ? t(locale, "library.filterEmpty")
- : t(locale, "library.empty")}
-
-
- {!props.mountsEmpty && q && props.onClearSearch ? (
-
- {t(locale, "library.clearSearch")}
-
- ) : !props.mountsEmpty && filter ? (
-
props.onFilterChange("")}
- >
- {t(locale, "library.filterAll")}
-
- ) : (
- <>
-
go(`/${locale}/browse`)}>
- {t(locale, "library.ctaBrowse")}
+
+
+
+
+ {item.title}
+
+
+ {badge.label}
+
+
+
+ {item.rawName}
+
+
+
+ props.onPlay(item.id, item.bangumiId)}
+ >
+ {t(locale, "library.play")}
go(`/${locale}/mounts`)}
+ onClick={() => props.onMatch(item.id, item.rawName)}
>
- {t(locale, "library.ctaMounts")}
+ {t(locale, "library.match")}
- >
- )}
-
-
- )}
-
+
props.onRescrape?.(item.id)}
+ >
+ {t(locale, "library.reScrape")}
+
+
+
+ );
+ })}
+
+
+ )}
+ {!props.hideEmpty && (props.mountsEmpty || (rows.length === 0 && !list.isPending)) && (
+
+
{t(locale, "library.title")}
+
+ {!props.mountsEmpty && q
+ ? t(locale, "library.noResults", { q })
+ : !props.mountsEmpty && filter
+ ? t(locale, "library.filterEmpty")
+ : t(locale, "library.empty")}
+
+
+ {!props.mountsEmpty && q && props.onClearSearch ? (
+
+ {t(locale, "library.clearSearch")}
+
+ ) : !props.mountsEmpty && filter ? (
+ props.onFilterChange("")}
+ >
+ {t(locale, "library.filterAll")}
+
+ ) : (
+ <>
+ go(`/${locale}/browse`)}>
+ {t(locale, "library.ctaBrowse")}
+
+ go(`/${locale}/mounts`)}
+ >
+ {t(locale, "library.ctaMounts")}
+
+ >
+ )}
+
+
+ )}
{hasMore && (
(null);
+ const [manageGroup, setManageGroup] = useState(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 && (
+ setManageGroup(null)}
+ onChanged={refreshAll}
+ onRemoved={(bangumiId) => {
+ if (selectedBangumiId === bangumiId) setSelectedBangumiId(null);
+ }}
+ />
+ )}
+
{matchId && (
diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx
index 7c85e3a..d1971dc 100644
--- a/app/web/src/components/pages/WatchBody.tsx
+++ b/app/web/src/components/pages/WatchBody.tsx
@@ -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 }) {
{episodes.map((ep) => {
const current = ep.id === mediaItemId;
+ const epNo = epNumberOf(ep);
return (
-
!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 && (
@@ -550,7 +557,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
{t(locale, "watch.episodeN", {
- n: nextEp.epNumber ?? episodes.indexOf(nextEp) + 1,
+ n: epNumberOf(nextEp) ?? episodes.indexOf(nextEp) + 1,
})}
openEpisode(nextEp.id)}>
diff --git a/app/web/src/layouts/AppLayout.astro b/app/web/src/layouts/AppLayout.astro
index b48dea0..42d2f9b 100644
--- a/app/web/src/layouts/AppLayout.astro
+++ b/app/web/src/layouts/AppLayout.astro
@@ -84,6 +84,10 @@ const nav = [
+ {/* ClientRouter onPopState 在 history.state 为 null 时会吞掉前进/后退,见 history-guard */}
+