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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user