refactor(web): simplify organize defaults and restore historical options
This commit is contained in:
1 parent
acf68ee03b
commit
c85b139ec4
11 files changed
+661
-439
No files matched your search
@@ -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<typeof recoveredRows>[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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
@@ -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<FileRef[] | null>(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<typeof preview>) => {
|
||||
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 (
|
||||
<>
|
||||
<Dialog open onOpenChange={(o) => !o && !submitting.current && onClose()}>
|
||||
<DialogContent
|
||||
showCloseButton={!exec.isPending}
|
||||
showCloseButton={!exec.isPending && !dry.isPending}
|
||||
className={cn(
|
||||
"flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 pg-float-solid sm:max-w-3xl",
|
||||
previewOpen && "sm:max-w-6xl",
|
||||
@@ -485,302 +523,395 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
{source.kind === "files" && source.scanSummary ? (
|
||||
<p className="text-meta text-muted-foreground">{source.scanSummary}</p>
|
||||
) : null}
|
||||
<FormSelect
|
||||
label={inPlace ? "整理位置" : "整理来源"}
|
||||
value={inPlace ? "none" : (profile?.id ?? (ambiguous ? "choose" : "none"))}
|
||||
onChange={pickProfile}
|
||||
choices={[
|
||||
...(ambiguous ? [{ value: "choose", label: "选择整理来源", disabled: true }] : []),
|
||||
{ value: "none", label: inPlace ? "原地整理 · 文件所在的媒体库目录" : "不用整理来源 · 按全局设置" },
|
||||
...matches.map((p) => ({
|
||||
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 ? (
|
||||
<p className="break-all text-meta text-muted-foreground">
|
||||
目标:{roots.find((r) => r.id === profile.target.root)?.hostPath ?? profile.target.root}
|
||||
{profile.target.path ? `/${profile.target.path}` : ""} · {profile.category ?? "自动分类"}
|
||||
<div className="space-y-1 rounded-xl border border-border px-3 py-3 text-caption">
|
||||
<p>
|
||||
{inPlace ? "原地整理" : options.action === "move" ? "移动整理" : "复制整理"} ·{" "}
|
||||
{profile?.name ?? "按全局设置"}
|
||||
</p>
|
||||
) : null}
|
||||
{profiles.error ? (
|
||||
<p className="text-meta text-warning">
|
||||
读取整理来源失败,可以手动设置。
|
||||
<button type="button" className="ml-2 underline" onClick={() => void profiles.refetch()}>
|
||||
重试
|
||||
</button>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{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 ? " · 整理后刮削" : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{!profiles.isPending && !profiles.data?.items.length ? (
|
||||
<Link
|
||||
className="text-meta text-muted-foreground underline"
|
||||
to="/console/settings#library/sources"
|
||||
onClick={onClose}
|
||||
>
|
||||
新增整理来源
|
||||
</Link>
|
||||
) : null}
|
||||
<fieldset disabled={exec.isPending} className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormSelect
|
||||
label="媒体类型"
|
||||
value={mediaType}
|
||||
onChange={(v) => {
|
||||
cancelLookup();
|
||||
setMediaType(v as typeof mediaType);
|
||||
setManual(null);
|
||||
if (
|
||||
v !== "auto" &&
|
||||
rows.some((r) => !r.excluded && (!r.entity || (v === "movie") !== (r.entity.kind === "movie")))
|
||||
) {
|
||||
reidentify.mutate(
|
||||
{
|
||||
items: rows.filter((r) => !r.excluded).map((r) => ({ root: r.root, path: r.path })),
|
||||
minSizeBytes: options.minSizeBytes,
|
||||
hints: { mediaKind: v === "movie" ? "movie" : "series" },
|
||||
},
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
setRows((rs) => mergeIdentified(rs, r.items));
|
||||
expectRemote(r.remoteTaskId);
|
||||
},
|
||||
onError: (e) => toast.error(filesErrorText(e, "按媒体类型重新识别失败。可以手动指定作品")),
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
choices={[
|
||||
{ value: "auto", label: "自动识别" },
|
||||
{ value: "movie", label: "电影" },
|
||||
{ value: "tv", label: "剧集" },
|
||||
]}
|
||||
disabled={identify.isPending || reidentify.isPending}
|
||||
/>
|
||||
<div className="space-y-1.5 text-caption">
|
||||
<span className="block text-muted-foreground">指定作品</span>
|
||||
<EntityPicker
|
||||
value={manual}
|
||||
mediaType={mediaType}
|
||||
onChange={(e) => {
|
||||
cancelLookup();
|
||||
pickEntity(e);
|
||||
}}
|
||||
placeholder="自动识别,或搜索作品"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_8rem]">
|
||||
<label htmlFor={`${formId}-tmdb`} className="space-y-1.5 text-caption">
|
||||
<span className="block text-muted-foreground">TMDB ID</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={`${formId}-tmdb`}
|
||||
aria-label="TMDB ID"
|
||||
value={tmdbId}
|
||||
onChange={(e) => {
|
||||
cancelLookup();
|
||||
setTmdbId(e.target.value);
|
||||
}}
|
||||
inputMode="numeric"
|
||||
placeholder={mediaType === "auto" ? "先选择电影或剧集" : "例如:693134"}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={mediaType === "auto" || !/^\d{1,12}$/.test(tmdbId.trim()) || lookupPending}
|
||||
onClick={() => void lookup()}
|
||||
>
|
||||
{lookupPending ? <LoaderCircle className="animate-spin" /> : <Search />}查找
|
||||
</Button>
|
||||
</div>
|
||||
</label>
|
||||
<label htmlFor={`${formId}-season`} className="space-y-1.5 text-caption">
|
||||
<span className="block text-muted-foreground">指定季号</span>
|
||||
<Input
|
||||
id={`${formId}-season`}
|
||||
aria-label="指定季号"
|
||||
inputMode="numeric"
|
||||
value={season}
|
||||
disabled={mediaType === "movie"}
|
||||
onChange={(e) => setSeason(e.target.value)}
|
||||
placeholder="自动"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{manual ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
全部文件按 {entityLabel(manual)}
|
||||
{manual.external ? ` · TMDB ${manual.external.externalId}` : ""} 整理。清除作品后恢复逐个识别。
|
||||
{workTitles.length ? (
|
||||
<p>
|
||||
{workTitles.slice(0, 3).join("、")}
|
||||
{workTitles.length > 3 ? ` 等 ${workTitles.length} 部作品` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormSelect
|
||||
label="整理方式"
|
||||
value={options.action}
|
||||
onChange={(v) =>
|
||||
set({
|
||||
action: v as WizardOptions["action"],
|
||||
...(v === "in_place" && !canScrapeInPlace ? { scrapeAfterImport: false } : {}),
|
||||
})
|
||||
}
|
||||
choices={[
|
||||
{ value: "in_place", label: "原地整理 · 保留位置与名称", disabled: !canInPlace },
|
||||
{ value: "copy", label: "复制 · 保留源文件" },
|
||||
{ value: "move", label: "移动 · 整理后删除源文件", disabled: hnrCount > 0 },
|
||||
]}
|
||||
/>
|
||||
{!inPlace ? (
|
||||
<FormSelect
|
||||
label="目标目录"
|
||||
value={targetValue}
|
||||
onChange={(v) => {
|
||||
if (v === "dir") setTargetPicker(true);
|
||||
else set({ target: v === "auto" ? { mode: "auto" } : { mode: "root", rootId: v } });
|
||||
}}
|
||||
choices={[
|
||||
{ value: "auto", label: profile ? `自动 · ${profile.name}` : "自动 · 按作品类型与已有目录" },
|
||||
...libRoots.map((r) => ({ value: r.id, label: r.name })),
|
||||
{ value: "dir", label: "选择目录…" },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<p className="self-end rounded-xl border border-border p-3 text-meta text-muted-foreground">
|
||||
文件留在原位置,不搬运、不改名,只登记到媒体库。可以在原目录写 NFO 和图片。
|
||||
</div>
|
||||
{source.kind === "records" ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
批量重新整理使用本次统一设置。要沿用各记录的洗版与文件覆写,逐条打开重新整理。
|
||||
</p>
|
||||
) : null}
|
||||
{src.recovery ? (
|
||||
<div className="space-y-2 text-caption">
|
||||
<p className="text-muted-foreground">
|
||||
{src.recovery.ready
|
||||
? "已恢复原整理设置。修改只对这次生效。"
|
||||
: "已填入历史记录中保留的设置;缺失部分需要确认。"}
|
||||
</p>
|
||||
{src.recovery.request.keepBoth || src.recovery.request.importMode === "replace" ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
沿用原任务:{src.recovery.request.keepBoth ? "保留双份文件" : ""}
|
||||
{src.recovery.request.importMode === "replace" ? " · 洗版替换指定资产" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!inPlace && options.target.mode === "dir" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto w-full justify-start break-all whitespace-normal text-left"
|
||||
onClick={() => setTargetPicker(true)}
|
||||
>
|
||||
{roots.find((r) => options.target.mode === "dir" && r.id === options.target.ref.root)?.hostPath}/
|
||||
{options.target.ref.path}
|
||||
</Button>
|
||||
) : null}
|
||||
{hnrCount && !inPlace ? (
|
||||
<p className="text-meta text-warning">{hnrCount} 个文件还要做种,只能复制。</p>
|
||||
) : null}
|
||||
{!inPlace ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormSelect
|
||||
label="目标文件已存在"
|
||||
value={options.onExisting}
|
||||
onChange={(v) => set({ onExisting: v as WizardOptions["onExisting"] })}
|
||||
choices={[
|
||||
{ value: "conflict", label: "标为需要处理" },
|
||||
{ value: "skip", label: "跳过" },
|
||||
{ value: "replace_with_verify", label: "核验新文件后替换" },
|
||||
]}
|
||||
/>
|
||||
<FormSelect
|
||||
label="命名方式"
|
||||
value={options.naming}
|
||||
onChange={(v) => set({ naming: v as WizardOptions["naming"] })}
|
||||
choices={[
|
||||
{ value: "template", label: "按命名模板" },
|
||||
{ value: "keep", label: "保留原文件名" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showEntities.slice(0, 5).map((e) => (
|
||||
<StructurePicker
|
||||
key={e.id}
|
||||
entity={e}
|
||||
showTitle={showEntities.length > 1}
|
||||
value={options.episodeStructures[e.id!]}
|
||||
onChange={(v) =>
|
||||
setOptions((o) => {
|
||||
const next = { ...o.episodeStructures };
|
||||
if (v) next[e.id!] = v;
|
||||
else delete next[e.id!];
|
||||
return { ...o, episodeStructures: next };
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-border px-3 py-2">
|
||||
<span className="text-caption">
|
||||
整理后刮削
|
||||
<span className="block text-meta text-muted-foreground">整理完成后写 NFO 和图片</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={options.scrapeAfterImport}
|
||||
onCheckedChange={(v) => set({ scrapeAfterImport: v })}
|
||||
disabled={inPlace && !canScrapeInPlace}
|
||||
aria-label="整理后刮削"
|
||||
/>
|
||||
</div>
|
||||
{!isHistory && successfulCount > 0 ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-border px-3 py-2">
|
||||
<span className="text-caption">
|
||||
跳过整理过的文件
|
||||
<span className="block text-meta text-muted-foreground">
|
||||
{successfulCount} 个文件以前整理成功过。只对这次生效
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={options.skipSuccessfulRecords}
|
||||
onCheckedChange={(v) => set({ skipSuccessfulRecords: v })}
|
||||
aria-label="跳过整理过的文件"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<details className="rounded-xl border border-border px-3 py-2">
|
||||
<summary className="cursor-pointer text-caption">更多选项</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<label htmlFor={`${formId}-size`} className="flex items-center justify-between gap-3 text-caption">
|
||||
最小视频体积(MiB)
|
||||
<Input
|
||||
id={`${formId}-size`}
|
||||
aria-label="最小视频体积(MiB)"
|
||||
className="w-24"
|
||||
inputMode="decimal"
|
||||
value={minMb}
|
||||
disabled={identify.isPending || reidentify.isPending || !started}
|
||||
aria-invalid={!minSizeValid || undefined}
|
||||
onChange={(e) => changeMinSize(e.target.value)}
|
||||
) : null}
|
||||
{needsReview ? (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={recoveryConfirmed}
|
||||
onChange={(e) => setRecoveryConfirmed(e.target.checked)}
|
||||
/>
|
||||
我已检查高级选项{src.recovery.missing.includes("episodeStructure") ? "及季集结构" : ""}
|
||||
,按当前设置整理
|
||||
</label>
|
||||
{!minSizeValid ? (
|
||||
<p role="alert" className="text-meta text-destructive">
|
||||
填一个不小于 0 的数
|
||||
) : null}
|
||||
{src.recovery.missing.includes("sourcePaths") ? (
|
||||
<Button variant="outline" onClick={() => setSourcePicker(true)}>
|
||||
补选来源目录
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<details
|
||||
open={advancedOpen || ambiguous || (needsReview && !recoveryConfirmed)}
|
||||
onToggle={(e) => setAdvancedOpen(e.currentTarget.open)}
|
||||
className="rounded-xl border border-border p-3"
|
||||
>
|
||||
<summary className="cursor-pointer text-caption">高级选项 · 仅本次生效</summary>
|
||||
<div className="mt-4 space-y-4">
|
||||
<FormSelect
|
||||
label={inPlace ? "整理位置" : "整理来源"}
|
||||
value={inPlace ? "none" : (profile?.id ?? (ambiguous ? "choose" : "none"))}
|
||||
onChange={pickProfile}
|
||||
choices={[
|
||||
...(ambiguous ? [{ value: "choose", label: "选择整理来源", disabled: true }] : []),
|
||||
{
|
||||
value: "none",
|
||||
label: inPlace ? "原地整理 · 文件所在的媒体库目录" : "不用整理来源 · 按全局设置",
|
||||
},
|
||||
...matches.map((p) => ({
|
||||
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 || dry.isPending || inPlace}
|
||||
/>
|
||||
{profile && !inPlace ? (
|
||||
<p className="break-all text-meta text-muted-foreground">
|
||||
目标:{roots.find((r) => r.id === profile.target.root)?.hostPath ?? profile.target.root}
|
||||
{profile.target.path ? `/${profile.target.path}` : ""} · {profile.category ?? "自动分类"}
|
||||
</p>
|
||||
) : null}
|
||||
{profiles.error ? (
|
||||
<p className="text-meta text-warning">
|
||||
读取整理来源失败,可以手动设置。
|
||||
<button type="button" className="ml-2 underline" onClick={() => void profiles.refetch()}>
|
||||
重试
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
{!profiles.isPending && !profiles.data?.items.length ? (
|
||||
<Link
|
||||
className="text-meta text-muted-foreground underline"
|
||||
to="/console/settings#library/sources"
|
||||
onClick={onClose}
|
||||
>
|
||||
新增整理来源
|
||||
</Link>
|
||||
) : null}
|
||||
<fieldset disabled={exec.isPending || dry.isPending} className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormSelect
|
||||
label="媒体类型"
|
||||
value={mediaType}
|
||||
onChange={(v) => {
|
||||
cancelLookup();
|
||||
setMediaType(v as typeof mediaType);
|
||||
setManual(null);
|
||||
if (
|
||||
v !== "auto" &&
|
||||
rows.some(
|
||||
(r) => !r.excluded && (!r.entity || (v === "movie") !== (r.entity.kind === "movie")),
|
||||
)
|
||||
) {
|
||||
reidentify.mutate(
|
||||
{
|
||||
items: rows.filter((r) => !r.excluded).map((r) => ({ root: r.root, path: r.path })),
|
||||
minSizeBytes: options.minSizeBytes,
|
||||
hints: { mediaKind: v === "movie" ? "movie" : "series" },
|
||||
},
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
setRows((rs) => mergeIdentified(rs, r.items));
|
||||
expectRemote(r.remoteTaskId);
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(filesErrorText(e, "按媒体类型重新识别失败。可以手动指定作品")),
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
choices={[
|
||||
{ value: "auto", label: "自动识别" },
|
||||
{ value: "movie", label: "电影" },
|
||||
{ value: "tv", label: "剧集" },
|
||||
]}
|
||||
disabled={identify.isPending || reidentify.isPending}
|
||||
/>
|
||||
<div className="space-y-1.5 text-caption">
|
||||
<span className="block text-muted-foreground">指定作品</span>
|
||||
<EntityPicker
|
||||
value={manual}
|
||||
mediaType={mediaType}
|
||||
onChange={(e) => {
|
||||
cancelLookup();
|
||||
pickEntity(e);
|
||||
}}
|
||||
placeholder="自动识别,或搜索作品"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_8rem]">
|
||||
<label htmlFor={`${formId}-tmdb`} className="space-y-1.5 text-caption">
|
||||
<span className="block text-muted-foreground">TMDB ID</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={`${formId}-tmdb`}
|
||||
aria-label="TMDB ID"
|
||||
value={tmdbId}
|
||||
onChange={(e) => {
|
||||
cancelLookup();
|
||||
setTmdbId(e.target.value);
|
||||
}}
|
||||
inputMode="numeric"
|
||||
placeholder={mediaType === "auto" ? "先选择电影或剧集" : "例如:693134"}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={mediaType === "auto" || !/^\d{1,12}$/.test(tmdbId.trim()) || lookupPending}
|
||||
onClick={() => void lookup()}
|
||||
>
|
||||
{lookupPending ? <LoaderCircle className="animate-spin" /> : <Search />}查找
|
||||
</Button>
|
||||
</div>
|
||||
</label>
|
||||
<label htmlFor={`${formId}-season`} className="space-y-1.5 text-caption">
|
||||
<span className="block text-muted-foreground">指定季号</span>
|
||||
<Input
|
||||
id={`${formId}-season`}
|
||||
aria-label="指定季号"
|
||||
inputMode="numeric"
|
||||
value={season}
|
||||
disabled={mediaType === "movie"}
|
||||
onChange={(e) => setSeason(e.target.value)}
|
||||
placeholder="自动"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{showEntities.length ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
手动指定季号只对本次生效。需要后续文件沿用时,到作品的集数映射设置:
|
||||
{showEntities.slice(0, 5).map((e) => (
|
||||
<Link
|
||||
key={e.id}
|
||||
className="ml-2 underline"
|
||||
to={mappingHref(consolePath("library", e.id!))}
|
||||
onClick={onClose}
|
||||
>
|
||||
{e.title}
|
||||
</Link>
|
||||
))}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-3 text-caption">
|
||||
刷新元数据
|
||||
{manual ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
全部文件按 {entityLabel(manual)}
|
||||
{manual.external ? ` · TMDB ${manual.external.externalId}` : ""} 整理。清除作品后恢复逐个识别。
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormSelect
|
||||
label="整理方式"
|
||||
value={options.action}
|
||||
onChange={(v) =>
|
||||
set({
|
||||
action: v as WizardOptions["action"],
|
||||
...(v === "in_place" && !canScrapeInPlace ? { scrapeAfterImport: false } : {}),
|
||||
})
|
||||
}
|
||||
choices={[
|
||||
{ value: "in_place", label: "原地整理 · 保留位置与名称", disabled: !canInPlace },
|
||||
{ value: "copy", label: "复制 · 保留源文件" },
|
||||
{ value: "move", label: "移动 · 整理后删除源文件", disabled: hnrCount > 0 },
|
||||
]}
|
||||
/>
|
||||
{!inPlace ? (
|
||||
<FormSelect
|
||||
label="目标目录"
|
||||
value={targetValue}
|
||||
onChange={(v) => {
|
||||
if (v === "dir") setTargetPicker(true);
|
||||
else set({ target: v === "auto" ? { mode: "auto" } : { mode: "root", rootId: v } });
|
||||
}}
|
||||
choices={[
|
||||
{
|
||||
value: "auto",
|
||||
label: profile ? `自动 · ${profile.name}` : "自动 · 按作品类型与已有目录",
|
||||
},
|
||||
...libRoots.map((r) => ({ value: r.id, label: r.name })),
|
||||
{ value: "dir", label: "选择目录…" },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<p className="self-end rounded-xl border border-border p-3 text-meta text-muted-foreground">
|
||||
文件留在原位置,不搬运、不改名,只登记到媒体库。可以在原目录写 NFO 和图片。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!inPlace && options.target.mode === "dir" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto w-full justify-start break-all whitespace-normal text-left"
|
||||
onClick={() => setTargetPicker(true)}
|
||||
>
|
||||
{roots.find((r) => options.target.mode === "dir" && r.id === options.target.ref.root)?.hostPath}
|
||||
/{options.target.ref.path}
|
||||
</Button>
|
||||
) : null}
|
||||
{hnrCount && !inPlace ? (
|
||||
<p className="text-meta text-warning">{hnrCount} 个文件还要做种,只能复制。</p>
|
||||
) : null}
|
||||
{!inPlace ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormSelect
|
||||
label="目标文件已存在"
|
||||
value={options.onExisting}
|
||||
onChange={(v) => set({ onExisting: v as WizardOptions["onExisting"] })}
|
||||
choices={[
|
||||
{ value: "conflict", label: "标为需要处理" },
|
||||
{ value: "skip", label: "跳过" },
|
||||
{ value: "replace_with_verify", label: "核验新文件后替换" },
|
||||
]}
|
||||
/>
|
||||
<FormSelect
|
||||
label="命名方式"
|
||||
value={options.naming}
|
||||
onChange={(v) => set({ naming: v as WizardOptions["naming"] })}
|
||||
choices={[
|
||||
{ value: "template", label: "按命名模板" },
|
||||
{ value: "keep", label: "保留原文件名" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showEntities.slice(0, 5).map((e) => (
|
||||
<StructurePicker
|
||||
key={e.id}
|
||||
entity={e}
|
||||
showTitle={showEntities.length > 1}
|
||||
value={options.episodeStructures[e.id!]}
|
||||
onChange={(v) =>
|
||||
setOptions((o) => {
|
||||
const next = { ...o.episodeStructures };
|
||||
if (v) next[e.id!] = v;
|
||||
else delete next[e.id!];
|
||||
return { ...o, episodeStructures: next };
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-border px-3 py-2">
|
||||
<span className="text-caption">
|
||||
整理后刮削
|
||||
<span className="block text-meta text-muted-foreground">整理完成后写 NFO 和图片</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={options.refreshMetadata}
|
||||
onCheckedChange={(v) => set({ refreshMetadata: v })}
|
||||
aria-label="刷新元数据"
|
||||
checked={options.scrapeAfterImport}
|
||||
onCheckedChange={(v) => set({ scrapeAfterImport: v })}
|
||||
disabled={inPlace && !canScrapeInPlace}
|
||||
aria-label="整理后刮削"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
同名字幕和外挂音轨随视频一起整理。NFO 和图片由刮削生成。
|
||||
<Link to="/console/settings#library/naming" className="underline" onClick={onClose}>
|
||||
修改命名模板
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</fieldset>
|
||||
{!isHistory && successfulCount > 0 ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-border px-3 py-2">
|
||||
<span className="text-caption">
|
||||
跳过整理过的文件
|
||||
<span className="block text-meta text-muted-foreground">
|
||||
{successfulCount} 个文件以前整理成功过。只对这次生效
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={options.skipSuccessfulRecords}
|
||||
onCheckedChange={(v) => set({ skipSuccessfulRecords: v })}
|
||||
aria-label="跳过整理过的文件"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="border-t border-border pt-2">
|
||||
<div className="mt-3 space-y-3">
|
||||
<label
|
||||
htmlFor={`${formId}-size`}
|
||||
className="flex items-center justify-between gap-3 text-caption"
|
||||
>
|
||||
最小视频体积(MiB)
|
||||
<Input
|
||||
id={`${formId}-size`}
|
||||
aria-label="最小视频体积(MiB)"
|
||||
className="w-24"
|
||||
inputMode="decimal"
|
||||
value={minMb}
|
||||
disabled={identify.isPending || reidentify.isPending || !started}
|
||||
aria-invalid={!minSizeValid || undefined}
|
||||
onChange={(e) => changeMinSize(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{!minSizeValid ? (
|
||||
<p role="alert" className="text-meta text-destructive">
|
||||
填一个不小于 0 的数
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-3 text-caption">
|
||||
刷新元数据
|
||||
<Switch
|
||||
checked={options.refreshMetadata}
|
||||
onCheckedChange={(v) => set({ refreshMetadata: v })}
|
||||
aria-label="刷新元数据"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
同名字幕和外挂音轨随视频一起整理。NFO 和图片由刮削生成。
|
||||
<Link to="/console/settings#library/naming" className="underline" onClick={onClose}>
|
||||
修改命名模板
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</details>
|
||||
{loading ? (
|
||||
<p role="status" className="flex items-center gap-2 text-caption text-muted-foreground">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
读取文件并识别作品…
|
||||
</p>
|
||||
) : src.error || (!src.items.length && !src.pending) ? (
|
||||
) : src.error || rootsQ.error || (!src.items.length && !src.pending) ? (
|
||||
<p role="alert" className="text-caption text-destructive">
|
||||
{src.error
|
||||
? filesErrorText(src.error, "读取来源失败")
|
||||
: "源文件不存在或不在登记目录里。回到文件管理重新选择"}
|
||||
{src.error || rootsQ.error
|
||||
? filesErrorText(src.error ?? rootsQ.error, "读取来源失败")
|
||||
: src.recovery?.missing.includes("sourcePaths")
|
||||
? "历史来源已缺失,请补选来源目录。"
|
||||
: "源文件不存在或不在登记目录里。确认文件位置与登记目录后重试。"}
|
||||
</p>
|
||||
) : identify.error ? (
|
||||
<div>
|
||||
@@ -799,6 +930,11 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
{!rows.length && started ? (
|
||||
<p className="text-meta text-muted-foreground">所选范围没有视频文件。</p>
|
||||
) : null}
|
||||
{stats.pending ? (
|
||||
<p className="text-meta text-warning">
|
||||
展开文件列表确认作品与季集;需要统一指定作品时,使用高级选项。
|
||||
</p>
|
||||
) : null}
|
||||
<Button variant="ghost" size="sm" onClick={() => setFilesOpen((v) => !v)}>
|
||||
{filesOpen ? "收起文件列表" : "查看文件和季集"}
|
||||
</Button>
|
||||
@@ -853,7 +989,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border px-4 py-3 sm:px-5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={exec.isPending}
|
||||
disabled={exec.isPending || dry.isPending}
|
||||
onClick={() => {
|
||||
if (submitting.current) return;
|
||||
onClose();
|
||||
@@ -864,15 +1000,19 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
队列
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={runPreview} disabled={!canPreview || dry.isPending || exec.isPending}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => runPreview()}
|
||||
disabled={!canPreview || dry.isPending || exec.isPending}
|
||||
>
|
||||
<Eye />
|
||||
{dry.isPending ? "预览中…" : "生成预览"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={runExec}
|
||||
disabled={
|
||||
!executable.length ||
|
||||
preview?.signature !== signature ||
|
||||
!canPreview ||
|
||||
(preview !== null && !executable.length) ||
|
||||
exec.isPending ||
|
||||
dry.isPending ||
|
||||
lookupPending ||
|
||||
@@ -880,12 +1020,30 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
|
||||
}
|
||||
>
|
||||
{exec.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
{exec.isPending ? "提交中…" : "加入队列"}
|
||||
{exec.isPending
|
||||
? "提交中…"
|
||||
: dry.isPending
|
||||
? "检查中…"
|
||||
: preview
|
||||
? `加入队列(${executable.length} 组)`
|
||||
: "开始整理"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{sourcePicker ? (
|
||||
<DirectoryDialog
|
||||
mode="source"
|
||||
allowRoot={false}
|
||||
onClose={() => setSourcePicker(false)}
|
||||
onSelect={(ref) => {
|
||||
setReplacementSources([ref]);
|
||||
setSourcePicker(false);
|
||||
setStarted(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{targetPicker ? (
|
||||
<DirectoryDialog
|
||||
mode="target"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 整理弹窗的来源:各入口传入的上下文 → 待整理文件(文件管理选中项 / 下载内容 / 整理记录源路径)
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { CursorPage, PathMapping } from "@umt/contracts";
|
||||
import type { CursorPage, OrganizationRecovery, PathMapping } from "@umt/contracts";
|
||||
import { useMemo } from "react";
|
||||
import { type FileRef, type FileRoot, filesMockEnabled, useRecordSources, useRecordSourcesMany } from "~/api/files.ts";
|
||||
import { useRecord } from "~/api/records.ts";
|
||||
@@ -35,6 +35,8 @@ export type OrganizeSource =
|
||||
| { kind: "records"; recordIds: string[]; preset?: WizardEntity | null };
|
||||
|
||||
export type Resolved = {
|
||||
recovery?: OrganizationRecovery;
|
||||
targetDir?: FileRef;
|
||||
items: FileRef[];
|
||||
hnrPaths: string[];
|
||||
unresolved: string[];
|
||||
@@ -107,11 +109,14 @@ export function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | un
|
||||
label,
|
||||
};
|
||||
}
|
||||
if (recSrc.isPending) return { ...base, items: [], hnrPaths: [], label: "整理记录", pending: true };
|
||||
if (recSrc.isPending || rec.isPending)
|
||||
return { ...base, items: [], hnrPaths: [], label: "整理记录", pending: true };
|
||||
if (recSrc.error) return { ...base, items: [], hnrPaths: [], label: "整理记录", error: recSrc.error };
|
||||
const m = rec.data?.media;
|
||||
return {
|
||||
...base,
|
||||
recovery: recSrc.data?.recovery ?? rec.data?.recovery,
|
||||
targetDir: recSrc.data?.targetDir,
|
||||
profileId: recSrc.data?.sourceConfigId,
|
||||
items: recSrc.data?.items ?? [],
|
||||
hnrPaths: [],
|
||||
@@ -131,6 +136,7 @@ export function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | un
|
||||
recSrc.error,
|
||||
recSrc.data,
|
||||
rec.data,
|
||||
rec.isPending,
|
||||
many.isPending,
|
||||
many.error,
|
||||
many.data,
|
||||
|
||||
@@ -24,11 +24,15 @@ export function StructurePicker({
|
||||
}) {
|
||||
const q = useEpisodeGroups(entity.id ?? undefined);
|
||||
const list = q.data;
|
||||
if (!list?.applicable || (!list.groups.length && !list.current)) return null;
|
||||
if (!list?.applicable || (!list.groups.length && !list.current && !value)) return null;
|
||||
const current = list.current;
|
||||
const choices = [
|
||||
{ value: FOLLOW, label: `跟随作品(当前:${current?.name ?? "TMDB 默认季"})` },
|
||||
...(current ? [{ value: DEFAULT, label: "TMDB 默认季" }] : []),
|
||||
...(value?.groupId && value.groupId === current?.groupId ? [{ value: current.groupId, label: current.name }] : []),
|
||||
...(current || value ? [{ value: DEFAULT, label: "TMDB 默认季" }] : []),
|
||||
...(value?.groupId && value.groupId !== current?.groupId && !list.groups.some((g) => g.id === value.groupId)
|
||||
? [{ value: value.groupId, label: `原任务剧集组 · ${value.groupId}` }]
|
||||
: []),
|
||||
...list.groups
|
||||
.filter((g) => g.id !== current?.groupId)
|
||||
.map((g) => ({
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { filesErrorText } from "~/api/files.ts";
|
||||
import { useOrganizeSources } from "~/api/organize-sources.ts";
|
||||
import type { OrganizeSource } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
|
||||
import { matchingOrganizeSources } from "./source-match.ts";
|
||||
|
||||
type FileSource = Extract<OrganizeSource, { kind: "files" }>;
|
||||
/** Wait for profiles even on the first click; ambiguous sources require an explicit choice. */
|
||||
export function FileSourceSelection({
|
||||
source,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
source: FileSource;
|
||||
onSelect: (source: FileSource) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const profiles = useOrganizeSources();
|
||||
const matches = matchingOrganizeSources(source.items, profiles.data?.items ?? []);
|
||||
useEffect(() => {
|
||||
if (!profiles.isSuccess || matches.length > 1) return;
|
||||
const profile = matches[0];
|
||||
onSelect(
|
||||
profile
|
||||
? { ...source, sourceConfigId: profile.id, defaultAction: profile.action, label: `整理来源 · ${profile.name}` }
|
||||
: source,
|
||||
);
|
||||
}, [profiles.isSuccess, matches, source, onSelect]);
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{profiles.isPending ? "读取整理来源…" : "选择整理来源"}</DialogTitle>
|
||||
<DialogDescription>整理时沿用所选整理来源的整理方式、目标目录和分类。</DialogDescription>
|
||||
</DialogHeader>
|
||||
{profiles.isPending ? (
|
||||
<p role="status" className="text-caption text-muted-foreground">
|
||||
匹配整理来源…
|
||||
</p>
|
||||
) : profiles.error ? (
|
||||
<>
|
||||
<p role="alert" className="text-caption text-destructive">
|
||||
{filesErrorText(profiles.error, "读取整理来源失败")}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void profiles.refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
</>
|
||||
) : matches.length > 1 ? (
|
||||
<div className="max-h-72 space-y-2 overflow-auto">
|
||||
{matches.map((profile) => (
|
||||
<Button
|
||||
key={profile.id}
|
||||
variant="outline"
|
||||
className="h-auto w-full justify-start whitespace-normal py-3 text-left"
|
||||
onClick={() =>
|
||||
onSelect({
|
||||
...source,
|
||||
sourceConfigId: profile.id,
|
||||
defaultAction: profile.action,
|
||||
label: `整理来源 · ${profile.name}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
{profile.name} · {profile.action === "copy" ? "复制" : "移动"} · {profile.category ?? "自动分类"}
|
||||
{!profile.enabled ? " · 已停用" : ""}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!profiles.isPending && (profiles.error || matches.length > 1) ? (
|
||||
<Button variant="outline" onClick={() => onSelect(source)}>
|
||||
不用整理来源,手动设置
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { OrganizeSourceView, OrganizeStockResult } from "@umt/contracts";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { filesErrorText } from "~/api/files.ts";
|
||||
import { useOrganizeSources, useOrganizeStock } from "~/api/organize-sources.ts";
|
||||
@@ -42,12 +41,13 @@ export function SourceSelection({
|
||||
const sources = useOrganizeSources();
|
||||
const stock = useOrganizeStock();
|
||||
const [manual, setManual] = useState(false);
|
||||
if (manual)
|
||||
const direct = sources.isSuccess && !sources.data.items.length;
|
||||
if (manual || direct)
|
||||
return (
|
||||
<DirectoryDialog
|
||||
mode="source"
|
||||
allowRoot={false}
|
||||
onClose={() => setManual(false)}
|
||||
onClose={direct ? onClose : () => setManual(false)}
|
||||
onSelect={(ref) => onSelect({ kind: "files", items: [ref], label: "手动选择的目录" })}
|
||||
/>
|
||||
);
|
||||
@@ -56,7 +56,7 @@ export function SourceSelection({
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择整理来源</DialogTitle>
|
||||
<DialogDescription>扫描来源目录里已有的文件,预览后再整理。扫描不改变自动整理的基线。</DialogDescription>
|
||||
<DialogDescription>选择来源,扫描已有文件后开始整理。扫描不改变自动整理的基线。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-72 space-y-2 overflow-auto">
|
||||
{sources.isPending ? <p className="text-caption text-muted-foreground">加载中…</p> : null}
|
||||
@@ -101,15 +101,6 @@ export function SourceSelection({
|
||||
<Button variant="outline" disabled={stock.isPending} onClick={() => setManual(true)}>
|
||||
手动选择目录
|
||||
</Button>
|
||||
{sources.isSuccess && !sources.data.items.length ? (
|
||||
<Link
|
||||
to="/console/settings#library/sources"
|
||||
onClick={onClose}
|
||||
className="text-caption text-muted-foreground underline"
|
||||
>
|
||||
新增整理来源
|
||||
</Link>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -122,7 +122,6 @@ type Dialog =
|
||||
| { kind: "refresh"; items: FileEntry[] }
|
||||
| { kind: "scrape"; items: FileEntry[] }
|
||||
| { kind: "wizard"; source: OrganizeSource }
|
||||
| { kind: "wizardSource"; source: Extract<OrganizeSource, { kind: "files" }> }
|
||||
| { kind: "shortcuts" };
|
||||
|
||||
/** 宽布局:内容区不受全站最大宽度限制,目录树贴着侧边栏(layout.tsx 读 handle.layout) */
|
||||
|
||||
@@ -99,6 +99,10 @@ curl -H "Authorization: Bearer umt-dev-admin-key" "localhost:4390/api/v1/events?
|
||||
|
||||
整理恢复区分两种情况:文件执行中断从原冻结计划和持久文件结果继续;作品或季集事实纠正后,通过 `retryOrganizeRecord` 从原业务请求重新规划。同一原记录的重规划串行,避免事件和巡检同时冻结重复计划。原请求保存一次性目标、操作方式和覆写,函数、任务身份与路径守卫不序列化,恢复时重新检查来源边界。已成功文件与资产保持原状,恢复主文件失败状态时也恢复其附属文件依赖。
|
||||
|
||||
来源身份复用既有 `external_identity`,只持久化经过 TMDB 校验的辅助来源映射。Bangumi/豆瓣允许同一作品有多个分季身份,来源 ID 的全局唯一归属保持不变;需要单值 ID 的命名、刮削等消费者必须显式处理多值,不能随机取一条。身份解析不顺带改写资源季集,季集继续由所属模块换算。
|
||||
|
||||
历史记录恢复仍走现有整理入口:从原请求、可信旧任务输入到冻结计划依次提取事实,区分可直接重试与需要补齐的预填。文件管理通过 `recoveryRecordId` 交给业务层恢复下载关联、洗版与未编辑覆写,表单只覆盖明确支持的本次选项;来源范围和隐含参数共同参与预览校验。前端共用恢复选项的纯转换,不另造恢复向导或第二套请求模型;默认开始整理和手动预览共用冻结计划与入队函数。
|
||||
|
||||
季集计算和识别词归 sources-identity,整理请求、文件操作与入库状态归 acquisition-organize。整理把真实失败文件和同批正常文件样本交给映射事项,模拟与写入共用同一判断;映射重算后以 `media.episode_mapping_changed` 通知整理模块,只有季集已经确定的未完成记录重新规划。恢复预检与规划复用 `planFileEpisodes`,保留原资源的发布时间等解析上下文。Agent 的调查运行、预算和授权沿用现有机制,不另外创建恢复框架;身份补齐、重新排队和实际入库完成分别按业务事实核实。
|
||||
|
||||
维护时先验证真实的成功、失败和恢复路径,尤其是授权、文件替换、取消与外部副作用;删除空壳或无使用者的机制时同步删除其专属测试与文档。不要为了目录对称增加转发层,也不要把不同阶段的状态合并成一个枚举。
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
作品有已接受的外部身份,且同一种文字(中文 / 拉丁)下已有确认的外部标题(主标题 / 原标题 / TMDB 译名与别名),这个名称对不上
|
||||
(`conflicts_external_title`);这个名称已经挂在别的作品上(`owned_by_other`)。
|
||||
|
||||
已通过 TMDB 结果验证的 Bangumi/豆瓣身份保存到现有外部身份表,后续发布和发现页优先复用 accepted 映射,避免重复访问辅助源。动漫的不同季可各有 Bangumi/豆瓣 ID,但关联同一 TMDB 剧集;撤销按单条身份处理。一个来源 ID 仍只能属于一个作品,有冲突时不会自动抢占或合并。命名、刮削等只能接收一个作品级 ID 的地方,存在多个分季 ID 时不任选其中一个。
|
||||
|
||||
资源的来源季集与作品身份分别处理。辅助源用于定位 TMDB 作品,不用辅助源的分季覆盖原资源季集;之后继续通过现有作品识别词与季集映射换算。含明确外部 ID 的资源即使复杂标题没有解析出名称,也先尝试 ID 链路。BT 仍沿用现有适配器与解析器,不另建动漫执行流程。
|
||||
|
||||
标题更新(RSS 标题与列表标题不同、站点改标题):按新标题重新解析,发 `release.observed(isNew=false)`,以 `force` 重新识别
|
||||
(已识别的也不短路,触发来源仍是 observed);重新识别不能确认时撤销原自动绑定、进池。人工锁定的不动。
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
- 契约:`packages/contracts/src/organization.ts`(`OrganizationListQuery`、`OrganizationStats*`、`OrganizationDelete*`),端点 `Api.organization.stats / remove`。
|
||||
- 前端:`apps/web/app/routes/console/organize.tsx`、`organize.$id.tsx`,`components/organize/`(删除确认框、标题 / 季集小件),批量重新整理走共用整理弹窗的 `{ kind: "records" }` 来源。
|
||||
|
||||
## 统一整理弹窗(v0.3.13)
|
||||
## 统一整理弹窗
|
||||
|
||||
手动选目录、文件管理器、下载详情与历史重新整理打开同一个弹窗。单文件只处理该文件,目录递归展开视频,多选合并去重。媒体类型可自动、电影或剧集;作品可自动识别、搜索本地/TMDB,也可选定类型后输入 TMDB ID 定位。手动指定作品与季号作用于整个本次范围,混合批次中需单独纠正的作品另行选取;文件明细可核对和修正季集、排除文件,没有另一套批量勾选语义。
|
||||
手动选目录、文件管理器、下载详情与历史重新整理打开同一个弹窗。默认展示识别作品、方式与目标摘要,点击「开始整理」先冻结预览,全部可执行时直接加入队列;有冲突或失败则展开预览,不自动提交。预览也可主动打开。作品、季号、目标和其他一次性设置收在「高级选项」,来源匹配不唯一或旧记录缺设置时自动展开。没有来源配置时直接选目录,不经过空的来源选择页。单文件只处理该文件,目录递归展开视频,多选合并去重。媒体类型可自动、电影或剧集;作品可自动识别、搜索本地/TMDB,也可选定类型后输入 TMDB ID 定位。手动指定作品与季号作用于整个本次范围,混合批次中需单独纠正的作品另行选取;文件明细可核对和修正季集、排除文件,没有另一套批量勾选语义。
|
||||
|
||||
各入口匹配相同来源配置,唯一匹配自动带出方式和分类/目标,多个匹配在弹窗内选定;不使用来源配置时沿用全局默认并手动指定。下载详情优先按已有 qB 标签匹配覆盖当前路径的来源。历史原配置仍覆盖可用输入时优先采用;成功移动后的目标不再属于旧来源时显示当前匹配配置或手动选项。
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
手动来源发现真正成功历史时显示数量及「跳过成功记录」,**默认关闭**,开启后仅本次跳过;失败/部分成功记录不算完整成功,最新失败也不会遮盖较早成功。目标已存在策略单独处理。从历史记录进入直接采用重新整理语义。修改识别或选项后旧预览失效,需重新生成。
|
||||
|
||||
确认预览后只提供「加入整理队列」。后台受理成功后弹窗自动关闭,可以继续操作,进度到队列查看,整理后的刮削任务也显示在队列,整理详情单独展示刮削结果;部分受理时已受理文件从本次范围移除,剩余文件可修改后再预览提交。队列复用现有后台调度,不要求浏览器停留到文件处理结束。记录、队列及弹窗明细均复用统一分页。
|
||||
主动打开预览后使用「加入队列」,明确显示本次可提交组数。后台受理成功后弹窗自动关闭,可以继续操作,进度到队列查看,整理后的刮削任务也显示在队列,整理详情单独展示刮削结果;部分受理时已受理文件从本次范围移除,剩余文件可修改后再预览提交。队列复用现有后台调度,不要求浏览器停留到文件处理结束。记录、队列及弹窗明细均复用统一分页。
|
||||
|
||||
## 整理时指定剧集组
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
整理记录保留本次业务请求。没识别出作品时,下载来源和按路径来源都能由人工或智能体指定作品,再沿用原来的目标目录、复制/移动、命名和文件覆写重新整理。只有重新规划已接受才算身份事项处理成功,实际入库结果仍看后续整理记录;重新规划失败会留下原因。
|
||||
|
||||
季集待确认按作品、来源季和发布组归集真实文件样本,资源站标题与文件名不同的情况也参与模拟。作品识别词必须同时解决待确认文件且不破坏已对上的资源,写入后同类文件沿用这条规则。映射或季表重算后,季集已确定的整理自动继续剩余文件;本次临时选择的季集结构保持原约定,缺少原请求的旧记录由人工重新选择来源。
|
||||
季集待确认按作品、来源季和发布组归集真实文件样本,资源站标题与文件名不同的情况也参与模拟。作品识别词必须同时解决待确认文件且不破坏已对上的资源,写入后同类文件沿用这条规则。映射或季表重算后,季集已确定的整理自动继续剩余文件;本次临时选择的季集结构保持原约定,旧记录先恢复原请求或可信的历史任务输入;只有冻结计划时预填已知设置并提示确认缺项,来源确实丢失才补选目录。原下载关联、版本、洗版与未编辑的文件覆写由后端恢复,不靠前端重新拼一份不完整请求。
|
||||
|
||||
设置中的整理自动识别控制作品与季集的单次调查。身份有把握时可重新整理;季集规则自动写入仍需要有效持续授权,没有授权则保留模拟建议供确认。无法确认的交还人工,不改变收录分辨率、版本优先级或洗版条件。
|
||||
|
||||
|
||||
Reference in new issue
Block a user