diff --git a/apps/web/app/components/organize-wizard/model.test.ts b/apps/web/app/components/organize-wizard/model.test.ts
index 15d07270..d13701ac 100644
--- a/apps/web/app/components/organize-wizard/model.test.ts
+++ b/apps/web/app/components/organize-wizard/model.test.ts
@@ -17,6 +17,8 @@ import {
pageOf,
patchRowEpisodes,
previewStats,
+ recoveredOptions,
+ recoveredRows,
rowProblem,
rowsFromIdentify,
setRowEntity,
@@ -535,3 +537,71 @@ describe("季集结构", () => {
expect(queue.episodeStructures).toEqual(preview.episodeStructures);
});
});
+
+describe("历史整理恢复", () => {
+ it("恢复实际保存的选项,包括 false 和零值,保留只用于本次的季集结构", () => {
+ const options = recoveredOptions(defaultOptions({ scrapeAfterImport: true }), {
+ ready: true,
+ source: "task",
+ missing: [],
+ request: {
+ mediaEntityId: "e1",
+ action: "move",
+ minSizeBytes: 0,
+ scrapeAfterImport: false,
+ onExisting: "skip",
+ namingMode: "keep",
+ targetLibraryRootId: "original",
+ episodeStructure: { groupId: "original-group", appliedToEntity: false },
+ },
+ });
+ expect(options).toMatchObject({
+ action: "move",
+ minSizeBytes: 0,
+ scrapeAfterImport: false,
+ onExisting: "skip",
+ naming: "keep",
+ target: { mode: "root", rootId: "lib:original" },
+ episodeStructures: { e1: { groupId: "original-group", apply: false } },
+ });
+ const request = organizeRequest(rowsFromIdentify([item("old.mkv")], new Set()), options, {
+ dryRun: true,
+ idempotencyKey: "test",
+ recoveryRecordId: "original-record",
+ });
+ expect(request.recoveryRecordId).toBe("original-record");
+ });
+ it("旧记录缺项仅预填已知事实,不臆造冲突策略;已验证目录优先于库根", () => {
+ const options = recoveredOptions(
+ defaultOptions({ onExisting: "conflict" }),
+ {
+ ready: false,
+ source: "plan",
+ missing: ["options"],
+ request: { action: "copy", targetLibraryRootId: "old" },
+ },
+ { root: "lib:actual", path: "Shows" },
+ );
+ expect(options.onExisting).toBe("conflict");
+ expect(options.target).toEqual({ mode: "dir", ref: { root: "lib:actual", path: "Shows" } });
+ });
+ it("历史季集显示限定原作品,用户编辑后不覆盖,也不把旧值当作新手动输入", () => {
+ const rows = rowsFromIdentify([item("show/a.mkv")], new Set());
+ const recovery = {
+ ready: true,
+ source: "request" as const,
+ missing: [],
+ request: {
+ mediaEntityId: "e1",
+ fileOverrides: { "/library/show/a.mkv": { season: 1, episodes: { start: 13, end: 14 } } },
+ },
+ };
+ const roots = [{ id: "lib:x", hostPath: "/library" }] as Parameters[2];
+ const restored = recoveredRows(rows, recovery, roots);
+ expect(restored[0]).toMatchObject({ season: "1", episodes: "13-14", episodeTouched: false });
+ const edited = [{ ...rows[0]!, episodes: "5", episodeTouched: true }];
+ expect(recoveredRows(edited, recovery, roots)[0]?.episodes).toBe("5");
+ const other = [{ ...rows[0]!, entity: { ...series, id: "other" } }];
+ expect(recoveredRows(other, recovery, roots)[0]?.episodes).toBe(rows[0]?.episodes);
+ });
+});
diff --git a/apps/web/app/components/organize-wizard/model.ts b/apps/web/app/components/organize-wizard/model.ts
index d282f4dc..3f10b3a1 100644
--- a/apps/web/app/components/organize-wizard/model.ts
+++ b/apps/web/app/components/organize-wizard/model.ts
@@ -1,5 +1,6 @@
// 整理向导的纯逻辑(docs/design/file-manager-v2.md §6):识别结果 → 行、未决判断、批量设置、请求体、预览统计、
// 来源换算(下载任务内容路径 → 登记目录 FileRef)。无 React 依赖,单测见 model.test.ts。
+import type { OrganizationRecovery } from "@umt/contracts";
import type {
FileIdentifyItem,
FileRef,
@@ -340,10 +341,17 @@ export function structureRequest(rows: WizardRow[], o: WizardOptions): FilesOrga
export function organizeRequest(
rows: WizardRow[],
o: WizardOptions,
- x: { dryRun: boolean; idempotencyKey: string; planIds?: string[]; sourceConfigId?: string },
+ x: {
+ dryRun: boolean;
+ idempotencyKey: string;
+ planIds?: string[];
+ sourceConfigId?: string;
+ recoveryRecordId?: string;
+ },
): FilesOrganizeRequest {
return {
idempotencyKey: x.idempotencyKey,
+ recoveryRecordId: x.recoveryRecordId,
items: organizeItems(rows, o),
action: o.action,
sourceConfigId: o.action === "in_place" ? undefined : x.sourceConfigId,
@@ -361,6 +369,65 @@ export function organizeRequest(
};
}
+/** 历史季集仅用于原作品且未编辑的行;实际请求仍由服务端恢复原覆写。 */
+export function recoveredRows(
+ rows: WizardRow[],
+ recovery: OrganizationRecovery | undefined,
+ roots: FileRoot[],
+): WizardRow[] {
+ const req = recovery?.request;
+ if (!req?.fileOverrides) return rows;
+ return rows.map((row) => {
+ if (row.episodeTouched || row.entity?.id !== req.mediaEntityId) return row;
+ const root = roots.find((r) => r.id === row.root);
+ const override = root ? req.fileOverrides?.[`${root.hostPath.replace(/\/+$/, "")}/${row.path}`] : undefined;
+ if (!override) return row;
+ return {
+ ...row,
+ item: override.season !== undefined && override.episodes ? { ...row.item, episodeUnresolved: false } : row.item,
+ season: override.season === undefined ? row.season : String(override.season),
+ episodes: override.episodes
+ ? override.episodes.start === override.episodes.end
+ ? String(override.episodes.start)
+ : `${override.episodes.start}-${override.episodes.end}`
+ : row.episodes,
+ };
+ });
+}
+
+/** 仅用历史中实际保存的设置覆盖当前默认值;缺项由向导要求用户确认。 */
+export function recoveredOptions(
+ base: WizardOptions,
+ recovery?: OrganizationRecovery,
+ targetDir?: FileRef,
+): WizardOptions {
+ const r = recovery?.request;
+ if (!r) return base;
+ return {
+ ...base,
+ ...(r.action ? { action: r.action } : {}),
+ ...(r.onExisting ? { onExisting: r.onExisting } : {}),
+ ...(r.namingMode ? { naming: r.namingMode } : {}),
+ ...(r.minSizeBytes !== undefined ? { minSizeBytes: r.minSizeBytes } : {}),
+ ...(r.scrapeAfterImport !== undefined ? { scrapeAfterImport: r.scrapeAfterImport } : {}),
+ ...(r.skipSuccessfulRecords !== undefined ? { skipSuccessfulRecords: r.skipSuccessfulRecords } : {}),
+ target: targetDir
+ ? { mode: "dir", ref: targetDir }
+ : r.targetLibraryRootId
+ ? { mode: "root", rootId: `lib:${r.targetLibraryRootId}` }
+ : base.target,
+ episodeStructures:
+ r.mediaEntityId && r.episodeStructure
+ ? {
+ [r.mediaEntityId]: {
+ groupId: r.episodeStructure.groupId,
+ apply: r.episodeStructure.appliedToEntity ?? false,
+ },
+ }
+ : base.episodeStructures,
+ };
+}
+
// ─── 预览 ────────────────────────────────────────────────────────
type GroupFile = FilesOrganizeGroup["files"][number];
diff --git a/apps/web/app/components/organize-wizard/organize-wizard.tsx b/apps/web/app/components/organize-wizard/organize-wizard.tsx
index 910add78..b3e49ca4 100644
--- a/apps/web/app/components/organize-wizard/organize-wizard.tsx
+++ b/apps/web/app/components/organize-wizard/organize-wizard.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import {
+ type FileRef,
type FilesOrganizeGroup,
filesErrorReason,
filesErrorText,
@@ -16,7 +17,9 @@ import { useOrganizeSources } from "~/api/organize-sources.ts";
import { apiParsed } from "~/api/parse.ts";
import { useSettings } from "~/api/settings.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
+import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
+import { mappingHref } from "~/components/episode-mapping/model.ts";
import { DirectoryDialog } from "~/components/files/directory-dialog.tsx";
import { baseName, newIdempotencyKey } from "~/components/files/model.ts";
import { matchingOrganizeSources } from "~/components/organize/source-match.ts";
@@ -34,12 +37,15 @@ import {
defaultOptions,
dialogRows,
entityLabel,
+ fileHasProblem,
groupExecutable,
MIB,
mergeIdentified,
organizeRequest,
patchRowEpisodes,
previewStats,
+ recoveredOptions,
+ recoveredRows,
rowProblem,
rowsFromIdentify,
structureEntities,
@@ -62,7 +68,17 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
const roots = rootsQ.data ?? [];
const settings = useSettings();
const profiles = useOrganizeSources();
- const src = useResolvedSource(source, rootsQ.data);
+ const resolved = useResolvedSource(source, rootsQ.data);
+ const [replacementSources, setReplacementSources] = useState(null);
+ const src = useMemo(
+ () => (replacementSources ? { ...resolved, items: replacementSources, unresolved: [] } : resolved),
+ [resolved, replacementSources],
+ );
+ const [sourcePicker, setSourcePicker] = useState(false);
+ const [advancedOpen, setAdvancedOpen] = useState(source.kind === "records");
+ const [recoveryConfirmed, setRecoveryConfirmed] = useState(false);
+ const needsReview = !!src.recovery?.missing.some((m) => m !== "sourcePaths");
+ const recoveryRecordId = source.kind === "record" ? source.recordId : undefined;
const matches = useMemo(() => {
const all = profiles.data?.items ?? [];
if (source.kind === "download") {
@@ -158,21 +174,27 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
});
const ready =
+ (rootsQ.isSuccess || filesMockEnabled()) &&
(settings.isFetched || settings.isError || filesMockEnabled()) &&
(profiles.isFetched || profiles.isError || filesMockEnabled());
const identifyMutate = identify.mutate;
useEffect(() => {
if (started || !ready || src.pending || !src.items.length || (ambiguous && !canInPlace)) return;
setStarted(true);
- const o = defaultOptions({
- action: canInPlace
- ? "in_place"
- : (profile?.action ??
- (source.kind === "files" ? source.defaultAction : undefined) ??
- (settings.data?.organize.defaultMode === "move" ? "move" : "copy")),
- minSizeBytes: settings.data?.organize.minSizeBytes ?? DEFAULT_MIN_SIZE,
- scrapeAfterImport: canInPlace && !canScrapeInPlace ? false : (settings.data?.organize.scrapeAfterImport ?? false),
- });
+ const o = recoveredOptions(
+ defaultOptions({
+ action: canInPlace
+ ? "in_place"
+ : (profile?.action ??
+ (source.kind === "files" ? source.defaultAction : undefined) ??
+ (settings.data?.organize.defaultMode === "move" ? "move" : "copy")),
+ minSizeBytes: settings.data?.organize.minSizeBytes ?? DEFAULT_MIN_SIZE,
+ scrapeAfterImport:
+ canInPlace && !canScrapeInPlace ? false : (settings.data?.organize.scrapeAfterImport ?? false),
+ }),
+ src.recovery,
+ src.targetDir,
+ );
setOptions(o);
setMinMb(String(o.minSizeBytes / MIB));
if (src.preset) {
@@ -216,10 +238,11 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
[rows, manual, mediaType],
);
const effectiveRows = useMemo(
- () => dialogRows(identified, manual, season, options.skipSuccessfulRecords),
- [identified, manual, season, options.skipSuccessfulRecords],
+ () => recoveredRows(dialogRows(identified, manual, season, options.skipSuccessfulRecords), src.recovery, roots),
+ [identified, manual, season, options.skipSuccessfulRecords, src.recovery, roots],
);
const active = effectiveRows.filter((r) => !r.excluded);
+ const workTitles = [...new Set(active.flatMap((r) => (r.entity ? [entityLabel(r.entity)] : [])))];
const showEntities = structureEntities(effectiveRows);
const stats = wizardStats(effectiveRows);
const hnrCount = active.filter((r) => r.hnrBlocked).length;
@@ -299,6 +322,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
Number(minMb) >= 0 &&
Number.isSafeInteger(Math.round(Number(minMb) * MIB));
const canPreview =
+ (!needsReview || recoveryConfirmed) &&
!ambiguous &&
!lookupPending &&
minSizeValid &&
@@ -307,48 +331,16 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
!identify.isPending &&
!reidentify.isPending &&
(inPlace || options.target.mode !== "dir" || !!options.target.ref.root);
- const runPreview = () => {
- if (!canPreview) return;
- const key = newIdempotencyKey();
- const snapshot = signature;
- setNotice(null);
- setPreview(null);
- setPreviewOpen(true);
- dry.mutate(
- organizeRequest(effectiveRows, options, {
- dryRun: true,
- idempotencyKey: `${key}-dry`,
- sourceConfigId: profile?.id,
- }),
- {
- onSuccess: (r) => {
- if (signatureRef.current !== snapshot) {
- setNotice("选项或识别结果有变化,重新生成预览");
- return;
- }
- setPreview({ groups: r.groups, rows: effectiveRows, key, signature: snapshot });
- },
- onError: (e) => toast.error(filesErrorText(e, "生成预览失败")),
- },
- );
- };
- const executable = preview?.groups.filter(groupExecutable) ?? [];
- const runExec = () => {
- if (
- !preview ||
- preview.signature !== signature ||
- !executable.length ||
- lookupPending ||
- !minSizeValid ||
- submitting.current
- )
- return;
+ const executePreview = (planned: NonNullable) => {
+ const executable = planned.groups.filter(groupExecutable);
+ if (planned.signature !== signatureRef.current || !executable.length || submitting.current) return;
submitting.current = true;
exec.mutate(
- organizeRequest(preview.rows, options, {
+ organizeRequest(planned.rows, options, {
dryRun: false,
- idempotencyKey: preview.key,
+ idempotencyKey: planned.key,
sourceConfigId: profile?.id,
+ recoveryRecordId,
planIds: executable.map((g) => g.planId as string),
}),
{
@@ -386,6 +378,51 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
},
);
};
+ const runPreview = (start = false) => {
+ if (!canPreview || submitting.current || dry.isPending || exec.isPending) return;
+ const key = newIdempotencyKey();
+ const snapshot = signature;
+ setNotice(null);
+ setPreview(null);
+ setPreviewOpen(!start);
+ submitting.current = true;
+ dry.mutate(
+ organizeRequest(effectiveRows, options, {
+ dryRun: true,
+ idempotencyKey: `${key}-dry`,
+ sourceConfigId: profile?.id,
+ recoveryRecordId,
+ }),
+ {
+ onSuccess: (r) => {
+ submitting.current = false;
+ if (signatureRef.current !== snapshot) {
+ setPreviewOpen(true);
+ setNotice("选项或识别结果有变化,重新生成预览");
+ return;
+ }
+ const planned = { groups: r.groups, rows: effectiveRows, key, signature: snapshot };
+ setPreview(planned);
+ if (start && r.groups.length && r.groups.every((g) => groupExecutable(g) && !g.files.some(fileHasProblem))) {
+ executePreview(planned);
+ } else {
+ setPreviewOpen(true);
+ if (start) setNotice("有文件需要处理,查看预览后再提交。");
+ }
+ },
+ onError: (e) => {
+ submitting.current = false;
+ setPreviewOpen(true);
+ toast.error(filesErrorText(e, "生成预览失败"));
+ },
+ },
+ );
+ };
+ const executable = preview?.groups.filter(groupExecutable) ?? [];
+ const runExec = () => {
+ if (preview && preview.signature === signature) executePreview(preview);
+ else runPreview(true);
+ };
const libRoots = roots.filter((r) => r.kind === "library" && r.writable && r.health !== "offline");
const targetValue =
options.target.mode === "auto" ? "auto" : options.target.mode === "dir" ? "dir" : options.target.rootId;
@@ -460,12 +497,13 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
),
},
];
- const loading = src.pending || (!started && !src.error && !!src.items.length && !ambiguous) || identify.isPending;
+ const loading =
+ src.pending || (!started && !src.error && !rootsQ.error && !!src.items.length && !ambiguous) || identify.isPending;
return (
<>
) : null}
- ({
- value: p.id,
- label: `${p.name} · ${p.action === "copy" ? "复制" : "移动"} · ${p.category ?? "自动分类"}`,
- })),
- ...(profile && !matches.some((p) => p.id === profile.id)
- ? [{ value: profile.id, label: profile.name }]
- : []),
- ]}
- disabled={exec.isPending || inPlace}
- />
- {profile && !inPlace ? (
-
- 目标:{roots.find((r) => r.id === profile.target.root)?.hostPath ?? profile.target.root}
- {profile.target.path ? `/${profile.target.path}` : ""} · {profile.category ?? "自动分类"}
+
+
+ {inPlace ? "原地整理" : options.action === "move" ? "移动整理" : "复制整理"} ·{" "}
+ {profile?.name ?? "按全局设置"}
- ) : null}
- {profiles.error ? (
-
- 读取整理来源失败,可以手动设置。
-
+
+ {options.target.mode === "dir"
+ ? `目标:${options.target.ref.path}`
+ : options.target.mode === "root"
+ ? `目标:${roots.find((r) => options.target.mode === "root" && r.id === options.target.rootId)?.name ?? "指定媒体库"}`
+ : "目标按分类与已有目录确定"}
+ {options.scrapeAfterImport ? " · 整理后刮削" : ""}
- ) : null}
- {!profiles.isPending && !profiles.data?.items.length ? (
-
- 新增整理来源
-
- ) : null}
-