feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)

生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 15:47:17 +08:00
1 parent 8013196e3f
commit ceb66f8c77
265 files changed
+17751 -2483

No files matched your search

@@ -0,0 +1,83 @@
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>
);
}
@@ -0,0 +1,189 @@
import { useQueryClient } from "@tanstack/react-query";
import type { EngineRun } from "@umt/contracts";
import { FileStack, RefreshCw } from "lucide-react";
import { Link } from "react-router";
import { useDownloadTask } from "~/api/downloads.ts";
import { useEngineRun, useEngineRuns } from "~/api/engine.ts";
import { useScrapeRecords } from "~/api/files.ts";
import { ACTION_LABEL, useRecord } from "~/api/records.ts";
import { useRecordsForDownload } from "~/api/tasks.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { relTime } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts";
import { organizeQueueStage, organizeRunInput, scrapeQueueStage } from "./queue-model.ts";
import { recordFileProgress, recordTitle } from "./record-parts.ts";
function ScrapeQueueItem({ run }: { run: EngineRun }) {
const detail = useEngineRun(run.id);
const input = organizeRunInput(detail.data?.input);
const scrapeId = input.scrapeRecordId ?? input.recordId;
const scrape = useScrapeRecords(scrapeId ? [scrapeId] : []);
const record = scrape.data?.records[0];
const status = run.status === "pending" ? "排队" : run.status === "sleeping" ? "等待" : "执行中";
return (
<li className="pg-slab space-y-2 rounded-xl p-4">
<div className="flex flex-wrap items-center gap-2">
<strong className="min-w-0 flex-1 break-words text-body">{record?.match?.title ?? "媒体库刮削"}</strong>
<StatusBadge>刮削</StatusBadge>
<StatusBadge tone={run.status === "sleeping" ? "warning" : "neutral"}>{status}</StatusBadge>
<span className="text-meta text-muted-foreground">{relTime(run.createdAt)}</span>
</div>
<div className="min-w-0 text-caption">
<span className="text-muted-foreground">目标目录 / 文件</span>
<p className="break-all font-mono text-meta">
{record ? `${record.root} / ${record.path || "(根目录)"}` : "读取刮削目标中…"}
</p>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
<span>{scrapeQueueStage(run)}</span>
{record ? (
<span>
已写入 {record.written} · 跳过 {record.skipped} · 失败 {record.failed}
</span>
) : null}
<Link
className="ml-auto hover:text-foreground hover:underline"
to={`/console/tasks/${encodeURIComponent(run.id)}`}
>
任务详情
</Link>
{record?.organizationRecordId ? (
<Link
className="hover:text-foreground hover:underline"
to={`/console/organize/${record.organizationRecordId}`}
>
关联整理记录
</Link>
) : null}
</div>
{detail.error || scrape.error ? (
<p className="text-meta text-warning">刮削详细信息暂时无法读取,任务状态仍会自动刷新。</p>
) : null}
</li>
);
}
function QueueItem({ run }: { run: EngineRun }) {
const detail = useEngineRun(run.id);
const input = organizeRunInput(detail.data?.input);
const recordId = run.business?.kind === "organization" ? run.business.refId : input.recordId;
const downloadId = run.business?.kind === "download" ? run.business.refId : input.downloadTaskId;
const related = useRecordsForDownload(downloadId);
const matchingRecord =
related.data?.items.find((item) => item.status === "queued" || item.status === "running") ??
related.data?.items.find((item) => item.createdAt >= run.createdAt);
const recordQuery = useRecord(recordId ?? matchingRecord?.id);
const download = useDownloadTask(downloadId);
const record = recordQuery.data ?? matchingRecord;
const progress = record ? recordFileProgress(record) : null;
const title = record ? recordTitle(record) : (download.data?.name ?? run.business?.title ?? "核验入库任务");
const source = record?.source.path ?? download.data?.contentPath ?? download.data?.savePath;
const status = run.status === "pending" ? "排队" : run.status === "sleeping" ? "等待" : "执行中";
return (
<li className="pg-slab space-y-2 rounded-xl p-4">
<div className="flex flex-wrap items-center gap-2">
<strong className="min-w-0 flex-1 break-words text-body">{title}</strong>
<StatusBadge tone={run.status === "sleeping" ? "warning" : "neutral"}>{status}</StatusBadge>
<span className="text-meta text-muted-foreground">{relTime(run.createdAt)}</span>
</div>
<div className="grid gap-2 text-caption sm:grid-cols-2">
<div className="min-w-0">
<span className="text-muted-foreground">来源</span>
<p className="break-all font-mono text-meta">
{source ??
(detail.isFetching || download.isFetching || recordQuery.isFetching
? "读取来源信息中…"
: "来源信息暂不可用")}
</p>
</div>
<div className="min-w-0">
<span className="text-muted-foreground">目标</span>
<p className="break-all font-mono text-meta">{record?.target.path ?? "核验与生成整理计划后确定"}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground">
<span>{organizeQueueStage(run, record)}</span>
{record ? <span>{ACTION_LABEL[record.action]}</span> : null}
{progress ? (
<span>
已完成 {progress.completed}/{progress.total} 个文件
{progress.current ? ` · 当前:${progress.current.split("/").pop()}` : ""}
</span>
) : null}
{run.attempts > 1 ? <span>第 {run.attempts} 次尝试</span> : null}
<Link
className="ml-auto hover:text-foreground hover:underline"
to={`/console/tasks/${encodeURIComponent(run.id)}`}
>
任务详情
</Link>
{record ? (
<Link className="hover:text-foreground hover:underline" to={`/console/organize/${record.id}`}>
整理详情
</Link>
) : null}
</div>
{detail.error || recordQuery.error || related.error || download.error ? (
<p className="text-meta text-warning">部分详细信息暂时无法读取,任务状态仍会自动刷新。</p>
) : null}
</li>
);
}
/** Existing engine workflows are authoritative, including jobs that have not created a record yet. */
export function OrganizeQueue() {
const qc = useQueryClient();
const runs = useEngineRuns(
{ workflowName: "verifyAndImport,scrapeMedia", status: "pending,running,sleeping" },
true,
5_000,
);
useSse(["task.created", "task.status_changed", "organization.changed", "download.status_changed"], () => {
void qc.invalidateQueries({ queryKey: qk.all("engine") });
void qc.invalidateQueries({ queryKey: ["records"] });
});
const rows = runs.data?.pages.flatMap((page) => page.items);
const paged = useListPagination(rows, {
hasMore: runs.hasNextPage,
onLoadMore: () => runs.fetchNextPage(),
loadingMore: runs.isFetchingNextPage,
error: runs.error,
resetKey: "organize-queue",
});
return (
<section aria-label="排队与执行中的整理任务" className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-caption text-muted-foreground">
下载核验、手动与自动整理、整理后的 NFO / 图片刮削在此排队。整理完成后进入记录,刮削继续显示到任务结束。
</p>
<Button variant="outline" size="sm" disabled={runs.isFetching} onClick={() => void runs.refetch()}>
<RefreshCw />
刷新
</Button>
</div>
{runs.isPending ? (
<LoadingState rows={4} />
) : runs.error && !rows ? (
<ErrorState error={runs.error} onRetry={() => runs.refetch()} />
) : !rows?.length ? (
<EmptyState icon={FileStack} title="没有排队或执行中的整理任务" />
) : (
<ul className="space-y-3">
{paged.items?.map((run) =>
run.workflowName === "scrapeMedia" ? (
<ScrapeQueueItem key={run.id} run={run} />
) : (
<QueueItem key={run.id} run={run} />
),
)}
</ul>
)}
{rows ? <Pagination {...paged.pagination} /> : null}
</section>
);
}
@@ -0,0 +1,78 @@
import { useQuery } from "@tanstack/react-query";
import { FilesScrapeRecordsResult } from "@umt/contracts";
import { Link } from "react-router";
import { apiParsed } from "~/api/parse.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { PaginatedList } from "~/components/console/paginated-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { SCRAPE_STATUS, scrapeCounts } from "~/components/library/scrape-panel.tsx";
import { reasonLine } from "~/lib/reasons.ts";
import { formatDateTime } from "~/lib/utils.ts";
/** 整理与刮削分别展示结果,避免将刮削失败当作文件搬运失败。 */
export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {
const query = useQuery({
queryKey: ["files", "scrape-records", "organization", recordId],
queryFn: ({ signal }) =>
apiParsed(FilesScrapeRecordsResult, "/files/scrape-records", {
params: { organizationRecordId: recordId },
signal,
}),
refetchInterval: 5_000,
});
return (
<section className="pg-slab space-y-3 rounded-xl p-4" aria-label="整理后刮削">
<h2 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">整理后刮削</h2>
{query.isPending ? (
<LoadingState rows={1} />
) : query.error ? (
<ErrorState error={query.error} onRetry={() => query.refetch()} />
) : query.data?.records.length ? (
<PaginatedList rows={query.data.records} label="关联刮削记录" resetKey={recordId} as="ul" className="space-y-3">
{(record) => (
<li key={record.id} className="space-y-1.5 rounded-xl border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={SCRAPE_STATUS[record.status].tone}>{SCRAPE_STATUS[record.status].label}</StatusBadge>
<span className="text-caption">NFO 与图片</span>
<span className="ml-auto text-meta text-muted-foreground">{formatDateTime(record.requestedAt)}</span>
</div>
<p className="break-all font-mono text-meta text-muted-foreground">
{record.match?.title ?? record.path} · {record.path || "/"}
</p>
<p className="text-meta text-muted-foreground">{scrapeCounts(record) || "等待写入 NFO 与图片"}</p>
{record.reason ? <p className="text-meta text-destructive">{reasonLine(record.reason)}</p> : null}
{record.files.some((file) => file.action === "failed") ? (
<details className="text-meta">
<summary className="cursor-pointer text-muted-foreground">失败文件</summary>
<PaginatedList
rows={record.files.filter((file) => file.action === "failed")}
label="刮削失败文件"
resetKey={record.id}
as="ul"
className="mt-2 space-y-1"
>
{(file) => (
<li key={`${file.path}:${file.role}`} className="break-all text-destructive">
{file.path} · {reasonLine(file.reason) || "写入失败"}
</li>
)}
</PaginatedList>
</details>
) : null}
{record.taskId ? (
<Link
to={`/console/tasks/${encodeURIComponent(record.taskId)}`}
className="inline-block text-meta underline"
>
查看刮削任务
</Link>
) : null}
</li>
)}
</PaginatedList>
) : (
<p className="text-caption text-muted-foreground">没有关联的刮削记录。</p>
)}
</section>
);
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { organizeQueueStage, organizeRunInput, scrapeQueueStage } from "./queue-model.ts";
describe("organize queue", () => {
it("reads pending workflow references before a record exists", () => {
expect(organizeRunInput({ input: { downloadTaskId: "download", taskId: "task" } })).toEqual({
downloadTaskId: "download",
recordId: undefined,
});
expect(organizeQueueStage({ status: "pending" })).toBe("等待执行");
expect(organizeQueueStage({ status: "running" })).toBe("核验文件与生成整理计划");
});
it("distinguishes file transfer, plan waiting, and workflow waiting", () => {
expect(organizeRunInput({ recordId: "record" }).recordId).toBe("record");
expect(organizeQueueStage({ status: "running" }, { status: "queued" })).toContain("等待搬运");
expect(organizeQueueStage({ status: "running" }, { status: "running" })).toContain("搬运文件");
expect(organizeQueueStage({ status: "sleeping" }, { status: "running" })).toBe("等待继续执行");
});
it("keeps the independent scrape stage distinct after importing finishes", () => {
expect(organizeRunInput({ input: { scrapeRecordId: "scrape" } }).scrapeRecordId).toBe("scrape");
expect(scrapeQueueStage({ status: "running" })).toBe("刮削 NFO / 图片");
expect(scrapeQueueStage({ status: "pending" })).toContain("等待刮削");
});
it("原地入库显示核验与登记,不显示搬运", () => {
expect(organizeQueueStage({ status: "running" }, { status: "running", action: "in_place" })).toBe(
"核验原文件与登记媒体库",
);
expect(organizeQueueStage({ status: "running" }, { status: "queued", action: "in_place" })).toBe("等待原地登记");
});
it("does not describe a finished business record as transferring", () => {
expect(organizeQueueStage({ status: "running" }, { status: "succeeded" })).toContain("收尾");
expect(organizeRunInput(null)).toEqual({});
expect(organizeRunInput({ input: { recordId: 5 } })).toEqual({ recordId: undefined, downloadTaskId: undefined });
});
});
@@ -0,0 +1,37 @@
import type { EngineRun, PublicOrganizationRecord } from "@umt/contracts";
export function organizeRunInput(value: unknown): {
recordId?: string;
downloadTaskId?: string;
scrapeRecordId?: string;
} {
if (!value || typeof value !== "object") return {};
const envelope = value as Record<string, unknown>;
const input =
envelope.input && typeof envelope.input === "object" ? (envelope.input as Record<string, unknown>) : envelope;
return {
recordId: typeof input.recordId === "string" ? input.recordId : undefined,
downloadTaskId: typeof input.downloadTaskId === "string" ? input.downloadTaskId : undefined,
...(typeof input.scrapeRecordId === "string" ? { scrapeRecordId: input.scrapeRecordId } : {}),
};
}
export function organizeQueueStage(
run: Pick<EngineRun, "status">,
record?: Pick<PublicOrganizationRecord, "status"> & Partial<Pick<PublicOrganizationRecord, "action">>,
) {
if (run.status === "sleeping") return "等待继续执行";
if (run.status === "pending") return "等待执行";
if (!record) return "核验文件与生成整理计划";
if (record.action === "in_place" && (record.status === "queued" || record.status === "running"))
return record.status === "queued" ? "等待原地登记" : "核验原文件与登记媒体库";
if (record.status === "queued") return "整理计划已生成,等待搬运";
if (record.status === "running") return "搬运文件与登记媒体库";
return "整理已结束,完成任务收尾";
}
export function scrapeQueueStage(run: Pick<EngineRun, "status">) {
if (run.status === "pending") return "等待刮削 NFO / 图片";
if (run.status === "sleeping") return "等待继续刮削 NFO / 图片";
return "刮削 NFO / 图片";
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { canReorganize, episodeTag, hasFiles, isRunning, recordTitle } from "./record-parts.ts";
import { canReorganize, episodeTag, hasFiles, isRunning, recordFileProgress, recordTitle } from "./record-parts.ts";
// HIS-1 整理记录展示小件:标题 / 季集、能否重新整理、能否连文件删除
@@ -52,3 +52,16 @@ describe("整理记录展示", () => {
expect(hasFiles({ action: "move" })).toBe(true);
});
});
describe("文件级进度", () => {
it("没有明细时不推算进度;已结束包含成功、失败和跳过", () => {
expect(recordFileProgress({ files: null })).toBeNull();
expect(recordFileProgress({ files: [] })).toBeNull();
const files = ["succeeded", "failed", "skipped", "running", "queued"].map((status, i) => ({
status,
source: path(`/source/${i}.mkv`),
target: path(null),
})) as unknown as NonNullable<Parameters<typeof recordFileProgress>[0]["files"]>;
expect(recordFileProgress({ files })).toEqual({ completed: 3, total: 5, current: "/source/3.mkv" });
});
});
@@ -23,13 +23,27 @@ export function recordTitle(r: Rec): string {
/** 能重新整理的记录:复制 / 移动 / 硬链接,且已结束 */
export const canReorganize = (r: Pick<PublicOrganizationRecord, "action" | "status">) =>
(r.action === "copy" || r.action === "move" || r.action === "hardlink") &&
(r.action === "copy" || r.action === "move" || r.action === "in_place" || r.action === "hardlink") &&
r.status !== "queued" &&
r.status !== "running";
/** 有源文件 / 媒体库文件可删的记录(改名、删除、还原类只删记录本身) */
export const hasFiles = (r: Pick<PublicOrganizationRecord, "action">) =>
r.action === "copy" || r.action === "move" || r.action === "hardlink";
r.action === "copy" || r.action === "move" || r.action === "in_place" || r.action === "hardlink";
export const isRunning = (r: Pick<PublicOrganizationRecord, "status">) =>
r.status === "queued" || r.status === "running";
/** File counts come from record detail, never from elapsed time or transferred bytes. */
export function recordFileProgress(
r: Pick<PublicOrganizationRecord, "files">,
): { completed: number; total: number; current: string | null } | null {
if (!r.files?.length) return null;
const files = r.files;
const current = files.find((f) => f.status === "running");
return {
completed: files.filter((f) => !["queued", "running"].includes(f.status)).length,
total: files.length,
current: current?.source.path ?? current?.target.path ?? null,
};
}
@@ -0,0 +1,50 @@
import type { OrganizeSourceConfig } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { matchingOrganizeSources } from "./source-match.ts";
const profile = (id: string, path: string, root = "dl:one:0"): OrganizeSourceConfig => ({
id,
name: id,
enabled: true,
source: { root, path },
target: { root: "lib:target", path: "" },
mediaType: "all",
category: null,
action: "copy",
trigger: "manual",
downloaderInstanceId: null,
tag: null,
intervalSec: 300,
});
const ref = (path: string, root = "dl:one:0") => ({ root, path });
describe("文件管理整理来源匹配", () => {
it("根目录来源覆盖根内所选文件,但不跨根", () => {
const root = profile("root", "");
expect(matchingOrganizeSources([ref(""), ref("movie/a.mkv")], [root])).toEqual([root]);
expect(matchingOrganizeSources([ref("movie/a.mkv", "dl:two:0")], [root])).toEqual([]);
});
it("目录匹配保持分段边界,也可选来源目录本身", () => {
const movie = profile("movie", "completed/movie");
expect(matchingOrganizeSources([ref("completed/movie"), ref("completed/movie/a.mkv")], [movie])).toEqual([movie]);
expect(matchingOrganizeSources([ref("completed/movies/a.mkv")], [movie])).toEqual([]);
expect(matchingOrganizeSources([ref("completed")], [movie])).toEqual([]);
});
it("所有所选文件必须归属同一配置,空选择不匹配", () => {
const movie = profile("movie", "movie");
expect(matchingOrganizeSources([ref("movie/a.mkv"), ref("tv/b.mkv")], [movie])).toEqual([]);
expect(matchingOrganizeSources([ref("movie/a.mkv"), ref("movie/b.mkv", "dl:two:0")], [movie])).toEqual([]);
expect(matchingOrganizeSources([], [movie])).toEqual([]);
});
it("优先最长来源路径,同长度的多个配置全部保留供用户选择", () => {
const all = profile("all", "");
const completed = profile("completed", "completed");
const a = profile("a", "completed/movie");
const b = profile("b", "completed/movie");
expect(matchingOrganizeSources([ref("completed/movie/a.mkv")], [all, completed, a])).toEqual([a]);
expect(matchingOrganizeSources([ref("completed/movie/a.mkv")], [all, a, b])).toEqual([a, b]);
expect(
matchingOrganizeSources([ref("completed/movie/a.mkv"), ref("completed/tv/b.mkv")], [all, completed, a]),
).toEqual([completed]);
});
});
@@ -0,0 +1,15 @@
import type { FileRef, OrganizeSourceConfig } from "@umt/contracts";
/** Match every selected item at directory boundaries, then prefer the most specific source. */
export function matchingOrganizeSources<T extends OrganizeSourceConfig>(items: FileRef[], profiles: T[]): T[] {
if (!items.length) return [];
const matches = profiles.filter((p) =>
items.every(
(item) =>
item.root === p.source.root &&
(p.source.path === "" || item.path === p.source.path || item.path.startsWith(`${p.source.path}/`)),
),
);
const longest = Math.max(-1, ...matches.map((p) => p.source.path.length));
return matches.filter((p) => p.source.path.length === longest);
}
@@ -0,0 +1,106 @@
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";
import { PaginatedList } from "~/components/console/paginated-list.tsx";
import { DirectoryDialog } from "~/components/files/directory-dialog.tsx";
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";
export function stockWizardSource(profile: OrganizeSourceView, stock: OrganizeStockResult): OrganizeSource {
return {
kind: "files",
items: stock.items,
sourceConfigId: profile.id,
defaultAction: profile.action,
label: `整理存量 · ${profile.name}`,
scanSummary: `找到 ${stock.items.length} 个文件,跳过 ${stock.skipped} 个,不可读取 ${stock.unavailable} 个${stock.truncated ? ";扫描已达到上限,仅显示本批结果" : ""}。本次扫描不改变自动整理基线,请先预览再执行。`,
};
}
export function SourceSelection({
onClose,
onSelect,
}: {
onClose: () => void;
onSelect: (source: OrganizeSource) => void;
}) {
const sources = useOrganizeSources();
const stock = useOrganizeStock();
const [manual, setManual] = useState(false);
if (manual)
return (
<DirectoryDialog
mode="source"
allowRoot={false}
onClose={() => setManual(false)}
onSelect={(ref) => onSelect({ kind: "files", items: [ref], label: "手动选择来源目录" })}
/>
);
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>选择整理来源</DialogTitle>
<DialogDescription>扫描来源存量后,进入文件识别、预览与执行。扫描不会改变自动整理基线。</DialogDescription>
</DialogHeader>
<div className="max-h-72 space-y-2 overflow-auto">
{sources.isPending ? <p>读取来源中…</p> : null}
{sources.error ? (
<p role="alert" className="text-destructive">
来源配置读取失败,仍可手动选择目录。
</p>
) : null}
{sources.data?.items.length ? (
<PaginatedList
rows={sources.data.items}
label="整理来源配置"
resetKey="organize-source-selection"
className="space-y-2"
>
{(p) => (
<Button
key={p.id}
variant="outline"
className="h-auto w-full justify-start py-3"
disabled={stock.isPending}
onClick={() =>
stock.mutate(
{ id: p.id },
{
onSuccess: (r) =>
r.items.length
? onSelect(stockWizardSource(p, r))
: toast.info(
`没有可整理文件;跳过 ${r.skipped},不可读取 ${r.unavailable}${r.truncated ? ",扫描已达到上限" : ""}`,
),
onError: (e) => toast.error(filesErrorText(e, "扫描存量失败")),
},
)
}
>
{p.name} · {p.action === "copy" ? "复制" : "移动"}
{!p.enabled ? " · 已停用" : ""}
</Button>
)}
</PaginatedList>
) : null}
</div>
<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>
);
}