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

@@ -95,7 +95,7 @@ function Option({
/**
* 作品选择器(§14 EntityPicker):上半识别候选(本地 + TMDB),下半搜索本地作品与 TMDB。
* 选 TMDB 条目 = 行标"将新建作品",执行时由后端建作品(G1)。
* 选 TMDB 条目 = 行标"将新建作品",生成预览时由后端建档(G1)。
*/
export function EntityPicker({
candidates = [],
@@ -104,6 +104,7 @@ export function EntityPicker({
placeholder = "选择作品…",
className,
invalid,
mediaType = "auto",
}: {
candidates?: FileIdentifyCandidate[];
value: WizardEntity | null;
@@ -111,6 +112,7 @@ export function EntityPicker({
placeholder?: string;
className?: string;
invalid?: boolean;
mediaType?: "auto" | "movie" | "tv";
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
@@ -126,7 +128,15 @@ export function EntityPicker({
setOpen(false);
setQ("");
};
const hits = search.data;
const hits = search.data
? {
...search.data,
local: search.data.local.filter(
(h) => mediaType === "auto" || (mediaType === "movie") === (h.kind === "movie"),
),
tmdb: search.data.tmdb.filter((h) => mediaType === "auto" || h.entityType === mediaType),
}
: undefined;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -5,6 +5,7 @@ import {
applyMinSize,
applyOffsetTo,
defaultOptions,
dialogRows,
displayPath,
downloadContentRef,
groupRows,
@@ -121,6 +122,18 @@ describe("识别结果 → 行", () => {
expect(rowProblem({ ...(rows[0] as WizardRow), season: "" })).toMatch(/季号/);
expect(rowProblem({ ...(rows[0] as WizardRow), episodes: "1-x" })).toMatch(/集号/);
});
it("只允许单集或连续集号,不让人工覆写被后端忽略", () => {
const r = rowsFromIdentify([item("show/a.mkv")], new Set())[0] as WizardRow;
expect(rowProblem({ ...r, episodes: "3,5" })).toBe("集号需要连续,如 3-5");
expect(wizardStats([{ ...r, episodes: "3,5" }]).pending).toBe(1);
expect(rowProblem({ ...r, episodes: "4,3,4" })).toBeNull();
expect(rowProblem({ ...r, episodes: "3-5" })).toBeNull();
expect(rowProblem({ ...r, episodes: "0" })).toBeNull();
expect(rowProblem({ ...r, episodes: "1-51" })).toMatch(/50/);
expect(rowProblem({ ...r, episodes: "3,5", excluded: "manual" })).toBeNull();
expect(rowProblem({ ...r, entity: tmdbMovie, episodes: "3,5", season: "" })).toBeNull();
});
});
describe("批量设置", () => {
@@ -152,6 +165,55 @@ describe("批量设置", () => {
});
});
describe("统一整理弹窗范围", () => {
it("指定作品覆盖整批,清除后恢复逐文件识别,季集解析保留", () => {
const rows = rowsFromIdentify([item("a.mkv"), item("b.mkv", { id: "e2" })], new Set());
const selected = dialogRows(rows, tmdbMovie, "", false);
expect(selected.map((r) => r.entity)).toEqual([tmdbMovie, tmdbMovie]);
expect(dialogRows(rows, null, "", false).map((r) => r.entity?.id)).toEqual(["e1", "e2"]);
expect(rows.map((r) => r.entity?.id)).toEqual(["e1", "e2"]);
expect(selected.map((r) => r.episodes)).toEqual(["2", "2"]);
});
it("成功历史默认参与;开启跳过后未识别的成功文件也不阻挡预览", () => {
const old = {
...item("old.mkv", { id: null }),
records: { count: 2, lastRecordId: "r", lastStatus: "failed" as const, successfulCount: 1 },
};
const rows = rowsFromIdentify([old, item("new.mkv")], new Set());
expect(wizardStats(dialogRows(rows, null, "", false)).pending).toBe(1);
const skipped = dialogRows(rows, null, "", true);
expect(skipped[0]?.excluded).toBe("successful_record");
expect(wizardStats(skipped).pending).toBe(0);
expect(
organizeRequest(skipped, defaultOptions({ skipSuccessfulRecords: true }), {
dryRun: true,
idempotencyKey: "k",
}).items.map((r) => r.path),
).toEqual(["new.mkv"]);
});
it("手动排除与体积排除不被统一作品或季号覆盖", () => {
const rows = rowsFromIdentify([item("a.mkv"), item("small.mkv", { skipped: "below_min_size" })], new Set());
rows[0]!.excluded = "manual";
const result = dialogRows(rows, tmdbMovie, "3", true);
expect(result.map((r) => r.excluded)).toEqual(["manual", "below_min_size"]);
expect(result.map((r) => r.season)).toEqual(["3", "3"]);
});
it("预览与入队携带相同刮削和历史跳过策略", () => {
const rows = rowsFromIdentify([item("a.mkv")], new Set());
const options = defaultOptions({ scrapeAfterImport: true, skipSuccessfulRecords: true });
const preview = organizeRequest(rows, options, { dryRun: true, idempotencyKey: "preview" });
const queue = organizeRequest(rows, options, { dryRun: false, idempotencyKey: "queue", planIds: ["plan"] });
expect(defaultOptions().skipSuccessfulRecords).toBe(false);
expect(preview).toMatchObject({ scrapeAfterImport: true, skipSuccessfulRecords: true });
const { dryRun: _p, idempotencyKey: _pk, planIds: _pp, ...p } = preview;
const { dryRun: _q, idempotencyKey: _qk, planIds: _qp, ...q } = queue;
expect(p).toEqual(q);
});
});
describe("请求体", () => {
it("本地作品带 mediaEntityId,TMDB 候选带 external;电影不带季集;排除项不提交", () => {
const rows = rowsFromIdentify([item("a.mkv"), item("b.mkv"), item("c.mkv")], new Set());
@@ -283,3 +345,63 @@ describe("来源换算", () => {
expect(downloadContentRef(null, "i", [], roots)).toBeNull();
});
});
describe("来源配置预览与执行", () => {
it("自动目标保持交给配置,手动目录可覆盖,两个请求都携带配置 ID", () => {
const rows = rowsFromIdentify([item("series/a.mkv")], new Set());
const options = defaultOptions({ action: "move" });
const dry = organizeRequest(rows, options, {
dryRun: true,
idempotencyKey: "preview",
sourceConfigId: "profile-1",
});
const exec = organizeRequest(rows, options, {
dryRun: false,
idempotencyKey: "execute",
sourceConfigId: "profile-1",
planIds: ["plan-1"],
});
expect(dry.sourceConfigId).toBe("profile-1");
expect(exec.sourceConfigId).toBe(dry.sourceConfigId);
expect(exec.items).toEqual(dry.items);
expect(dry.action).toBe("move");
expect(dry.targetLibraryRootId).toBeUndefined();
expect(dry.items[0]?.targetDir).toBeUndefined();
const manual = organizeRequest(
rows,
{ ...options, target: { mode: "dir", ref: { root: "lib:target", path: "chosen" } } },
{ dryRun: true, idempotencyKey: "manual", sourceConfigId: "profile-1" },
);
expect(manual.items[0]?.targetDir).toEqual({ root: "lib:target", path: "chosen" });
expect(manual.sourceConfigId).toBe("profile-1");
});
});
it("原地入库请求不携带旧搬运目标、分类来源与替换策略,预览和入队一致", () => {
const options = defaultOptions({
action: "in_place",
target: { mode: "dir", ref: { root: "lib:old", path: "old" } },
onExisting: "replace_with_verify",
naming: "template",
});
const rows = rowsFromIdentify([item("Original.S01E02.mkv")], new Set());
const dry = organizeRequest(rows, options, {
dryRun: true,
idempotencyKey: "in-place-dry",
sourceConfigId: "old-source",
});
const exec = organizeRequest(rows, options, {
dryRun: false,
idempotencyKey: "in-place-exec",
sourceConfigId: "old-source",
planIds: ["plan"],
});
expect(dry).toMatchObject({ action: "in_place", naming: "keep", onExisting: "conflict" });
expect(dry.items).toHaveLength(1);
expect(dry.items[0]?.targetDir).toBeUndefined();
expect(dry.sourceConfigId).toBeUndefined();
expect(dry.targetLibraryRootId).toBeUndefined();
expect(exec.action).toBe(dry.action);
expect(exec.naming).toBe(dry.naming);
expect(exec.onExisting).toBe(dry.onExisting);
});
@@ -13,7 +13,7 @@ import { formatEpisodes, parentRel, parseEpisodes, shiftEpisodes } from "~/compo
// ─── 作品 ────────────────────────────────────────────────────────
/** 行上选中的作品:本地作品(id)或只在 TMDB 上的候选(external,执行时由后端就地建作品) */
/** 行上选中的作品:本地作品(id)或只在 TMDB 上的候选(external,生成预览时由后端建档) */
export type WizardEntity = {
id: string | null;
external: { provider: "tmdb"; externalId: string; kind: "movie" | "tv" } | null;
@@ -32,7 +32,7 @@ export const isMovieKind = (k: string | null | undefined) => k === "movie";
// ─── 行 ──────────────────────────────────────────────────────────
export type ExcludeReason = "manual" | "below_min_size";
export type ExcludeReason = "manual" | "below_min_size" | "successful_record";
export type WizardRow = {
key: string;
@@ -55,10 +55,14 @@ export type WizardRow = {
export function rowProblem(r: WizardRow): string | null {
if (r.excluded) return null;
if (!r.entity) return "未选择作品";
if (isMovieKind(r.entity.kind)) return null;
const eps = parseEpisodes(r.episodes);
if (eps === null) return "集号格式不对,如 3、3-5、3,4";
if (eps === null) return "集号格式不对,请填写单集或连续区间,如 3、3-5";
const unique = [...new Set(eps)].sort((a, b) => a - b);
if (eps.length > 50) return "单个文件最多指定 50 个集号";
if (unique.some((n, i) => i > 0 && n !== unique[i - 1]! + 1)) return "集号需要连续,如 3-5";
if (r.season.trim() && !/^\d{1,4}$/.test(r.season.trim())) return "季号必须是数字";
if (!isMovieKind(r.entity.kind) && !r.season.trim()) return "剧集需要季号";
if (!r.season.trim()) return "剧集需要季号";
return null;
}
@@ -186,6 +190,28 @@ export function wizardStats(rows: WizardRow[]): WizardStats {
};
}
/** 共用弹窗的作品、季号作用于整个本次范围;成功历史跳过独立于目标冲突。 */
export function dialogRows(
rows: WizardRow[],
manual: WizardEntity | null,
season: string,
skipSuccessfulRecords: boolean,
): WizardRow[] {
return rows.map((row) => {
const excluded =
!row.excluded && skipSuccessfulRecords && (row.item.records?.successfulCount ?? 0) > 0
? ("successful_record" as const)
: row.excluded;
return {
...row,
excluded,
entity: manual ?? row.entity,
slotId: manual && entityKey(manual) !== entityKey(row.entity) ? "" : row.slotId,
season: season.trim() || row.season,
};
});
}
/** 按源目录分组(保持首次出现顺序) */
export function groupRows(rows: WizardRow[]): { group: string; dir: string; rows: WizardRow[] }[] {
const map = new Map<string, WizardRow[]>();
@@ -198,11 +224,13 @@ export function groupRows(rows: WizardRow[]): { group: string; dir: string; rows
export type WizardTarget = { mode: "auto" } | { mode: "root"; rootId: string } | { mode: "dir"; ref: FileRef };
export type WizardOptions = {
action: "copy" | "move";
action: "copy" | "move" | "in_place";
target: WizardTarget;
onExisting: FilesOnExisting;
naming: "template" | "keep";
refreshMetadata: boolean;
scrapeAfterImport: boolean;
skipSuccessfulRecords: boolean;
minSizeBytes: number;
};
@@ -215,6 +243,8 @@ export const defaultOptions = (o: Partial<WizardOptions> = {}): WizardOptions =>
onExisting: "conflict",
naming: "template",
refreshMetadata: true,
scrapeAfterImport: false,
skipSuccessfulRecords: false,
minSizeBytes: DEFAULT_MIN_SIZE,
...o,
});
@@ -235,7 +265,7 @@ export function organizeItems(rows: WizardRow[], o: WizardOptions): FilesOrganiz
if (!movie && r.season.trim()) item.season = Number(r.season.trim());
if (!movie && eps?.length) item.episodes = eps;
if (r.slotId && e.id) item.slotId = r.slotId;
if (o.target.mode === "dir") item.targetDir = o.target.ref;
if (o.action !== "in_place" && o.target.mode === "dir") item.targetDir = o.target.ref;
return item;
});
}
@@ -244,19 +274,23 @@ export function organizeItems(rows: WizardRow[], o: WizardOptions): FilesOrganiz
export function organizeRequest(
rows: WizardRow[],
o: WizardOptions,
x: { dryRun: boolean; idempotencyKey: string; planIds?: string[] },
x: { dryRun: boolean; idempotencyKey: string; planIds?: string[]; sourceConfigId?: string },
): FilesOrganizeRequest {
return {
idempotencyKey: x.idempotencyKey,
items: organizeItems(rows, o),
action: o.action,
targetLibraryRootId: o.target.mode === "root" ? libraryRootIdOf(o.target.rootId) : undefined,
sourceConfigId: o.action === "in_place" ? undefined : x.sourceConfigId,
targetLibraryRootId:
o.action !== "in_place" && o.target.mode === "root" ? libraryRootIdOf(o.target.rootId) : undefined,
dryRun: x.dryRun,
planIds: x.planIds,
minSizeBytes: o.minSizeBytes,
onExisting: o.onExisting,
naming: o.naming,
onExisting: o.action === "in_place" ? "conflict" : o.onExisting,
naming: o.action === "in_place" ? "keep" : o.naming,
refreshMetadata: o.refreshMetadata,
scrapeAfterImport: o.scrapeAfterImport,
skipSuccessfulRecords: o.skipSuccessfulRecords,
};
}
@@ -326,6 +360,7 @@ export function nestSidecars(files: GroupFile[]): { main: GroupFile; sidecars: G
}
export const EXCLUDE_REASON: Record<string, string> = {
successful_record: "已有成功整理记录",
below_min_size: "低于最小体积",
sample: "样片",
too_small: "体积过小",
@@ -339,6 +374,7 @@ export const EXCLUDE_REASON: Record<string, string> = {
};
export const SKIP_REASON: Record<string, string> = {
successful_record: "已有成功整理记录",
already_in_library: "已在库中",
target_exists: "目标已存在",
main_not_transferred: "主文件未整理",
File diff suppressed because it is too large. Load diff
@@ -70,6 +70,7 @@ export function StepOptions({
roots,
hnrCount,
kinds,
profileLabel,
}: {
options: WizardOptions;
onChange: (o: WizardOptions) => void;
@@ -78,6 +79,7 @@ export function StepOptions({
hnrCount: number;
/** 本次涉及的作品类型(过滤库根) */
kinds: Set<string>;
profileLabel?: string;
}) {
const set = (p: Partial<WizardOptions>) => onChange({ ...options, ...p });
const libRoots = roots.filter((r) => r.kind === "library" && r.writable && r.health !== "offline");
@@ -109,7 +111,6 @@ export function StepOptions({
title="移动"
hint="入库后删源;跨卷时按复制 → 核验 → 删源"
/>
<Choice checked={false} onSelect={() => {}} disabled="还不支持硬链接" title="硬链接" />
</div>
</Section>
@@ -144,7 +145,11 @@ export function StepOptions({
checked={t.mode === "auto"}
onSelect={() => set({ target: { mode: "auto" } })}
title="自动"
hint="已绑定的作品沿用原目录,否则按作品类型选媒体库目录"
hint={
profileLabel
? `按来源「${profileLabel}」的目标目录与分类规则整理`
: "已绑定的作品沿用原目录,否则按作品类型选媒体库目录"
}
/>
{libRoots.map((r) => (
<Choice
@@ -214,7 +219,9 @@ export function StepOptions({
aria-label="入库后刷新元数据"
/>
</div>
<p className="text-meta text-muted-foreground">同名字幕、NFO、图片随主文件一起处理,预览里列出。</p>
<p className="text-meta text-muted-foreground">
同目录同名字幕和外挂音轨会随视频整理,预览里列出。现有 NFO、图片不搬入媒体库,可在整理后刮削生成。
</p>
</Section>
</div>
</div>
@@ -4,8 +4,10 @@ import { Link } from "react-router";
import type { FileRoot, FilesOrganizeGroup } from "~/api/files.ts";
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE } from "~/api/records.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { baseName, parentRel } from "~/components/files/model.ts";
import { Button } from "~/components/ui/button.tsx";
import { recordFileProgress } from "~/components/organize/record-parts.ts";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import {
@@ -18,7 +20,6 @@ import {
groupHasProblem,
nestSidecars,
type PreviewStats,
pageOf,
SKIP_REASON,
TEMPLATE_SOURCE,
} from "./model.ts";
@@ -108,6 +109,8 @@ function FileLine({ f, roots, nested }: { f: GroupFile; roots: FileRoot[]; neste
<StatusBadge>跳过{f.skipReason ? ` · ${SKIP_REASON[f.skipReason] ?? f.skipReason}` : ""}</StatusBadge>
) : f.action === "replace" ? (
<StatusBadge tone="warning">替换</StatusBadge>
) : f.target === f.source ? (
<StatusBadge>原地登记</StatusBadge>
) : !f.target ? (
<StatusBadge>不入库</StatusBadge>
) : null;
@@ -202,6 +205,12 @@ export function PreviewGroupCard({
{errorText(g.error)}
</p>
) : null}
{result && record && recordFileProgress(record) ? (
<p className="break-all text-caption text-muted-foreground">
已完成 {recordFileProgress(record)?.completed}/{recordFileProgress(record)?.total} 个文件
{recordFileProgress(record)?.current ? ` · 当前:${recordFileProgress(record)?.current}` : ""}
</p>
) : null}
{record?.reason && result ? <p className="text-caption text-warning">{record.reason.message}</p> : null}
{g.warnings.map((w) => (
<p key={w} className="flex items-start gap-1.5 text-caption text-warning">
@@ -245,25 +254,21 @@ export function PreviewList({
titleOf,
roots,
onlyProblems,
page,
onPage,
}: {
groups: FilesOrganizeGroup[];
titleOf: GroupTitle;
roots: FileRoot[];
onlyProblems: boolean;
page: number;
onPage: (p: number) => void;
}) {
const shown = onlyProblems ? groups.filter(groupHasProblem) : groups;
const pg = pageOf(shown, page);
const { items, pagination } = useListPagination(shown, { resetKey: String(onlyProblems) });
if (!shown.length)
return (
<p className="py-8 text-center text-caption text-muted-foreground">没有问题项。关闭「只看问题」查看全部。</p>
);
return (
<div className="space-y-3">
{pg.items.map((g, i) => (
{items?.map((g, i) => (
<PreviewGroupCard
key={`${g.mediaEntityId}-${g.contentUnitId}-${g.planId ?? i}`}
g={g}
@@ -272,19 +277,7 @@ export function PreviewList({
onlyProblems={onlyProblems}
/>
))}
{pg.pages > 1 ? (
<div className="flex items-center justify-center gap-2 text-caption">
<Button size="sm" variant="outline" disabled={pg.page === 0} onClick={() => onPage(pg.page - 1)}>
上一页
</Button>
<span className="text-muted-foreground tabular-nums">
{pg.page + 1} / {pg.pages}
</span>
<Button size="sm" variant="outline" disabled={pg.page >= pg.pages - 1} onClick={() => onPage(pg.page + 1)}>
下一页
</Button>
</div>
) : null}
<Pagination {...pagination} />
</div>
);
}