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 ( <> !o && !submitting.current && onClose()}> {source.scanSummary}

) : 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} -
-
- { - 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} - /> -
- 指定作品 - { - cancelLookup(); - pickEntity(e); - }} - placeholder="自动识别,或搜索作品" - className="h-9" - /> -
-
-
- - -
- {manual ? ( -

- 全部文件按 {entityLabel(manual)} - {manual.external ? ` · TMDB ${manual.external.externalId}` : ""} 整理。清除作品后恢复逐个识别。 + {workTitles.length ? ( +

+ {workTitles.slice(0, 3).join("、")} + {workTitles.length > 3 ? ` 等 ${workTitles.length} 部作品` : ""}

) : null} -
- - 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 ? ( - { - 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: "选择目录…" }, - ]} - /> - ) : ( -

- 文件留在原位置,不搬运、不改名,只登记到媒体库。可以在原目录写 NFO 和图片。 +

+ {source.kind === "records" ? ( +

+ 批量重新整理使用本次统一设置。要沿用各记录的洗版与文件覆写,逐条打开重新整理。 +

+ ) : null} + {src.recovery ? ( +
+

+ {src.recovery.ready + ? "已恢复原整理设置。修改只对这次生效。" + : "已填入历史记录中保留的设置;缺失部分需要确认。"} +

+ {src.recovery.request.keepBoth || src.recovery.request.importMode === "replace" ? ( +

+ 沿用原任务:{src.recovery.request.keepBoth ? "保留双份文件" : ""} + {src.recovery.request.importMode === "replace" ? " · 洗版替换指定资产" : ""}

- )} -
- {!inPlace && options.target.mode === "dir" ? ( - - ) : null} - {hnrCount && !inPlace ? ( -

{hnrCount} 个文件还要做种,只能复制。

- ) : null} - {!inPlace ? ( -
- set({ onExisting: v as WizardOptions["onExisting"] })} - choices={[ - { value: "conflict", label: "标为需要处理" }, - { value: "skip", label: "跳过" }, - { value: "replace_with_verify", label: "核验新文件后替换" }, - ]} - /> - set({ naming: v as WizardOptions["naming"] })} - choices={[ - { value: "template", label: "按命名模板" }, - { value: "keep", label: "保留原文件名" }, - ]} - /> -
- ) : null} - {showEntities.slice(0, 5).map((e) => ( - 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 }; - }) - } - /> - ))} -
- - 整理后刮削 - 整理完成后写 NFO 和图片 - - set({ scrapeAfterImport: v })} - disabled={inPlace && !canScrapeInPlace} - aria-label="整理后刮削" - /> -
- {!isHistory && successfulCount > 0 ? ( -
- - 跳过整理过的文件 - - {successfulCount} 个文件以前整理成功过。只对这次生效 - - - set({ skipSuccessfulRecords: v })} - aria-label="跳过整理过的文件" - /> -
- ) : null} -
- 更多选项 -
-
+ ) : null} +
setAdvancedOpen(e.currentTarget.open)} + className="rounded-xl border border-border p-3" + > + 高级选项 · 仅本次生效 +
+ ({ + 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 ? ( +

+ 目标:{roots.find((r) => r.id === profile.target.root)?.hostPath ?? profile.target.root} + {profile.target.path ? `/${profile.target.path}` : ""} · {profile.category ?? "自动分类"} +

+ ) : null} + {profiles.error ? ( +

+ 读取整理来源失败,可以手动设置。 + +

+ ) : null} + {!profiles.isPending && !profiles.data?.items.length ? ( + + 新增整理来源 + + ) : null} +
+
+ { + 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} + /> +
+ 指定作品 + { + cancelLookup(); + pickEntity(e); + }} + placeholder="自动识别,或搜索作品" + className="h-9" + /> +
+
+
+ + +
+ {showEntities.length ? ( +

+ 手动指定季号只对本次生效。需要后续文件沿用时,到作品的集数映射设置: + {showEntities.slice(0, 5).map((e) => ( + + {e.title} + + ))}

) : null} -
- 刷新元数据 + {manual ? ( +

+ 全部文件按 {entityLabel(manual)} + {manual.external ? ` · TMDB ${manual.external.externalId}` : ""} 整理。清除作品后恢复逐个识别。 +

+ ) : null} +
+ + 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 ? ( + { + 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: "选择目录…" }, + ]} + /> + ) : ( +

+ 文件留在原位置,不搬运、不改名,只登记到媒体库。可以在原目录写 NFO 和图片。 +

+ )} +
+ {!inPlace && options.target.mode === "dir" ? ( + + ) : null} + {hnrCount && !inPlace ? ( +

{hnrCount} 个文件还要做种,只能复制。

+ ) : null} + {!inPlace ? ( +
+ set({ onExisting: v as WizardOptions["onExisting"] })} + choices={[ + { value: "conflict", label: "标为需要处理" }, + { value: "skip", label: "跳过" }, + { value: "replace_with_verify", label: "核验新文件后替换" }, + ]} + /> + set({ naming: v as WizardOptions["naming"] })} + choices={[ + { value: "template", label: "按命名模板" }, + { value: "keep", label: "保留原文件名" }, + ]} + /> +
+ ) : null} + {showEntities.slice(0, 5).map((e) => ( + 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 }; + }) + } + /> + ))} +
+ + 整理后刮削 + 整理完成后写 NFO 和图片 + set({ refreshMetadata: v })} - aria-label="刷新元数据" + checked={options.scrapeAfterImport} + onCheckedChange={(v) => set({ scrapeAfterImport: v })} + disabled={inPlace && !canScrapeInPlace} + aria-label="整理后刮削" />
-

- 同名字幕和外挂音轨随视频一起整理。NFO 和图片由刮削生成。 - - 修改命名模板 - -

-
-
-
+ {!isHistory && successfulCount > 0 ? ( +
+ + 跳过整理过的文件 + + {successfulCount} 个文件以前整理成功过。只对这次生效 + + + set({ skipSuccessfulRecords: v })} + aria-label="跳过整理过的文件" + /> +
+ ) : null} +
+
+ + {!minSizeValid ? ( +

+ 填一个不小于 0 的数 +

+ ) : null} +
+ 刷新元数据 + set({ refreshMetadata: v })} + aria-label="刷新元数据" + /> +
+

+ 同名字幕和外挂音轨随视频一起整理。NFO 和图片由刮削生成。 + + 修改命名模板 + +

+
+
+ +
+ {loading ? (

读取文件并识别作品…

- ) : src.error || (!src.items.length && !src.pending) ? ( + ) : src.error || rootsQ.error || (!src.items.length && !src.pending) ? (

- {src.error - ? filesErrorText(src.error, "读取来源失败") - : "源文件不存在或不在登记目录里。回到文件管理重新选择"} + {src.error || rootsQ.error + ? filesErrorText(src.error ?? rootsQ.error, "读取来源失败") + : src.recovery?.missing.includes("sourcePaths") + ? "历史来源已缺失,请补选来源目录。" + : "源文件不存在或不在登记目录里。确认文件位置与登记目录后重试。"}

) : identify.error ? (
@@ -799,6 +930,11 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on {!rows.length && started ? (

所选范围没有视频文件。

) : null} + {stats.pending ? ( +

+ 展开文件列表确认作品与季集;需要统一指定作品时,使用高级选项。 +

+ ) : null} @@ -853,7 +989,7 @@ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; on
-
+ {sourcePicker ? ( + setSourcePicker(false)} + onSelect={(ref) => { + setReplacementSources([ref]); + setSourcePicker(false); + setStarted(false); + }} + /> + ) : null} {targetPicker ? ( g.id === value.groupId) + ? [{ value: value.groupId, label: `原任务剧集组 · ${value.groupId}` }] + : []), ...list.groups .filter((g) => g.id !== current?.groupId) .map((g) => ({ diff --git a/apps/web/app/components/organize/file-source-selection.tsx b/apps/web/app/components/organize/file-source-selection.tsx deleted file mode 100644 index 098794e0..00000000 --- a/apps/web/app/components/organize/file-source-selection.tsx +++ /dev/null @@ -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; -/** 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 ( - !open && onClose()}> - - - {profiles.isPending ? "读取整理来源…" : "选择整理来源"} - 整理时沿用所选整理来源的整理方式、目标目录和分类。 - - {profiles.isPending ? ( -

- 匹配整理来源… -

- ) : profiles.error ? ( - <> -

- {filesErrorText(profiles.error, "读取整理来源失败")} -

- - - ) : matches.length > 1 ? ( -
- {matches.map((profile) => ( - - ))} -
- ) : null} - {!profiles.isPending && (profiles.error || matches.length > 1) ? ( - - ) : null} -
-
- ); -} diff --git a/apps/web/app/components/organize/source-selection.tsx b/apps/web/app/components/organize/source-selection.tsx index c2363a3d..18ac0754 100644 --- a/apps/web/app/components/organize/source-selection.tsx +++ b/apps/web/app/components/organize/source-selection.tsx @@ -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 ( setManual(false)} + onClose={direct ? onClose : () => setManual(false)} onSelect={(ref) => onSelect({ kind: "files", items: [ref], label: "手动选择的目录" })} /> ); @@ -56,7 +56,7 @@ export function SourceSelection({ 选择整理来源 - 扫描来源目录里已有的文件,预览后再整理。扫描不改变自动整理的基线。 + 选择来源,扫描已有文件后开始整理。扫描不改变自动整理的基线。
{sources.isPending ?

加载中…

: null} @@ -101,15 +101,6 @@ export function SourceSelection({ - {sources.isSuccess && !sources.data.items.length ? ( - - 新增整理来源 - - ) : null} ); diff --git a/apps/web/app/routes/console/files.tsx b/apps/web/app/routes/console/files.tsx index cd7d49a0..a8431ca6 100644 --- a/apps/web/app/routes/console/files.tsx +++ b/apps/web/app/routes/console/files.tsx @@ -122,7 +122,6 @@ type Dialog = | { kind: "refresh"; items: FileEntry[] } | { kind: "scrape"; items: FileEntry[] } | { kind: "wizard"; source: OrganizeSource } - | { kind: "wizardSource"; source: Extract } | { kind: "shortcuts" }; /** 宽布局:内容区不受全站最大宽度限制,目录树贴着侧边栏(layout.tsx 读 handle.layout) */ diff --git a/docs/runbook/dev.md b/docs/runbook/dev.md index e0951dda..318680d5 100644 --- a/docs/runbook/dev.md +++ b/docs/runbook/dev.md @@ -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 的调查运行、预算和授权沿用现有机制,不另外创建恢复框架;身份补齐、重新排队和实际入库完成分别按业务事实核实。 维护时先验证真实的成功、失败和恢复路径,尤其是授权、文件替换、取消与外部副作用;删除空壳或无使用者的机制时同步删除其专属测试与文档。不要为了目录对称增加转发层,也不要把不同阶段的状态合并成一个枚举。 diff --git a/docs/runbook/identity.md b/docs/runbook/identity.md index a3e0912c..7b58f72b 100644 --- a/docs/runbook/identity.md +++ b/docs/runbook/identity.md @@ -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);重新识别不能确认时撤销原自动绑定、进池。人工锁定的不动。 diff --git a/docs/runbook/organize.md b/docs/runbook/organize.md index 0663d87a..d5986480 100644 --- a/docs/runbook/organize.md +++ b/docs/runbook/organize.md @@ -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 @@ 整理记录保留本次业务请求。没识别出作品时,下载来源和按路径来源都能由人工或智能体指定作品,再沿用原来的目标目录、复制/移动、命名和文件覆写重新整理。只有重新规划已接受才算身份事项处理成功,实际入库结果仍看后续整理记录;重新规划失败会留下原因。 -季集待确认按作品、来源季和发布组归集真实文件样本,资源站标题与文件名不同的情况也参与模拟。作品识别词必须同时解决待确认文件且不破坏已对上的资源,写入后同类文件沿用这条规则。映射或季表重算后,季集已确定的整理自动继续剩余文件;本次临时选择的季集结构保持原约定,缺少原请求的旧记录由人工重新选择来源。 +季集待确认按作品、来源季和发布组归集真实文件样本,资源站标题与文件名不同的情况也参与模拟。作品识别词必须同时解决待确认文件且不破坏已对上的资源,写入后同类文件沿用这条规则。映射或季表重算后,季集已确定的整理自动继续剩余文件;本次临时选择的季集结构保持原约定,旧记录先恢复原请求或可信的历史任务输入;只有冻结计划时预填已知设置并提示确认缺项,来源确实丢失才补选目录。原下载关联、版本、洗版与未编辑的文件覆写由后端恢复,不靠前端重新拼一份不完整请求。 设置中的整理自动识别控制作品与季集的单次调查。身份有把握时可重新整理;季集规则自动写入仍需要有效持续授权,没有授权则保留模拟建议供确认。无法确认的交还人工,不改变收录分辨率、版本优先级或洗版条件。