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,321 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, rename, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createQbClient } from "@umt/adapters";
|
||||
import type { OrganizeSourceConfig } from "@umt/contracts";
|
||||
import { createMockQb, type MockQb } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
deleteOrganizeSource,
|
||||
listOrganizeSources,
|
||||
saveOrganizeSource,
|
||||
} from "../../src/modules/file-manager/organize-sources.ts";
|
||||
import { advanceSnapshot, scanOrganizeSource, scanStock } from "../../src/modules/file-manager/source-monitor.ts";
|
||||
import { createFmHarness, type FmHarness, pgAvailable, seedDownload, seedEntity } from "./harness.ts";
|
||||
|
||||
const fact = { size: 100, mtimeMs: 1000 };
|
||||
describe("source snapshot observations", () => {
|
||||
it("baselines historical files and requires two stable observations for new and changed files", () => {
|
||||
const baseline = advanceSnapshot(null, { "old.mkv": fact });
|
||||
expect(baseline.eligible).toEqual([]);
|
||||
const added = advanceSnapshot(baseline.snapshot, { "old.mkv": fact, "new.mkv": fact });
|
||||
expect(added.eligible).toEqual([]);
|
||||
const stable = advanceSnapshot(added.snapshot, { "old.mkv": fact, "new.mkv": fact });
|
||||
expect(stable.eligible).toEqual(["new.mkv"]);
|
||||
const changed = advanceSnapshot(stable.snapshot, { "old.mkv": { ...fact, size: 200 }, "new.mkv": fact });
|
||||
expect(changed.eligible).not.toContain("old.mkv");
|
||||
expect(advanceSnapshot(changed.snapshot, { "old.mkv": { ...fact, size: 200 } }).eligible).toEqual(["old.mkv"]);
|
||||
});
|
||||
});
|
||||
|
||||
const skip = !(await pgAvailable());
|
||||
describe.skipIf(skip)("persistent source monitoring", () => {
|
||||
let h: FmHarness | undefined;
|
||||
let qb: MockQb | undefined;
|
||||
afterEach(async () => {
|
||||
await qb?.close();
|
||||
qb = undefined;
|
||||
await h?.close();
|
||||
h = undefined;
|
||||
});
|
||||
const setup = async () => {
|
||||
qb = await createMockQb();
|
||||
h = await createFmHarness("source_monitor", {
|
||||
ao: {
|
||||
downloader: async (inst) =>
|
||||
createQbClient({ instanceId: inst.id, baseUrl: qb!.url, username: "admin", password: "adminadmin" }),
|
||||
},
|
||||
});
|
||||
const config: Omit<OrganizeSourceConfig, "id"> = {
|
||||
name: "来源",
|
||||
enabled: true,
|
||||
source: { root: h.dlRoot, path: "" },
|
||||
target: { root: h.libRoot, path: "" },
|
||||
mediaType: "all",
|
||||
category: null,
|
||||
action: "copy",
|
||||
trigger: "snapshot",
|
||||
downloaderInstanceId: null,
|
||||
tag: null,
|
||||
intervalSec: 60,
|
||||
};
|
||||
const id = randomUUID();
|
||||
await saveOrganizeSource(h.module.deps(), id, config);
|
||||
return { h, id, config, qb };
|
||||
};
|
||||
it("first scan persists stock baseline without records; stock never changes baseline", async () => {
|
||||
const { h, id } = await setup();
|
||||
await writeFile(join(h.dl, "old.mkv"), "old video content");
|
||||
const result = await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
expect(result).toMatchObject({ baseline: true, queued: 0 });
|
||||
const before = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
|
||||
expect(before.baseline_at).not.toBeNull();
|
||||
await writeFile(join(h.dl, "new.mkv"), "new video content");
|
||||
const stock = await scanStock(h.module.deps(), id);
|
||||
expect(stock.items.map((f) => f.path).sort()).toEqual(["new.mkv", "old.mkv"]);
|
||||
const after = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
|
||||
expect(after).toEqual(before);
|
||||
expect(await h.db.selectFrom("organization_record").select("id").execute()).toEqual([]);
|
||||
expect((await listOrganizeSources(h.module.deps())).items[0]?.fileCount).toBe(1);
|
||||
});
|
||||
it("unavailable source preserves baseline and retry does not import historical files", async () => {
|
||||
const { h, id } = await setup();
|
||||
await writeFile(join(h.dl, "old.mkv"), "old video content");
|
||||
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
const before = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
|
||||
await rename(h.dl, `${h.dl}-offline`);
|
||||
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
const failed = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
|
||||
expect(failed.snapshot).toEqual(before.snapshot);
|
||||
expect(failed.baseline_at).toEqual(before.baseline_at);
|
||||
expect(failed.last_error).not.toBeNull();
|
||||
await rename(`${h.dl}-offline`, h.dl);
|
||||
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({
|
||||
baseline: false,
|
||||
eligible: 0,
|
||||
queued: 0,
|
||||
});
|
||||
});
|
||||
it("an identified stable new file plans and queues import once", async () => {
|
||||
const { h, id, config } = await setup();
|
||||
await saveOrganizeSource(h.module.deps(), id, { ...config, category: "欧美电影" });
|
||||
const entityId = await seedEntity(h.db, { kind: "movie", title: "Snapshot Film", year: 2024 });
|
||||
await h.db
|
||||
.insertInto("external_identity")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
provider: "tmdb",
|
||||
external_id: "123456",
|
||||
status: "accepted",
|
||||
entity_type: "movie",
|
||||
})
|
||||
.execute();
|
||||
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
await writeFile(join(h.dl, "Snapshot.Film.2024.tmdb-123456.1080p.BluRay.mkv"), "video content for snapshot import");
|
||||
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 0, queued: 0 });
|
||||
const stableResult = await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
expect(stableResult).toMatchObject({ eligible: 1, queued: 1 });
|
||||
const imports = h.engine.enqueued.filter((e) => e.task === "verifyAndImport");
|
||||
expect(imports).toHaveLength(1);
|
||||
expect(imports[0]?.input).toMatchObject({ recordId: expect.any(String) });
|
||||
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
expect(h.engine.enqueued.filter((e) => e.task === "verifyAndImport")).toHaveLength(1);
|
||||
});
|
||||
it("new stable files create one scoped decision and never reprocess baseline stock", async () => {
|
||||
const { h, id } = await setup();
|
||||
await writeFile(join(h.dl, "historical.mkv"), "historical video content");
|
||||
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
await writeFile(join(h.dl, "unidentified.mkv"), "unidentified video content");
|
||||
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 0, queued: 0 });
|
||||
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 1, queued: 0 });
|
||||
const decisions = await h.db.selectFrom("decision").selectAll().execute();
|
||||
expect(decisions).toHaveLength(1);
|
||||
expect(decisions[0]?.evidence).toMatchObject({ sourceConfigId: id, root: h.dlRoot, path: "unidentified.mkv" });
|
||||
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
expect(await h.db.selectFrom("decision").select("id").execute()).toHaveLength(1);
|
||||
});
|
||||
it("active and completed QB tasks remain under the downloader tag policy", async () => {
|
||||
const { h, id } = await setup();
|
||||
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
await writeFile(join(h.dl, "active.mkv"), "active video content");
|
||||
await writeFile(join(h.dl, "completed.mkv"), "completed video content");
|
||||
await seedDownload(h.db, h, { name: "active.mkv", status: "downloading" });
|
||||
await seedDownload(h.db, h, { name: "completed.mkv", status: "completed" });
|
||||
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 2, queued: 0 });
|
||||
expect(await h.db.selectFrom("organization_record").select("id").execute()).toEqual([]);
|
||||
expect(await scanStock(h.module.deps(), id)).toMatchObject({
|
||||
unavailable: 1,
|
||||
items: [{ root: h.dlRoot, path: "completed.mkv" }],
|
||||
});
|
||||
});
|
||||
it("untagged paused QB downloads without DB rows wait for completion before snapshot import", async () => {
|
||||
const { h, id, qb, config } = await setup();
|
||||
await saveOrganizeSource(h.module.deps(), id, { ...config, category: "欧美电影" });
|
||||
const entityId = await seedEntity(h.db, { kind: "movie", title: "Snapshot Film", year: 2024 });
|
||||
await h.db
|
||||
.insertInto("external_identity")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
provider: "tmdb",
|
||||
external_id: "123456",
|
||||
status: "accepted",
|
||||
entity_type: "movie",
|
||||
})
|
||||
.execute();
|
||||
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
const name = "Snapshot.Film.2024.tmdb-123456.1080p.BluRay.mkv";
|
||||
await writeFile(join(h.dl, name), "stable incomplete video content");
|
||||
qb.seed({
|
||||
hash: "a".repeat(40),
|
||||
name,
|
||||
tags: [],
|
||||
state: "pausedDL",
|
||||
progress: 0.5,
|
||||
amount_left: 50,
|
||||
save_path: h.dl,
|
||||
content_path: join(h.dl, name),
|
||||
});
|
||||
expect(await h.db.selectFrom("download_task").select("id").execute()).toEqual([]);
|
||||
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 1, queued: 0 });
|
||||
expect(await scanStock(h.module.deps(), id)).toMatchObject({ items: [], unavailable: 1 });
|
||||
expect(await h.db.selectFrom("decision").select("id").execute()).toEqual([]);
|
||||
expect(h.engine.enqueued.filter((e) => e.task === "verifyAndImport")).toEqual([]);
|
||||
await qb.complete("a".repeat(40));
|
||||
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 1, queued: 1 });
|
||||
expect(await h.db.selectFrom("download_task").select("id").execute()).toEqual([]);
|
||||
});
|
||||
it("unreachable QB preserves the previous baseline and rejects stock", async () => {
|
||||
const { h, id, qb } = await setup();
|
||||
await writeFile(join(h.dl, "baseline.mkv"), "baseline video content");
|
||||
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
const before = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
|
||||
qb.fault("torrents/info", { status: 503, times: 10 });
|
||||
await writeFile(join(h.dl, "later.mkv"), "later video content");
|
||||
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ queued: 0 });
|
||||
const after = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
|
||||
expect(after.snapshot).toEqual(before.snapshot);
|
||||
expect(after.baseline_at).toEqual(before.baseline_at);
|
||||
expect(after.last_error).toContain("无法查询来源下载器");
|
||||
await expect(scanStock(h.module.deps(), id)).rejects.toThrow("无法查询来源下载器");
|
||||
});
|
||||
it("partial records exclude successful files while leaving failed files available", async () => {
|
||||
const { h, id } = await setup();
|
||||
await writeFile(join(h.dl, "success.mkv"), "successful video content");
|
||||
await writeFile(join(h.dl, "failure.mkv"), "failed video content");
|
||||
const record = await h.db
|
||||
.insertInto("organization_record")
|
||||
.values({ trigger: "manual", actor_type: "system", action: "copy", status: "partial" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("organization_file_result")
|
||||
.values([
|
||||
{
|
||||
organization_record_id: record.id,
|
||||
ordinal: 0,
|
||||
mode: "copy",
|
||||
source_path: join(h.dl, "success.mkv"),
|
||||
status: "succeeded",
|
||||
},
|
||||
{
|
||||
organization_record_id: record.id,
|
||||
ordinal: 1,
|
||||
mode: "copy",
|
||||
source_path: join(h.dl, "failure.mkv"),
|
||||
status: "failed",
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
expect(await scanStock(h.module.deps(), id)).toMatchObject({
|
||||
items: [{ root: h.dlRoot, path: "failure.mkv" }],
|
||||
skipped: 1,
|
||||
});
|
||||
});
|
||||
it("historical success excludes only matching source facts, so replacement files remain candidates", async () => {
|
||||
const { h, id } = await setup();
|
||||
const names = ["same.mkv", "size-changed.mkv", "mtime-changed.mkv"];
|
||||
await Promise.all(names.map((n) => writeFile(join(h.dl, n), "video content")));
|
||||
const facts = Object.fromEntries(
|
||||
await Promise.all(
|
||||
names.map(async (n) => {
|
||||
const st = await stat(join(h.dl, n));
|
||||
return [join(h.dl, n), { size: st.size, mtimeMs: st.mtimeMs }];
|
||||
}),
|
||||
),
|
||||
);
|
||||
facts[join(h.dl, "size-changed.mkv")]!.size -= 1;
|
||||
facts[join(h.dl, "mtime-changed.mkv")]!.mtimeMs -= 1;
|
||||
const record = await h.db
|
||||
.insertInto("organization_record")
|
||||
.values({
|
||||
trigger: "manual",
|
||||
actor_type: "system",
|
||||
action: "copy",
|
||||
status: "succeeded",
|
||||
plan: JSON.stringify({ sourceFacts: facts }),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("organization_file_result")
|
||||
.values(
|
||||
names.map((n, ordinal) => ({
|
||||
organization_record_id: record.id,
|
||||
ordinal,
|
||||
mode: "copy" as const,
|
||||
source_path: join(h.dl, n),
|
||||
source_size_bytes: facts[join(h.dl, n)]!.size,
|
||||
status: "succeeded" as const,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
const stock = await scanStock(h.module.deps(), id);
|
||||
expect(stock.skipped).toBe(1);
|
||||
expect(stock.items.map((f) => f.path).sort()).toEqual(["mtime-changed.mkv", "size-changed.mkv"]);
|
||||
});
|
||||
it("stock batches stay within the identify wizard limit", async () => {
|
||||
const { h, id } = await setup();
|
||||
await Promise.all(Array.from({ length: 201 }, (_, n) => writeFile(join(h.dl, `stock-${n}.mkv`), "video content")));
|
||||
const stock = await scanStock(h.module.deps(), id);
|
||||
expect(stock.items).toHaveLength(200);
|
||||
expect(stock.truncated).toBe(true);
|
||||
expect(await h.db.selectFrom("organize_source_snapshot").selectAll().execute()).toEqual([]);
|
||||
});
|
||||
it("stock omits successfully organized files", async () => {
|
||||
const { h, id } = await setup();
|
||||
const path = join(h.dl, "organized.mkv");
|
||||
await writeFile(path, "organized video content");
|
||||
const record = await h.db
|
||||
.insertInto("organization_record")
|
||||
.values({ trigger: "manual", actor_type: "system", action: "copy", status: "succeeded" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("organization_file_result")
|
||||
.values({ organization_record_id: record.id, mode: "copy", source_path: path, status: "succeeded" })
|
||||
.execute();
|
||||
expect(await scanStock(h.module.deps(), id)).toMatchObject({ items: [], skipped: 1 });
|
||||
});
|
||||
it("source edits reset baseline; identical categories coexist; deleting removes state", async () => {
|
||||
const { h, id, config } = await setup();
|
||||
await mkdir(join(h.dl, "other"));
|
||||
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
await saveOrganizeSource(h.module.deps(), randomUUID(), { ...config, category: "电影" });
|
||||
await saveOrganizeSource(h.module.deps(), id, {
|
||||
...config,
|
||||
category: "电影",
|
||||
source: { ...config.source, path: "other" },
|
||||
});
|
||||
expect((await listOrganizeSources(h.module.deps())).items).toHaveLength(2);
|
||||
expect(
|
||||
await h.db.selectFrom("organize_source_snapshot").selectAll().where("source_id", "=", id).executeTakeFirst(),
|
||||
).toBeUndefined();
|
||||
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||
await deleteOrganizeSource(h.module.deps(), id);
|
||||
expect(
|
||||
await h.db.selectFrom("organize_source_snapshot").selectAll().where("source_id", "=", id).executeTakeFirst(),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user