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

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@umt/server",
"version": "0.3.7",
"version": "0.3.14",
"exports": { ".": "./src/main.ts" },
"tasks": {
"dev": "deno run -A --watch --env-file=../../.env src/main.ts",
@@ -0,0 +1,142 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import type { TmdbClient } from "@umt/adapters";
import { DEFAULT_CLASSIFICATION_YAML } from "@umt/domain";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { AuthEnv } from "../../../http/auth.ts";
import { createSettingsReader } from "../../auth-settings/provided.ts";
import { registerSettingsRoutes } from "../../auth-settings/settings-routes.ts";
import { SourcesRuntime } from "../../sources-identity/runtime.ts";
import { classifyEntity } from "../services/classification.ts";
import { createHarness, type Harness, pgAvailable, seedEntity } from "./harness.ts";
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("TMDB classification API and accepted identities (real PG)", () => {
let h: Harness;
beforeAll(async () => {
h = await createHarness("classification");
const settingsApp = new OpenAPIHono<AuthEnv>();
registerSettingsRoutes(settingsApp, h.ctx);
h.app.route("/api/v1", settingsApp);
}, 60_000);
afterEach(() => vi.restoreAllMocks());
afterAll(async () => await h?.close());
it("legacy stored organize settings receive default YAML without losing preferences", async () => {
await h.setSettings("organize", { minSizeBytes: 456789, conflictDefault: "skip" });
const reader = createSettingsReader({ db: h.db, logger: h.ctx.logger });
expect(await reader.get("organize")).toMatchObject({
minSizeBytes: 456789,
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
});
});
it("ordered categories and raw preview do not write settings", async () => {
const view = await h.api("GET", "/classification");
expect(view.status).toBe(200);
expect(view.body.data.yaml).toBe(DEFAULT_CLASSIFICATION_YAML);
expect(view.body.data.categories.movie).toEqual(["动画电影", "演唱会", "华语电影", "亚洲电影", "欧美电影"]);
const preview = await h.api("POST", "/classification/preview", {
yaml: "movie:\n 日本片:\n production_countries: JP\ntv: {}\n",
mediaType: "movie",
tmdb: { production_countries: [{ iso_3166_1: "JP" }] },
});
expect(preview.status).toBe(200);
expect(preview.body.data).toEqual({ category: "日本片", matchedFields: ["production_countries"], fallback: false });
expect((await h.ctx.settings.get("organize")).classificationYaml).toBe(DEFAULT_CLASSIFICATION_YAML);
});
it("PUT preserves organize preferences, audits and emits settings change; invalid writes reject", async () => {
await h.setSettings("organize", { ...(await h.ctx.settings.get("organize")), minSizeBytes: 765432 });
const publish = vi.spyOn(h.ctx.events, "publish");
const yaml = "movie:\n 新分类:\n original_language: zh\n 其它: null\ntv: {}\n";
const saved = await h.api("PUT", "/classification", { yaml });
expect(saved.status).toBe(200);
expect(saved.body.data.categories.movie).toEqual(["新分类", "其它"]);
expect(await h.ctx.settings.get("organize")).toMatchObject({ classificationYaml: yaml, minSizeBytes: 765432 });
expect(publish).toHaveBeenCalledWith("settings.changed", { keys: ["organize"] });
expect(
(await h.db.selectFrom("audit_event").selectAll().where("action", "=", "settings.update").execute()).length,
).toBeGreaterThan(0);
for (const invalid of ["movie:\n ../escape: null\ntv: {}", "movie:\n 同名: null\n 同名: null\ntv: {}"]) {
expect((await h.api("PUT", "/classification", { yaml: invalid })).status).toBe(422);
expect((await h.api("PATCH", "/settings", { organize: { classificationYaml: invalid } })).status).toBe(422);
}
expect((await h.ctx.settings.get("organize")).classificationYaml).toBe(yaml);
await h.api("PUT", "/classification", { yaml: DEFAULT_CLASSIFICATION_YAML });
});
it("PUT merges current row after waiting rather than overwriting a newer source edit", async () => {
const source = {
id: "33333333-3333-4333-8333-333333333333",
name: "Concurrent source",
enabled: true,
source: { root: "lib:11111111-1111-4111-8111-111111111111", path: "" },
target: { root: "lib:22222222-2222-4222-8222-222222222222", path: "" },
mediaType: "all",
category: null,
action: "copy",
trigger: "manual",
downloaderInstanceId: null,
tag: null,
intervalSec: 300,
};
const originalAll = h.ctx.settings.all.bind(h.ctx.settings);
vi.spyOn(h.ctx.settings, "all").mockImplementationOnce(async () => {
const stale = await originalAll();
await h.setSettings("organize", { ...stale.organize, minSizeBytes: 654321, sources: [source] });
return stale;
});
expect((await h.api("PUT", "/classification", { yaml: DEFAULT_CLASSIFICATION_YAML })).status).toBe(200);
const stored = await h.db
.selectFrom("settings")
.select("value")
.where("key", "=", "organize")
.executeTakeFirstOrThrow();
expect(stored.value).toMatchObject({
minSizeBytes: 654321,
sources: [source],
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
});
});
it("requires accepted movie/tv identity, uses raw detail, and fails without fabricating fallback", async () => {
await h.setSettings("organize", {
...(await h.ctx.settings.get("organize")),
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
});
const id = await seedEntity(h.db, { kind: "movie", title: "测试片", year: 2020 });
const entity = await h.db.selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const getJson = vi.fn().mockResolvedValue({ genres: [{ id: 16 }], original_language: "zh" });
const factory = vi.spyOn(SourcesRuntime.prototype, "tmdb").mockResolvedValue({ getJson } as unknown as TmdbClient);
expect((await classifyEntity(h.d, entity)).category).toBeNull();
expect(factory).not.toHaveBeenCalled();
const identity = await h.db
.insertInto("external_identity")
.values({ media_entity_id: id, provider: "tmdb", entity_type: "movie", external_id: "123", status: "proposed" })
.returning("id")
.executeTakeFirstOrThrow();
expect((await classifyEntity(h.d, entity)).category).toBeNull();
await h.db.updateTable("external_identity").set({ status: "accepted" }).where("id", "=", identity.id).execute();
expect(await classifyEntity(h.d, entity)).toEqual({
category: "动画电影",
matchedFields: ["genre_ids"],
fallback: false,
});
expect(getJson).toHaveBeenCalledWith("/movie/123");
getJson.mockRejectedValue(new Error("offline"));
expect(await classifyEntity(h.d, entity)).toEqual({ category: null, matchedFields: [], fallback: false });
getJson.mockResolvedValue({});
expect((await classifyEntity(h.d, entity)).category).toBeNull();
getJson.mockResolvedValue({ genres: [{ id: 10764 }], origin_country: ["CN"] });
const tvId = await seedEntity(h.db, { kind: "series", title: "测试剧", year: 2020 });
const tv = await h.db.selectFrom("media_entity").selectAll().where("id", "=", tvId).executeTakeFirstOrThrow();
await h.db
.insertInto("external_identity")
.values({ media_entity_id: tvId, provider: "tmdb", entity_type: "tv", external_id: "456", status: "accepted" })
.execute();
expect((await classifyEntity(h.d, tv)).category).toBe("综艺");
expect(getJson).toHaveBeenCalledWith("/tv/456");
factory.mockResolvedValue(null);
expect((await classifyEntity(h.d, entity)).category).toBeNull();
});
});
@@ -0,0 +1,207 @@
import type { TaskRunView } from "@umt/tasks";
import { createTempTree, type TempTree } from "@umt/testkit";
import { sql } from "kysely";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { enqueueSweep, enqueueTracked } from "../services/common.ts";
import { type QueueScrapeInput, queueScrape, runScrapeRecord } from "../services/scrape.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
const hasPg = await pgAvailable();
const runView = (runId: string, input: unknown, status: TaskRunView["status"] = "pending"): TaskRunView => ({
runId,
taskName: "scrapeMedia",
version: 2,
status,
attempts: 0,
idempotencyKey: null,
traceId: null,
priority: 0,
input,
output: null,
error: null,
cancelRequestedAt: null,
cancelReason: null,
availableAt: null,
createdAt: new Date(),
startedAt: null,
finishedAt: null,
});
describe.skipIf(!hasPg)("入队关联与巡检恢复", () => {
let h: Harness;
let lib: TempTree;
let rootId: string;
let entityId: string;
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true });
h = await createHarness("enqueue_recovery");
({ rootId } = await seedBase(h.db, { libraryPath: lib.root, cachePath: lib.root, qbUrl: "http://127.0.0.1:9" }));
entityId = await seedEntity(h.db, { kind: "movie", title: "恢复测试", year: 2020 });
await sql`create function reject_association() returns trigger language plpgsql as $$
begin
if new.engine_run_id is not null and new.engine_run_id is distinct from old.engine_run_id
then raise exception 'injected association failure'; end if;
return new;
end $$`.execute(h.db);
});
async function rejectAssociation(table: "task" | "scrape_record") {
await sql
.raw(`create trigger reject_association before update on ${table}
for each row execute function reject_association()`)
.execute(h.db);
}
async function allowAssociation() {
for (const table of ["task", "scrape_record"])
await sql.raw(`drop trigger if exists reject_association on ${table}`).execute(h.db);
}
afterEach(async () => {
vi.restoreAllMocks();
await allowAssociation();
});
afterAll(async () => {
await h?.close();
await lib?.cleanup();
});
const task = (key: string) => ({ kind: "reconcileLibrary" as const, idempotencyKey: key, input: {} });
const scrape = (path: string, idempotencyKey?: string): QueueScrapeInput => ({
rootId: `lib:${rootId}`,
rootPath: lib.root,
libraryRootId: rootId,
path,
match: {
mediaEntityId: entityId,
provider: null,
externalId: null,
kind: "movie",
title: "恢复测试",
year: 2020,
source: "chosen",
},
trigger: "manual",
mode: "missing",
...(idempotencyKey ? { idempotencyKey } : {}),
});
it("引擎查询异常跳过本轮,下一轮仍查原任务", async () => {
const t = task("query_error");
const q = await enqueueTracked(h.d, t);
const enqueue = vi.spyOn(h.engine, "enqueue");
const get = vi
.spyOn(h.engine, "get")
.mockRejectedValueOnce(new Error("temporary engine outage"))
.mockResolvedValue(runView(q.runId, {}, "running"));
expect(await enqueueSweep(h.d, t, "next")).toBeNull();
expect(await enqueueSweep(h.d, t, "later")).toBeNull();
expect(enqueue).not.toHaveBeenCalled();
expect(get).toHaveBeenNthCalledWith(2, q.runId);
expect(
(await h.db.selectFrom("task").select("engine_run_id").where("id", "=", q.taskId).executeTakeFirstOrThrow())
.engine_run_id,
).toBe(q.runId);
});
it.each(["missing", "completed"])("明确 %s 时允许补投", async (state) => {
const t = task(`retry_${state}`);
const q = await enqueueTracked(h.d, t);
vi.spyOn(h.engine, "get").mockResolvedValue(state === "missing" ? null : runView(q.runId, {}, "completed"));
const enqueue = vi.spyOn(h.engine, "enqueue");
expect(await enqueueSweep(h.d, t, "next")).not.toBeNull();
expect(enqueue).toHaveBeenCalledOnce();
expect(enqueue.mock.calls[0]?.[2].idempotencyKey).toBe(`${t.kind}:${t.idempotencyKey}:next`);
});
it.each([false, true])("关联写回失败后巡检按原 taskId 找回已接受的 run (旧关联=%s)", async (oldAssociation) => {
const t = task(`association_${oldAssociation}`);
if (oldAssociation) await enqueueTracked(h.d, t);
await rejectAssociation("task");
const q = await enqueueTracked(h.d, t, { engineKey: "new-slot" });
await h.db
.insertInto("tasks_run_meta")
.values({ run_id: q.runId, task_name: t.kind, task_version: 1, idempotency_key: "new-slot", trace_id: q.taskId })
.execute();
await allowAssociation();
vi.spyOn(h.engine, "get").mockResolvedValue(runView(q.runId, {}, "running"));
const enqueue = vi.spyOn(h.engine, "enqueue");
expect(await enqueueSweep(h.d, t, "later")).toBeNull();
expect(enqueue).not.toHaveBeenCalled();
expect(
(await h.db.selectFrom("task").select("engine_run_id").where("id", "=", q.taskId).executeTakeFirstOrThrow())
.engine_run_id,
).toBe(q.runId);
});
it("刮削已接受而关联失败时保持 queued,重试复用记录与原幂等键", async () => {
const input = scrape("association-retry");
await rejectAssociation("scrape_record");
const q = await queueScrape(h.d, input);
expect(
await h.db
.selectFrom("scrape_record")
.select(["status", "reason", "engine_run_id"])
.where("id", "=", q.recordId)
.executeTakeFirstOrThrow(),
).toMatchObject({ status: "queued", reason: null, engine_run_id: null });
const key = h.engine.enqueued.at(-1)?.key;
await allowAssociation();
const enqueue = vi.spyOn(h.engine, "enqueue").mockResolvedValue({ runId: q.taskId!, deduplicated: true });
vi.spyOn(h.engine, "get").mockResolvedValue(runView(q.taskId!, { recordId: q.recordId }));
expect(await queueScrape(h.d, input)).toEqual({ recordId: q.recordId, taskId: q.taskId, deduplicated: true });
expect(enqueue.mock.calls[0]?.[2].idempotencyKey).toBe(key);
expect((await h.db.selectFrom("scrape_record").select("id").where("path", "=", input.path).execute()).length).toBe(
1,
);
});
it("刮削处理器开始时补关联,不会把已接受的工作误标为入队失败", async () => {
await rejectAssociation("scrape_record");
const q = await queueScrape(h.d, scrape("worker-backfill"));
await allowAssociation();
await h.db.updateTable("library_root").set({ management_mode: "register_only" }).where("id", "=", rootId).execute();
const r = await runScrapeRecord(h.d, q.recordId, undefined, q.taskId!);
expect(r.reason).toBe("scrape_root_read_only");
expect(
(
await h.db
.selectFrom("scrape_record")
.select("engine_run_id")
.where("id", "=", q.recordId)
.executeTakeFirstOrThrow()
).engine_run_id,
).toBe(q.taskId);
});
it("确实入队失败的新刮削记录标失败", async () => {
vi.spyOn(h.engine, "enqueue").mockRejectedValue(new Error("enqueue rejected"));
const input = scrape("enqueue-failed");
await expect(queueScrape(h.d, input)).rejects.toThrow("enqueue rejected");
expect(
await h.db
.selectFrom("scrape_record")
.select(["status", "reason"])
.where("path", "=", input.path)
.executeTakeFirstOrThrow(),
).toMatchObject({ status: "failed", reason: "task_enqueue_failed" });
});
it("自动重放从原 run 输入找回未关联记录,只删除本次多建的记录", async () => {
const input = scrape("auto-replay", "auto-stable-key");
await rejectAssociation("scrape_record");
const q = await queueScrape(h.d, input);
await allowAssociation();
await h.db.updateTable("scrape_record").set({ status: "succeeded" }).where("id", "=", q.recordId).execute();
vi.spyOn(h.engine, "enqueue").mockResolvedValue({ runId: q.taskId!, deduplicated: true });
vi.spyOn(h.engine, "get").mockResolvedValue(runView(q.taskId!, { recordId: q.recordId }, "completed"));
expect(await queueScrape(h.d, input)).toEqual({ recordId: q.recordId, taskId: q.taskId, deduplicated: true });
const recs = await h.db
.selectFrom("scrape_record")
.select(["id", "status", "engine_run_id"])
.where("path", "=", input.path)
.execute();
expect(recs).toEqual([{ id: q.recordId, status: "succeeded", engine_run_id: q.taskId }]);
});
});
@@ -2,7 +2,7 @@ import type { DB } from "@umt/db";
import type { Kysely } from "kysely";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { backfillVersionSpecs } from "../services/version-backfill.ts";
import { createHarness, type Harness, pgAvailable, seedEntity, seedSlot } from "./harness.ts";
import { createHarness, type Harness, pgAvailable, seedEntity, seedRelease, seedSlot } from "./harness.ts";
// LIBV-1:入库版本 API(每集多个版本、季汇总、单目标比对)与规格回填(dry-run / apply)。
@@ -272,4 +272,68 @@ describe.skipIf(!hasPg)("LIBV-1 入库版本", () => {
const dv = r.body.data.seasons[0].episodes[0].versions.find((x: { assetId: string }) => x.assetId === ids.e1dv);
expect(dv).toMatchObject({ specStored: true, hdr: ["DV", "HDR10"], releaseGroup: "FLUX" });
});
it("作品详情保留跨过旧条数上限的整理、下载和失败计划供分页查看", async () => {
const entityId = await seedEntity(h.db, { kind: "movie", title: "历史分页测试", year: 2026 });
const instance = await h.db
.insertInto("downloader_instance")
.values({ name: "历史下载器", base_url: "http://127.0.0.1:1" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("download_task")
.values(
Array.from({ length: 105 }, (_, i) => ({
instance_id: instance.id,
media_entity_id: entityId,
torrent_hash: String(i + 1).padStart(40, "0"),
name: `历史下载 ${i + 1}`,
})),
)
.execute();
const slotId = await seedSlot(h.db, { entityId, name: "历史版本" });
const site = await h.db
.insertInto("site")
.values({ key: "history-source", name: "历史来源", base_url: "http://127.0.0.1:1", adapter: "nexusphp" })
.returning("id")
.executeTakeFirstOrThrow();
const releaseId = await seedRelease(h.db, {
siteId: site.id,
entityId,
title: "历史分页测试",
size: 1000,
hash: "a".repeat(40),
});
await h.db
.insertInto("acquisition_plan")
.values(
Array.from({ length: 51 }, (_, i) => ({
slot_id: slotId,
source_release_id: releaseId,
status: "failed" as const,
payload_hash: `history-${i}`,
idempotency_key: `history:${i}`,
})),
)
.execute();
await h.db
.insertInto("organization_record")
.values(
Array.from({ length: 105 }, (_, i) => ({
media_entity_id: entityId,
trigger: "manual" as const,
actor_type: "user" as const,
action: "copy" as const,
status: "succeeded" as const,
source_path: `/history/${i}.mkv`,
target_path: `/library/history/${i}.mkv`,
})),
)
.execute();
const response = await h.api("GET", `/library/${entityId}`);
expect(response.status).toBe(200);
expect(response.body.data.organizationRecords).toHaveLength(105);
expect(response.body.data.downloads).toHaveLength(105);
expect(response.body.data.droppedPlans).toHaveLength(51);
});
});
@@ -0,0 +1,153 @@
import { ManualAcquisitionRequest } from "@umt/contracts";
import { createMockQb, createTempTree, makeTorrent } from "@umt/testkit";
import { describe, expect, it } from "vitest";
import { evaluateAcquisition } from "../services/acquisition.ts";
import { submitDownload } from "../services/downloads.ts";
import { planManualAcquisition } from "../services/manual-acquisition.ts";
import { createHarness, fakeDisk, pgAvailable, seedBase, seedEntity, seedRelease, seedSlot } from "./harness.ts";
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("独立手动下载", () => {
it("无订阅下载,未知资源确认,同hash沿用原路径", async () => {
const qb = await createMockQb({ addVisibleAfterPolls: 0 });
const cache = await createTempTree({});
const lib = await createTempTree({}, { sentinel: true });
const torrents = new Map<string, Uint8Array>();
const disk = fakeDisk(10_000_000);
disk.setPct(20);
const h = await createHarness("manual_acquisition", {
diskUsage: disk.fn,
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
});
try {
const base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
const entityId = await seedEntity(h.db, { kind: "movie", title: "Manual Movie", year: 2026 });
const torrent = makeTorrent({ name: "Manual.Movie.2026.1080p.mkv", length: 300_000 });
const relId = await seedRelease(h.db, {
siteId: base.siteId,
entityId,
title: torrent.name,
size: 300_000,
hash: torrent.hash,
});
torrents.set(relId, torrent.bytes);
const request = (id: string, opts = {}) => ManualAcquisitionRequest.parse({ sourceReleaseId: id, ...opts });
const first = await planManualAcquisition(h.ctx, request(relId));
expect(first).toMatchObject({ submitted: true, slotId: null });
expect(await h.db.selectFrom("subscription").selectAll().execute()).toHaveLength(0);
expect(await h.db.selectFrom("acquisition_slot").selectAll().execute()).toHaveLength(0);
const sub = await submitDownload(h.d, first.plan!.id);
expect(sub.status).toBe("submitted");
const rel2 = await seedRelease(h.db, {
siteId: base.siteId,
entityId,
title: "Other.Release.2160p.mkv",
size: 300_000,
hash: torrent.hash,
});
torrents.set(rel2, torrent.bytes);
const second = await planManualAcquisition(h.ctx, request(rel2, { savePath: `${cache.root}/other` }));
const reused = await submitDownload(h.d, second.plan!.id);
expect(reused).toMatchObject({ reason: "already_exists", downloadTaskId: sub.downloadTaskId });
const dt = await h.db
.selectFrom("download_task")
.selectAll()
.where("id", "=", sub.downloadTaskId!)
.executeTakeFirstOrThrow();
expect(dt.save_path).toBe(cache.root);
expect(dt.media_entity_id).toBe(entityId);
const unknown = makeTorrent({ name: "Unknown.1080p.mkv", length: 300_000, pieceSeed: 42 });
const uid = await seedRelease(h.db, {
siteId: base.siteId,
entityId,
title: unknown.name,
size: 300_000,
hash: unknown.hash,
});
await h.db.updateTable("source_release").set({ media_entity_id: null }).where("id", "=", uid).execute();
torrents.set(uid, unknown.bytes);
expect(await planManualAcquisition(h.ctx, request(uid))).toMatchObject({
submitted: false,
requiresConfirmation: true,
});
const confirmed = await planManualAcquisition(h.ctx, request(uid, { allowUnrecognized: true }));
const submitted = await submitDownload(h.d, confirmed.plan!.id);
expect(submitted.status).toBe("submitted");
expect(
(
await h.db
.selectFrom("download_task")
.select("media_entity_id")
.where("id", "=", submitted.downloadTaskId!)
.executeTakeFirstOrThrow()
).media_entity_id,
).toBeNull();
const autoEntity = await seedEntity(h.db, { kind: "movie", title: "Auto Movie", year: 2026 });
const slotId = await seedSlot(h.db, { entityId: autoEntity });
const autoTorrent = makeTorrent({
name: "Auto.Movie.2026.1080p.BluRay.x264-GRP.mkv",
length: 300_000,
pieceSeed: 100,
});
const autoRelease = await seedRelease(h.db, {
siteId: base.siteId,
entityId: autoEntity,
title: autoTorrent.name,
size: 300_000,
hash: autoTorrent.hash,
});
torrents.set(autoRelease, autoTorrent.bytes);
const auto = await evaluateAcquisition(h.d, { sourceReleaseId: autoRelease });
expect(auto.plans).toHaveLength(1);
const autoTask = await submitDownload(h.d, auto.plans[0]!);
const oldAsset = await h.db
.insertInto("media_asset")
.values({ media_entity_id: autoEntity, kind: "video", size_bytes: 300_000, status: "active" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.updateTable("download_task")
.set({ status: "imported" })
.where("id", "=", autoTask.downloadTaskId!)
.execute();
const unchanged = await planManualAcquisition(h.ctx, request(autoRelease));
expect(unchanged.plan!.id).toBe(auto.plans[0]);
const wash = await planManualAcquisition(
h.ctx,
request(autoRelease, { importMode: "replace", replacementAssetIds: [oldAsset.id] }),
);
const washed = await submitDownload(h.d, wash.plan!.id);
expect(washed.downloadTaskId).toBe(autoTask.downloadTaskId);
const linked = await h.db
.selectFrom("download_task")
.selectAll()
.where("id", "=", autoTask.downloadTaskId!)
.executeTakeFirstOrThrow();
expect(linked).toMatchObject({
acquisition_plan_id: auto.plans[0],
import_plan_id: wash.plan!.id,
status: "completed",
});
expect(
(
await h.db
.selectFrom("acquisition_plan")
.select("slot_id")
.where("id", "=", auto.plans[0]!)
.executeTakeFirstOrThrow()
).slot_id,
).toBe(slotId);
expect(h.engine.enqueued).toContainEqual(
expect.objectContaining({
task: "verifyAndImport",
input: expect.objectContaining({ downloadTaskId: autoTask.downloadTaskId }),
}),
);
} finally {
await h.close();
await qb.close();
await cache.cleanup();
await lib.cleanup();
}
});
});
@@ -0,0 +1,248 @@
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type { TmdbClient } from "@umt/adapters";
import { DEFAULT_SETTINGS, type OrganizeSourceConfig } from "@umt/contracts";
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { SourcesRuntime } from "../../sources-identity/runtime.ts";
import { syncDownloads } from "../services/downloads.ts";
import { executeRecord, planImport } from "../services/organize.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
const hasPg = await pgAvailable();
const FILE = "Example.2020.1080p.BluRay.x264-GRP.mkv";
describe.skipIf(!hasPg)("configured organize sources (real PG, files, mock qB)", () => {
let h: Harness;
let lib: TempTree;
let cache: TempTree;
let qb: MockQb;
let otherQb: MockQb;
let base: Awaited<ReturnType<typeof seedBase>>;
let otherInstance: string;
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true });
cache = await createTempTree({ [`a/${FILE}`]: 200_000, [`b/${FILE}`]: 200_000, [`tagged/${FILE}`]: 200_000 });
qb = await createMockQb();
otherQb = await createMockQb();
h = await createHarness("source_config");
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
otherInstance = (
await h.db
.insertInto("downloader_instance")
.values({
name: "other-qb",
base_url: otherQb.url,
username: "admin",
settings: JSON.stringify({ savePath: cache.root }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}, 60_000);
afterEach(() => vi.restoreAllMocks());
afterAll(async () => {
await h?.close();
await qb?.close();
await otherQb?.close();
await lib?.cleanup();
await cache?.cleanup();
});
const profile = (patch: Partial<OrganizeSourceConfig> = {}): OrganizeSourceConfig => ({
id: randomUUID(),
name: "整理来源",
enabled: true,
source: { root: `dl:${base.instanceId}:0`, path: "a" },
target: { root: `lib:${base.rootId}`, path: "华语电影" },
mediaType: "movie",
category: "华语电影",
action: "copy",
trigger: "manual",
downloaderInstanceId: null,
tag: null,
intervalSec: 300,
...patch,
});
const settings = (
sources: OrganizeSourceConfig[],
classificationYaml = DEFAULT_SETTINGS.organize.classificationYaml,
) => h.setSettings("organize", { ...DEFAULT_SETTINGS.organize, minSizeBytes: 100_000, sources, classificationYaml });
const request = (entityId: string, source: OrganizeSourceConfig, folder = "a") => ({
sourcePaths: [cache.path(`${folder}/${FILE}`)],
mediaEntityId: entityId,
sourceConfigId: source.id,
trigger: "manual" as const,
actorType: "user" as const,
dryRun: true,
});
it("fixed category adds no duplicate; explicit same-category source selects own target and rejects wrong source", async () => {
const a = profile();
const b = profile({
source: { root: `dl:${base.instanceId}:0`, path: "b" },
target: { root: `lib:${base.rootId}`, path: "另一个目录/华语电影" },
});
await settings([a, b]);
const movie = await seedEntity(h.db, { kind: "movie", title: "固定片", year: 2020 });
const first = await planImport(h.d, request(movie, a));
expect(first.plan?.sourceConfigId).toBe(a.id);
expect(first.plan?.newBindings[0]?.relativePath).toBe("华语电影/固定片 (2020)");
const explicit = await planImport(h.d, { ...request(movie, a), targetDir: join(lib.root, "华语电影") });
expect(explicit.plan?.newBindings).toEqual([]);
const explicitDst = explicit.plan?.organize.files.find((f) => f.role === "main")?.dst;
expect(explicitDst?.startsWith(`${join(lib.root, "华语电影")}/`)).toBe(true);
expect(explicitDst?.includes("华语电影/华语电影")).toBe(false);
const second = await planImport(h.d, request(movie, b, "b"));
expect(second.plan?.sourceConfigId).toBe(b.id);
expect(second.plan?.newBindings[0]?.relativePath).toBe("另一个目录/华语电影/固定片 (2020)");
const mismatch = await planImport(h.d, request(movie, a, "b"));
expect(mismatch.reason).toBe("source_config_mismatch");
expect(mismatch.plan).toBeUndefined();
});
it("automatic category prefixes new binding; changed YAML/target preserves existing binding", async () => {
const automatic = profile({ category: null, target: { root: `lib:${base.rootId}`, path: "自动" } });
await settings([automatic]);
const movie = await seedEntity(h.db, { kind: "movie", title: "动画片", year: 2020 });
await h.db
.insertInto("external_identity")
.values({
media_entity_id: movie,
provider: "tmdb",
entity_type: "movie",
external_id: "987",
status: "accepted",
})
.execute();
const getJson = vi.fn().mockResolvedValue({ genres: [{ id: 16 }], original_language: "zh" });
vi.spyOn(SourcesRuntime.prototype, "tmdb").mockResolvedValue({ getJson } as unknown as TmdbClient);
const plan = await planImport(h.d, { ...request(movie, automatic), dryRun: false });
expect(plan.status).toBe("running");
const record = await h.db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", plan.recordId!)
.executeTakeFirstOrThrow();
expect(record.plan).toMatchObject({
classification: { category: "动画电影" },
newBindings: [{ relativePath: "自动/动画电影/动画片 (2020)" }],
});
expect((await executeRecord(h.d, plan.recordId!)).status).toBe("succeeded");
await settings(
[{ ...automatic, target: { ...automatic.target, path: "新目录" } }],
"movie:\n 新分类: null\ntv: {}\n",
);
const updated = await planImport(h.d, request(movie, automatic));
expect(updated.plan?.classification?.category).toBe("新分类");
expect(updated.plan?.newBindings).toEqual([]);
expect(updated.plan?.organize.files.find((f) => f.role === "main")?.dst).toContain(
join(lib.root, "自动", "动画电影", "动画片 (2020)"),
);
expect(
(
await h.db
.selectFrom("path_binding")
.select("relative_path")
.where("media_entity_id", "=", movie)
.executeTakeFirstOrThrow()
).relative_path,
).toBe("自动/动画电影/动画片 (2020)");
});
it("explicit root creates an unbound path there and refuses to relocate an existing binding", async () => {
const alternate = await createTempTree({}, { sentinel: true });
const alternateRoot = await h.db
.insertInto("library_root")
.values({ name: "alternate", media_kind: "mixed", local_path: alternate.root, management_mode: "managed" })
.returning("id")
.executeTakeFirstOrThrow();
try {
const source = profile();
await settings([source]);
const fresh = await seedEntity(h.db, { kind: "movie", title: "新根片", year: 2020 });
const override = await planImport(h.d, { ...request(fresh, source), targetLibraryRootId: alternateRoot.id });
expect(override.plan?.libraryRootId).toBe(alternateRoot.id);
expect(override.plan?.newBindings[0]?.relativePath).toBe("新根片 (2020)");
const existing = await seedEntity(h.db, { kind: "movie", title: "已绑定片", year: 2020 });
const first = await planImport(h.d, { ...request(existing, source), dryRun: false });
expect((await executeRecord(h.d, first.recordId!)).status).toBe("succeeded");
const attempted = await planImport(h.d, { ...request(existing, source), targetLibraryRootId: alternateRoot.id });
expect(attempted.reason).toBe("binding_on_other_root");
expect(
await h.db
.selectFrom("path_binding")
.select(["library_root_id", "relative_path"])
.where("media_entity_id", "=", existing)
.executeTakeFirstOrThrow(),
).toMatchObject({ library_root_id: base.rootId, relative_path: "华语电影/已绑定片 (2020)" });
} finally {
await h.db.updateTable("library_root").set({ enabled: false }).where("id", "=", alternateRoot.id).execute();
await alternate.cleanup();
}
});
it("tag and source path adopt only matching instance; removing tag stops management", async () => {
const tag = "source-only-tag";
await settings([
profile({
source: { root: `dl:${base.instanceId}:0`, path: "tagged" },
trigger: "qb_tag",
downloaderInstanceId: base.instanceId,
tag,
}),
]);
const hash = (n: number) => n.toString(16).padStart(40, "0");
qb.seed({ hash: hash(1), name: FILE, save_path: cache.path("tagged"), tags: [tag] });
qb.seed({ hash: hash(2), name: "No.Tag.mkv", save_path: cache.path("tagged"), tags: [] });
qb.seed({ hash: hash(3), name: "Wrong.Path.mkv", save_path: cache.path("b"), tags: [tag] });
otherQb.seed({ hash: hash(1), name: FILE, save_path: cache.path("tagged"), tags: [tag] });
const result = await syncDownloads(h.d);
expect(result.adopted).toBe(1);
const rows = await h.db.selectFrom("download_task").selectAll().execute();
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ instance_id: base.instanceId, torrent_hash: hash(1), managed: true });
expect(rows.some((r) => r.instance_id === otherInstance)).toBe(false);
qb.get(hash(1))!.tags = [];
await syncDownloads(h.d);
expect(
(await h.db.selectFrom("download_task").selectAll().where("id", "=", rows[0]!.id).executeTakeFirstOrThrow())
.managed,
).toBe(false);
qb.get(hash(1))!.tags = [tag];
await syncDownloads(h.d);
expect(
(await h.db.selectFrom("download_task").selectAll().where("id", "=", rows[0]!.id).executeTakeFirstOrThrow())
.managed,
).toBe(true);
});
it("automatic source with movie-only media type refuses a TV entity instead of generic organization", async () => {
const tag = "movie-source";
await settings([
profile({
source: { root: `dl:${base.instanceId}:0`, path: "tagged" },
trigger: "qb_tag",
downloaderInstanceId: base.instanceId,
tag,
}),
]);
const hash = "4".padStart(40, "0");
const name = "Only.TV.S01E01.1080p.WEB-DL.x264-GRP.mkv";
qb.seed({ hash, name, save_path: cache.path("tagged"), tags: [tag] });
await qb.complete(hash, { files: [{ path: name, size: 200_000 }] });
await syncDownloads(h.d);
const row = await h.db
.selectFrom("download_task")
.selectAll()
.where("torrent_hash", "=", hash)
.executeTakeFirstOrThrow();
const entity = await seedEntity(h.db, { kind: "series", title: "Only TV", year: 2020 });
await h.db.updateTable("download_task").set({ media_entity_id: entity }).where("id", "=", row.id).execute();
const planned = await planImport(h.d, {
downloadTaskId: row.id,
trigger: "auto",
actorType: "system",
dryRun: true,
});
expect(planned.reason).toBe("source_config_mismatch");
expect(planned.plan).toBeUndefined();
});
});
@@ -108,6 +108,33 @@ describe.skipIf(skip)("整理记录筛选与统计", () => {
expect(movies.body.data).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
});
it("历史分页和统计排除排队与运行记录,显式active仍可查询", async () => {
const inserted = await h.db
.insertInto("organization_record")
.values([
{ trigger: "manual", actor_type: "user", action: "copy", status: "queued", source_path: "/queued.mkv" },
{ trigger: "manual", actor_type: "user", action: "move", status: "running", source_path: "/running.mkv" },
])
.returning("id")
.execute();
try {
expect(await list("phase=history")).toHaveLength(4);
expect(idsOf(await list("phase=active"))).toEqual(inserted.map((item) => item.id).sort());
expect(await list("phase=history&status=queued")).toHaveLength(0);
const stats = await h.api("GET", "/organization-records/stats?phase=history");
expect(stats.body.data).toMatchObject({ total: 4, running: 0 });
} finally {
await h.db
.deleteFrom("organization_record")
.where(
"id",
"in",
inserted.map((item) => item.id),
)
.execute();
}
});
it("列表项带海报、大小、下载器名称与 hash", async () => {
const [film] = await list("q=Arrival.2016");
expect(film).toMatchObject({
@@ -0,0 +1,440 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { DEFAULT_SETTINGS } from "@umt/contracts";
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { executeRecord, planImport } from "../services/organize.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("one-time replacement (real PG and library files)", () => {
let h: Harness;
let lib: TempTree;
let src: TempTree;
let rootId: string;
let instanceId: string;
let siteId: string;
let qb: MockQb;
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true });
src = await createTempTree({
"Movie.2020.1080p.BluRay.x264-GRP.mkv": 200_000,
"Same.2020.1080p.BluRay.x264-GRP.mkv": 200_000,
...Object.fromEntries(
Array.from({ length: 6 }, (_, i) => [
`Show.S01E${String(i + 1).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`,
200_000,
]),
),
});
qb = await createMockQb();
h = await createHarness("replacement");
({ rootId, instanceId, siteId } = await seedBase(h.db, {
libraryPath: lib.root,
cachePath: src.root,
qbUrl: qb.url,
}));
await h.setSettings("naming", {
tv: "{{title}} ({{year}})/Season {{season}}/{{title}} - {{season_episode}} - {{videoFormat}}{{fileExt}}",
});
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
await lib?.cleanup();
await src?.cleanup();
});
const old = async (entityId: string, path: string, parent?: string) => {
await mkdir(dirname(lib.path(path)), { recursive: true });
await writeFile(lib.path(path), Buffer.alloc(200_001, 7));
const asset = await h.db
.insertInto("media_asset")
.values({
media_entity_id: entityId,
kind: parent ? "subtitle" : "video",
parent_asset_id: parent ?? null,
status: "active",
size_bytes: 200_001,
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("asset_location")
.values({
media_asset_id: asset.id,
location_kind: "library",
library_root_id: rootId,
relative_path: path,
status: "available",
is_primary: true,
})
.execute();
return asset.id;
};
const request = (entityId: string, paths: string[], assetIds: string[] = []) => ({
mediaEntityId: entityId,
sourcePaths: paths.map((p) => src.path(p)),
trigger: "manual" as const,
actorType: "user" as const,
action: "copy" as const,
importMode: "replace" as const,
replacementAssetIds: assetIds,
});
const status = async (id: string) =>
(await h.db.selectFrom("media_asset").select("status").where("id", "=", id).executeTakeFirstOrThrow()).status;
it("new movie registers first, retires only selected old and its private subtitle, preserving unselected/shared/cache", async () => {
const e = await seedEntity(h.db, {
kind: "movie",
title: "Movie",
year: 2020,
});
const path = "Movie (2020)/Movie (2020) - 720p.mkv";
const selected = await old(e, path);
const subtitle = await old(e, path.replace(".mkv", ".en.srt"), selected);
const kept = await old(e, "Movie (2020)/Movie (2020) - 2160p.mkv");
await writeFile(lib.path("Movie (2020)/movie.nfo"), "shared");
const planned = await planImport(h.d, request(e, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
expect(planned.status).toBe("running");
expect(existsSync(lib.path(path))).toBe(true);
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
expect(await status(selected)).toBe("retired");
expect(await status(subtitle)).toBe("retired");
expect(await status(kept)).toBe("active");
expect(existsSync(lib.path(path))).toBe(false);
expect(existsSync(lib.path("Movie (2020)/movie.nfo"))).toBe(true);
expect(existsSync(src.path("Movie.2020.1080p.BluRay.x264-GRP.mkv"))).toBe(true);
expect(await h.db.selectFrom("trash_item").selectAll().execute()).toHaveLength(2);
await executeRecord(h.d, planned.recordId!);
expect(await h.db.selectFrom("trash_item").selectAll().execute()).toHaveLength(2);
});
it("same-path replaces selected old asset with a fresh asset, but normal/unselected cannot overwrite", async () => {
const e = await seedEntity(h.db, {
kind: "movie",
title: "Same",
year: 2020,
});
const selected = await old(e, "Same (2020)/Same (2020) - 1080p.mkv");
const normal = await planImport(h.d, {
...request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"]),
importMode: "normal",
dryRun: true,
});
expect(normal.plan?.organize.files[0]?.action).toBe("hold");
const planned = await planImport(h.d, request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
expect(planned.status).toBe("running");
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
expect(await status(selected)).toBe("retired");
const active = await h.db
.selectFrom("media_asset")
.select("id")
.where("media_entity_id", "=", e)
.where("status", "=", "active")
.execute();
expect(active).toHaveLength(1);
expect(active[0]!.id).not.toBe(selected);
});
it("selected grants cannot overwrite an unselected path, and changed old files survive cleanup", async () => {
const e = await seedEntity(h.db, {
kind: "movie",
title: "Protected",
year: 2020,
});
const lower = await old(e, "Protected (2020)/Protected (2020) - 720p.mkv");
const occupied = await old(e, "Protected (2020)/Protected (2020) - 1080p.mkv");
const blocked = await planImport(h.d, {
...request(e, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [lower]),
dryRun: true,
});
expect(blocked.plan?.organize.files[0]?.action).toBe("hold");
expect(await status(occupied)).toBe("active");
const other = await seedEntity(h.db, {
kind: "movie",
title: "Stale",
year: 2020,
});
const oldPath = "Stale (2020)/Stale (2020) - 720p.mkv";
const stale = await old(other, oldPath);
const planned = await planImport(h.d, request(other, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [stale]));
await writeFile(lib.path(oldPath), "changed-after-plan");
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
expect(await status(stale)).toBe("active");
expect((await readFile(lib.path(oldPath))).toString()).toBe("changed-after-plan");
});
it("same-path failed final probe restores old bytes and asset, even with delete policy", async () => {
const e = await seedEntity(h.db, {
kind: "movie",
title: "Same",
year: 2021,
});
const path = "Same (2021)/Same (2021) - 1080p.mkv";
const selected = await old(e, path);
const savedProbe = h.d.ports.probe;
const savedConfig = h.ctx.config;
h.ctx.config = { ...savedConfig, FS_REPLACE_OLD: "delete" };
h.d.ports.probe = async () => ({
status: "ok",
video: [],
audio: [],
subtitles: [],
});
try {
const planned = await planImport(h.d, request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
expect(planned.status).toBe("running");
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("failed");
expect((await readFile(lib.path(path))).equals(Buffer.alloc(200_001, 7))).toBe(true);
expect(await status(selected)).toBe("active");
expect(
(
await h.db
.selectFrom("asset_location")
.select("status")
.where("media_asset_id", "=", selected)
.executeTakeFirstOrThrow()
).status,
).toBe("available");
await executeRecord(h.d, planned.recordId!);
expect((await readFile(lib.path(path))).length).toBe(200_001);
} finally {
h.d.ports.probe = savedProbe;
h.ctx.config = savedConfig;
}
});
it("old E01-E10 only loses successful new E01-E06; failed E04 and old merged range survive", async () => {
const e = await seedEntity(h.db, {
kind: "series",
title: "Show",
year: 2020,
});
const ids: string[] = [];
for (let i = 1; i <= 10; i++) {
ids.push(await old(e, `Show (2020)/Season 1/Show - S01E${String(i).padStart(2, "0")} - 720p.mkv`));
}
const merged = await old(e, "Show (2020)/Season 1/Show - S01E01-E10 - 480p.mkv");
const saved = h.d.ports.probe;
h.d.ports.probe = async (path) =>
path.includes("E04")
? { status: "ok", video: [], audio: [], subtitles: [] }
: { status: "unknown", reason: "ffprobe_unavailable" };
try {
const paths = Array.from(
{ length: 6 },
(_, i) => `Show.S01E${String(i + 1).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`,
);
const planned = await planImport(h.d, request(e, paths, [...ids, merged]));
expect(planned.status).toBe("running");
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("partial");
for (let i = 1; i <= 10; i++) {
expect(await status(ids[i - 1]!)).toBe(i <= 6 && i !== 4 ? "retired" : "active");
}
expect(await status(merged)).toBe("active");
} finally {
h.d.ports.probe = saved;
}
});
it("unknown manual task resolves Local, classifies and registers in the same import, then explicit reused-hash replacement uses its new plan", async () => {
const name = "Unknown.2022.1080p.WEB-DL.x264-GRP.mkv";
const hash = "a".repeat(40);
qb.seed({ hash, name, tags: ["umt"], save_path: src.root });
await qb.complete(hash, {
writeFiles: true,
files: [{ path: name, size: 200_000 }],
});
const release = await h.db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: randomUUID(),
title: name,
size_bytes: 200_000,
raw: JSON.stringify({}),
resolution_status: "unresolved",
})
.returning("id")
.executeTakeFirstOrThrow();
const makePlan = async (evaluation: object) =>
(
await h.db
.insertInto("acquisition_plan")
.values({
slot_id: null,
source_release_id: release.id,
status: "submitted",
payload_hash: randomUUID(),
idempotency_key: randomUUID(),
evaluation: JSON.stringify(evaluation),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const initialPlan = await makePlan({
manual: true,
mediaEntityId: null,
importMode: "normal",
replacementAssetIds: [],
});
const dt = await h.db
.insertInto("download_task")
.values({
instance_id: instanceId,
torrent_hash: hash,
name,
save_path: src.root,
content_path: src.path(name),
managed: true,
tags: ["umt"],
status: "completed",
source_release_id: release.id,
acquisition_plan_id: initialPlan,
import_plan_id: initialPlan,
})
.returning("id")
.executeTakeFirstOrThrow();
const local = await seedEntity(h.db, {
kind: "movie",
title: "本地作品",
year: 2022,
});
await h.db
.updateTable("media_entity")
.set({
metadata: JSON.stringify({
title: {
value: "本地作品",
source: "web",
evidence: { sourceUrl: "https://example.test/detail" },
},
originalLanguage: { value: "zh", source: "web" },
genres: { value: ["剧情"], source: "web" },
}),
})
.where("id", "=", local)
.execute();
const sourceId = randomUUID();
await h.setSettings("organize", {
...DEFAULT_SETTINGS.organize,
minSizeBytes: 100_000,
scrapeAfterImport: false,
sources: [
{
id: sourceId,
name: "Local来源",
enabled: true,
source: { root: `dl:${instanceId}:0`, path: "" },
target: { root: `lib:${rootId}`, path: "" },
mediaType: "movie",
category: null,
action: "copy",
trigger: "manual",
downloaderInstanceId: null,
tag: null,
intervalSec: 300,
},
],
});
const has = vi.spyOn(h.ctx.capabilities, "has").mockReturnValue(true);
const invoke = vi.spyOn(h.ctx.capabilities, "invoke").mockResolvedValue({
mediaEntityId: local,
mode: "local",
enrichmentId: randomUUID(),
reason: null,
});
try {
const planned = await planImport(h.d, {
downloadTaskId: dt.id,
sourceConfigId: sourceId,
trigger: "auto",
actorType: "system",
});
expect(planned.status).toBe("running");
expect(
(
await h.db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", planned.recordId!)
.executeTakeFirstOrThrow()
).plan,
).toMatchObject({ metadataMode: "local" });
expect(invoke).toHaveBeenCalledWith(
"metadata.resolve_organize",
{ sourceReleaseId: release.id },
expect.objectContaining({ source: "task" }),
);
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
expect(
await h.db
.selectFrom("media_asset")
.select("id")
.where("media_entity_id", "=", local)
.where("status", "=", "active")
.execute(),
).toHaveLength(1);
expect(
(
await h.db
.selectFrom("path_binding")
.select("relative_path")
.where("media_entity_id", "=", local)
.executeTakeFirstOrThrow()
).relative_path,
).toBe("华语电影/本地作品 (2022)");
expect(h.engine.enqueued.some((j) => j.task === "scrapeMedia")).toBe(true);
const selected = await old(local, "华语电影/本地作品 (2022)/本地作品 (2022) - 720p.mkv");
const secondPlan = await makePlan({
manual: true,
mediaEntityId: local,
importMode: "replace",
replacementAssetIds: [selected],
});
await h.db
.updateTable("download_task")
.set({ import_plan_id: secondPlan, status: "completed" })
.where("id", "=", dt.id)
.execute();
const rerun = await planImport(h.d, {
downloadTaskId: dt.id,
sourceConfigId: sourceId,
trigger: "auto",
actorType: "system",
});
expect(rerun.status).toBe("running");
expect(
(
await h.db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", rerun.recordId!)
.executeTakeFirstOrThrow()
).plan,
).toMatchObject({ metadataMode: "local" });
expect(invoke).toHaveBeenCalledTimes(1);
await executeRecord(h.d, rerun.recordId!);
expect(await status(selected)).toBe("retired");
expect(
(
await h.db
.selectFrom("acquisition_plan")
.select("status")
.where("id", "=", secondPlan)
.executeTakeFirstOrThrow()
).status,
).toBe("completed");
expect(
(
await h.db
.selectFrom("download_task")
.select("acquisition_plan_id")
.where("id", "=", dt.id)
.executeTakeFirstOrThrow()
).acquisition_plan_id,
).toBe(initialPlan);
} finally {
has.mockRestore();
invoke.mockRestore();
}
});
});
@@ -3,7 +3,7 @@ import { join, relative } from "node:path";
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord, planImport } from "../services/organize.ts";
import { runScrapeRecord, SCRAPE_TASK, scrapeEntity } from "../services/scrape.ts";
import { entityScrapeLocations, runScrapeRecord, SCRAPE_TASK, scrapeEntity } from "../services/scrape.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
// 刮削(SCR-1 / SCR-2):入库成功后自动对作品目录排 scrapeMedia → 写 NFO 与图片(临时库根);只补缺失 / 全部覆盖;
@@ -299,6 +299,21 @@ describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
expect(nfo).toContain("<runtime>169</runtime>");
}, 60_000);
it("蓝光原盘资产:原盘目录本身就是刮削目录,不取上一级", async () => {
const disc = await seedEntity(h.db, { kind: "movie", title: "原盘", year: 2020 });
const root = await h.db.selectFrom("library_root").select("id").executeTakeFirstOrThrow();
const asset = await h.db
.insertInto("media_asset")
.values({ media_entity_id: disc, kind: "bluray_dir" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("asset_location")
.values({ media_asset_id: asset.id, library_root_id: root.id, relative_path: "Movies/原盘 (2020)" })
.execute();
expect((await entityScrapeLocations(h.d, disc, "movie")).map((l) => l.path)).toEqual(["Movies/原盘 (2020)"]);
});
it("写入失败记原因;只登记库根不写文件;没有入库文件的作品 412", async () => {
const files = await tree(lib.root);
const video = files.find((p) => p.includes("星际穿越") && p.endsWith(".mkv"))!;
@@ -249,16 +249,19 @@ describe.skipIf(!hasPg)("订阅增强(真实 PG + mock qB + 临时目录)", () =
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 3, now: new Date() }));
const r2 = await release(entityId, "Unknown.Total.S01E02.1080p.WEB-DL.x264-GRP");
await completeAndImport(r2.hash, await submit(await planFor(r2.id)));
const acceptedTask = await submit(await planFor(r2.id));
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
expect((await slotRow(slotId)).status).toBe("downloading");
await completeAndImport(r2.hash, acceptedTask);
expect((await slotRow(slotId)).status).toBe("satisfied");
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "slot_satisfied" });
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
const hist = await db()
.selectFrom("subscription_history")
.selectAll()
.where("subscription_id", "=", subId)
.execute();
expect(hist).toHaveLength(1);
expect(hist[0]).toMatchObject({ reason: "slot_satisfied", media_entity_id: entityId, mode: "once" });
expect(hist[0]).toMatchObject({ reason: "download_accepted", media_entity_id: entityId, mode: "once" });
expect((hist[0]!.progress as { seasons: { complete: boolean }[] }).seasons[0]!.complete).toBe(true);
// 历史 API + 重新订阅(改回 active)
const api = await h.api("GET", `/subscription-history?subscriptionId=${subId}`);
@@ -267,6 +270,32 @@ describe.skipIf(!hasPg)("订阅增强(真实 PG + mock qB + 临时目录)", () =
expect(re.body.data.status).toBe("active");
}, 90_000);
it("多个目标版本按各自已接收单元完成,单版本入库不提前完成订阅", async () => {
const { entityId, subId } = await seriesSlot("双版本剧", { mode: "once" });
await db()
.insertInto("acquisition_slot")
.values({
media_entity_id: entityId,
subscription_id: subId,
name: "720p",
hard_conditions: JSON.stringify({ resolutions: ["720p"] }),
})
.execute();
await db()
.transaction()
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 1, now: new Date() }));
const first = await release(entityId, "Two.Versions.S01E01.1080p.WEB-DL.x264-GRP");
const firstTask = await submit(await planFor(first.id));
expect((await subRow(subId)).status).toBe("active");
await completeAndImport(first.hash, firstTask);
expect((await subRow(subId)).status).toBe("active");
const second = await release(entityId, "Two.Versions.S01E01.720p.WEB-DL.x264-GRP");
await submit(await planFor(second.id));
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
await qb.complete(second.hash, { writeFiles: true });
await syncDownloads(h.d);
}, 60_000);
it("pending(TMDB 未就绪)照常下载但不完成;暂停的订阅不评估", async () => {
const { entityId, slotId, subId } = await seriesSlot("待定剧", { mode: "once", status: "pending" });
await db()
@@ -532,7 +532,8 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
if (!dt) throw new CapabilityError("not_found", "下载任务不存在");
if (!["completed", "importing", "imported"].includes(dt.status))
throw new CapabilityError("precondition", `下载任务尚未完成(${dt.status})`);
const action: "copy" | "move" = input.action === "move" ? "move" : "copy";
const action: "copy" | "move" | "in_place" =
input.action === "in_place" ? "in_place" : input.action === "move" ? "move" : "copy";
const request = {
downloadTaskId: input.downloadTaskId,
action,
@@ -603,7 +604,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
if (!art) throw new CapabilityError("not_found", "整理计划不存在");
if (art.content_hash !== input.payloadHash) throw new CapabilityError("conflict", "payloadHash 与计划不一致");
const content = art.content as {
request: { downloadTaskId: string; action: "copy" | "move"; targetLibraryRootId?: string };
request: { downloadTaskId: string; action: "copy" | "move" | "in_place"; targetLibraryRootId?: string };
};
const req = content.request;
// 已执行过(同计划)→ 返回已有记录
@@ -45,13 +45,7 @@ async function taskRow(
runId,
...(subject ? { subjectType: subject.type, subjectId: subject.id } : {}),
}));
if (given)
await d.ctx.db
.updateTable("task")
.set({ engine_run_id: runId })
.where("id", "=", given)
.where("engine_run_id", "is", null)
.execute();
if (given) await d.ctx.db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", given).execute();
await markTask(d.ctx.db, taskId, "running");
return taskId;
}
@@ -197,7 +191,7 @@ export function registerAoTasks(registry: TaskRegistry, d: AoDeps) {
retryPolicy: { maximumAttempts: 1 },
handler: async (t) =>
t.step.run("scrape", async () => {
const r = await runScrapeRecord(d, t.input.recordId, t.abortSignal);
const r = await runScrapeRecord(d, t.input.recordId, t.abortSignal, t.runId);
return { status: r.status, written: r.written, skipped: r.skipped, failed: r.failed, reason: r.reason };
}),
}),
@@ -7,6 +7,7 @@ import { registerAoCapabilities } from "./capabilities.ts";
import { registerAoTasks } from "./handlers.ts";
import { type AoOptions, type AoPorts, createPorts } from "./ports.ts";
import { registerAssetRoutes } from "./routes/assets.ts";
import { registerClassificationRoutes } from "./routes/classification.ts";
import { registerDashboardRoutes } from "./routes/dashboard.ts";
import { registerDownloadRoutes } from "./routes/downloads.ts";
import { registerLibraryRoutes } from "./routes/library.ts";
@@ -77,6 +78,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
registerDashboardRoutes(app, d);
registerAssetRoutes(app, d);
registerNamingRoutes(app, d);
registerClassificationRoutes(app, d);
},
registerTasks(registry, ctx) {
registerAoTasks(registry, lazy(ctx));
@@ -31,6 +31,7 @@ import {
createEntityFromTmdbCandidate,
enqueueNewEntityRefresh,
} from "../sources-identity/service/entity-from-candidate.ts";
import { fetchPublicImage } from "../sources-identity/service/image-proxy.ts";
import { metadataLanguages, metadataTextOptions } from "../sources-identity/service/metadata-rich.ts";
import { identifyParsed, type NameIdentifyResult } from "../sources-identity/service/resolve.ts";
import type { ServerContext } from "../types.ts";
@@ -200,6 +201,11 @@ export function createPorts(
return { kind: "url", url: t.url };
},
async scrapeImage(tmdbPath) {
if (/^https?:\/\//i.test(tmdbPath)) {
const got = await fetchPublicImage(externalFetch, tmdbPath, { maxBytes: 8 * 1024 * 1024, label: "封面" });
if (!got.ok) throw new Error(got.message);
return { content: got.bytes, mime: got.mime };
}
images ??= new ImageCache({
dir: join(dataPaths(ctx.config).dataDir, "cache", "images"),
fetch: externalFetch,
@@ -0,0 +1,63 @@
import { Api, type ClassificationConfigView } from "@umt/contracts";
import { sql } from "@umt/db";
import { classifyTmdb, parseClassificationYaml } from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import { audit, endpoint } from "../../auth-settings/endpoint.ts";
import { type CachedSettingsReader, SETTINGS_CHANGED_EVENT } from "../../auth-settings/provided.ts";
import type { ModuleApp } from "../../types.ts";
import type { AoDeps } from "../services/common.ts";
const isCached = (s: unknown): s is CachedSettingsReader =>
typeof (s as CachedSettingsReader)?.invalidate === "function";
export function classificationView(yaml: string): ClassificationConfigView {
try {
const config = parseClassificationYaml(yaml);
return { yaml, categories: { movie: config.movie.map((r) => r.category), tv: config.tv.map((r) => r.category) } };
} catch (err) {
throw new ApiException("validation_failed", err instanceof Error ? err.message : String(err));
}
}
export function registerClassificationRoutes(app: ModuleApp, d: AoDeps) {
const settings = async () =>
isCached(d.ctx.settings) ? (await d.ctx.settings.fresh()).organize : await d.ctx.settings.get("organize");
endpoint(app, { ...Api.classification.get, tags: ["classification"] }, async () =>
classificationView((await settings()).classificationYaml),
);
endpoint(app, { ...Api.classification.put, tags: ["classification"] }, async ({ body, actor }) => {
const view = classificationView(body.yaml);
const defaults = await settings();
await d.ctx.db.transaction().execute(async (trx) => {
await sql`select pg_advisory_xact_lock(hashtext('organize.sources'))`.execute(trx);
const row = await trx
.selectFrom("settings")
.select("value")
.where("key", "=", "organize")
.forUpdate()
.executeTakeFirst();
const current = row?.value && typeof row.value === "object" && !Array.isArray(row.value) ? row.value : {};
const next = { ...defaults, ...current, classificationYaml: body.yaml };
await trx
.insertInto("settings")
.values({ key: "organize", value: JSON.stringify(next), updated_at: new Date() })
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify(next), updated_at: new Date() }))
.execute();
});
if (isCached(d.ctx.settings)) d.ctx.settings.invalidate();
void d.ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys: ["organize"] });
await audit(
d.ctx,
actor,
"settings.update",
{ type: "settings", id: "organize" },
{ patch: { organize: { classificationYaml: body.yaml } } },
);
return view;
});
endpoint(app, { ...Api.classification.preview, tags: ["classification"] }, async ({ body }) => {
const yaml = body.yaml ?? (await settings()).classificationYaml;
classificationView(yaml);
return classifyTmdb(parseClassificationYaml(yaml), body.mediaType, body.tmdb);
});
}
@@ -302,7 +302,6 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
]),
)
.orderBy("dt.created_at", "desc")
.limit(100)
.execute();
const entityRecs = await db()
.selectFrom("organization_record")
@@ -321,7 +320,6 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
])
.where("media_entity_id", "=", id)
.orderBy("created_at", "desc")
.limit(100)
.execute();
const planIds = dts.map((t) => t.acquisition_plan_id).filter((x): x is string => !!x);
const plans = planIds.length
@@ -356,7 +354,6 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
),
)
.orderBy("p.updated_at", "desc")
.limit(50)
.execute()
: [];
const rootPath = new Map(roots.map((r) => [r.id, r.local_path]));
@@ -266,11 +266,13 @@ function recordFilters<Q extends SelectQueryBuilder<DB, "organization_record", a
): Q {
// biome-ignore lint/suspicious/noExplicitAny: kysely 泛型在链式 where 后无法保持 Q
let q = q0 as any;
if (query.phase === "active") q = q.where("status", "in", ["queued", "running"]);
else if (query.phase === "history") q = q.where("status", "not in", ["queued", "running"]);
if (query.trigger) q = q.where("trigger", "=", query.trigger);
if (query.mediaEntityId) q = q.where("media_entity_id", "=", query.mediaEntityId);
if (query.downloadTaskId) q = q.where("download_task_id", "=", query.downloadTaskId);
if (query.since) q = q.where("created_at", ">=", new Date(query.since));
if (query.organizeOnly) q = q.where("action", "in", ["copy", "move", "hardlink"]);
if (query.organizeOnly) q = q.where("action", "in", ["copy", "move", "in_place", "hardlink"]);
if (query.kind === "none") q = q.where("media_entity_id", "is", null);
else if (query.kind)
q = q.where("media_entity_id", "in", (eb: ExpressionBuilder<DB, "organization_record">) =>
@@ -0,0 +1,105 @@
import type { ClassificationPreviewResult } from "@umt/contracts";
import type { MediaEntityRow } from "@umt/db";
import { classifyTmdb, DEFAULT_CLASSIFICATION_YAML, parseClassificationYaml } from "@umt/domain";
import { SourcesRuntime } from "../../sources-identity/runtime.ts";
import type { AoDeps } from "./common.ts";
/** Use accepted TMDB identity and the existing shared raw-detail cache. */
export async function classifyEntity(d: AoDeps, entity: MediaEntityRow): Promise<ClassificationPreviewResult> {
const none: ClassificationPreviewResult = {
category: null,
matchedFields: [],
fallback: false,
};
const config = parseClassificationYaml(
(await d.ctx.settings.get("organize")).classificationYaml ?? DEFAULT_CLASSIFICATION_YAML,
);
const mediaType = entity.kind === "movie" ? "movie" : "tv";
if (!config[mediaType].length) return none;
const identity = await d.ctx.db
.selectFrom("external_identity")
.select("external_id")
.where("media_entity_id", "=", entity.id)
.where("provider", "=", "tmdb")
.where("entity_type", "=", mediaType)
.where("content_unit_id", "is", null)
.where("status", "=", "accepted")
.orderBy("id")
.executeTakeFirst();
if (!identity) {
const metadata = entity.metadata as Record<string, unknown>;
const value = (field: string) => {
const entry = metadata[field];
return entry && typeof entry === "object" && "value" in entry ? (entry as { value: unknown }).value : undefined;
};
if (!Object.values(metadata).some((v) => v && typeof v === "object" && "value" in v)) return none;
const genres = value("genres");
const country = value("originCountry");
const year = value("year") ?? entity.year;
const genreIds: Record<string, number> = {
动画: 16,
动漫: 16,
animation: 16,
儿童: 10762,
kids: 10762,
纪录: 99,
纪录片: 99,
documentary: 99,
综艺: 10764,
reality: 10764,
talk: 10767,
音乐: 10402,
music: 10402,
动作: 28,
action: 28,
冒险: 12,
adventure: 12,
喜剧: 35,
comedy: 35,
犯罪: 80,
crime: 80,
剧情: 18,
drama: 18,
家庭: 10751,
family: 10751,
奇幻: 14,
fantasy: 14,
历史: 36,
history: 36,
恐怖: 27,
horror: 27,
悬疑: 9648,
mystery: 9648,
爱情: 10749,
romance: 10749,
科幻: 878,
"science fiction": 878,
惊悚: 53,
thriller: 53,
战争: 10752,
war: 10752,
西部: 37,
western: 37,
};
const raw = {
original_language: value("originalLanguage"),
origin_country: country,
production_countries: country,
genre_ids: (Array.isArray(genres) ? genres : genres ? [genres] : []).map(
(g) => genreIds[String(g).toLowerCase()] ?? g,
),
...(year ? { release_date: String(year), first_air_date: String(year) } : {}),
};
return classifyTmdb(config, mediaType, raw);
}
if (!/^\d+$/.test(identity.external_id)) return none;
try {
const tmdb = await new SourcesRuntime(d.ctx).tmdb();
if (!tmdb) return none;
const raw = await tmdb.getJson<Record<string, unknown>>(`/${mediaType}/${identity.external_id}`);
return classifyTmdb(config, mediaType, raw);
} catch (err) {
d.ctx.logger.warn({ mediaEntityId: entity.id, err }, "TMDB 分类数据读取失败");
return none;
}
}
@@ -3,7 +3,7 @@ import { type DB, type DownloaderInstanceRow, type Kysely, type LibraryRootRow,
import { type PathMapping, toDownloaderPath, toHostPath } from "@umt/domain";
import type { ServerContext } from "../../types.ts";
import type { AoPorts } from "../ports.ts";
import { hashJson, jsonb } from "../util.ts";
import { errMessage, hashJson, jsonb } from "../util.ts";
// 本模块各服务共用:决策(待处理)、候选评估记录、业务任务行、实例拓扑(路径映射/缓存卷)。
@@ -132,7 +132,7 @@ export async function markTask(
* 巡检补投等需要"同一业务行、按时间槽重投"的场景传 engineKey。
*/
export async function enqueueTracked(
d: AoDeps,
d: Pick<AoDeps, "ctx">,
t: Omit<TaskRowInput, "runId">,
opts: { engineKey?: string } = {},
): Promise<{ taskId: string; runId: string }> {
@@ -145,7 +145,12 @@ export async function enqueueTracked(
traceId: taskId,
},
);
await d.ctx.db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", taskId).execute();
try {
await d.ctx.db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", taskId).execute();
} catch (err) {
// run 已被接受;处理器和下一轮巡检会按 taskId 补回关联。
d.ctx.logger.warn({ taskId, runId, err: { message: errMessage(err) } }, "任务已入队,业务关联暂未写回");
}
return { taskId, runId };
}
@@ -163,13 +168,34 @@ export async function enqueueSweep(
): Promise<{ taskId: string; runId: string } | null> {
const prev = await d.ctx.db
.selectFrom("task")
.select(["engine_run_id"])
.select(["id", "engine_run_id"])
.where("kind", "=", t.kind)
.where("idempotency_key", "=", t.idempotencyKey)
.executeTakeFirst();
if (prev?.engine_run_id) {
const run = await d.ctx.engine.get(prev.engine_run_id).catch(() => null);
if (run && ACTIVE_RUN.has(run.status)) return null;
if (prev) {
try {
// 关联写回失败时,用入队已有的 traceId 找回 run,不随时间槽生成另一个任务。
const runId =
(
await d.ctx.db
.selectFrom("tasks_run_meta")
.select("run_id")
.where("task_name", "=", t.kind)
.where("trace_id", "=", prev.id)
.orderBy("created_at", "desc")
.executeTakeFirst()
)?.run_id ?? prev.engine_run_id;
if (runId) {
const run = await d.ctx.engine.get(runId);
if (prev.engine_run_id !== runId && run) {
await d.ctx.db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", prev.id).execute();
}
if (run && ACTIVE_RUN.has(run.status)) return null;
}
} catch (err) {
d.ctx.logger.warn({ taskId: prev.id, err: { message: errMessage(err) } }, "任务状态查询失败,跳过本轮补投");
return null;
}
}
return enqueueTracked(d, t, { engineKey: `${t.kind}:${t.idempotencyKey}:${slot}` });
}
@@ -250,6 +250,7 @@ export async function buildSubscriptionModels(
: r.dt_status === "completed" || r.dt_status === "importing"
? "verifying"
: "downloading";
if (!r.slot_id) continue;
const list = inflightBySlot.get(r.slot_id) ?? [];
const prev = list.find((x) => x.id === r.id);
if (!prev) list.push({ id: r.id, stage, unitKeys: r.unit_keys });
@@ -167,14 +167,14 @@ export function planWaitOf(status: string, deferred: string | null): PlanWait {
}
/** 等待中的计划 → 面板行 */
async function planItems(db: Exec, rows: { id: string; slot_id: string; source_release_id: string }[]) {
async function planItems(db: Exec, rows: { id: string; slot_id: string | null; source_release_id: string }[]) {
if (!rows.length)
return new Map<string, Omit<DownloadBoardPlanItem, "kind" | "id" | "createdAt" | "purpose" | "status">>();
const ids = rows.map((r) => r.id);
const [extra, approvals, tasks] = await Promise.all([
db
.selectFrom("acquisition_plan as p")
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
.leftJoin("media_entity as e", "e.id", "s.media_entity_id")
.leftJoin("source_release as r", "r.id", "p.source_release_id")
.select([
@@ -39,6 +39,7 @@ import {
downloaderPathOf,
type Exec,
enqueueSweep,
enqueueTracked,
hostPathOf,
type InstanceTopology,
loadInstances,
@@ -46,8 +47,10 @@ import {
timeSlot,
upsertDecision,
} from "./common.ts";
import { matchesQbSource, qbSourceTags } from "./organize-config.ts";
import { hnrSatisfied, retentionElapsed } from "./retention.ts";
import {
afterDownloadAccepted,
afterSlotVerified,
type SlotCompletion,
slotComplete,
@@ -107,10 +110,11 @@ async function verifiedContext(
export async function applySlotEvent(
d: AoDeps,
trx: Exec,
slotId: string,
slotId: string | null,
event: SlotEvent,
extra: { currentAssetId?: string | null; reason?: string; planId?: string | null } = {},
): Promise<boolean> {
if (!slotId) return false;
const slot = await trx
.selectFrom("acquisition_slot")
.selectAll()
@@ -217,10 +221,21 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
const db = d.ctx.db;
const plan = await db.selectFrom("acquisition_plan").selectAll().where("id", "=", planId).executeTakeFirst();
if (!plan) return { status: "skipped", reason: "plan_not_found" };
if (plan.status !== "reserved") return { status: "skipped", reason: `plan_${plan.status}` };
if (plan.status !== "reserved") {
if (["submitted", "completed"].includes(plan.status) && plan.status_reason !== "accepted_unconfirmed")
await afterDownloadAccepted(d, plan.id);
return { status: "skipped", reason: `plan_${plan.status}` };
}
const blocked = await automationBlock(d);
if (blocked) return { status: "deferred", reason: blocked };
const ev = (plan.evaluation ?? {}) as { instanceId?: string; volumeKey?: string; savePath?: string };
const ev = (plan.evaluation ?? {}) as {
instanceId?: string;
volumeKey?: string;
savePath?: string;
mediaEntityId?: string | null;
manual?: boolean;
importMode?: string;
};
const topo = (await loadInstances(db)).find((t) => t.inst.id === ev.instanceId);
if (!topo?.savePath) return failPlan(d, plan.id, plan.slot_id, "downloader_unavailable");
@@ -313,16 +328,17 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
}
const r = res as Extract<SubmitResult, { hash: string }>;
const ins = await sql<{ id: string; inserted: boolean }>`
insert into download_task (instance_id, torrent_hash, infohash_v1, infohash_v2, acquisition_plan_id, source_release_id,
insert into download_task (instance_id, torrent_hash, infohash_v1, infohash_v2, acquisition_plan_id, import_plan_id, source_release_id,
media_entity_id, name,
save_path, content_path, category, tags, managed, origin, status, size_bytes, next_check_at)
values (${topo.inst.id}, ${r.hash}, ${r.hash.length === 40 ? r.hash : null}, ${r.hash.length === 64 ? r.hash : null},
${plan.id}, ${rel.id}, (select media_entity_id from acquisition_slot where id = ${plan.slot_id}),
${plan.id}, ${ev.manual ? plan.id : null}, ${rel.id}, coalesce((select media_entity_id from acquisition_slot where id = ${plan.slot_id}), ${ev.mediaEntityId ?? rel.media_entity_id}::uuid),
${r.name}, ${r.savePath}, ${r.contentPath}, ${topo.category},
${r.tags}, true, ${r.kind === "already_exists" ? "adopted" : "submitted"},
${r.kind === "accepted_unconfirmed" ? "submitting" : "queued"}, ${rel.size_bytes}, now())
on conflict (instance_id, torrent_hash) do update
set acquisition_plan_id = coalesce(download_task.acquisition_plan_id, excluded.acquisition_plan_id),
import_plan_id = coalesce(excluded.import_plan_id, download_task.import_plan_id),
source_release_id = coalesce(download_task.source_release_id, excluded.source_release_id),
media_entity_id = coalesce(download_task.media_entity_id, excluded.media_entity_id),
managed = true, tags = excluded.tags, updated_at = now()
@@ -357,6 +373,7 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
d.ctx.logger.warn({ planId: plan.id, err: { message: errMessage(err) } }, "季包选文件失败,按整包下载"),
);
}
if (result.kind !== "accepted_unconfirmed") await afterDownloadAccepted(d, plan.id);
if (!dtId) {
// 账本已有结果(重放):按 hash 找回任务行
dtId = (
@@ -368,6 +385,20 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
.executeTakeFirst()
)?.id;
}
if (result.kind === "already_exists" && dtId && ev.manual && ev.importMode === "replace") {
const existing = await db.selectFrom("download_task").select("status").where("id", "=", dtId).executeTakeFirst();
if (existing?.status === "imported") {
await db.updateTable("download_task").set({ status: "completed" }).where("id", "=", dtId).execute();
await enqueueTracked(d, {
kind: "verifyAndImport",
idempotencyKey: `manual-import:${plan.id}`,
input: { downloadTaskId: dtId },
subjectType: "download_task",
subjectId: dtId,
triggeredBy: "user",
});
}
}
return { status: "submitted", reason: result.kind, ...(dtId ? { downloadTaskId: dtId } : {}) };
}
@@ -385,6 +416,7 @@ async function selectPackFiles(
plan: AcquisitionPlanRow,
rel: SourceReleaseRow,
): Promise<void> {
if (!plan.slot_id) return;
const parsed = parsedOf(rel);
const isPack = !parsed.episodes && !parsed.airDate;
const multi = !!parsed.episodes && parsed.episodes.end > parsed.episodes.start;
@@ -465,7 +497,7 @@ async function selectPackFiles(
);
}
async function failPlanTx(d: AoDeps, trx: Exec, planId: string, slotId: string, reason: string) {
async function failPlanTx(d: AoDeps, trx: Exec, planId: string, slotId: string | null, reason: string) {
await trx
.updateTable("acquisition_plan")
.set({ status: "failed", status_reason: reason.slice(0, 500) })
@@ -502,7 +534,7 @@ async function releaseRemovedPlan(d: AoDeps, trx: Exec, planId: string) {
);
}
async function failPlan(d: AoDeps, planId: string, slotId: string, reason: string): Promise<SubmitOutcome> {
async function failPlan(d: AoDeps, planId: string, slotId: string | null, reason: string): Promise<SubmitOutcome> {
await d.ctx.db.transaction().execute((trx) => failPlanTx(d, trx, planId, slotId, reason));
return { status: "failed", reason };
}
@@ -593,9 +625,14 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats)
const db = d.ctx.db;
const now = d.ports.now();
const client = await d.ports.downloader(topo.inst);
const sourceTags = await qbSourceTags(d, topo);
let list: QbTorrent[];
try {
list = await client.list({ tag: topo.inst.managed_tag });
const tags = [...new Set([topo.inst.managed_tag, ...sourceTags.map((s) => s.config.tag!)])];
const batches = await Promise.all(tags.map((tag) => client.list({ tag })));
list = [...new Map(batches.flat().map((t) => [t.hash, t])).values()].filter(
(t) => t.tags.includes(topo.inst.managed_tag) || matchesQbSource(t, topo, sourceTags),
);
} catch (err) {
// 下载器失联:不改任何任务状态(失败 ≠ 不存在)
stats.unreachable.push(topo.inst.id);
@@ -722,6 +759,7 @@ async function observeLocked(
.set({
status: merged.status,
pause_reason: merged.status === "paused" ? merged.pauseReason : null,
managed: true,
raw_state: t.rawState,
progress: Math.min(1, Math.max(0, t.progress)),
size_bytes: sizeOf(t),
@@ -0,0 +1,149 @@
import type { ManualAcquisitionRequest, ManualAcquisitionResult } from "@umt/contracts";
import { sql } from "@umt/db";
import { ApiException } from "../../../http/errors.ts";
import type { ServerContext } from "../../types.ts";
import { hashJson, jsonb, toApi } from "../util.ts";
import { subscriptionSavePath } from "./acquisition.ts";
import { enqueueTracked, loadInstances } from "./common.ts";
/** Manual choice is a one-off plan, independent of subscription filters and slots. */
export async function planManualAcquisition(
ctx: ServerContext,
body: ManualAcquisitionRequest,
): Promise<ManualAcquisitionResult> {
const db = ctx.db;
const rel = await db
.selectFrom("source_release")
.selectAll()
.where("id", "=", body.sourceReleaseId)
.executeTakeFirst();
if (!rel) throw new ApiException("not_found", "资源不存在");
const entityId = body.entityId ?? rel.media_entity_id;
if (!entityId && !body.allowUnrecognized)
return {
submitted: false,
requiresConfirmation: true,
plan: null,
slotId: null,
taskId: null,
rejected: [
{ slotId: null, code: "unrecognized", message: "资源尚未识别作品,确认后可以先下载,整理时补全元数据" },
],
};
if (entityId && !(await db.selectFrom("media_entity").select("id").where("id", "=", entityId).executeTakeFirst()))
throw new ApiException("not_found", "作品不存在");
const instances = await loadInstances(db, body.downloaderInstanceId ? { ids: [body.downloaderInstanceId] } : {});
const topo = instances.find((i) => i.savePath);
if (!topo?.savePath) throw new ApiException("precondition_failed", "没有可用的下载器或保存目录");
const replacementIds = body.importMode === "replace" ? [...new Set(body.replacementAssetIds)] : [];
if (body.importMode === "replace" && (!entityId || replacementIds.length === 0))
throw new ApiException("bad_request", "洗版入库需要明确作品及要替换的已有版本");
if (replacementIds.length) {
const assets = await db
.selectFrom("media_asset")
.select(["id", "media_entity_id"])
.where("id", "in", replacementIds)
.execute();
if (assets.length !== replacementIds.length || assets.some((a) => a.media_entity_id !== entityId))
throw new ApiException("bad_request", "要替换的版本必须属于本次下载的作品");
}
const knownHash = rel.infohash_v1 ?? rel.infohash_v2;
if (knownHash && body.importMode !== "replace") {
const existing = await db
.selectFrom("download_task")
.select(["id", "acquisition_plan_id", "import_plan_id"])
.where("instance_id", "=", topo.inst.id)
.where("torrent_hash", "=", knownHash)
.where("status", "=", "imported")
.where("removed_at", "is", null)
.executeTakeFirst();
if (existing) {
const id = existing.import_plan_id ?? existing.acquisition_plan_id;
const p = id
? await db.selectFrom("acquisition_plan").selectAll().where("id", "=", id).executeTakeFirst()
: undefined;
return {
submitted: true,
requiresConfirmation: false,
plan: p ? (toApi(p) as ManualAcquisitionResult["plan"]) : null,
slotId: p?.slot_id ?? null,
taskId: p?.task_id ?? null,
rejected: [],
};
}
}
const savePath = body.savePath ? subscriptionSavePath(topo, body.savePath) : topo.savePath;
if (!savePath) throw new ApiException("bad_request", "保存目录不在该下载器的路径映射范围内");
const evaluation = {
manual: true,
mediaEntityId: entityId,
instanceId: topo.inst.id,
volumeKey: topo.volumeKey,
savePath,
importMode: body.importMode,
replacementAssetIds: replacementIds,
reasons: ["manual_selection"],
note: body.note ?? null,
};
// Pending requests converge. Completed/failed plans do not prevent another explicit choice.
const key = `manual:${hashJson({ release: rel.id, ...evaluation })}`;
const plan = await db.transaction().execute(async (trx) => {
await sql`select pg_advisory_xact_lock(hashtextextended(${key}, 0))`.execute(trx);
const existing = await trx
.selectFrom("acquisition_plan")
.selectAll()
.where("source_release_id", "=", rel.id)
.where("slot_id", "is", null)
.where("status", "in", ["reserved", "submitted"])
.where("payload_hash", "=", hashJson(evaluation))
.where(sql<boolean>`evaluation->>'instanceId' = ${topo.inst.id}`)
.orderBy("created_at", "desc")
.executeTakeFirst();
if (existing) return existing;
return trx
.insertInto("acquisition_plan")
.values({
slot_id: null,
source_release_id: rel.id,
task_id: null,
purpose: body.importMode === "replace" ? "upgrade" : "fill",
status: "reserved",
priority: "immediate",
evaluation: jsonb(evaluation),
reserved_bytes: rel.size_bytes ?? 0,
budget_reservation: jsonb({}),
payload_hash: hashJson(evaluation),
idempotency_key: `${key}:${crypto.randomUUID()}`,
expires_at: null,
status_reason: "manual_selection",
file_selection: null,
})
.returningAll()
.executeTakeFirstOrThrow();
});
const { taskId } = await enqueueTracked(
{ ctx },
{
kind: "submitDownload",
idempotencyKey: `plan:${plan.id}`,
input: { acquisitionPlanId: plan.id },
subjectType: "acquisition_plan",
subjectId: plan.id,
triggeredBy: "user",
},
);
const updated = await db
.updateTable("acquisition_plan")
.set({ task_id: taskId })
.where("id", "=", plan.id)
.returningAll()
.executeTakeFirstOrThrow();
return {
submitted: true,
requiresConfirmation: false,
plan: toApi(updated) as ManualAcquisitionResult["plan"],
slotId: null,
taskId,
rejected: [],
};
}
@@ -0,0 +1,74 @@
import { join } from "node:path";
import type { OrganizeSourceConfig } from "@umt/contracts";
import { loadRootDefs, within } from "../../file-manager/common.ts";
import type { AoDeps, InstanceTopology } from "./common.ts";
import { hostPathOf } from "./common.ts";
export async function configuredSources(d: AoDeps) {
const settings = await d.ctx.settings.get("organize");
if (!settings.sources?.length) return [];
const roots = await loadRootDefs(d.ctx.db);
return (settings.sources ?? []).flatMap((config) => {
const root = roots.find((r) => r.id === config.source.root);
return root ? [{ config, sourcePath: join(root.hostPath, config.source.path) }] : [];
});
}
export async function resolveOrganizeSource(
d: AoDeps,
input: {
id?: string | undefined;
paths: string[];
mediaType: "movie" | "tv";
instanceId?: string | undefined;
tags?: string[] | undefined;
},
): Promise<OrganizeSourceConfig | null> {
const sources = await configuredSources(d);
const compatible = sources.filter(
({ config, sourcePath }) =>
(config.mediaType === "all" || config.mediaType === input.mediaType) &&
input.paths.length > 0 &&
input.paths.every((p) => within(p, sourcePath)),
);
if (input.id) {
const selected = compatible.find(({ config }) => config.id === input.id);
if (!selected) throw new Error("整理来源不存在,或文件/媒体类型不属于该来源");
return selected.config;
}
// qB automation selects only its configured tag; manual rows require explicit selection.
const selected = sources
.filter(
({ config, sourcePath }) =>
config.enabled &&
config.trigger === "qb_tag" &&
config.downloaderInstanceId === input.instanceId &&
!!config.tag &&
input.tags?.includes(config.tag) &&
input.paths.length > 0 &&
input.paths.every((p) => within(p, sourcePath)),
)
.sort((a, b) => b.sourcePath.length - a.sourcePath.length)[0]?.config;
if (selected && selected.mediaType !== "all" && selected.mediaType !== input.mediaType)
throw new Error("媒体类型与 qB 标签来源配置不匹配");
return selected ?? null;
}
export async function qbSourceTags(d: AoDeps, topology: InstanceTopology) {
return (await configuredSources(d)).filter(
({ config }) =>
config.enabled && config.trigger === "qb_tag" && config.downloaderInstanceId === topology.inst.id && !!config.tag,
);
}
export function matchesQbSource(
t: { tags: string[]; contentPath: string | null; savePath: string; name: string },
topology: InstanceTopology,
sources: Awaited<ReturnType<typeof qbSourceTags>>,
) {
const path = hostPathOf(topology, t.contentPath ?? join(t.savePath, t.name));
return (
!!path &&
sources.some(({ config, sourcePath }) => !!config.tag && t.tags.includes(config.tag) && within(path, sourcePath))
);
}
@@ -135,7 +135,7 @@ export function requestKey(body: {
sourcePaths?: string[] | undefined;
mediaEntityId?: string | undefined;
contentUnitId?: string | undefined;
action: "copy" | "move";
action: "copy" | "move" | "in_place";
targetLibraryRootId?: string | undefined;
}): string {
return canonicalJson({
@@ -0,0 +1,20 @@
import type { AoDeps } from "./common.ts";
/** Count complete successful histories for each local main-video source. */
export async function successfulOrganizeCounts(db: AoDeps["ctx"]["db"], paths: string[]) {
const counts = new Map<string, number>();
if (!paths.length) return counts;
const rows = await db
.selectFrom("organization_file_result as f")
.innerJoin("organization_record as r", "r.id", "f.organization_record_id")
.select(["f.source_path", "r.id"])
.distinct()
.where("r.status", "=", "succeeded")
.where("r.source_path_view", "=", "local")
.where("f.role", "=", "main")
.where("f.status", "=", "succeeded")
.where("f.source_path", "in", paths)
.execute();
for (const row of rows) counts.set(row.source_path, (counts.get(row.source_path) ?? 0) + 1);
return counts;
}
File diff suppressed because it is too large. Load diff
@@ -30,7 +30,7 @@ export async function planWhys(db: Exec, planIds: readonly string[]): Promise<Ma
if (!ids.length) return out;
const plans = await db
.selectFrom("acquisition_plan as p")
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
.leftJoin("source_release as r", "r.id", "p.source_release_id")
.leftJoin("site", "site.id", "r.site_id")
.select([
@@ -100,7 +100,7 @@ export async function planWhys(db: Exec, planIds: readonly string[]): Promise<Ma
if (agent?.agent_run_id) {
const goal = (agent.goal ?? "").trim().replace(/\s+/g, " ");
source = { kind: "agent", runId: agent.agent_run_id, name: goal.length > 40 ? `${goal.slice(0, 40)}…` : goal };
} else if (inv.length) source = { kind: "manual" };
} else if (inv.length || evOf(p).manual === true) source = { kind: "manual" };
else if (sub) {
const title = sub.title ?? sub.title_query ?? "";
source = {
@@ -0,0 +1,502 @@
import { randomUUID } from "node:crypto";
import { mkdir, readdir, realpath, rm } from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path";
import { DEFAULT_ROOT_SENTINEL, type FileFacts } from "@umt/adapters";
import { sql } from "@umt/db";
import { type ReplacementScope, replacementCovers, replacementScope } from "@umt/domain";
import { jsonb } from "../util.ts";
import { type AoDeps, upsertDecision } from "./common.ts";
import { type FrozenPlan, fsRuntime } from "./organize.ts";
export interface ReplacementFile {
assetId: string;
locationId: string;
rootId: string;
rootPath: string;
sentinel: string;
path: string;
facts: Pick<FileFacts, "size" | "mtimeMs" | "ino" | "dev">;
trashId: string;
trashDay: string;
}
export interface ReplacementAsset {
assetId: string;
scope: ReplacementScope;
files: ReplacementFile[];
}
export interface FrozenReplacement {
mode: "replace";
assets: ReplacementAsset[];
}
export async function freezeReplacement(
d: AoDeps,
entityId: string,
mediaType: "movie" | "tv",
ids: string[],
): Promise<FrozenReplacement> {
const selected = [...new Set(ids)];
if (!selected.length) throw new Error("洗版入库必须选择旧版本");
const assets = await d.ctx.db.selectFrom("media_asset").selectAll().where("id", "in", selected).execute();
if (
assets.length !== selected.length ||
assets.some(
(a) => a.media_entity_id !== entityId || a.status !== "active" || !["video", "bluray_dir"].includes(a.kind),
)
) {
throw new Error("旧版本必须是当前作品的有效库内视频资产");
}
const out: ReplacementAsset[] = [];
for (const asset of assets) {
const locations = await d.ctx.db
.selectFrom("asset_location as l")
.innerJoin("library_root as r", "r.id", "l.library_root_id")
.select([
"l.id",
"l.media_asset_id",
"l.relative_path",
"r.id as root_id",
"r.local_path",
"r.sentinel_file",
"r.management_mode",
])
.where("l.media_asset_id", "=", asset.id)
.where("l.location_kind", "=", "library")
.where("l.status", "=", "available")
.execute();
if (!locations.length || locations.some((l) => l.management_mode !== "managed")) {
throw new Error("旧版本必须有可写的库内位置");
}
const units = await d.ctx.db
.selectFrom("asset_unit_coverage as c")
.innerJoin("content_unit as u", "u.id", "c.content_unit_id")
.select(["u.season_number", "u.episode_number"])
.where("c.media_asset_id", "=", asset.id)
.where("c.coverage", "=", "full")
.execute();
const seasons = new Set(units.map((u) => u.season_number).filter((s) => s !== null));
const explicit =
seasons.size === 1
? {
season: [...seasons][0]!,
episodes: units.flatMap((u) => (u.episode_number === null ? [] : [u.episode_number])),
}
: undefined;
const scope = replacementScope(
locations[0]!.relative_path,
mediaType,
explicit?.episodes.length ? explicit : undefined,
);
if (seasons.size > 1) scope.episodes = [];
const files: ReplacementFile[] = [];
for (const loc of locations) {
const children = await d.ctx.db
.selectFrom("media_asset as a")
.innerJoin("asset_location as l", "l.media_asset_id", "a.id")
.select(["a.id as media_asset_id", "l.id", "l.relative_path"])
.where("a.parent_asset_id", "=", asset.id)
.where("a.status", "=", "active")
.where("l.library_root_id", "=", loc.root_id)
.where("l.status", "=", "available")
.execute();
const stem = basename(loc.relative_path).replace(/\.[^.]+$/, "");
const privateChildren = children.filter(
(c) =>
dirname(c.relative_path) === dirname(loc.relative_path) && basename(c.relative_path).startsWith(`${stem}.`),
);
for (const file of [loc, ...privateChildren]) {
const path = join(loc.local_path, file.relative_path);
const st = await fsRuntime(d).proxy.statSafe(path, {
root: loc.local_path,
sentinel: loc.sentinel_file ?? DEFAULT_ROOT_SENTINEL,
});
if (st.kind !== "ok" || !st.facts.isFile || st.facts.isSymbolicLink) {
throw new Error(`旧版本文件不可核验:${file.relative_path}`);
}
files.push({
assetId: file.media_asset_id,
locationId: file.id,
rootId: loc.root_id,
rootPath: loc.local_path,
sentinel: loc.sentinel_file ?? DEFAULT_ROOT_SENTINEL,
path: file.relative_path,
facts: {
size: st.facts.size,
mtimeMs: st.facts.mtimeMs,
ino: st.facts.ino,
dev: st.facts.dev,
},
trashId: randomUUID(),
trashDay: d.ports.now().toISOString().slice(0, 10).replace(/-/g, ""),
});
}
}
out.push({ assetId: asset.id, scope, files });
}
return { mode: "replace", assets: out };
}
/** Called after successful new assets commit; only library locations and private sidecars are eligible. */
export async function cleanupReplacement(d: AoDeps, frozen: FrozenPlan, recordId: string): Promise<void> {
if (!frozen.replacement) return;
const db = d.ctx.db;
const successful = await db
.selectFrom("organization_file_result as f")
.innerJoin("media_asset as a", "a.id", "f.media_asset_id")
.select(["f.source_path", "f.target_path", "f.media_asset_id"])
.where("f.organization_record_id", "=", recordId)
.where("f.role", "=", "main")
.where((eb) =>
eb.or([
eb("f.status", "=", "succeeded"),
eb.and([eb("f.status", "=", "skipped"), eb("f.skip_reason", "=", "already_in_library")]),
]),
)
.where("a.status", "=", "active")
.execute();
const registeredUnits = successful.length
? await db
.selectFrom("asset_unit_coverage as c")
.innerJoin("content_unit as u", "u.id", "c.content_unit_id")
.select(["c.media_asset_id", "u.season_number", "u.episode_number"])
.where(
"c.media_asset_id",
"in",
successful.map((f) => f.media_asset_id!),
)
.where("c.coverage", "=", "full")
.execute()
: [];
const scopes = successful.flatMap((f) => {
const units = registeredUnits.filter(
(u) => u.media_asset_id === f.media_asset_id && u.season_number !== null && u.episode_number !== null,
);
if (units.length) {
return [...new Set(units.map((u) => u.season_number!))].map((season) =>
replacementScope(f.source_path, frozen.mediaType, {
season,
episodes: units.filter((u) => u.season_number === season).map((u) => u.episode_number!),
}),
);
}
const facts = frozen.facts[f.source_path];
return [
replacementScope(
f.source_path,
frozen.mediaType,
facts?.episodes
? {
season: facts.season,
episodes: Array.from(
{ length: facts.episodes.end - facts.episodes.start + 1 },
(_, i) => facts.episodes!.start + i,
),
}
: undefined,
),
];
});
for (const old of frozen.replacement.assets) {
if (successful.some((f) => f.media_asset_id === old.assetId)) continue;
if (!replacementCovers(old.scope, scopes)) {
if (successful.length) {
await upsertDecision(d, db, {
kind: "other",
severity: "info",
title: "旧版本未完整覆盖,保留文件",
summary: "本次成功文件未明确覆盖旧文件的全部季集或 Part;旧文件保留,可核对范围后处理",
subjectType: "media_asset",
subjectId: old.assetId,
dedupeKey: `replacement_scope:${recordId}:${old.assetId}`,
evidence: { recordId, oldScope: old.scope, successfulScopes: scopes },
});
}
continue;
}
for (const file of old.files) {
const opId = `replacement:${recordId}:${file.locationId}`;
const existing = await db
.selectFrom("asset_operation")
.select(["status", "result"])
.where("operation_id", "=", opId)
.executeTakeFirst();
if (existing?.status === "succeeded") continue;
const location = await db
.selectFrom("asset_location")
.select(["status", "media_asset_id", "relative_path"])
.where("id", "=", file.locationId)
.executeTakeFirst();
if (!location || location.media_asset_id !== file.assetId || location.relative_path !== file.path) continue;
// A selected same-path replacement has already been quarantined by copyWithVerify and its old location retired.
if (location.status === "removed") continue;
if (successful.some((f) => frozen.libraryRootId === file.rootId && f.target_path === file.path)) continue;
try {
const fs = fsRuntime(d);
if ((await fs.proxy.checkRoot(file.rootPath, file.sentinel)).kind !== "online") {
throw new Error("旧版本库根离线");
}
const rootReal = await realpath(file.rootPath);
const abs = join(rootReal, file.path);
const parent = await realpath(dirname(abs));
const rel = relative(rootReal, parent);
if (rel.startsWith("..") || file.path.split("/").some((s) => s === "..")) throw new Error("旧文件位置越界");
const trash = join(rootReal, ".umt-trash", file.trashDay, file.trashId, file.path);
const st = await fs.proxy.statSafe(abs, {
root: rootReal,
sentinel: file.sentinel,
});
const moved = await fs.proxy.statSafe(trash);
const resumed =
existing?.status === "started" &&
st.kind === "missing" &&
(fs.replaceOld === "delete" || moved.kind === "ok");
if (
!resumed &&
(st.kind !== "ok" ||
st.facts.isSymbolicLink ||
Object.entries(file.facts).some(([k, v]) => st.facts[k as keyof typeof file.facts] !== v))
) {
throw new Error("旧文件已变化,保留待核对");
}
await db
.insertInto("asset_operation")
.values({
operation_id: opId,
phase: "cleanup",
kind: "delete_target",
status: "started",
organization_record_id: recordId,
media_asset_id: file.assetId,
intent: jsonb({ file, mode: fs.replaceOld }),
started_at: d.ports.now(),
})
.onConflict((oc) => oc.column("operation_id").doUpdateSet({ status: "started" }))
.execute();
if (!resumed) {
if (fs.replaceOld === "delete") await rm(abs);
else {
await mkdir(dirname(trash), { recursive: true });
const result = await fs.proxy.moveSafe(abs, trash);
if (result.status !== "moved") {
throw new Error(`旧文件回收失败:${result.status}`);
}
}
}
await db.transaction().execute(async (trx) => {
if (fs.replaceOld === "trash") {
await trx
.insertInto("trash_item")
.values({
id: file.trashId,
root_id: `lib:${file.rootId}`,
library_root_id: file.rootId,
root_host_path: rootReal,
original_path: file.path,
trash_path: trash,
is_dir: false,
size_bytes: file.facts.size,
actor: "system:organize",
deleted_at: d.ports.now(),
expires_at: new Date(d.ports.now().getTime() + 7 * 86_400_000),
asset_snapshot: jsonb({
source: "one_time_replacement",
recordId,
assets: [
{
assetId: file.assetId,
locationId: file.locationId,
},
],
}),
})
.onConflict((oc) => oc.column("id").doNothing())
.execute();
}
await trx
.updateTable("asset_location")
.set({ status: "removed" })
.where("id", "=", file.locationId)
.execute();
await sql`update media_asset set status='retired' where id=${file.assetId} and not exists(select 1 from asset_location where media_asset_id=${file.assetId} and status <> 'removed')`.execute(
trx,
);
await trx
.updateTable("asset_operation")
.set({
status: "succeeded",
result: jsonb({
disposition: fs.replaceOld,
path: fs.replaceOld === "trash" ? trash : null,
}),
finished_at: d.ports.now(),
})
.where("operation_id", "=", opId)
.execute();
});
} catch (error) {
await upsertDecision(d, db, {
kind: "other",
title: "旧版本清理未完成",
summary: error instanceof Error ? error.message : String(error),
subjectType: "media_asset",
subjectId: file.assetId,
dedupeKey: `replacement_cleanup:${recordId}:${file.locationId}`,
evidence: { recordId, file },
});
}
}
}
}
/** Keep the old same-path file until the new asset has passed final validation. */
export async function settleSamePathReplacement(d: AoDeps, frozen: FrozenPlan, recordId: string): Promise<void> {
if (!frozen.replacement) return;
const results = await d.ctx.db
.selectFrom("organization_file_result")
.selectAll()
.where("organization_record_id", "=", recordId)
.execute();
const bySource = new Map(results.map((f) => [f.source_path, f]));
for (const pf of frozen.organize.files) {
if (pf.action !== "replace") continue;
const old = frozen.replacement.assets.flatMap((a) => a.files).find((f) => join(f.rootPath, f.path) === pf.dst);
if (!old) continue;
const main = bySource.get(pf.mainSrc ?? pf.src);
const file = bySource.get(pf.src);
if (!file) continue;
const opId = `org:${recordId}:${file.ordinal}`;
const op = await d.ctx.db
.selectFrom("asset_operation")
.select("result")
.where("operation_id", "=", opId)
.executeTakeFirst();
const result = op?.result as
| {
replaced?: { disposition: string; path?: string; trashItemId?: string };
rollback?: boolean;
}
| undefined;
const backup = result?.replaced;
if (!backup?.path || backup.disposition !== "trash" || result?.rollback) {
continue;
}
try {
const fs = fsRuntime(d);
if (main?.status === "succeeded" && main.media_asset_id) {
if (fs.replaceOld === "delete") {
await rm(backup.path, { force: true });
if (backup.trashItemId) {
await d.ctx.db.deleteFrom("trash_item").where("id", "=", backup.trashItemId).execute();
}
await d.ctx.db
.updateTable("asset_operation")
.set({
result: jsonb({
...result,
replaced: { ...backup, disposition: "deleted" },
}),
})
.where("operation_id", "=", opId)
.execute();
}
continue;
}
if (main?.status !== "failed") continue;
if ((await fs.proxy.checkRoot(old.rootPath, old.sentinel)).kind !== "online") {
throw new Error("旧版本库根离线,保留回收副本");
}
const rejected = join(old.rootPath, ".umt-trash", old.trashDay, old.trashId, old.path);
const oldBackup = await fs.proxy.statSafe(backup.path);
const target = await fs.proxy.statSafe(pf.dst);
const alreadyRestored =
oldBackup.kind === "missing" &&
target.kind === "ok" &&
Object.entries(old.facts).every(([key, value]) => target.facts[key as keyof typeof old.facts] === value);
if (!alreadyRestored) {
if (
oldBackup.kind !== "ok" ||
oldBackup.facts.size !== old.facts.size ||
oldBackup.facts.ino !== old.facts.ino
) {
throw new Error("旧文件回收副本已变化");
}
if (!file.checksum?.startsWith("sha256:")) {
throw new Error("失败目标缺少全量校验,旧文件留在回收站");
}
if (target.kind === "ok") {
if ((await fs.proxy.sha256(pf.dst)).digest !== file.checksum.slice(7)) {
throw new Error("失败目标已变化,旧文件留在回收站");
}
await mkdir(dirname(rejected), { recursive: true });
const parked = await fs.proxy.moveSafe(pf.dst, rejected);
if (parked.status !== "moved") throw new Error("失败新文件无法暂存");
} else if (target.kind !== "missing" || (await fs.proxy.sha256(rejected)).digest !== file.checksum.slice(7)) {
throw new Error("失败新文件暂存无法核验");
}
const restored = await fs.proxy.moveSafe(backup.path, pf.dst);
if (restored.status !== "moved") {
throw new Error("旧文件恢复未完成,副本保留");
}
}
await d.ctx.db.transaction().execute(async (trx) => {
if (backup.trashItemId) {
await trx.deleteFrom("trash_item").where("id", "=", backup.trashItemId).execute();
}
await trx
.insertInto("trash_item")
.values({
id: old.trashId,
root_id: `lib:${old.rootId}`,
library_root_id: old.rootId,
root_host_path: old.rootPath,
original_path: old.path,
trash_path: rejected,
is_dir: false,
size_bytes: pf.size,
actor: "system:organize",
deleted_at: d.ports.now(),
expires_at: new Date(d.ports.now().getTime() + 7 * 86_400_000),
asset_snapshot: jsonb({ source: "rejected_replacement", recordId }),
})
.onConflict((oc) => oc.column("id").doNothing())
.execute();
await trx
.updateTable("asset_operation")
.set({
result: jsonb({
...result,
rollback: true,
replaced: { ...backup, disposition: "restored" },
}),
})
.where("operation_id", "=", opId)
.execute();
});
} catch (error) {
await upsertDecision(d, d.ctx.db, {
kind: "other",
title: "洗版旧文件恢复待核对",
summary: error instanceof Error ? error.message : String(error),
subjectType: "media_asset",
subjectId: old.assetId,
dedupeKey: `replacement_restore:${recordId}:${old.locationId}`,
evidence: { recordId, old, backup },
});
}
}
}
export async function stagedReplacementBackup(d: AoDeps, file: ReplacementFile): Promise<string | null> {
const stage = join(file.rootPath, ".umt-trash", "replacement-staging", file.trashId);
for (const dir of await readdir(stage).catch(() => [] as string[])) {
const candidate = join(stage, dir, basename(file.path));
const st = await fsRuntime(d).proxy.statSafe(candidate);
if (
st.kind === "ok" &&
!st.facts.isSymbolicLink &&
Object.entries(file.facts).every(([key, value]) => st.facts[key as keyof typeof file.facts] === value)
)
return candidate;
}
return null;
}
@@ -23,7 +23,13 @@ const credits = (v: unknown): Credit[] => (Array.isArray(v) ? (v as Credit[]) :
const person = (c: Credit): NfoPerson | null => {
const name = str(c.name);
if (!name) return null;
return { tmdbId: numOf(c.id), name, role: str(c.role), profilePath: str(c.profilePath) };
const id = numOf(c.id);
return {
tmdbId: id !== null && Number.isInteger(id) && id > 0 ? id : null,
name,
role: str(c.role),
profilePath: str(c.profilePath),
};
};
/** 制片国:ISO 3166-1 代码 → 中文名(Intl);认不出的保留原码 */
@@ -132,7 +138,10 @@ export function nfoEpisode(season: number, episode: number, u: UnitForNfo | unde
/** 图片路径:作品海报 / 背景图,季海报,集剧照 */
export function imagePathsOf(entityMeta: unknown) {
return { posterPath: str(val(entityMeta, "posterPath")), backdropPath: str(val(entityMeta, "backdropPath")) };
return {
posterPath: str(val(entityMeta, "posterPath")) ?? str(val(entityMeta, "posterUrl")),
backdropPath: str(val(entityMeta, "backdropPath")),
};
}
export const unitPosterPath = (m: unknown) => str(val(m, "posterPath"));
export const unitStillPath = (m: unknown) => str(val(m, "stillPath"));
@@ -170,51 +170,75 @@ export async function queueScrape(
(cur.media_entity_id ?? null) === input.match.mediaEntityId &&
((cur.match as ScrapeMatch | null)?.externalId ?? null) === input.match.externalId;
if (cur && sameMatch && cur.engine_run_id) return { recordId: cur.id, taskId: cur.engine_run_id, deduplicated: true };
const rec = await db
.insertInto("scrape_record")
.values({
root_id: input.rootId,
root_path: input.rootPath,
library_root_id: input.libraryRootId,
path: input.path,
media_entity_id: input.match.mediaEntityId,
match: jsonb(input.match),
status: "queued",
trigger: input.trigger,
mode,
organization_record_id: input.organizationRecordId ?? null,
actor_type: input.actor?.type ?? (input.trigger === "auto" ? "system" : null),
actor_id: input.actor?.id ?? null,
})
.returning("id")
.executeTakeFirstOrThrow();
const reused = !!cur && sameMatch;
const rec = reused
? cur
: await db
.insertInto("scrape_record")
.values({
root_id: input.rootId,
root_path: input.rootPath,
library_root_id: input.libraryRootId,
path: input.path,
media_entity_id: input.match.mediaEntityId,
match: jsonb(input.match),
status: "queued",
trigger: input.trigger,
mode,
organization_record_id: input.organizationRecordId ?? null,
actor_type: input.actor?.type ?? (input.trigger === "auto" ? "system" : null),
actor_id: input.actor?.id ?? null,
})
.returning("id")
.executeTakeFirstOrThrow();
let queued: { runId: string; deduplicated: boolean };
try {
const { runId, deduplicated } = await d.ctx.engine.enqueue(
queued = await d.ctx.engine.enqueue(
SCRAPE_TASK,
{ recordId: rec.id },
{ idempotencyKey: input.idempotencyKey ?? `${SCRAPE_TASK}:${rec.id}`, traceId: randomUUID() },
);
if (deduplicated) {
// 同一幂等键已排过(自动刮削重放):这条记录作废,返回已有的
await db.deleteFrom("scrape_record").where("id", "=", rec.id).execute();
const prev = await db
.selectFrom("scrape_record")
.select(["id"])
.where("engine_run_id", "=", runId)
.executeTakeFirst();
return { recordId: prev?.id ?? rec.id, taskId: runId, deduplicated: true };
}
await db.updateTable("scrape_record").set({ engine_run_id: runId }).where("id", "=", rec.id).execute();
return { recordId: rec.id, taskId: runId, deduplicated: false };
} catch (err) {
await db
.updateTable("scrape_record")
.set({ status: "failed", reason: "task_enqueue_failed", finished_at: new Date() })
.where("id", "=", rec.id)
.execute()
.catch(() => {});
// 只有新记录的入队失败才标失败;复用的记录可能已有接受但未关联的 run。
if (!reused)
await db
.updateTable("scrape_record")
.set({ status: "failed", reason: "task_enqueue_failed", finished_at: new Date() })
.where("id", "=", rec.id)
.where("status", "=", "queued")
.execute()
.catch(() => {});
throw err;
}
const { runId, deduplicated } = queued;
let recordId = rec.id;
if (deduplicated) {
// 自动刮削重放时,返回引擎真正持有的记录;确认之前不能删除新记录。
const prev = await db
.selectFrom("scrape_record")
.select("id")
.where("engine_run_id", "=", runId)
.executeTakeFirst();
if (prev) recordId = prev.id;
else {
const run = await d.ctx.engine.get(runId);
const original = (run?.input as { recordId?: unknown } | undefined)?.recordId;
if (typeof original !== "string") throw new Error(`刮削任务缺少记录:${runId}`);
const existing = await db.selectFrom("scrape_record").select("id").where("id", "=", original).executeTakeFirst();
if (!existing) throw new Error(`刮削记录不存在:${original}`);
recordId = existing.id;
}
}
try {
await db.transaction().execute(async (trx) => {
await trx.updateTable("scrape_record").set({ engine_run_id: runId }).where("id", "=", recordId).execute();
if (recordId !== rec.id) await trx.deleteFrom("scrape_record").where("id", "=", rec.id).execute();
});
} catch (err) {
// run 已被接受,保持 queued;处理器开始时补回关联,重试也复用原记录/幂等键。
d.ctx.logger.warn({ recordId, runId, err: { message: errMessage(err) } }, "刮削已入队,记录关联暂未写回");
}
return { recordId, taskId: runId, deduplicated };
}
// ── 作品 → 目录(作品详情「重新刮削」、入库后自动刮削) ──
@@ -267,7 +291,7 @@ export async function entityScrapeLocations(
.selectFrom("media_asset as a")
.innerJoin("asset_location as l", "l.media_asset_id", "a.id")
.innerJoin("library_root as r", "r.id", "l.library_root_id")
.select(["l.relative_path", "r.id as root_id", "r.local_path"])
.select(["a.kind", "l.relative_path", "r.id as root_id", "r.local_path"])
.where("a.media_entity_id", "=", entityId)
.where("a.status", "=", "active")
.where("a.kind", "in", ["video", "bluray_dir"])
@@ -291,7 +315,10 @@ export async function entityScrapeLocations(
!!b.relative_path &&
(l.relative_path === b.relative_path || l.relative_path.startsWith(`${b.relative_path}/`)),
);
const path = bound?.relative_path ?? inferWorkDir(kind, l.relative_path) ?? l.relative_path;
// 原盘目录本身就是作品目录(MP:BDMV 只对原盘根目录刮削)
const path =
bound?.relative_path ??
(l.kind === "bluray_dir" ? l.relative_path : (inferWorkDir(kind, l.relative_path) ?? l.relative_path));
const key = `${l.root_id}\u0000${path}`;
if (!out.has(key))
out.set(key, { rootId: `lib:${l.root_id}`, rootPath: l.local_path, libraryRootId: l.root_id, path });
@@ -335,12 +362,53 @@ export async function scrapeEntity(
return out;
}
/** 整理入库成功后:设置打开时对作品目录自动刮削(失败只记日志,不影响整理结果) */
export async function scrapeAfterImport(d: AoDeps, entityId: string | null, recordId: string): Promise<void> {
/** 整理入库成功后:设置打开或本次 Local 补全要求写本地元数据时,复用作品目录刮削任务。 */
export async function scrapeAfterImport(
d: AoDeps,
entityId: string | null,
recordId: string,
opts: { force?: boolean; enabled?: boolean | undefined } = {},
): Promise<void> {
if (!entityId) return;
if (opts.enabled === false) return;
try {
const s = await scrapeSettings(d);
if (!s.scrapeAfterImport) return;
if (!s.scrapeAfterImport && !opts.force && opts.enabled !== true) return;
const organization = await d.ctx.db
.selectFrom("organization_record")
.select(["action", "target_library_root_id"])
.where("id", "=", recordId)
.executeTakeFirst();
if (organization?.action === "in_place" && organization.target_library_root_id) {
const match = await entityScrapeMatch(d, entityId, "library");
const root = await d.ctx.db
.selectFrom("library_root")
.selectAll()
.where("id", "=", organization.target_library_root_id)
.executeTakeFirst();
if (!match || !root || root.management_mode !== "managed") return;
const files = await d.ctx.db
.selectFrom("organization_file_result")
.select("target_path")
.where("organization_record_id", "=", recordId)
.where("role", "=", "main")
.where("status", "=", "succeeded")
.orderBy("ordinal")
.execute();
for (const file of files)
if (file.target_path)
await queueScrape(d, {
rootId: `lib:${root.id}`,
rootPath: root.local_path,
libraryRootId: root.id,
path: file.target_path,
match,
trigger: "auto",
organizationRecordId: recordId,
idempotencyKey: `${SCRAPE_TASK}:record:${recordId}:${file.target_path}`,
});
return;
}
await scrapeEntity(d, entityId, { trigger: "auto", organizationRecordId: recordId });
} catch (err) {
d.ctx.logger.warn({ entityId, recordId, err: { message: errMessage(err) } }, "入库后自动刮削没排上");
@@ -469,7 +537,12 @@ interface Fields {
episodeUnit: Map<string, UnitForNfo>;
}
export async function runScrapeRecord(d: AoDeps, recordId: string, signal?: AbortSignal): Promise<ScrapeRecord> {
export async function runScrapeRecord(
d: AoDeps,
recordId: string,
signal?: AbortSignal,
runId?: string,
): Promise<ScrapeRecord> {
const db = d.ctx.db;
const rec = await db.selectFrom("scrape_record").selectAll().where("id", "=", recordId).executeTakeFirst();
if (!rec) throw new Error(`刮削记录不存在:${recordId}`);
@@ -477,7 +550,7 @@ export async function runScrapeRecord(d: AoDeps, recordId: string, signal?: Abor
const now = () => d.ports.now();
await db
.updateTable("scrape_record")
.set({ status: "running", started_at: now(), reason: null })
.set({ status: "running", started_at: now(), reason: null, ...(runId ? { engine_run_id: runId } : {}) })
.where("id", "=", recordId)
.execute();
const settings = await scrapeSettings(d);
@@ -5,6 +5,7 @@ import {
episodeKey,
movieCompletion,
type ParsedRelease,
parseRelease,
parseUnitKey,
priorityLadder,
type QualityFacts,
@@ -319,8 +320,19 @@ export async function slotComplete(
>,
mediaType: "movie" | "tv",
newAssets: readonly string[],
opts: { includeSubmitted?: boolean } = {},
): Promise<SlotCompletion> {
const completion = (slot.completion ?? {}) as { kind?: string; cuts?: string[] };
const accepted = opts.includeSubmitted
? await exec
.selectFrom("acquisition_plan as p")
.innerJoin("source_release as r", "r.id", "p.source_release_id")
.select(["p.id as plan_id", "p.unit_keys", "r.parsed", "r.title"])
.where("p.slot_id", "=", slot.id)
.where("p.status", "in", ["submitted", "completed"])
.where("p.status_reason", "!=", "accepted_unconfirmed")
.execute()
: [];
if (mediaType === "movie") {
const assets = newAssets.length
? await exec
@@ -329,14 +341,20 @@ export async function slotComplete(
.where("id", "in", [...newAssets])
.execute()
: [];
const complete = movieCompletion(
completion.cuts as never,
assets.map((a) => ({
const complete = movieCompletion(completion.cuts as never, [
...assets.map((a) => ({
assetId: a.id,
verified: true,
...(a.content_edition ? { cut: a.content_edition as never } : {}),
})),
).complete;
...accepted.map((p) => ({
assetId: p.plan_id,
verified: true,
...(((p.parsed as { cut?: string } | null)?.cut ?? parseRelease(p.title).cut)
? { cut: ((p.parsed as { cut?: string } | null)?.cut ?? parseRelease(p.title).cut) as never }
: {}),
})),
]).complete;
return { complete, seasons: [], suspectedMissing: [] };
}
const sub = slot.subscription_id
@@ -357,6 +375,12 @@ export async function slotComplete(
const u = parseUnitKey(k);
if (u?.episode !== undefined) inflightEps.set(u.season, [...(inflightEps.get(u.season) ?? []), u.episode]);
}
for (const p of accepted)
for (const key of p.unit_keys) {
const unit = parseUnitKey(key);
if (unit?.episode !== undefined)
covered.episodes.set(unit.season, new Set([...(covered.episodes.get(unit.season) ?? []), unit.episode]));
}
const ledgers = await seasonLedgers(exec, slot.media_entity_id);
const seasons = await scopeSeasons(exec, slot, sub, covered.episodes, ledgers);
const range = rangeOf(sub);
@@ -490,7 +514,25 @@ export async function afterSlotVerified(
.executeTakeFirst();
const seasonDone = sub?.media_kind !== "movie" && sub?.season_scope === "current_season";
if (sub && sub.status === "active" && (sub.mode === "once" || seasonDone)) {
await completeSubscription(d, trx, sub, "slot_satisfied", { seasons: input.completion.seasons });
const siblings = await trx
.selectFrom("acquisition_slot")
.selectAll()
.where("subscription_id", "=", sub.id)
.where("id", "!=", slot.id)
.execute();
const others = await Promise.all(
siblings.map((s) =>
slotComplete(
trx,
s,
sub.media_kind === "movie" ? "movie" : "tv",
s.current_asset_id ? [s.current_asset_id] : [],
{ includeSubmitted: true },
),
),
);
if (others.every((p) => p.complete))
await completeSubscription(d, trx, sub, "slot_satisfied", { seasons: input.completion.seasons });
}
}
// 计划结束:Slot 到期重评(忙时延后的发布、连续更新的下一集)
@@ -856,3 +898,46 @@ export async function subscriptionProgress(exec: Exec, subId: string): Promise<S
(sub.media_kind === "movie" || (seasons.length > 0 && seasons.every((s) => s.totalKnown)));
return { subscriptionId: sub.id, status: sub.status, completable, seasons, slots: slotOut };
}
/** MP ordinary subscription progress is settled on confirmed downloader acceptance.
* Reserved/uncertain tasks and season wildcard claims never count as covered episodes.
*/
export async function afterDownloadAccepted(d: AoDeps, planId: string): Promise<void> {
await d.ctx.db.transaction().execute(async (trx) => {
const plan = await trx
.selectFrom("acquisition_plan")
.select(["slot_id"])
.where("id", "=", planId)
.executeTakeFirst();
if (!plan?.slot_id) return;
const slot = await trx.selectFrom("acquisition_slot").selectAll().where("id", "=", plan.slot_id).executeTakeFirst();
if (!slot?.subscription_id) return;
const sub = await trx
.selectFrom("subscription")
.selectAll()
.where("id", "=", slot.subscription_id)
.forUpdate()
.executeTakeFirst();
if (sub?.status !== "active") return;
await touchProgress(trx, { subscriptionId: sub.id }, d.ports.now());
if (sub.mode !== "once" && sub.season_scope !== "current_season") return;
const slots = await trx.selectFrom("acquisition_slot").selectAll().where("subscription_id", "=", sub.id).execute();
if (!slots.length) return;
const progress = await Promise.all(
slots.map((s) =>
slotComplete(
trx,
s,
sub.media_kind === "movie" ? "movie" : "tv",
s.current_asset_id ? [s.current_asset_id] : [],
{ includeSubmitted: true },
),
),
);
if (progress.every((p) => p.complete))
await completeSubscription(d, trx, sub, "download_accepted", {
slots: progress,
seasons: progress.flatMap((p) => p.seasons),
});
});
}
+4 -2
View File
@@ -55,8 +55,10 @@ export async function planBinding(db: Kysely<DB>, planType: string, planId: stri
if (planType === "acquisition_plan") {
const r = await db
.selectFrom("acquisition_plan as p")
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
.innerJoin("media_entity as e", "e.id", "s.media_entity_id")
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
.leftJoin("media_entity as e", (j) =>
j.onRef("e.id", "=", sql<string>`coalesce(s.media_entity_id, (p.evaluation->>'mediaEntityId')::uuid)`),
)
.select(["p.payload_hash", "p.expected_revision", "e.id as entity_id", "e.kind", "p.reserved_bytes"])
.where("p.id", "=", planId)
.executeTakeFirst();
@@ -3,6 +3,7 @@ import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
import { capabilitySpec, type DownloadStatus } from "@umt/contracts";
import type { DownloaderInstanceRow } from "@umt/db";
import type { ServerContext } from "../types.ts";
import { planBinding } from "./authorize.ts";
import type { AgentModuleOptions } from "./runner.ts";
// download.recheck(REQ §8.4.2 / §10.7 授权内恢复):对已纳管的下载任务发起 qB 强制重新校验或恢复暂停。
@@ -29,15 +30,8 @@ async function checkGrantScope(ctx: ServerContext, grantId: string, planId: stri
const g = await ctx.db.selectFrom("automation_grant").select("scope").where("id", "=", grantId).executeTakeFirst();
const scope = (g?.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[] };
if (!scope.mediaEntityIds?.length && !scope.mediaKinds?.length) return;
const e = planId
? await ctx.db
.selectFrom("acquisition_plan as p")
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
.innerJoin("media_entity as e", "e.id", "s.media_entity_id")
.select(["e.id", "e.kind"])
.where("p.id", "=", planId)
.executeTakeFirst()
: undefined;
const binding = planId ? await planBinding(ctx.db, "acquisition_plan", planId) : null;
const e = binding?.mediaEntityId && binding.mediaKind ? { id: binding.mediaEntityId, kind: binding.mediaKind } : null;
if (!e) throw new CapabilityError("unauthorized", `授权 ${grantId} 限定了对象范围,但下载任务没有关联作品`);
if (scope.mediaEntityIds?.length && !scope.mediaEntityIds.includes(e.id))
throw new CapabilityError("unauthorized", `授权 ${grantId} 的对象范围不含目标作品`);
@@ -16,6 +16,7 @@ import {
} from "@umt/contracts";
import { pgCacheFor } from "@umt/db";
import { resolveRunUpstream } from "../pi/index.ts";
import { SourcesRuntime } from "../sources-identity/runtime.ts";
import {
AUTO_ENRICH_ACTOR,
type EnrichDeps,
@@ -24,6 +25,7 @@ import {
suggestEnrichment,
} from "../sources-identity/service/enrich.ts";
import { robotsAllows } from "../sources-identity/service/enrich-sources.ts";
import { resolveOrganizeMetadata } from "../sources-identity/service/organize-metadata.ts";
import type { ServerContext } from "../types.ts";
import { agentSettings } from "./runner.ts";
@@ -129,6 +131,37 @@ export function registerMetadataCapabilities(
ctx: ServerContext,
opts: MetadataCapabilityOptions = {},
): void {
registry.register(capabilitySpec("metadata.resolve_organize"), async (input, c) => {
const sub = (scope: string) => ({
actorId: scope,
taskId: c.taskId,
scopeIds: [],
signal: c.signal,
source: "task" as const,
});
const deps: EnrichDeps = {
async search(query, scope) {
const out = (await registry.invoke("web.search", { query }, sub(scope))) as WebSearchOutput;
return out.results.map((r) => ({ title: r.title || null, url: r.url, snippet: r.snippet || null }));
},
async fetch(url, scope) {
const p = (await registry.invoke("web.fetch", { url }, sub(scope))) as WebFetchOutput;
return { url: p.url, finalUrl: p.finalUrl, title: p.title, text: p.text, image: p.image ?? null };
},
complete: opts.complete ?? ((system, user, signal) => upstreamComplete(ctx, system, user, signal)),
robotsAllowed: (url, signal) => robotsAllowedFor(ctx, url, { ...opts, ...(signal ? { signal } : {}) }),
};
try {
return await resolveOrganizeMetadata(new SourcesRuntime(ctx), input.sourceReleaseId, deps, {
actor: { type: "system", id: c.actorId },
signal: c.signal,
});
} catch (e) {
if (c.signal.aborted) throw e;
// Budget/provider errors leave the normal identity decision pending for manual resolution.
return { mediaEntityId: null, mode: null, enrichmentId: null, reason: (e as Error).message.slice(0, 500) };
}
});
registry.register(capabilitySpec("metadata.enrich"), async (input, c) => {
// 网页搜索没开就不占预算
const agent = await ctx.settings.get("agent").catch(() => null);
+13 -6
View File
@@ -258,14 +258,16 @@ async function actionDetail(db: Kysely<DB>, planType: string, planId: string): P
if (planType === "acquisition_plan") {
const r = await db
.selectFrom("acquisition_plan as p")
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
.innerJoin("media_entity as e", "e.id", "s.media_entity_id")
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
.leftJoin("media_entity as e", (j) =>
j.onRef("e.id", "=", sql<string>`coalesce(s.media_entity_id, (p.evaluation->>'mediaEntityId')::uuid)`),
)
.select(["e.title as entity", "p.reserved_bytes"])
.where("p.id", "=", planId)
.executeTakeFirst();
if (!r) return null;
const gb = Number(r.reserved_bytes ?? 0) / 1024 ** 3;
return safe(`《${r.entity}》${gb > 0 ? `· ${gb.toFixed(1)} GB` : ""}`, 200);
return safe(`《${r.entity ?? "未识别资源"}》${gb > 0 ? `· ${gb.toFixed(1)} GB` : ""}`, 200);
}
if (planType === "organize_plan") {
const r = await db.selectFrom("artifact").select("content").where("id", "=", planId).executeTakeFirst();
@@ -471,15 +473,20 @@ async function planSummary(db: Kysely<DB>, planType: string, planId: string): Pr
if (planType === "acquisition_plan") {
const r = await db
.selectFrom("acquisition_plan as p")
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
.innerJoin("media_entity as e", "e.id", "s.media_entity_id")
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
.leftJoin("media_entity as e", (j) =>
j.onRef("e.id", "=", sql<string>`coalesce(s.media_entity_id, (p.evaluation->>'mediaEntityId')::uuid)`),
)
.leftJoin("source_release as r", "r.id", "p.source_release_id")
.select(["e.title as entity", "r.title as release", "p.reserved_bytes"])
.where("p.id", "=", planId)
.executeTakeFirst();
if (r) {
const gb = Number(r.reserved_bytes ?? 0) / 1024 ** 3;
return safe(`下载《${r.entity}》:${r.release ?? "资源"}${gb > 0 ? `(${gb.toFixed(1)} GB)` : ""}`, 300);
return safe(
`下载《${r.entity ?? "未识别资源"}》:${r.release ?? "资源"}${gb > 0 ? `(${gb.toFixed(1)} GB)` : ""}`,
300,
);
}
}
if (planType === "organize_plan") {
+36 -61
View File
@@ -4,12 +4,12 @@ import {
Api,
CAPABILITY_CATALOG,
type CapabilityDescriptor,
type DownloadPlanOutput,
type KnownCapabilityId,
} from "@umt/contracts";
import { type AgentRunRow, sql } from "@umt/db";
import { hasScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import { planManualAcquisition } from "../acquisition-organize/services/manual-acquisition.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import { planBinding } from "./authorize.ts";
import { cancelTask, retryTask, taskToApi } from "./capabilities.ts";
@@ -426,73 +426,48 @@ export function registerAgentRoutes(
}
});
// ── 手动提交下载:同一条 download.plan → download.submit 能力路径(授权/审计/幂等与 Agent 一致)──
// 手动资源选择不依赖订阅,提交后仍走共同下载及整理任务。
route(app, Api.acquisitionPlans.create, async ({ body, actorId, c }) => {
const rel = await db()
.selectFrom("source_release")
.select(["id", "media_entity_id"])
.select("media_entity_id")
.where("id", "=", body.sourceReleaseId)
.executeTakeFirst();
if (!rel) throw new ApiException("not_found", "资源不存在");
let slotIds: string[];
if (body.slotId) {
const s = await db().selectFrom("acquisition_slot").select("id").where("id", "=", body.slotId).executeTakeFirst();
if (!s) throw new ApiException("not_found", "收录版本不存在");
slotIds = [s.id];
} else {
const entityId = body.entityId ?? rel.media_entity_id;
if (!entityId)
throw new ApiException("precondition_failed", "资源还没有关联本地作品,先在「身份与元数据」指定作品");
slotIds = (
await db()
.selectFrom("acquisition_slot")
.select("id")
.where("media_entity_id", "=", entityId)
.orderBy("created_at")
.execute()
).map((r) => r.id);
}
const rejected: { slotId: string | null; code: string; message: string }[] = [];
if (slotIds.length === 0)
rejected.push({ slotId: null, code: "no_slot", message: "这个作品没有收录版本,先在订阅里新建" });
const invokeCtx = {
actorId,
taskId: `http:${c.get("requestId") ?? "req"}`,
scopeIds: [],
signal: c.req.raw.signal,
source: "web" as const,
};
try {
for (const slotId of slotIds) {
const out = (await ctx.capabilities.invoke(
"download.plan",
{ slotId, sourceReleaseId: rel.id },
invokeCtx,
)) as DownloadPlanOutput;
if (!out.plan) {
for (const r of out.rejected) rejected.push({ slotId, ...r });
continue;
}
const sub = (await ctx.capabilities.invoke("download.submit", { planId: out.plan.id }, invokeCtx)) as {
taskId: string;
};
const plan = await db()
.selectFrom("acquisition_plan")
.selectAll()
.where("id", "=", out.plan.id)
.executeTakeFirstOrThrow();
await audit(ctx, actorId, "acquisition_plan.manual_submit", plan.id, {
sourceReleaseId: rel.id,
slotId,
taskId: sub.taskId,
note: body.note ?? null,
});
return { submitted: true, plan: camel(plan), slotId, taskId: sub.taskId, rejected };
if (
rel &&
!rel.media_entity_id &&
!body.entityId &&
!body.allowUnrecognized &&
ctx.capabilities.get("identity.resolve")
) {
try {
await ctx.capabilities.invoke(
"identity.resolve",
{ sourceReleaseId: body.sourceReleaseId },
{
actorId,
taskId: `http:${c.get("requestId") ?? "req"}`,
scopeIds: [],
signal: c.req.raw.signal,
source: "web",
},
);
} catch (err) {
ctx.logger.warn(
{ sourceReleaseId: body.sourceReleaseId, error: err instanceof Error ? err.message : String(err) },
"手动下载识别未完成,允许确认后下载",
);
}
} catch (err) {
capabilityHttpError(err);
}
return { submitted: false, plan: null, slotId: null, taskId: null, rejected };
const result = await planManualAcquisition(ctx, body);
if (result.plan)
await audit(ctx, actorId, "acquisition_plan.manual_submit", result.plan.id, {
sourceReleaseId: body.sourceReleaseId,
taskId: result.taskId,
importMode: body.importMode,
note: body.note ?? null,
});
return result;
});
// ── 产物(整理计划 / 汇报 / 证据):只读 ──
@@ -128,7 +128,13 @@ export function engineQueries(db: Kysely<DB>, schema = "openworkflow"): EngineQu
const cur: Cursor | null = decodeCursor(q.cursor);
const conds = [sql`namespace_id = ${NAMESPACE}`];
if (q.status?.length) conds.push(sql`status = any(${q.status}::text[])`);
if (q.workflowName) conds.push(sql`workflow_name = ${q.workflowName}`);
if (q.workflowName) {
const workflows = q.workflowName
.split(",")
.map((name) => name.trim())
.filter(Boolean);
conds.push(sql`workflow_name = any(${workflows}::text[])`);
}
if (q.from) conds.push(sql`created_at >= ${new Date(q.from)}`);
if (q.to) conds.push(sql`created_at < ${new Date(q.to)}`);
conds.push(...businessConds(q));
@@ -1,6 +1,6 @@
import {
type AuthenticationResponseJSON,
type AuthenticatorTransportFuture,
type AuthenticatorTransport,
generateAuthenticationOptions,
generateRegistrationOptions,
type RegistrationResponseJSON,
@@ -108,8 +108,8 @@ async function takeChallenge(
// ── 登记的 Passkey ──
const transportsOf = (v: unknown): AuthenticatorTransportFuture[] =>
Array.isArray(v) ? (v.filter((x) => typeof x === "string") as AuthenticatorTransportFuture[]) : [];
const transportsOf = (v: unknown): AuthenticatorTransport[] =>
Array.isArray(v) ? (v.filter((x) => typeof x === "string") as AuthenticatorTransport[]) : [];
export function toPasskeyInfo(r: AdminPasskeyRow): PasskeyInfo {
return {
@@ -7,6 +7,8 @@ import {
type SettingsKey,
secretInput,
} from "@umt/contracts";
import { sql } from "@umt/db";
import { parseClassificationYaml } from "@umt/domain";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import { audit, endpoint } from "./endpoint.ts";
@@ -39,6 +41,13 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
throw new ApiException("validation_failed", msg);
}
const next = parsed.data;
if (patch.organize?.classificationYaml !== undefined) {
try {
parseClassificationYaml(next.organize.classificationYaml);
} catch (err) {
throw new ApiException("validation_failed", err instanceof Error ? err.message : String(err));
}
}
const created: string[] = [];
const dropped: string[] = [];
for (const sl of slots) {
@@ -58,6 +67,20 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
try {
await ctx.db.transaction().execute(async (trx) => {
for (const key of keys) {
if (key === "organize") {
await sql`select pg_advisory_xact_lock(hashtext('organize.sources'))`.execute(trx);
const stored = await trx
.selectFrom("settings")
.select("value")
.where("key", "=", key)
.forUpdate()
.executeTakeFirst();
next.organize = {
...next.organize,
...((stored?.value as Partial<typeof next.organize>) ?? {}),
...patch.organize,
};
}
await trx
.insertInto("settings")
.values({ key, value: JSON.stringify(next[key]), updated_at: new Date() })
@@ -142,14 +142,13 @@ describe.skipIf(!up)("浏览器 HTTP 端点", () => {
expect(d.artifact.sourceUrl).not.toContain(PASSKEY);
expect(d.verification).toMatchObject({ valid: true, infohashV1: h.torrent.infoHash, private: true });
expect(d.sourceReleaseId).toBe(h.releaseId);
// 资源未识别:与 POST /acquisition-plans 相同的拒绝,产物照常返回
expect(d.submission).toBeNull();
expect(d.note).toContain("提交未通过");
// 资源未识别:与 POST /acquisition-plans 相同的确认结果,产物照常返回
expect(d.submission).toMatchObject({ submitted: false, requiresConfirmation: true });
const files = await readdir(h.stagingDir);
expect(files).toHaveLength(1);
expect(new Uint8Array(await readFile(join(h.stagingDir, files[0]!)))).toEqual(h.torrent.bytes);
// 识别到作品后再下载:走同一流程(无收录版本 → no_slot);同一文件第二次捕获标 duplicate、不提交
// 识别到作品后再下载:同一文件第二次捕获标 duplicate、不提交
const entity = await h.db
.insertInto("media_entity")
.values({ kind: "movie", title: "奥本海默", year: 2023, status: "active" })
@@ -0,0 +1,74 @@
import { getSiteDefinition } from "@umt/adapters";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { resourceIdFromUrl } from "../resource.ts";
import { type BrowserHarness, createBrowserHarness, ORIGIN, pgAvailable, READ_KEY } from "./harness.ts";
const def = getSiteDefinition("audiences")!;
describe("原站资源详情定位", () => {
it("仅按本站详情路径定位,不把列表、登录或别站同 id 当作资源", () => {
expect(resourceIdFromUrl(`${ORIGIN}/details.php?id=73`, `${ORIGIN}/`, def)).toBe("73");
expect(resourceIdFromUrl(`${ORIGIN}/torrents.php?id=73`, `${ORIGIN}/`, def)).toBeNull();
expect(resourceIdFromUrl(`${ORIGIN}/login.php?id=73`, `${ORIGIN}/`, def)).toBeNull();
expect(resourceIdFromUrl("https://else.example/details.php?id=73", `${ORIGIN}/`, def)).toBeNull();
});
});
const up = await pgAvailable();
describe.skipIf(!up)("原站详情页交接(隔离 PG 与浏览器 fixture)", () => {
let h: BrowserHarness;
let sid: string;
beforeAll(async () => {
h = await createBrowserHarness("resource", {
html: () => `<!doctype html><html><head><title>资源详情</title></head><body>
<h1 id="top">Example.Movie.2024.2160p.WEB-DL.DV</h1>
<table><tr><td class="rowhead">副标题</td><td class="rowfollow">测试译名与简介</td></tr>
<tr><td class="rowhead">基本信息</td><td class="rowfollow">大小: 2 GB 类型: 电影</td></tr></table>
<div id="kdescr">详情页提供的影片简介 <a href="https://www.imdb.com/title/tt1234567/">IMDb</a></div>
</body></html>`,
});
h.mock.loggedIn.add(`umt-site-${h.siteId}-acct-${h.accountId}`);
const session = await h.req("/browser/sessions", {
body: { siteId: h.siteId, url: `${ORIGIN}/details.php?id=73` },
});
expect(session.status).toBe(200);
sid = session.body.data.id;
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("未在快照/搜索中的详情可直接登记,缓存PT内容供元数据兜底,不点击原站下载按钮", async () => {
const before = h.mock.actions.length;
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data.release).toMatchObject({
siteId: h.siteId,
siteReleaseId: "73",
title: "Example.Movie.2024.2160p.WEB-DL.DV",
});
expect(r.body.data.detailUrl).toBe(`${ORIGIN}/details.php?id=73`);
const row = await h.db
.selectFrom("source_release")
.selectAll()
.where("id", "=", r.body.data.release.id)
.executeTakeFirstOrThrow();
expect(row.detail_fetched_at).not.toBeNull();
expect(row.raw).toMatchObject({ _detail: { imdbId: "tt1234567", sourceUrl: `${ORIGIN}/details.php?id=73` } });
expect(h.mock.actions.slice(before).some((a) => a.action === "click")).toBe(false);
const again = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
expect(again.body.data.release.id).toBe(row.id);
});
it("使用当前页面而非旧会话地址;列表页明确提示进入详情", async () => {
const tab = [...h.mock.tabs.values()][0]!;
tab.url = `${ORIGIN}/torrents.php`;
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
expect(r.status).toBe(400);
expect(r.body.error.message).toContain("详情页");
});
it("只读账号不能通过该入口登记下载资源", async () => {
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {}, key: READ_KEY });
expect(r.status).toBe(403);
});
});
@@ -0,0 +1,85 @@
import {
getSiteDefinition,
type IndexerDefinition,
parseDetailPage,
type SiteDefinition,
type SiteReleaseDetail,
} from "@umt/adapters";
import type { BrowserResourceResult } from "@umt/contracts";
import { sql } from "@umt/db";
import { saveDetailPage } from "../sources-identity/service/detail-page.ts";
import { mergeDetailOverList, upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
import { releaseDetail } from "../sources-identity/service/workbench.ts";
import { type Actor, type BrowserService, BrowserServiceError } from "./service.ts";
/** 详情页定位遵循站点定义;列表、登录等页面不能作为资源提交。 */
export function resourceIdFromUrl(value: string, baseUrl: string, def: SiteDefinition): string | null {
const url = new URL(value);
const base = new URL(baseUrl);
if (url.origin !== base.origin) return null;
if (def.umt.kind === "nexusphp") {
const expected = new URL(def.umt.detailPath, base);
if (url.pathname !== expected.pathname) return null;
const id = url.searchParams.get("id");
return id && /^\d{1,20}$/.test(id) ? id : null;
}
const match = /^\/detail\/(\d{1,20})\/?$/.exec(url.pathname);
return match?.[1] ?? null;
}
export async function currentResource(
service: BrowserService,
sessionId: string,
actor: Actor,
tabId?: string,
): Promise<BrowserResourceResult> {
const runtime = service.opts.runtime?.(service.ctx);
if (!runtime) throw new BrowserServiceError("precondition", "站点详情解析不可用");
const current = await service.run(
sessionId,
actor,
async (provider, ref, context) => {
const page = provider.pageContent ? await provider.pageContent(ref, { currentVisible: true }) : null;
if (!page?.url) throw new BrowserServiceError("precondition", "无法读取当前页面,请重新连接浏览器");
const def = getSiteDefinition(context.site.key);
if (!def) throw new BrowserServiceError("precondition", "这个站点还没有详情页解析配置");
const resourceId = resourceIdFromUrl(page.url, context.site.base_url, def);
if (!resourceId) throw new BrowserServiceError("bad_request", "请先进入本站资源详情页,再点击下载");
let detail: SiteReleaseDetail;
if (def.umt.kind === "nexusphp") {
const parsed = parseDetailPage(page.html, def as IndexerDefinition, resourceId, service.now().toISOString());
if (parsed.kind === "login") throw new BrowserServiceError("precondition", "请先在原站登录");
if (!parsed.detail)
throw new BrowserServiceError("precondition", "当前详情页未能解析出资源,请检查站点详情配置");
detail = parsed.detail;
} else {
detail = await runtime.siteAdapter(context.site).detail(resourceId);
}
return { site: context.site, detail, url: page.url };
},
{ write: false, ...(tabId ? { tabId } : {}) },
);
const old = await runtime.ctx.db
.selectFrom("source_release")
.selectAll()
.where("site_id", "=", current.site.id)
.where("site_release_id", "=", current.detail.siteReleaseId)
.executeTakeFirst();
const { page, descriptionText: _description, ...plain } = current.detail;
const detail = old ? mergeDetailOverList(plain, old) : plain;
const { detailCacheSec } = await runtime.siteAccess(current.site.key);
const id = await runtime.ctx.db.transaction().execute(async (trx) => {
const row = await upsertSiteRelease(runtime, trx, current.site, detail);
await trx
.updateTable("source_release")
.set({ detail_fetched_at: runtime.now() })
.where("id", "=", row.id)
.execute();
if (page) await saveDetailPage(runtime, trx, row.id, page, detailCacheSec);
await sql`update source_release set raw = jsonb_set(raw, '{_detail}',
coalesce(raw->'_detail', '{}'::jsonb) || jsonb_build_object('sourceUrl', ${current.url}::text))
where id = ${row.id}`.execute(trx);
return row.id;
});
return { release: (await releaseDetail(runtime, current.site.id, id, false)).release, detailUrl: current.url };
}
@@ -15,6 +15,7 @@ import { type AuthEnv, requireScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import { InternalDispatcher } from "../search/dispatch.ts";
import type { ModuleApp } from "../types.ts";
import { currentResource } from "./resource.ts";
import { type Actor, type BrowserService, BrowserServiceError, imageSize } from "./service.ts";
// C1-E 浏览器 HTTP 端点:原站视图与外部程序共用。读(会话/快照/截图)任何时候可用;
@@ -85,6 +86,8 @@ export function registerBrowserRoutes(app: ModuleApp, svc: () => BrowserService)
const dispatch = new InternalDispatcher(app);
const B = Api.browser;
route(app, B.resource, ({ params, body, c }) => currentResource(svc(), params.id, human(c), body.tabId));
route(app, B.providers, async ({ query }) => ({ items: await svc().listProviders(query.probe === "true") }));
route(app, B.createProvider, async ({ body }) => svc().createProvider(body));
route(app, B.updateProvider, async ({ params, body }) => svc().updateProvider(params.id, body));
@@ -21,6 +21,7 @@ import {
} from "@umt/domain";
import { localIdentityCandidate } from "@umt/mapping";
import type { Principal } from "../../http/auth.ts";
import { successfulOrganizeCounts } from "../acquisition-organize/services/organize-history.ts";
import { EXTERNAL_INVOKE_TIMEOUT_MS, sharedInvokeLimiter } from "../agent/invoke-limits.ts";
import { actorOfP, errMessage, FilesError, type FmDeps, resolveIn } from "./common.ts";
import { kindOfName } from "./list.ts";
@@ -218,6 +219,10 @@ export async function identifyCore(
order by f.source_path, r.created_at desc`.execute(db)
: { rows: [] };
const recordsBy = new Map(recordRows.rows.map((r) => [r.source_path, r]));
const successfulCounts = await successfulOrganizeCounts(
db,
videos.map((v) => v.abs),
);
const localCache = new Map<string, IdentityCandidate[]>();
const remoteCache = new Map<string, { list: IdentityCandidate[]; warning: string | null; pending?: boolean }>();
const entityKinds = new Map<string, string>();
@@ -371,6 +376,7 @@ export async function identifyCore(
count: rec?.n ?? 0,
lastRecordId: rec?.last_id ?? null,
lastStatus: (rec?.last_status ?? null) as OrganizationStatus | null,
successfulCount: successfulCounts.get(v.abs) ?? 0,
};
const parsed0 = parseReleasePath(segments(v.rel));
const parsed: ParsedRelease = {
+23 -1
View File
@@ -10,7 +10,9 @@ import { SourcesRuntime } from "../sources-identity/runtime.ts";
import type { ServerContext, ServerModule } from "../types.ts";
import { defaultOptions, type FmDeps, type FmOptions } from "./common.ts";
import { IDENTIFY_REMOTE_TASK, runIdentifyRemote } from "./identify.ts";
import { registerOrganizeSourceRoutes } from "./routes/organize-sources.ts";
import { registerFileRoutes } from "./routes.ts";
import { queueDueSourceScans, scanOrganizeSource } from "./source-monitor.ts";
import { purgeTrash } from "./trash.ts";
/**
@@ -52,9 +54,18 @@ export function createFileManagerModule(overrides: FmOverrides = {}): ServerModu
},
registerRoutes(app, ctx) {
registerFileRoutes(app, lazy(ctx));
registerOrganizeSourceRoutes(app, lazy(ctx));
},
registerTasks(registry, ctx) {
registry.register(
defineTask({
name: "scanOrganizeSource",
version: 1,
lane: "maintenance",
inputSchema: z.object({ sourceId: z.string().uuid() }),
retryPolicy: { maximumAttempts: 3, initialInterval: "5s" },
handler: async (t) => t.step.run("scan", () => scanOrganizeSource(lazy(ctx), t.input)),
}),
defineTask({
name: "purgeTrash",
version: 1,
@@ -84,7 +95,18 @@ export function createFileManagerModule(overrides: FmOverrides = {}): ServerModu
},
start(ctx) {
const d = lazy(ctx);
if (!d.options.backgroundLoops || d.options.purgeIntervalMs <= 0) return;
if (!d.options.backgroundLoops) return;
registerScheduledJob(ctx, {
key: "organize.sources",
defaults: {
name: "整理来源扫描",
description: "检查来源目录的新文件",
group: "maintain",
schedule: { type: "interval", everyMs: 60_000 },
},
run: async (c) => queueDueSourceScans(d, c.slot),
});
if (d.options.purgeIntervalMs <= 0) return;
registerScheduledJob(ctx, {
key: "trash.purge",
defaults: {
@@ -0,0 +1,112 @@
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { access } from "node:fs/promises";
import type { OrganizeSourceConfig } from "@umt/contracts";
import { sql } from "@umt/db";
import { SETTINGS_CHANGED_EVENT } from "../auth-settings/provided.ts";
import { FilesError, type FmDeps, jsonb, resolveIn } from "./common.ts";
import { isWritableTarget } from "./list.ts";
export async function sourceConfigs(d: FmDeps): Promise<OrganizeSourceConfig[]> {
const org = await d.ctx.settings.get("organize");
return (org as { sources?: OrganizeSourceConfig[] }).sources ?? [];
}
export async function sourceConfig(d: FmDeps, id: string): Promise<OrganizeSourceConfig> {
const found = (await sourceConfigs(d)).find((s) => s.id === id);
if (!found) throw new FilesError("not_found", "整理来源不存在");
return found;
}
const iso = (v: Date | string | null | undefined) => (v ? new Date(v).toISOString() : null);
export async function listOrganizeSources(d: FmDeps) {
const rows = await d.ctx.db.selectFrom("organize_source_snapshot").selectAll().execute();
return {
items: (await sourceConfigs(d)).map((s) => {
const row = rows.find((r) => r.source_id === s.id);
const files = (row?.snapshot as { files?: Record<string, unknown> } | undefined)?.files ?? {};
return {
...s,
baselineAt: iso(row?.baseline_at),
lastScanAt: iso(row?.last_scan_at),
lastError: row?.last_error ?? null,
fileCount: Object.keys(files).length,
};
}),
};
}
async function editSources(
d: FmDeps,
edit: (items: OrganizeSourceConfig[]) => OrganizeSourceConfig[],
resetId?: string,
lockId = resetId,
) {
const defaults = await d.ctx.settings.get("organize");
await d.ctx.db.transaction().execute(async (trx) => {
if (lockId) await sql`select pg_advisory_xact_lock(hashtext(${`organize-source:${lockId}`}))`.execute(trx);
// Serialize even the first insert, where no settings row exists to lock.
await sql`select pg_advisory_xact_lock(hashtext('organize.sources'))`.execute(trx);
const row = await trx
.selectFrom("settings")
.select("value")
.where("key", "=", "organize")
.forUpdate()
.executeTakeFirst();
const org = (row?.value ?? defaults) as typeof defaults & { sources?: OrganizeSourceConfig[] };
await trx
.insertInto("settings")
.values({ key: "organize", value: jsonb({ ...org, sources: edit(org.sources ?? []) }), updated_at: new Date() })
.onConflict((oc) =>
oc
.column("key")
.doUpdateSet({ value: jsonb({ ...org, sources: edit(org.sources ?? []) }), updated_at: new Date() }),
)
.execute();
if (resetId) await trx.deleteFrom("organize_source_snapshot").where("source_id", "=", resetId).execute();
});
const settings = d.ctx.settings as typeof d.ctx.settings & { invalidate?: () => void };
settings.invalidate?.();
await d.ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys: ["organize"] });
}
export async function saveOrganizeSource(d: FmDeps, id: string, input: Omit<OrganizeSourceConfig, "id">) {
const source = await resolveIn(d, input.source.root, input.source.path);
if (source.isSymlink || !source.lst?.isDirectory()) throw new FilesError("bad_request", "来源必须为可访问的目录");
const target = await resolveIn(d, input.target.root, input.target.path);
if (
target.isSymlink ||
!target.lst?.isDirectory() ||
!(await isWritableTarget(target.def, target.rootReal)) ||
!(await access(target.abs, constants.W_OK).then(
() => true,
() => false,
))
)
throw new FilesError("not_writable", "目标必须为可写的纳管媒体库目录");
if (input.trigger === "qb_tag") {
if (!input.downloaderInstanceId || !input.tag?.trim())
throw new FilesError("bad_request", "标签触发需要下载器和标签");
const instance = await d.ctx.db
.selectFrom("downloader_instance")
.select("id")
.where("id", "=", input.downloaderInstanceId)
.executeTakeFirst();
if (!instance || (source.def.instanceId && source.def.instanceId !== instance.id))
throw new FilesError("bad_request", "来源和下载器不匹配");
}
const previous = (await sourceConfigs(d)).find((s) => s.id === id);
const reset = !previous || previous.source.root !== input.source.root || previous.source.path !== input.source.path;
const config = { ...input, id };
await editSources(d, (items) => [...items.filter((s) => s.id !== id), config], reset ? id : undefined, id);
if (reset)
await d.ctx.engine.enqueue(
"scanOrganizeSource",
{ sourceId: id },
{ idempotencyKey: `source-baseline:${id}:${randomUUID()}`, traceId: randomUUID() },
);
return (await listOrganizeSources(d)).items.find((s) => s.id === id)!;
}
export async function deleteOrganizeSource(d: FmDeps, id: string) {
const deleted = (await sourceConfigs(d)).some((s) => s.id === id);
await editSources(d, (items) => items.filter((s) => s.id !== id), id);
return { deleted };
}
@@ -29,7 +29,7 @@ type Req = {
sourcePaths: string[];
mediaEntityId: string;
contentUnitId?: string | undefined;
action: "copy" | "move";
action: "copy" | "move" | "in_place";
targetLibraryRootId?: string | undefined;
};
@@ -45,7 +45,7 @@ interface Frozen {
interface Group {
entity: string;
unit: string | null;
action: "copy" | "move";
action: "copy" | "move" | "in_place";
targetDir: string | null;
targetLibraryRootId: string | null;
slotId: string | null;
@@ -67,7 +67,7 @@ function filesOf(plan: FrozenPlan | undefined): GroupFile[] {
const heldByExisting = new Set(
plan.organize.conflicts.filter((c) => c.kind === "target_exists" && c.blocking).map((c) => c.src),
);
return plan.organize.files.map((f) => {
const active = plan.organize.files.map((f) => {
const existing: GroupFile["existing"] =
f.action === "replace"
? "replace"
@@ -88,16 +88,36 @@ function filesOf(plan: FrozenPlan | undefined): GroupFile[] {
skipReason: f.skipReason ?? null,
};
});
return [
...active,
...(plan.skippedSuccessful ?? []).map((f) => ({
source: f.src,
target: f.dst ?? null,
kind: f.kind,
size: f.size,
action: "skip" as const,
existing: null,
skipReason: "successful_record",
})),
];
}
/** 被排除的文件(G2:too_small → below_min_size) */
function excludedOf(plan: FrozenPlan | undefined): NonNullable<FilesOrganizeGroup["excluded"]> {
return (plan?.organize.excluded ?? []).map((x) => ({
source: x.src,
kind: x.kind,
size: x.reason === "too_small" && x.detail && /^\d+$/.test(x.detail) ? Number(x.detail) : null,
reason: x.reason === "too_small" ? "below_min_size" : x.reason,
}));
return [
...(plan?.organize.excluded ?? []).map((x) => ({
source: x.src,
kind: x.kind,
size: x.reason === "too_small" && x.detail && /^\d+$/.test(x.detail) ? Number(x.detail) : null,
reason: x.reason === "too_small" ? "below_min_size" : x.reason,
})),
...(plan?.skippedSuccessful ?? []).map((f) => ({
source: f.src,
kind: f.kind,
size: f.size,
reason: "successful_record",
})),
];
}
/** 目标目录分段(相对库根;G4):剧集末段为季目录;bound = 沿用已有路径绑定(本次不新建) */
@@ -105,7 +125,8 @@ function segmentsOf(plan: FrozenPlan | undefined, overrideDir: boolean): FilesOr
const dir = plan?.targetDirs[0];
if (!plan || !dir) return [];
const parts = relative(plan.rootPath, dir).split("/").filter(Boolean);
if (overrideDir) return parts.map((text) => ({ text, role: "dir" as const, bound: false }));
if (overrideDir || plan.organize.operation === "in_place")
return parts.map((text) => ({ text, role: "dir" as const, bound: false }));
const newEntity = plan.newBindings.some((b) => b.scope === "entity");
const newSeason = plan.newBindings.some((b) => b.scope === "season");
const rootIdx = plan.mediaType === "tv" ? parts.length - 2 : parts.length - 1;
@@ -152,11 +173,30 @@ export async function organizeFiles(
const actorType = principal?.kind === "api_key" ? "api_key" : "user";
const actor = actorOfP(principal);
// G1:外部候选 → 本地作品(就地建,幂等);每项解析出 mediaEntityId
const created = new Map<string, { entityId: string; created: boolean; title: string; externalId: string }>();
// 先核验整批人工集号,避免无效输入创建外部作品或冻结计划。数字范围由请求 schema 校验。
const localIds = [...new Set(body.items.flatMap((it) => (it.mediaEntityId ? [it.mediaEntityId] : [])))];
const localKinds = new Map(
localIds.length
? (await db.selectFrom("media_entity").select(["id", "kind"]).where("id", "in", localIds).execute()).map((e) => [
e.id,
e.kind,
])
: [],
);
for (const it of body.items) {
if (!!it.mediaEntityId === !!it.external)
throw new FilesError("bad_request", `整理项 ${it.path || "/"} 需要且只能指定 mediaEntityId 或 external 之一`);
const kind = it.external?.kind ?? localKinds.get(it.mediaEntityId!);
if (!it.episodes?.length || kind === undefined || kind === "movie") continue;
if (!episodeRange(it.episodes))
throw new FilesError("bad_request", `${it.path}:集号 ${it.episodes.join(",")} 不连续,只支持连续区间`);
const r = await resolveIn(d, it.root, it.path);
if (r.lst?.isDirectory()) throw new FilesError("bad_request", `${r.rel}:目录项不能指定集号,请展开后逐个视频指定`);
}
// G1:外部候选 → 本地作品(就地建,幂等);每项解析出 mediaEntityId
const created = new Map<string, { entityId: string; created: boolean; title: string; externalId: string }>();
for (const it of body.items) {
if (!it.external) continue;
const k = externalKey(it.external);
if (created.has(k)) continue;
@@ -228,7 +268,13 @@ export async function organizeFiles(
// 目标目录覆写:writable 库根下已存在的目录
let targetDir: string | null = null;
let targetLib: string | null = body.targetLibraryRootId ?? null;
if (it.targetDir) {
if (action === "in_place") {
// 本次保留实际库根与路径,不使用来源配置的搬运目标。
if (!r.def.libraryRootId)
throw new FilesError("bad_request", "原地入库需要文件位于已登记的媒体库目录;请先在设置中登记该目录");
targetLib = r.def.libraryRootId;
}
if (action !== "in_place" && it.targetDir) {
const t = await resolveIn(d, it.targetDir.root, it.targetDir.path);
if (!t.lst?.isDirectory() || t.isSymlink || !t.def.libraryRootId)
throw new FilesError("bad_request", `目标目录必须是媒体库目录下已存在的目录:${it.targetDir.path || "/"}`);
@@ -244,9 +290,9 @@ export async function organizeFiles(
if (it.season !== undefined) ov.season = it.season;
if (it.episodes?.length) {
const range = episodeRange(it.episodes);
if (!range) warnings.push(`${r.rel}:集号 ${it.episodes.join(",")} 不连续,未应用(只支持连续区间)`);
else if (isDir) warnings.push(`${r.rel}:目录项不能指定集号(会套到其下所有视频),未应用`);
else ov.episodes = range;
if (!range) throw new FilesError("bad_request", `${r.rel}:集号不连续,只支持连续区间`);
if (isDir) throw new FilesError("bad_request", `${r.rel}:目录项不能指定集号,请展开后逐个视频指定`);
ov.episodes = range;
}
}
}
@@ -339,6 +385,7 @@ export async function organizeFiles(
const hasOverrides = Object.keys(g.overrides).length > 0;
const reqHash = hashJson({
key: requestKey(req),
...(body.sourceConfigId && g.action !== "in_place" ? { sourceConfigId: body.sourceConfigId } : {}),
...(hasOverrides ? { overrides: g.overrides } : {}),
...(g.targetDir ? { targetDir: g.targetDir } : {}),
...(g.slotId ? { slotId: g.slotId } : {}),
@@ -346,6 +393,8 @@ export async function organizeFiles(
...(minSizeBytes !== undefined ? { minSizeBytes } : {}),
...(onExisting !== "conflict" ? { onExisting } : {}),
...(namingMode !== "template" ? { naming: namingMode } : {}),
...(body.skipSuccessfulRecords ? { skipSuccessfulRecords: true } : {}),
...(body.scrapeAfterImport !== undefined ? { scrapeAfterImport: body.scrapeAfterImport } : {}),
});
const ext = fromExternal.get(g.entity);
const base = {
@@ -363,6 +412,7 @@ export async function organizeFiles(
};
const importReq = (dryRun: boolean, taskId?: string) => ({
...req,
...(body.sourceConfigId && g.action !== "in_place" ? { sourceConfigId: body.sourceConfigId } : {}),
...(hasOverrides ? { fileOverrides: g.overrides } : {}),
...(g.targetDir ? { targetDir: g.targetDir } : {}),
...(g.slotId ? { slotId: g.slotId } : {}),
@@ -371,6 +421,8 @@ export async function organizeFiles(
// 向导里显式选择"替换"即为人工授权;替换走 replace_with_verify(新文件核验通过后旧文件进回收站)
...(onExisting === "replace_with_verify" ? { grantReplace: true } : {}),
namingMode,
skipSuccessfulRecords: body.skipSuccessfulRecords,
scrapeAfterImport: body.scrapeAfterImport,
trigger: "manual" as const,
actorType: actorType as "api_key" | "user",
actorId: actor,
@@ -421,6 +473,20 @@ export async function organizeFiles(
const nowPairs = pairsOf(again).map(({ src, dst }) => ({ src, dst }));
if (hashJson(nowPairs) !== hashJson(frozen.pairs))
throw new FilesError("plan_stale", "目标目录或绑定已变化,与预览不一致。重新预览");
if (again.plan?.skippedSuccessful?.length && !again.plan.organize.files.length) {
await retirePlan(d, planId);
out.push({
...base,
...planExtras(again.plan, !!g.targetDir),
planId,
files: filesOf(again.plan),
truncated: false,
recordId: null,
taskId: null,
error: null,
});
continue;
}
if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`);
if (g.action === "move") {
@@ -116,7 +116,7 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) {
mount(app, F.scrape, async (c, { body }) =>
idem(d, c, "scrape", body, () => scrapeFiles(d, c.get("principal"), body)),
);
mount(app, F.scrapeRecords, async (_c, { query }) => scrapeRecords(d, query.ids));
mount(app, F.scrapeRecords, async (_c, { query }) => scrapeRecords(d, query.ids, query.organizationRecordId));
mount(app, F.probe, async (_c, { body }) => probeFiles(d, body));
mount(app, F.records, async (_c, { query }) => findRecords(d, query));
mount(app, F.download, async (c, { query }) => downloadFile(d, c.get("principal"), query), true);
@@ -0,0 +1,24 @@
import { Api } from "@umt/contracts";
import { ApiException } from "../../../http/errors.ts";
import { endpoint } from "../../auth-settings/endpoint.ts";
import type { ModuleApp } from "../../types.ts";
import { type FmDeps, fsErrorToFiles } from "../common.ts";
import { deleteOrganizeSource, listOrganizeSources, saveOrganizeSource } from "../organize-sources.ts";
import { scanStock } from "../source-monitor.ts";
async function guarded<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
const converted = fsErrorToFiles(error);
if (converted) throw new ApiException(converted.code, `${converted.reason}: ${converted.message}`);
throw error;
}
}
export function registerOrganizeSourceRoutes(app: ModuleApp, d: FmDeps) {
const defs = Api.organizeSources;
endpoint(app, defs.list, () => guarded(() => listOrganizeSources(d)));
endpoint(app, defs.save, ({ params, body }) => guarded(() => saveOrganizeSource(d, params.id, body)));
endpoint(app, defs.remove, ({ params }) => guarded(() => deleteOrganizeSource(d, params.id)));
endpoint(app, defs.stock, ({ params, body }) => guarded(() => scanStock(d, params.id, body)));
}
+18 -3
View File
@@ -204,8 +204,23 @@ export async function scrapeFiles(
};
}
export async function scrapeRecords(d: FmDeps, idsParam: string) {
const ids = [...new Set(idsParam.split(",").map((s) => s.trim()))].filter((s) => /^[0-9a-f-]{36}$/i.test(s));
if (!ids.length || ids.length > 100) throw new FilesError("bad_request", "ids 需要 1–100 个刮削记录 id");
export async function scrapeRecords(d: FmDeps, idsParam?: string, organizationRecordId?: string) {
let ids =
idsParam === undefined
? []
: [...new Set(idsParam.split(",").map((s) => s.trim()))].filter((s) => /^[0-9a-f-]{36}$/i.test(s));
if (idsParam !== undefined && (!ids.length || ids.length > 100))
throw new FilesError("bad_request", "ids 需要 1–100 个刮削记录 id");
if (!idsParam && !organizationRecordId) throw new FilesError("bad_request", "需要 ids 或 organizationRecordId");
if (organizationRecordId) {
let query = d.ctx.db
.selectFrom("scrape_record")
.select("id")
.where("organization_record_id", "=", organizationRecordId);
if (idsParam !== undefined) query = query.where("id", "in", ids);
const matched = await query.orderBy("requested_at").orderBy("id").execute();
const allowed = new Set(matched.map((r) => r.id));
ids = idsParam === undefined ? matched.map((r) => r.id) : ids.filter((id) => allowed.has(id));
}
return { records: await loadScrapeRecords(d.ao, ids) };
}
@@ -0,0 +1,412 @@
import { lstat, opendir, realpath } from "node:fs/promises";
import { basename, join } from "node:path";
import { isDownloadComplete } from "@umt/adapters";
import type { FileRef, OrganizeSourceConfig } from "@umt/contracts";
import { sql } from "@umt/db";
import { hostPathOf, loadInstances, upsertDecision } from "../acquisition-organize/services/common.ts";
import { planImport } from "../acquisition-organize/services/organize.ts";
import { downloadTasksUnder } from "../acquisition-organize/services/takeover.ts";
import { entityFromCandidate } from "./candidate.ts";
import {
FilesError,
type FmDeps,
hashJson,
isTempName,
jsonb,
recheck,
relOf,
resolveIn,
rootDef,
within,
} from "./common.ts";
import { candidateKey, identifyCore } from "./identify.ts";
import { kindOfName } from "./list.ts";
import { sourceConfig, sourceConfigs } from "./organize-sources.ts";
export interface SnapshotFile {
size: number;
mtimeMs: number;
accepted: boolean;
}
interface Snapshot {
files: Record<string, SnapshotFile>;
}
export const SOURCE_SCAN_LIMIT = 5000;
/** A changed file becomes eligible only after two observations of the same size and mtime. */
export function advanceSnapshot(previous: Snapshot | null, observed: Record<string, Omit<SnapshotFile, "accepted">>) {
const files: Snapshot["files"] = {};
const eligible: string[] = [];
for (const [path, fact] of Object.entries(observed)) {
const old = previous?.files[path];
const same = old?.size === fact.size && old.mtimeMs === fact.mtimeMs;
const accepted = previous === null || (same && old.accepted);
files[path] = { ...fact, accepted };
if (same && !accepted) eligible.push(path);
}
return { snapshot: { files }, eligible };
}
export async function walkSource(d: FmDeps, config: OrganizeSourceConfig, path = config.source.path) {
if (!within(path, config.source.path) && config.source.path !== "")
throw new FilesError("path_escape", "扫描路径必须在来源目录内");
const start = await resolveIn(d, config.source.root, path);
if (start.isSymlink || !start.lst?.isDirectory()) throw new FilesError("bad_request", "来源必须为目录");
const files: Record<string, Omit<SnapshotFile, "accepted">> = {};
const stack = [start.abs];
let seen = 0;
let truncated = false;
while (stack.length && !truncated) {
const directory = await opendir(stack.pop()!);
for await (const entry of directory) {
if (++seen > SOURCE_SCAN_LIMIT) {
truncated = true;
break;
}
if (entry.name.startsWith(".") || isTempName(entry.name) || entry.isSymbolicLink()) continue;
const abs = join(directory.path, entry.name);
// Re-resolve parents before traversing to keep directory replacement races inside the root guard.
const resolved = await resolveIn(d, config.source.root, relOf(start.rootReal, abs));
if (resolved.isSymlink) continue;
if (resolved.lst?.isDirectory()) stack.push(resolved.abs);
else if (resolved.lst?.isFile() && kindOfName(entry.name) === "video") {
const fact = await lstat(resolved.abs);
if (!fact.isFile() || fact.isSymbolicLink() || fact.size <= 0) continue;
files[relOf(start.rootReal, resolved.abs)] = { size: Number(fact.size), mtimeMs: Number(fact.mtimeMs) };
}
}
}
return { files, truncated, rootReal: start.rootReal };
}
/** Query unfiltered QB state once: an untagged download may never have a DB task row. */
async function activeDownloaderPaths(d: FmDeps, config: OrganizeSourceConfig): Promise<string[]> {
const source = await rootDef(d.ctx.db, config.source.root);
const instanceIds = [...new Set([config.downloaderInstanceId, source.instanceId].filter((id): id is string => !!id))];
if (!instanceIds.length) return [];
const topology = await loadInstances(d.ctx.db, { enabledOnly: false });
const paths: string[] = [];
for (const instanceId of instanceIds) {
const instance = topology.find((t) => t.inst.id === instanceId);
if (!instance) throw new FilesError("upstream", "来源下载器不存在,无法确认文件是否下载完成");
try {
const torrents = await (await d.ao.ports.downloader(instance.inst)).list();
for (const torrent of torrents) {
if (isDownloadComplete({ progress: torrent.progress, amountLeft: torrent.amountLeft, state: torrent.rawState }))
continue;
const content = torrent.contentPath || join(torrent.savePath, torrent.name);
const host = hostPathOf(instance, content);
if (!host) throw new Error("下载器内容路径未映射,无法确认源文件是否下载完成");
paths.push(await realpath(host).catch(() => host));
}
} catch (error) {
throw new FilesError(
"upstream",
`无法查询来源下载器,暂停本次整理:${error instanceof Error ? error.message : String(error)}`,
);
}
}
return paths;
}
async function organizedPaths(
d: FmDeps,
paths: string[],
scan: { files: Record<string, Omit<SnapshotFile, "accepted">>; rootReal: string },
) {
if (!paths.length) return new Set<string>();
const rows = await d.ctx.db
.selectFrom("organization_file_result as f")
.innerJoin("organization_record as r", "r.id", "f.organization_record_id")
.select([
"f.source_path",
"f.source_size_bytes",
"f.status as file_status",
"f.skip_reason",
"r.status as record_status",
"r.plan",
])
.where("f.source_path", "in", paths)
.where("r.status", "in", ["running", "queued", "succeeded", "partial"])
.execute();
const done = new Set<string>();
for (const row of rows) {
if (["running", "queued"].includes(row.record_status)) {
done.add(row.source_path);
continue;
}
if (row.file_status !== "succeeded" && !(row.file_status === "skipped" && row.skip_reason === "already_in_library"))
continue;
const observed = scan.files[relOf(scan.rootReal, row.source_path)];
if (!observed) continue;
const recorded = (row.plan as { sourceFacts?: Record<string, { size: number; mtimeMs: number }> })?.sourceFacts?.[
row.source_path
];
if (
recorded
? recorded.size === observed.size && recorded.mtimeMs === observed.mtimeMs
: row.source_size_bytes === null || Number(row.source_size_bytes) === observed.size
)
done.add(row.source_path);
}
return done;
}
export async function scanStock(d: FmDeps, id: string, input: { path?: string } = {}) {
const config = await sourceConfig(d, id);
const liveActive = await activeDownloaderPaths(d, config);
const scan = await walkSource(d, config, input.path ?? config.source.path);
const paths = Object.keys(scan.files);
const done = await organizedPaths(
d,
paths.map((p) => join(scan.rootReal, p)),
scan,
);
const items: FileRef[] = [];
let skipped = 0;
let unavailable = 0;
let truncated = scan.truncated;
const stockLimit = Math.min(200, d.options.identifyMaxVideos);
const known = await downloadTasksUnder(
d.ctx.db,
paths.map((p) => join(scan.rootReal, p)),
);
const topology = await loadInstances(d.ctx.db, { enabledOnly: false });
const active = known
.filter((t) => !["completed", "seeding", "removed"].includes(t.status))
.flatMap((t) => {
const instance = topology.find((i) => i.inst.id === t.instance_id);
const host = instance && t.content_path ? hostPathOf(instance, t.content_path) : null;
return host ? [host] : [];
});
active.push(...liveActive);
for (const path of paths) {
const abs = join(scan.rootReal, path);
if (done.has(abs)) {
skipped++;
continue;
}
if (active.some((p) => within(abs, p))) {
unavailable++;
continue;
}
if (items.length >= stockLimit) {
truncated = true;
continue;
}
items.push({ root: config.source.root as FileRef["root"], path });
}
return { items, skipped, unavailable, truncated };
}
export async function scanOrganizeSource(d: FmDeps, input: { sourceId: string }) {
const config = await sourceConfig(d, input.sourceId).catch(() => null);
if (!config) return { baseline: false, eligible: 0, queued: 0 };
// Cross-worker lock covers observations and planning. A duplicate scan never counts as a second stable observation.
return d.ctx.db.transaction().execute(async (trx) => {
const lock = await sql<{
locked: boolean;
}>`select pg_try_advisory_xact_lock(hashtext(${`organize-source:${config.id}`})) as locked`.execute(trx);
if (!lock.rows[0]?.locked) return { baseline: false, eligible: 0, queued: 0 };
const stored = await trx.selectFrom("settings").select("value").where("key", "=", "organize").executeTakeFirst();
const freshConfig = (
(stored?.value as { sources?: OrganizeSourceConfig[] })?.sources ?? (await sourceConfigs(d))
).find((s) => s.id === config.id);
if (!freshConfig || hashJson(freshConfig) !== hashJson(config)) return { baseline: false, eligible: 0, queued: 0 };
const row = await trx
.selectFrom("organize_source_snapshot")
.selectAll()
.where("source_id", "=", config.id)
.executeTakeFirst();
const now = new Date();
try {
const liveActive = await activeDownloaderPaths(d, config);
const scan = await walkSource(d, config);
if (scan.truncated) throw new FilesError("too_large", "来源超过单次扫描上限,请选择更小的目录");
const baseline = !row?.baseline_at;
const advance = advanceSnapshot(baseline ? null : (row!.snapshot as unknown as Snapshot), scan.files);
let queued = 0;
const automation = await d.ctx.settings.get("automation");
if (
!baseline &&
config.enabled &&
config.trigger === "snapshot" &&
automation.enabled &&
!automation.readOnlyReconcile
) {
const done = await organizedPaths(
d,
advance.eligible.map((p) => join(scan.rootReal, p)),
scan,
);
for (const path of advance.eligible) {
const abs = join(scan.rootReal, path);
if (done.has(abs)) {
advance.snapshot.files[path]!.accepted = true;
continue;
}
if (liveActive.some((p) => within(abs, p))) continue;
// Any known QB task stays under QB's completion/tag policy, including unmanaged and untagged tasks.
const known = await downloadTasksUnder(d.ctx.db, [abs]);
if (known.length) continue;
const identified = await identifyCore(
d,
{
items: [{ root: config.source.root as FileRef["root"], path }],
hints: config.mediaType === "all" ? {} : { mediaKind: config.mediaType === "tv" ? "series" : "movie" },
},
{ mode: "remote", actorId: "system:source-monitor" },
);
const key = identified.autoPicks.get(`${config.source.root}\u0000${path}`);
const candidate = identified.result.items[0]?.candidates.find((c) => candidateKey(c) === key);
if (!candidate) {
const dedupeKey = `source_identity:${config.id}:${hashJson(path)}`;
const existing = await trx
.selectFrom("decision")
.select("id")
.where("dedupe_key", "=", dedupeKey)
.where("status", "in", ["open", "investigating", "awaiting_user"])
.executeTakeFirst();
if (!existing)
await upsertDecision(d.ao, trx, {
kind: "identity_ambiguous",
title: `整理来源待识别:${basename(path)}`,
subjectType: "organize_source",
subjectId: config.id,
dedupeKey,
evidence: { sourceConfigId: config.id, root: config.source.root, path },
options: [{ key: "dismiss", label: "忽略" }],
});
advance.snapshot.files[path]!.accepted = true;
continue;
}
const entity =
candidate.mediaEntityId ??
(
await entityFromCandidate(d, undefined, {
provider: "tmdb",
externalId: candidate.externalId!,
kind: candidate.kind === "movie" ? "movie" : "tv",
})
).mediaEntityId;
const organizeSettings = await d.ctx.settings.get("organize");
const fingerprint = hashJson({
config,
classificationYaml: organizeSettings.classificationYaml,
path,
...scan.files[path],
});
const result = await planImport(d.ao, {
sourcePaths: [abs],
pathGuard: (p) => recheck(p, scan.rootReal),
mediaEntityId: entity,
action: config.action,
trigger: "auto",
actorType: "system",
sourceConfigId: config.id,
idempotencyKey: `source:${fingerprint}`,
});
if (result.recordId && ["running", "queued"].includes(result.status)) {
await d.ctx.engine.enqueue(
"verifyAndImport",
{ recordId: result.recordId },
{ idempotencyKey: `verifyAndImport:source:${fingerprint}`, traceId: config.id },
);
queued++;
}
if (result.status === "failed" && result.reason !== "classification_unavailable") {
const dedupeKey = `source_blocked:${config.id}:${hashJson(path)}`;
const existing = await trx
.selectFrom("decision")
.select("id")
.where("dedupe_key", "=", dedupeKey)
.where("status", "in", ["open", "investigating", "awaiting_user"])
.executeTakeFirst();
if (!existing)
await upsertDecision(d.ao, trx, {
kind: "other",
title: `来源整理未完成:${basename(path)}`,
summary: `原因:${result.reason ?? "整理计划失败"}。可从整理来源的存量整理向导处理这个文件。`,
subjectType: "organize_source",
subjectId: config.id,
dedupeKey,
evidence: {
sourceConfigId: config.id,
root: config.source.root,
path,
recordId: result.recordId,
reason: result.reason,
fingerprint,
},
options: [{ key: "dismiss", label: "忽略" }],
});
}
advance.snapshot.files[path]!.accepted = result.reason !== "classification_unavailable";
}
}
// A source edit/delete while scanning invalidates the observation instead of restoring the old baseline.
const settings = await trx
.selectFrom("settings")
.select("value")
.where("key", "=", "organize")
.executeTakeFirst();
const current = (
(settings?.value as { sources?: OrganizeSourceConfig[] })?.sources ?? (await sourceConfigs(d))
).find((s) => s.id === config.id);
if (!current || hashJson(current.source) !== hashJson(config.source))
return { baseline: false, eligible: 0, queued };
await trx
.insertInto("organize_source_snapshot")
.values({
source_id: config.id,
snapshot: jsonb(advance.snapshot),
baseline_at: row?.baseline_at ?? now,
last_scan_at: now,
last_error: null,
})
.onConflict((oc) =>
oc.column("source_id").doUpdateSet({
snapshot: jsonb(advance.snapshot),
baseline_at: row?.baseline_at ?? now,
last_scan_at: now,
last_error: null,
}),
)
.execute();
return { baseline, eligible: advance.eligible.length, queued };
} catch (error) {
await trx
.insertInto("organize_source_snapshot")
.values({
source_id: config.id,
snapshot: jsonb({ files: {} }),
baseline_at: null,
last_scan_at: now,
last_error: error instanceof Error ? error.message : String(error),
})
.onConflict((oc) =>
oc
.column("source_id")
.doUpdateSet({ last_scan_at: now, last_error: error instanceof Error ? error.message : String(error) }),
)
.execute();
return { baseline: false, eligible: 0, queued: 0 };
}
});
}
export async function queueDueSourceScans(d: FmDeps, slot: string | number) {
const rows = await d.ctx.db.selectFrom("organize_source_snapshot").select(["source_id", "last_scan_at"]).execute();
let queued = 0;
for (const config of await sourceConfigs(d)) {
if (!config.enabled || config.trigger !== "snapshot") continue;
const last = rows.find((r) => r.source_id === config.id)?.last_scan_at;
if (last && Date.now() - new Date(last).getTime() < config.intervalSec * 1000) continue;
await d.ctx.engine.enqueue(
"scanOrganizeSource",
{ sourceId: config.id },
{ idempotencyKey: `source-scan:${config.id}:${slot}`, traceId: config.id },
);
queued++;
}
return { summary: `已入队 ${queued} 个来源扫描` };
}
+31 -8
View File
@@ -76,13 +76,23 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
const db = d.ctx.db;
const rec = await db
.selectFrom("organization_record")
.select(["id", "media_entity_id", "action", "status", "source_path", "source_path_view"])
.select([
"id",
"media_entity_id",
"action",
"status",
"source_path",
"source_path_view",
"target_path",
"target_library_root_id",
"plan",
])
.where("id", "=", recordId)
.executeTakeFirst();
if (!rec) throw new FilesError("not_found", "整理记录不存在");
const mains = await db
.selectFrom("organization_file_result")
.select(["source_path"])
.select(["source_path", "target_path", "target_library_root_id"])
.where("organization_record_id", "=", rec.id)
.where("role", "=", "main")
.orderBy("ordinal")
@@ -95,13 +105,20 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
? { root: def.id as FileRootId, path: abs === def.hostPath ? "" : abs.slice(def.hostPath.length + 1) }
: null;
};
// 多个主视频都在记录的源目录下、且源目录不是登记目录本身 → 给目录(向导按目录展开);否则逐个主视频
let sources = mains.map((m) => m.source_path);
const dir = rec.source_path;
// Successful moves have removed their inputs: reorganize the existing library files.
const moved = rec.action === "move" && rec.status === "succeeded";
const targetOf = (rootId: string | null, path: string | null) => {
const root = defs.find((d) => d.libraryRootId === rootId);
return root && path && within(join(root.hostPath, path), root.hostPath) ? join(root.hostPath, path) : null;
};
let sources = moved
? mains.map((m) => targetOf(m.target_library_root_id, m.target_path)).filter((p): p is string => !!p)
: mains.map((m) => m.source_path);
const dir = moved ? targetOf(rec.target_library_root_id, rec.target_path) : rec.source_path;
if (
dir &&
mains.length > 1 &&
mains.every((m) => within(m.source_path, dir)) &&
sources.length > 1 &&
sources.every((p) => within(p, dir)) &&
!defs.some((x) => x.hostPath === dir.replace(/\/+$/, ""))
)
sources = [dir];
@@ -109,7 +126,7 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
const items: FilesRecordSourcesResult["items"] = [];
const unresolved: string[] = [];
for (const abs of [...new Set(sources)]) {
const ref = rec.source_path_view === "local" ? toRef(abs) : null;
const ref = moved || rec.source_path_view === "local" ? toRef(abs) : null;
const exists = ref
? await lstat(abs).then(
() => true,
@@ -126,5 +143,11 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
status: rec.status as OrganizationStatus,
items,
unresolved,
...(rec.plan &&
typeof rec.plan === "object" &&
"sourceConfigId" in rec.plan &&
typeof rec.plan.sourceConfigId === "string"
? { sourceConfigId: rec.plan.sourceConfigId }
: {}),
};
}
@@ -108,6 +108,10 @@ export interface SuggestOptions {
trigger: EnrichmentTrigger;
createdBy: string;
signal?: AbortSignal;
/** Authenticated PT detail already fetched by the site adapter; never fetch arbitrary URLs here. */
seedPages?: EnrichPage[];
seedCandidates?: Candidate[];
kind?: "movie" | "series";
}
// ── 域名白名单 ──
@@ -502,6 +506,9 @@ export async function suggestEnrichment(
id: row.id,
logger: ctx.logger,
doubanSubjects,
seedPages: opts.seedPages,
seedCandidates: opts.seedCandidates,
kind: opts.kind,
...(opts.signal ? { signal: opts.signal } : {}),
},
usage,
@@ -539,6 +546,9 @@ export async function suggestEnrichment(
}
export interface PipelineInput {
seedPages?: EnrichPage[];
seedCandidates?: Candidate[];
kind?: "movie" | "series";
q: { title: string; year: number | null; hints: string | null };
settings: { domains: string[]; snippetAllSites: boolean };
/** 已登记站点的域名:一律不读、不用摘要 */
@@ -573,7 +583,8 @@ export async function runEnrichPipeline(
const siteFilter = siteFilterDomains(settings.domains, SITE_FILTER_DOMAINS)
.map((d) => `site:${d}`)
.join(" OR ");
const queries = [`${baseTitle} 短剧`, `${baseTitle} 短剧${siteFilter ? ` ${siteFilter}` : ""}`].map((x) =>
const term = input.kind ? (input.kind === "movie" ? "电影" : "电视剧") : "短剧";
const queries = [`${baseTitle} ${term}`, `${baseTitle} ${term}${siteFilter ? ` ${siteFilter}` : ""}`].map((x) =>
x.slice(0, 400),
);
const sources: EnrichSource[] = [];
@@ -583,7 +594,7 @@ export async function runEnrichPipeline(
/** 交给模型、参与核对的搜索摘要(白名单网站相关的 + 摘要模式用上的) */
const snippets: EnrichSearchHit[] = [];
const snippetEv: EvidenceSource[] = [];
const cands: Candidate[] = [];
const cands: Candidate[] = [...(input.seedCandidates ?? [])];
const results: Partial<Record<SourceKind, number>> = {};
const fetchedKinds: Partial<Record<SourceKind, number>> = {};
const kindOf = new Map<string, SourceKind>();
@@ -598,7 +609,11 @@ export async function runEnrichPipeline(
const scope = `system:enrich:${id}:${i}`;
used.push(query);
usage.searchCalls++;
const hits = await deps.search(query, scope, signal);
const hits = await deps.search(query, scope, signal).catch((e) => {
if (signal?.aborted || !input.seedPages?.length) throw e;
logger.warn({ enrichmentId: id }, "网页搜索不可用,使用已获取的 PT 详情继续补全");
return [];
});
for (const h of hits) {
const host = hostOf(h.url);
if (!host || sources.some((x) => x.url === h.url)) continue;
@@ -651,8 +666,37 @@ export async function runEnrichPipeline(
}
}
}
const pages: EnrichPage[] = [];
const pageEv: EvidenceSource[] = [];
const pages: EnrichPage[] = [...(input.seedPages ?? [])];
const pageEv: EvidenceSource[] = pages.map((p) => ({
url: p.finalUrl,
host: hostOf(p.finalUrl) ?? "",
kind: "other",
page: true,
text: p.text,
image: p.image,
}));
for (const p of pages) {
sources.push({
url: p.finalUrl,
title: p.title,
domain: hostOf(p.finalUrl) ?? "",
allowed: true,
fetched: true,
skipped: null,
error: null,
kind: "other",
});
cands.push(
...extractFromPage(q.title, {
url: p.finalUrl,
host: hostOf(p.finalUrl) ?? "",
kind: "other",
title: p.title,
text: p.text,
image: p.image,
}),
);
}
// 先读标题完全对得上的,再按白名单顺序(百科、平台靠前)
const rank = (x: EnrichSource) => settings.domains.findIndex((d) => x.domain === d || x.domain.endsWith(`.${d}`));
const toFetch = sources
@@ -753,7 +797,7 @@ export async function runEnrichPipeline(
suggestion: {
fields,
// 补全面向短剧 / 网剧:缺省按剧集建档,应用时可改为电影
kind: "series",
kind: input.kind ?? "series",
queries: used,
sources,
extractor,
@@ -846,6 +890,14 @@ export async function applyEnrichment(
const same = await sameTitleEntities(trx, [title, q.title ?? ""]);
const hit = same.find((e) => year === null || e.year === null || e.year === year);
entityId = hit?.mediaEntityId ?? null;
if (entityId && actor.type === "system") {
const existing = await trx
.selectFrom("media_entity")
.select("kind")
.where("id", "=", entityId)
.executeTakeFirst();
if (existing?.kind !== kind) entityId = null;
}
}
if (!entityId) {
const e = await trx
@@ -919,7 +971,7 @@ export async function applyEnrichment(
}
let units = 0;
const episodes = want.has("episodeCount") ? (f.episodeCount?.value ?? null) : null;
if (kind === "series") {
if (kind === "series" && actor.type !== "system") {
const parsed = {
season: 1,
...(episodes ? { episodes: { start: 1, end: Math.min(episodes, 200) } } : {}),
@@ -0,0 +1,332 @@
import { recallTmdbCandidates } from "@umt/adapters";
import type { OrganizeMetadataOutput } from "@umt/contracts";
import { parseRelease, resolveIdentity } from "@umt/domain";
import { jsonb } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { type Actor, audit } from "./common.ts";
import { detailScreenshotUrl, loadDetailPage } from "./detail-page.ts";
import { applyEnrichment, type EnrichDeps, type EnrichPage, suggestEnrichment } from "./enrich.ts";
import type { Candidate } from "./enrich-sources.ts";
import { assignReleaseIdentity } from "./identity-admin.ts";
import { type EnrichmentFieldWrite, project, writeEnrichmentFields } from "./metadata.ts";
import { resolveRelease } from "./resolve.ts";
import { tmdbToCandidate } from "./tmdb-candidate.ts";
/** Only values explicitly present in a fetched PT page are considered facts. */
export function ptDetailFacts(text: string, url: string) {
const lines = text
.replace(/◎/g, "\n")
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
const label = (re: RegExp) => {
const line = lines.find((s) => re.test(s));
return (
line
?.replace(re, "")
.replace(/^\s*[::]\s*/, "")
.trim() || null
);
};
const split = (s: string | null) => (s ? s.split(/\s*[/、|,,]\s*/).filter(Boolean) : []);
const title = label(/^(?:译\s*名|中文名|中文片名|片\s*名)\s*/);
const original = label(/^(?:原\s*名|原片名|外文名)\s*/);
const names = [
...new Set([title, original, ...split(label(/^(?:又\s*名|别\s*名)\s*/))].filter((s): s is string => !!s)),
];
const yearText = label(/^(?:年\s*代|年\s*份|上映年份)\s*/);
const year = yearText?.match(/\b(19\d{2}|20\d{2})\b/)?.[0];
const synopsisAt = lines.findIndex((s) => /^(?:简\s*介|剧情简介|内容简介)/.test(s));
const synopsis =
synopsisAt >= 0
? lines
.slice(synopsisAt)
.join("\n")
.replace(/^(?:简\s*介|剧情简介|内容简介)\s*[::]?\s*/, "")
.slice(0, 2000)
: null;
const base = {
sourceUrl: url,
host: new URL(url).hostname,
kind: "other" as const,
confidence: 0.8,
verbatim: true,
by: "rule" as const,
};
const candidates: Candidate[] = [];
const add = (field: Candidate["field"], value: string | number | null) => {
if (value !== null && value !== "") candidates.push({ ...base, field, value });
};
add("title", title);
for (const name of names) add("aliases", name);
add("year", year ? Number(year) : null);
const episodeCount = label(/^(?:集\s*数|总集数)\s*/)?.match(/\d+/)?.[0];
if (episodeCount && Number(episodeCount) > 0 && Number(episodeCount) <= 2000)
add("episodeCount", Number(episodeCount));
add("synopsis", synopsis);
for (const g of split(label(/^(?:类\s*别|类\s*型)\s*/))) add("genre", g);
for (const name of split(label(/^(?:主\s*演|演员)\s*/))) add("cast", name);
const countries = split(label(/^(?:产\s*地|制片国家\/地区|国家\/地区|地\s*区)\s*/));
const language = label(/^(?:语\s*言|对白语言)\s*/);
const langCodes: Record<string, string> = {
汉语普通话: "zh",
普通话: "zh",
中文: "zh",
粤语: "zh",
英语: "en",
日语: "ja",
韩语: "ko",
法语: "fr",
德语: "de",
西班牙语: "es",
};
const countryCodes: Record<string, string> = {
中国大陆: "CN",
中国: "CN",
中国香港: "HK",
香港: "HK",
中国台湾: "TW",
台湾: "TW",
美国: "US",
英国: "GB",
日本: "JP",
韩国: "KR",
法国: "FR",
德国: "DE",
};
const extras: { field: EnrichmentFieldWrite["field"]; value: unknown }[] = [];
if (original) extras.push({ field: "originalTitle", value: original });
if (countries.length) extras.push({ field: "originCountry", value: countries.map((s) => countryCodes[s] ?? s) });
const languageName = split(language)[0];
if (languageName && langCodes[languageName])
extras.push({ field: "originalLanguage", value: langCodes[languageName] });
const directors = split(label(/^(?:导\s*演)\s*/));
if (directors.length)
extras.push({
field: "crew",
value: directors.map((name, i) => ({
id: -(i + 1),
name,
originalName: null,
role: "Director",
profilePath: null,
})),
});
return { candidates, extras, names, title, year: year ? Number(year) : null };
}
/** Reuses identity matching, audited enrichment, field projection and the existing local entity identity. */
export async function resolveOrganizeMetadata(
rt: SourcesRuntime,
sourceReleaseId: string,
deps: EnrichDeps,
opts: { actor: Actor; signal?: AbortSignal },
): Promise<OrganizeMetadataOutput> {
const { ctx } = rt;
const result = (
mediaEntityId: string | null,
mode: "tmdb" | "local" | null,
reason: string | null,
enrichmentId: string | null = null,
): OrganizeMetadataOutput => ({ mediaEntityId, mode, reason, enrichmentId });
const modeOf = async (id: string) =>
(await ctx.db
.selectFrom("external_identity")
.select("id")
.where("media_entity_id", "=", id)
.where("provider", "=", "tmdb")
.where("status", "=", "accepted")
.executeTakeFirst())
? ("tmdb" as const)
: ("local" as const);
const release = await ctx.db
.selectFrom("source_release")
.selectAll()
.where("id", "=", sourceReleaseId)
.executeTakeFirst();
if (!release) return result(null, null, "release_not_found");
if (release.media_entity_id) return result(release.media_entity_id, await modeOf(release.media_entity_id), null);
// Failure to query TMDB is not evidence that no TMDB record exists.
const normal = await resolveRelease(rt, { sourceReleaseId, force: true, trigger: "manual", escalate: true });
if (normal.mediaEntityId) return result(normal.mediaEntityId, await modeOf(normal.mediaEntityId), null);
if (normal.outcome === "lookup_failed" || /error|failed|unavailable/.test(normal.reason ?? ""))
return result(null, null, normal.reason);
const pool = await ctx.db
.selectFrom("identity_candidate")
.select("id")
.where("source_release_id", "=", sourceReleaseId)
.executeTakeFirst();
if (!pool) return result(null, null, "identity_pool_missing");
const page = await loadDetailPage(rt, sourceReleaseId);
const site = await ctx.db.selectFrom("site").select("base_url").where("id", "=", release.site_id).executeTakeFirst();
const raw = release.raw as { _site?: { detailUrl?: string }; _detail?: { sourceUrl?: string } };
const candidateUrl = raw?._detail?.sourceUrl ?? raw?._site?.detailUrl;
let seedUrl: string | null = null;
if (site?.base_url) {
const u = new URL(
candidateUrl || `details.php?id=${encodeURIComponent(release.site_release_id)}`,
`${site.base_url.replace(/\/$/, "")}/`,
);
if (u.origin === new URL(site.base_url).origin) {
u.username = "";
u.password = "";
u.hash = "";
for (const key of [...u.searchParams.keys()])
if (/passkey|token|cookie|secret|api.?key|auth/i.test(key)) u.searchParams.delete(key);
seedUrl = u.toString();
}
}
const facts = page?.descriptionText && seedUrl ? ptDetailFacts(page.descriptionText, seedUrl) : null;
const seedPages: EnrichPage[] =
page?.descriptionText && seedUrl
? [
{
url: seedUrl,
finalUrl: seedUrl,
title: facts?.title ?? release.title,
text: page.descriptionText,
image: await detailScreenshotUrl(rt, sourceReleaseId, 0),
},
]
: [];
if (facts?.title && seedPages[0]?.image && seedUrl)
facts.candidates.push({
field: "posterUrl",
value: seedPages[0].image,
sourceUrl: seedUrl,
host: new URL(seedUrl).hostname,
kind: "other",
confidence: 0.6,
verbatim: true,
by: "rule",
});
const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {});
const kind =
parsed.mediaTypeHint === "tv" ||
parsed.season !== undefined ||
/剧集|电视剧|连续剧/.test(release.category_raw ?? "") ||
facts?.candidates.some((c) => c.field === "episodeCount")
? ("series" as const)
: ("movie" as const);
const out = await suggestEnrichment(
ctx,
deps,
{ poolEntryId: pool.id, fresh: true },
{
trigger: "auto",
createdBy: "system:organize",
...(opts.signal ? { signal: opts.signal } : {}),
seedPages,
seedCandidates: facts?.candidates ?? [],
kind,
},
);
if (out.status === "failed" || !out.suggestion)
return result(null, null, out.error ?? "metadata_no_evidence", out.enrichmentId);
const fields = out.suggestion.fields;
for (const key of ["aliases", "genre", "cast"] as const) fields[key] = fields[key].filter((v) => v.verbatim);
await ctx.db
.updateTable("metadata_enrichment")
.set({ suggestion: jsonb(out.suggestion) })
.where("id", "=", out.enrichmentId)
.execute();
const names = [
...new Set(
[
fields.title?.value,
...fields.aliases.filter((v) => v.verbatim).map((v) => v.value),
...(facts?.names ?? []),
].filter((s): s is string => !!s),
),
];
const tmdb = await rt.tmdb();
if (tmdb && names.length) {
const year = fields.year?.verbatim ? fields.year.value : (facts?.year ?? parsed.year);
const candidates = await recallTmdbCandidates(tmdb, {
names,
type: kind === "movie" ? "movie" : "tv",
...(year ? { year } : {}),
maxNames: 4,
});
const matched = new Map<string, NonNullable<(typeof candidates)[number]>>();
let ambiguous = false;
for (const name of names) {
const query = { ...parsed, title: name, titleCn: undefined, titleEn: undefined, ...(year ? { year } : {}) };
const r = resolveIdentity(query, candidates.map(tmdbToCandidate));
if (r.status === "ambiguous") ambiguous = true;
if (r.status === "auto" && r.best) {
const c = candidates.find(
(c) => c.externalId === r.best!.candidate.externalId && c.entityType === r.best!.candidate.entityType,
);
if (c) matched.set(`${c.entityType}:${c.externalId}`, c);
}
}
if (matched.size === 1 && !ambiguous) {
const c = [...matched.values()][0]!;
const assigned = await assignReleaseIdentity(
rt,
sourceReleaseId,
{
external: { provider: "tmdb", entityType: c.entityType, externalId: c.externalId },
lock: false,
reason: `整理 Agent 别名补全 ${out.enrichmentId}`,
},
opts.actor,
);
return result(assigned.mediaEntityId, "tmdb", null, out.enrichmentId);
}
if (matched.size > 1 || ambiguous) return result(null, null, "metadata_tmdb_ambiguous", out.enrichmentId);
}
if (normal.outcome === "ambiguous" || normal.outcome === "proposed")
return result(null, null, "metadata_tmdb_ambiguous", out.enrichmentId);
// No invented placeholder entity: require a supported title and at least one additional factual field.
if (
!fields.title?.verbatim ||
!(
fields.year?.verbatim ||
fields.synopsis?.verbatim ||
fields.cast.some((v) => v.verbatim) ||
fields.genre.some((v) => v.verbatim)
)
)
return result(null, null, "metadata_insufficient_evidence", out.enrichmentId);
const allowedFields = Object.entries(fields)
.filter(([, v]) => (Array.isArray(v) ? v.some((x) => x.verbatim) : v?.verbatim))
.map(([k]) => k) as NonNullable<Parameters<typeof applyEnrichment>[2]["fields"]>;
const applied = await applyEnrichment(
rt,
pool.id,
{ enrichmentId: out.enrichmentId, kind, fields: allowedFields, lock: false },
opts.actor,
);
if (facts?.extras.length) {
const writes: EnrichmentFieldWrite[] = facts.extras.map((f) => ({
...f,
source: "web",
evidence: {
sourceUrl: seedUrl,
enrichmentId: out.enrichmentId,
evidenceLevel: "rule_confirmed",
extractor: "rule",
},
}));
await ctx.db.transaction().execute(async (trx) => {
await writeEnrichmentFields(trx, applied.mediaEntityId, writes, `enrich:${out.enrichmentId}:pt`, rt.now());
await project(trx, applied.mediaEntityId, rt.now());
await audit(
trx,
opts.actor,
"metadata.organize_local",
{ type: "media_entity", id: applied.mediaEntityId },
{ sourceReleaseId, enrichmentId: out.enrichmentId },
);
});
}
// Automatic fallback must remain revisitable by future identity review.
await ctx.db
.updateTable("source_release")
.set({ resolution_locked: false })
.where("id", "=", sourceReleaseId)
.execute();
return result(applied.mediaEntityId, await modeOf(applied.mediaEntityId), null, out.enrichmentId);
}
@@ -261,6 +261,11 @@ function escalationFetchPolicy(
return "no";
}
const RESOLUTION_RANK: Record<IdentityResolution["status"], number> = { no_match: 0, ambiguous: 1, auto: 2 };
/** 升级链的结果只在更确定时采用,原有歧义候选不被更差的结果冲掉 */
const betterResolution = (next: IdentityResolution, cur: IdentityResolution) =>
RESOLUTION_RANK[next.status] > RESOLUTION_RANK[cur.status];
interface EscalationOutcome {
evidence: EscalationEvidence;
resolution?: IdentityResolution;
@@ -305,9 +310,11 @@ async function escalateIdentity(
const remote = await tmdbByExplicitIds(tmdb, after);
if (remote.length) {
const res = resolveIdentity(p, [...local, ...remote.map(tmdbToCandidate)], trustedOf(after));
best = { evidence: ev, resolution: res, remote, parsed: p };
ev.via = "site_id";
if (res.status === "auto") return best;
if (res.status !== "no_match") {
best = { evidence: ev, resolution: res, remote, parsed: p };
ev.via = "site_id";
if (res.status === "auto") return best;
}
}
}
// 2) 豆瓣 ID → 缓存的豆瓣条目(标题 + 年份)→ TMDB
@@ -317,7 +324,8 @@ async function escalateIdentity(
if (d) {
ev.douban = { id: d.subject.id, title: d.subject.title, year: d.subject.year, source: d.subject.source };
const res = resolveIdentity(d.parsed, [...local, ...d.remote.map(tmdbToCandidate)]);
if (res.status === "auto" || !best || (best.resolution?.status === "no_match" && res.status !== "no_match")) {
// 与 MP 识别兜底一致:无匹配的结果不顶替任何已有结果
if (res.status === "auto" || (!best && res.status !== "no_match")) {
ev.via = "douban";
best = { evidence: ev, resolution: res, remote: d.remote, parsed: d.parsed };
}
@@ -445,7 +453,7 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
try {
const up = await escalateIdentity(rt, release, parsed, local, siteIds, policy);
escalation = up.evidence;
if (up.resolution && up.remote && up.parsed) {
if (up.resolution && up.remote && up.parsed && betterResolution(up.resolution, resolution)) {
resolution = up.resolution;
remote = up.remote;
scored = up.parsed;
@@ -37,6 +37,24 @@ describe.skipIf(!hasPg)("POST /engine/runs/{id}/retry", () => {
await h?.close();
});
it("工作流多值筛选保留排队核验与独立刮削,单值筛选兼容", async () => {
const table = sql.raw(`"${h.engineSchema}".workflow_runs`);
await sql`insert into ${table} (namespace_id, id, workflow_name, version, status, config, context, input, attempts, created_at, updated_at)
values ('default', 'scrape-queue', 'scrapeMedia', 'v2', 'pending', '{}'::jsonb, null, '{"input":{}}'::jsonb, 0, now(), now())`.execute(
h.db,
);
try {
const both = await json(
await admin("GET", "/engine/runs?workflowName=verifyAndImport,scrapeMedia&status=pending"),
);
expect(both.data.items.map((run: { id: string }) => run.id).sort()).toEqual(["p1", "scrape-queue"]);
const single = await json(await admin("GET", "/engine/runs?workflowName=verifyAndImport&status=pending"));
expect(single.data.items.map((run: { id: string }) => run.id)).toEqual(["p1"]);
} finally {
await sql`delete from ${table} where id = 'scrape-queue'`.execute(h.db);
}
});
it("失败的 run:按原任务名与原始输入重新入队,序号递增,写审计", async () => {
const r1 = await json(await admin("POST", "/engine/runs/f1/retry"));
expect(r1.data).toEqual({ runId: "new-1", retryOf: "f1", attempt: 1 });
+102 -17
View File
@@ -10,7 +10,7 @@ import {
waitFor,
} from "./harness.ts";
// E10(原编号与 e6-auto-recover 重复,W6-B 改名) W4-A 前后端集成端点:手动提交下载(与自动同流程)、候选评估去重、库存叠加补字段、
// E10(原编号与 e6-auto-recover 重复,W6-B 改名) W4-A 前后端集成端点:独立手动下载与自动订阅下载分别验证、候选评估去重、库存叠加补字段、
// 整理记录 downloadTaskId、只读资产/产物端点、偏好标签修订历史、列表带作品标题、事件 JSON 补读。
const hasPg = await pgAvailable();
@@ -29,13 +29,17 @@ describe.skipIf(!hasPg)("E10 W4-A 集成端点", () => {
await e?.close();
}, 60_000);
it("POST /acquisition-plans:硬条件不满足 → 不提交并给出原因;重复评估只留一条候选记录", async () => {
it("download.plan:自动订阅硬条件不满足 → 无计划并给出原因;重复评估只留一条候选记录", async () => {
for (let i = 0; i < 2; i++) {
const r = await e.api("POST", "/acquisition-plans", { sourceReleaseId: target.r720, slotId: target.slotId });
const r = await e.api("POST", "/capabilities/download.plan/invoke", {
input: { sourceReleaseId: target.r720, slotId: target.slotId },
});
expect(r.status).toBe(200);
expect(r.body.data).toMatchObject({ submitted: false, plan: null, taskId: null });
expect(r.body.data.rejected.length).toBeGreaterThan(0);
expect(r.body.data.rejected[0].slotId).toBe(target.slotId);
const out = r.body.data.output ?? r.body.data;
expect(out.plan).toBeNull();
expect(out.slot.id).toBe(target.slotId);
expect(out.rejected.length).toBeGreaterThan(0);
expect(await e.db.selectFrom("download_task").select("id").execute()).toHaveLength(0);
}
const n = await sql<{ n: number }>`select count(*)::int as n from artifact
where title = 'candidate_evaluation' and subject_id = ${target.slotId}
@@ -53,23 +57,30 @@ describe.skipIf(!hasPg)("E10 W4-A 集成端点", () => {
expect(c.reasons.length).toBeGreaterThan(0);
});
it("POST /acquisition-plans:按作品选 Slot,走 download.plan → download.submit,qB 收到种子;整理记录带 downloadTaskId", async () => {
const r = await e.api("POST", "/acquisition-plans", {
sourceReleaseId: target.r1080,
entityId: target.entityId,
note: "工作台手动",
it("自动订阅 download.plan → download.submit 推进 Slot,qB 收到种子;整理记录带 downloadTaskId", async () => {
const planned = await e.api("POST", "/capabilities/download.plan/invoke", {
input: { sourceReleaseId: target.r1080, slotId: target.slotId },
});
expect(r.status).toBe(200);
expect(r.body.data).toMatchObject({ submitted: true, slotId: target.slotId });
expect(r.body.data.plan.status).toMatch(/reserved|submitted/);
expect(r.body.data.taskId).toBeTruthy();
expect(planned.status).toBe(200);
const out = planned.body.data.output ?? planned.body.data;
expect(out.plan).toMatchObject({ slotId: target.slotId, sourceReleaseId: target.r1080 });
const grant = await e.api("POST", "/automation-grants", {
action: "download.submit",
scope: { mediaEntityIds: [target.entityId] },
reason: "e10 自动订阅收录验证",
});
expect(grant.status).toBe(201);
const submitted = await e.api("POST", "/capabilities/download.submit/invoke", { input: { planId: out.plan.id } });
expect(submitted.status).toBe(200);
const accepted = submitted.body.data.output ?? submitted.body.data;
expect(accepted.planId).toBe(out.plan.id);
expect(accepted.taskId).toBeTruthy();
const audits = await e.db
.selectFrom("audit_event")
.select(["action", "target_id"])
.where("action", "in", ["capability.invoke", "acquisition_plan.manual_submit"])
.where("action", "=", "capability.invoke")
.execute();
expect(audits.filter((a) => a.target_id === "download.submit")).toHaveLength(1);
expect(audits.some((a) => a.action === "acquisition_plan.manual_submit")).toBe(true);
await completeDownload(e, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP");
const rec = await waitFor(
@@ -104,6 +115,80 @@ describe.skipIf(!hasPg)("E10 W4-A 集成端点", () => {
expect(slots.body.data.items[0].currentAsset).toMatchObject({ resolution: "1080p" });
});
it("POST /acquisition-plans:没有订阅和 Slot 的作品也能独立手动下载", async () => {
const standalone = await e.db
.insertInto("media_entity")
.values({ kind: "movie", title: "独立手动电影", year: 2025, status: "active" })
.returning("id")
.executeTakeFirstOrThrow();
const site = await e.db
.selectFrom("source_release")
.select("site_id")
.where("id", "=", target.r720)
.executeTakeFirstOrThrow();
const release = await e.db
.insertInto("source_release")
.values({
site_id: site.site_id,
site_release_id: "880003",
title: "Manual.Standalone.2025.720p.WEB-DL.x264-GRP",
size_bytes: 300000,
media_entity_id: standalone.id,
resolution_status: "resolved",
raw: JSON.stringify({ _site: { hr: false } }),
})
.returning("id")
.executeTakeFirstOrThrow();
const before = await e.db.selectFrom("subscription").select("id").execute();
const slotBefore = await e.db
.selectFrom("acquisition_slot")
.select(["current_asset_id", "revision"])
.where("id", "=", target.slotId)
.executeTakeFirstOrThrow();
const r = await e.api("POST", "/acquisition-plans", {
sourceReleaseId: release.id,
entityId: standalone.id,
note: "工作台独立手动",
});
expect(r.status).toBe(200);
expect(r.body.data).toMatchObject({
submitted: true,
slotId: null,
rejected: [],
plan: { slotId: null, evaluation: { manual: true, mediaEntityId: standalone.id } },
});
expect(r.body.data.taskId).toBeTruthy();
await waitFor(
async () =>
e.db
.selectFrom("download_task")
.selectAll()
.where("import_plan_id", "=", r.body.data.plan.id)
.executeTakeFirst(),
60000,
"独立手动下载已提交",
);
expect(await e.db.selectFrom("subscription").select("id").execute()).toEqual(before);
expect(
await e.db.selectFrom("acquisition_slot").select("id").where("media_entity_id", "=", standalone.id).execute(),
).toHaveLength(0);
expect(
await e.db
.selectFrom("acquisition_slot")
.select(["current_asset_id", "revision"])
.where("id", "=", target.slotId)
.executeTakeFirstOrThrow(),
).toEqual(slotBefore);
expect(
await e.db
.selectFrom("audit_event")
.select("id")
.where("action", "=", "acquisition_plan.manual_submit")
.where("target_id", "=", r.body.data.plan.id)
.executeTakeFirst(),
).toBeTruthy();
}, 90000);
it("GET /artifacts:列表与详情", async () => {
const list = await e.api("GET", "/artifacts?kind=evidence");
expect(list.status).toBe(200);
+11 -2
View File
@@ -1,3 +1,4 @@
import { CAPABILITY_IDS } from "@umt/contracts";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { delegateScript } from "./fixtures/scripts.ts";
import {
@@ -45,8 +46,16 @@ describe.skipIf(!hasPg)("E2 Agent 委托闭环(fake 模型 + 真实能力/授权
return r.body.data.id as string;
};
it("启动断言:41 个能力全部注册,并在能力目录里可见", async () => {
expect(e.booted.ctx.capabilities.list()).toHaveLength(41);
it("启动断言:合同中的能力全部注册,整理兜底不可重放,并在能力目录里可见", async () => {
expect(
e.booted.ctx.capabilities
.list()
.map((c) => c.descriptor.id)
.sort(),
).toEqual([...CAPABILITY_IDS].sort());
expect(
e.booted.ctx.capabilities.list().find((c) => c.descriptor.id === "metadata.resolve_organize")?.descriptor,
).toMatchObject({ kind: "execute", replay: "never" });
const r = await e.api("GET", "/capabilities");
expect(r.body.data.items.map((c: { id: string }) => c.id).sort()).toEqual(
e.booted.ctx.capabilities
+6 -3
View File
@@ -1,6 +1,7 @@
import { createQbClient } from "@umt/adapters";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { torrentFor } from "./fixtures/modules.ts";
import { createE2E, type E2E, pgAvailable, seedBase, waitFor } from "./harness.ts";
@@ -81,9 +82,11 @@ describe.skipIf(!hasPg)("E8 AT24 标签移除 / 任务消失", () => {
}, 60_000);
const submit = async (k: K) => {
const r = await e.api("POST", "/acquisition-plans", { sourceReleaseId: ids[k].releaseId });
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data.submitted).toBe(true);
const planned = await evaluateAcquisition(deps(), {
sourceReleaseId: ids[k].releaseId,
acquisitionSlotId: ids[k].slotId,
});
expect(planned.plans, JSON.stringify(planned)).toHaveLength(1);
await waitFor(async () => e.qb.get(ids[k].hash), 60_000, `qB 收到 ${k}`);
const row = await waitFor(
async () =>
@@ -254,7 +254,7 @@ describe.skipIf(!hasPg)("E9 连续更新剧集:Slot 忙时到达的集全部入
30_000,
"订阅完成",
);
expect(sub.status_reason).toBe("slot_satisfied");
expect(sub.status_reason).toBe("download_accepted");
const slot = await e.db
.selectFrom("acquisition_slot")
.selectAll()
@@ -263,7 +263,7 @@ describe.skipIf(!hasPg)("E9 连续更新剧集:Slot 忙时到达的集全部入
expect(slot.status).toBe("satisfied");
const hist = await e.api("GET", `/subscription-history?subscriptionId=${subId}`);
expect(hist.body.data.items).toHaveLength(1);
expect(hist.body.data.items[0]).toMatchObject({ reason: "slot_satisfied", mediaEntityId: entityId });
expect(hist.body.data.items[0]).toMatchObject({ reason: "download_accepted", mediaEntityId: entityId });
// 进度 API:三集已覆盖,总集数已知
const prog = await e.api("GET", `/subscriptions/${subId}/progress`);
expect(prog.body.data.seasons[0]).toMatchObject({ episodeTotal: 3, totalKnown: true, complete: true });
@@ -0,0 +1,175 @@
import { randomUUID } from "node:crypto";
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "./harness.ts";
const skip = !(await pgAvailable());
describe.skipIf(skip)("file manager in-place import", () => {
let h: FmHarness;
beforeAll(async () => {
h = await createFmHarness("in_place", {}, { runTransfers: false });
});
afterAll(async () => {
await h?.close();
});
async function source(label: string, kind: "movie" | "series" = "movie") {
const title = `${label}-${randomUUID().slice(0, 8)}`;
const entity = await seedEntity(h.db, { title, kind, year: 2024 });
await mkdir(join(h.lib, title), { recursive: true });
const path = `${title}/${kind === "movie" ? "original.name.mkv" : "original.name.S03E04.mkv"}`;
await writeFile(join(h.lib, path), "untouched video");
return { entity, path, items: [{ root: h.libRoot, path, mediaEntityId: entity }] };
}
async function preview(items: unknown[], extra: Record<string, unknown> = {}) {
const payload = { items, action: "in_place", minSizeBytes: 0, scrapeAfterImport: false, ...extra };
const r = await h.api("POST", "/files/organize", { ...payload, idempotencyKey: key(), dryRun: true });
expect(r.status).toBe(200);
return { payload, data: r.body.data };
}
async function queue(items: unknown[], extra: Record<string, unknown> = {}) {
const r = await preview(items, extra);
expect(r.data.groups[0].error).toBeNull();
const accepted = await h.api("POST", "/files/organize", {
...r.payload,
idempotencyKey: key(),
dryRun: false,
planIds: r.data.groups.map((g: { planId: string }) => g.planId),
});
expect(accepted.status).toBe(200);
expect(accepted.body.data.groups[0].error).toBeNull();
return { ...r, recordId: accepted.body.data.recordIds[0] as string };
}
it("preserves movie and matched subtitle bytes, paths and mtimes; registers once and never transfers", async () => {
const s = await source("Movie");
const subtitle = s.path.replace(/\.mkv$/, ".chs.srt");
await writeFile(join(h.lib, subtitle), "untouched subtitle");
const before = await Promise.all([s.path, subtitle].map((p) => stat(join(h.lib, p))));
const r = await queue([{ root: h.libRoot, path: s.path.split("/")[0]!, mediaEntityId: s.entity }], {
naming: "template",
onExisting: "replace_with_verify",
});
expect(r.data.groups[0].files).toHaveLength(2);
expect(r.data.groups[0].files.every((f: { source: string; target: string }) => f.source === f.target)).toBe(true);
expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded");
expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded");
expect(await readFile(join(h.lib, s.path), "utf8")).toBe("untouched video");
expect(await readFile(join(h.lib, subtitle), "utf8")).toBe("untouched subtitle");
const after = await Promise.all([s.path, subtitle].map((p) => stat(join(h.lib, p))));
expect(after.map((f) => [f.size, f.mtimeMs, f.ino])).toEqual(before.map((f) => [f.size, f.mtimeMs, f.ino]));
expect(await readdir(join(h.lib, s.path.split("/")[0]!))).toEqual(["original.name.chs.srt", "original.name.mkv"]);
const assets = await h.db.selectFrom("media_asset").selectAll().where("media_entity_id", "=", s.entity).execute();
expect(assets).toHaveLength(2);
expect(assets.find((a) => a.kind === "subtitle")?.parent_asset_id).toBe(assets.find((a) => a.kind === "video")?.id);
const ops = await h.db
.selectFrom("asset_operation")
.select(["kind", "phase"])
.where("organization_record_id", "=", r.recordId)
.orderBy("ordinal")
.execute();
expect(ops).toEqual([
{ kind: "register", phase: "verify" },
{ kind: "register", phase: "verify" },
]);
expect(
await h.db.selectFrom("path_binding").select("id").where("media_entity_id", "=", s.entity).execute(),
).toEqual([]);
const again = await queue(s.items);
await executeRecord(h.module.deps().ao, again.recordId);
expect(
await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", s.entity).execute(),
).toHaveLength(2);
// 文件管理装配不挂整理记录路由:直接查记录表
const history = await h.db
.selectFrom("organization_record")
.select("action")
.where("media_entity_id", "=", s.entity)
.execute();
expect(history.some((item) => item.action === "in_place")).toBe(true);
});
it("registers TV episodes in their existing directory and honors manual season/episode", async () => {
const s = await source("TV", "series");
const r = await queue(s.items.map((i) => ({ ...i, season: 2, episodes: [7] })));
expect(r.data.groups[0].files[0].target).toBe(join(h.lib, s.path));
expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded");
const units = await h.db
.selectFrom("content_unit")
.select(["season_number", "episode_number"])
.where("media_entity_id", "=", s.entity)
.execute();
expect(units.some((u) => u.season_number === 2 && u.episode_number === 7)).toBe(true);
});
it("scrapes only the newly registered file, retaining its organization association on resume", async () => {
const s = await source("Scrape");
await h.db
.insertInto("external_identity")
.values({
media_entity_id: s.entity,
provider: "tmdb",
entity_type: "movie",
external_id: "123456",
status: "accepted",
})
.execute();
await writeFile(join(h.lib, s.path.split("/")[0]!, "Unrelated.mkv"), "unrelated video");
const r = await queue(s.items, { scrapeAfterImport: true });
await executeRecord(h.module.deps().ao, r.recordId);
await executeRecord(h.module.deps().ao, r.recordId);
const scrapes = await h.db
.selectFrom("scrape_record")
.select(["path", "organization_record_id"])
.where("organization_record_id", "=", r.recordId)
.execute();
expect(scrapes).toEqual([{ path: s.path, organization_record_id: r.recordId }]);
expect(await readFile(join(h.lib, s.path.split("/")[0]!, "Unrelated.mkv"), "utf8")).toBe("unrelated video");
});
it("rejects an unregistered download directory without submitting a task", async () => {
const entity = await seedEntity(h.db, { kind: "movie", title: "Outside", year: 2024 });
await writeFile(join(h.dl, "outside.mkv"), "video");
const r = await h.api("POST", "/files/organize", {
items: [{ root: h.dlRoot, path: "outside.mkv", mediaEntityId: entity }],
action: "in_place",
minSizeBytes: 0,
idempotencyKey: key(),
dryRun: true,
});
expect(r.status).toBe(400);
expect(await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", entity).execute()).toEqual(
[],
);
});
it("does not bind a file to another work or import changed files after queue acceptance", async () => {
const s = await source("Changed");
const r = await queue(s.items);
await writeFile(join(h.lib, s.path), "changed video longer");
expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("failed");
expect(await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", s.entity).execute()).toEqual(
[],
);
const original = await source("Owned");
const a = await queue(original.items);
await executeRecord(h.module.deps().ao, a.recordId);
const other = await seedEntity(h.db, { kind: "movie", title: "Other", year: 2024 });
const conflict = await preview([{ root: h.libRoot, path: original.path, mediaEntityId: other }]);
expect(conflict.data.groups[0].error).not.toBeNull();
expect(await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", other).execute()).toEqual(
[],
);
});
it("registers read-only library roots without writing metadata", async () => {
const s = await source("ReadOnly");
const rootId = h.libRoot.slice(4);
await h.db.updateTable("library_root").set({ management_mode: "register_only" }).where("id", "=", rootId).execute();
try {
const r = await queue(s.items, { scrapeAfterImport: true });
expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded");
expect(
await h.db.selectFrom("scrape_record").select("id").where("organization_record_id", "=", r.recordId).execute(),
).toEqual([]);
expect(await readdir(join(h.lib, s.path.split("/")[0]!))).toEqual(["original.name.mkv"]);
} finally {
await h.db.updateTable("library_root").set({ management_mode: "managed" }).where("id", "=", rootId).execute();
}
});
});
@@ -0,0 +1,277 @@
import { randomUUID } from "node:crypto";
import { access, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "./harness.ts";
const skip = !(await pgAvailable());
describe.skipIf(skip)("manual organize history and per-import scraping", () => {
let h: FmHarness;
beforeAll(async () => {
h = await createFmHarness("manual_options", {}, { runTransfers: false });
});
afterAll(async () => {
await h?.close();
});
async function source(label: string, local = false) {
const title = `${label} ${randomUUID().slice(0, 8)}`;
const entity = await seedEntity(h.db, { kind: "movie", title, year: 2024 });
if (local) {
await h.db
.updateTable("media_entity")
.set({
metadata: JSON.stringify({
overview: { value: "Local synopsis", source: "web", sources: [], locked: false },
}),
})
.where("id", "=", entity)
.execute();
} else {
await h.db
.insertInto("external_identity")
.values({
media_entity_id: entity,
provider: "tmdb",
entity_type: "movie",
external_id: String(Math.floor(Math.random() * 1_000_000)),
status: "accepted",
})
.execute();
}
const path = `${title.replaceAll(" ", ".")}.2024.1080p.mkv`;
await writeFile(join(h.dl, path), `video-${title}`);
return { entity, path, items: [{ root: h.dlRoot, path, mediaEntityId: entity }] };
}
async function organize(items: unknown[], extra: Record<string, unknown> = {}) {
const payload = { action: "copy", items, minSizeBytes: 0, ...extra };
const dry = await h.api("POST", "/files/organize", { ...payload, idempotencyKey: key(), dryRun: true });
expect(dry.status).toBe(200);
expect(dry.body.data.groups[0].error).toBeNull();
const exec = await h.api("POST", "/files/organize", {
...payload,
idempotencyKey: key(),
dryRun: false,
planIds: dry.body.data.groups.map((g: { planId: string }) => g.planId),
});
expect(exec.status).toBe(200);
expect(exec.body.data.groups[0].error).toBeNull();
return { dry: dry.body.data, exec: exec.body.data };
}
it("successful move reorganizes the target; failed move and copy retain their source", async () => {
const s = await source("Moved");
const r = await organize(s.items, { action: "move", scrapeAfterImport: false });
const rid = r.exec.recordIds[0];
expect((await executeRecord(h.module.deps().ao, rid)).status).toBe("succeeded");
await expect(access(join(h.dl, s.path))).rejects.toThrow();
const refs = await h.api("GET", `/files/record-sources?recordId=${rid}`);
expect(refs.status).toBe(200);
expect(refs.body.data.unresolved).toEqual([]);
expect(refs.body.data.items).toEqual([
{ root: h.libRoot, path: r.dry.groups[0].files[0].target.slice(h.lib.length + 1) },
]);
expect(await readFile(r.dry.groups[0].files[0].target, "utf8")).toContain("video-Moved");
const c = await source("Copied");
const copy = await organize(c.items, { scrapeAfterImport: false });
const crid = copy.exec.recordIds[0];
await executeRecord(h.module.deps().ao, crid);
expect((await h.api("GET", `/files/record-sources?recordId=${crid}`)).body.data.items).toEqual([
{ root: h.dlRoot, path: c.path },
]);
await h.db
.updateTable("organization_record")
.set({ action: "move", status: "failed" })
.where("id", "=", crid)
.execute();
expect((await h.api("GET", `/files/record-sources?recordId=${crid}`)).body.data.items).toEqual([
{ root: h.dlRoot, path: c.path },
]);
});
it("successful-history skip includes matched sidecars and does not enqueue work; latest failure does not hide success", async () => {
const s = await source("History");
const sidecar = s.path.replace(/\.mkv$/, ".srt");
await writeFile(join(h.dl, sidecar), "subtitle");
const first = await organize(s.items, { scrapeAfterImport: false });
await executeRecord(h.module.deps().ao, first.exec.recordIds[0]);
const old = await h.db
.selectFrom("organization_record")
.selectAll()
.where("id", "=", first.exec.recordIds[0])
.executeTakeFirstOrThrow();
const laterId = randomUUID();
await h.db
.insertInto("organization_record")
.values({
...old,
id: laterId,
change_seq: undefined,
status: "failed",
reason: "later_failure",
created_at: new Date(Date.now() + 1000),
})
.execute();
const main = await h.db
.selectFrom("organization_file_result")
.selectAll()
.where("organization_record_id", "=", old.id)
.where("role", "=", "main")
.executeTakeFirstOrThrow();
await h.db
.insertInto("organization_file_result")
.values({ ...main, id: randomUUID(), organization_record_id: laterId, status: "failed" })
.execute();
const identify = await h.api("POST", "/files/identify", {
items: [{ root: h.dlRoot, path: s.path }],
minSizeBytes: 0,
});
expect(identify.body.data.items[0].records).toMatchObject({ successfulCount: 1, lastStatus: "failed" });
const before = h.engine.enqueued.filter((x) => x.task === "verifyAndImport").length;
const second = await organize(s.items, { skipSuccessfulRecords: true, scrapeAfterImport: false });
expect(second.dry.groups[0].files).toHaveLength(2);
expect(
second.dry.groups[0].files.every(
(f: { action: string; skipReason: string }) => f.action === "skip" && f.skipReason === "successful_record",
),
).toBe(true);
expect(second.exec.recordIds).toEqual([]);
expect(h.engine.enqueued.filter((x) => x.task === "verifyAndImport")).toHaveLength(before);
const normal = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
items: s.items,
minSizeBytes: 0,
dryRun: true,
});
expect(
normal.body.data.groups[0].files.some((f: { skipReason: string }) => f.skipReason === "successful_record"),
).toBe(false);
});
it.each(["failed", "partial"])("%s records do not count as successful histories", async (status) => {
const s = await source(status);
const first = await organize(s.items, { scrapeAfterImport: false });
await executeRecord(h.module.deps().ao, first.exec.recordIds[0]);
await h.db
.updateTable("organization_record")
.set({ status: status as "failed" | "partial" })
.where("id", "=", first.exec.recordIds[0])
.execute();
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
items: s.items,
minSizeBytes: 0,
dryRun: true,
skipSuccessfulRecords: true,
});
expect(
dry.body.data.groups[0].files.some((f: { skipReason: string }) => f.skipReason === "successful_record"),
).toBe(false);
});
it("mixed selection skips successful files while organizing the remaining video", async () => {
const s = await source("Mixed");
const first = await organize(s.items, { scrapeAfterImport: false });
await executeRecord(h.module.deps().ao, first.exec.recordIds[0]);
const newPath = s.path.replace("1080p", "2160p");
await writeFile(join(h.dl, newPath), "different new video version");
const result = await organize([...s.items, { root: h.dlRoot, path: newPath, mediaEntityId: s.entity }], {
skipSuccessfulRecords: true,
scrapeAfterImport: false,
});
expect(
result.dry.groups[0].files.filter((f: { skipReason: string }) => f.skipReason === "successful_record"),
).toHaveLength(1);
expect(result.exec.recordIds).toHaveLength(1);
expect((await executeRecord(h.module.deps().ao, result.exec.recordIds[0])).status).toBe("succeeded");
const files = await h.db
.selectFrom("organization_file_result")
.select("source_path")
.where("organization_record_id", "=", result.exec.recordIds[0])
.execute();
expect(files).toEqual([{ source_path: join(h.dl, newPath) }]);
});
it("a succeeded record with an unsuccessful main file does not count as successful", async () => {
const s = await source("MainFailed");
const first = await organize(s.items, { scrapeAfterImport: false });
await executeRecord(h.module.deps().ao, first.exec.recordIds[0]);
await h.db
.updateTable("organization_file_result")
.set({ status: "failed" })
.where("organization_record_id", "=", first.exec.recordIds[0])
.where("role", "=", "main")
.execute();
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
items: s.items,
minSizeBytes: 0,
dryRun: true,
skipSuccessfulRecords: true,
});
expect(
dry.body.data.groups[0].files.some((f: { skipReason: string }) => f.skipReason === "successful_record"),
).toBe(false);
});
it.each([false, true])(
"TMDB per-import scrape=%s overrides the opposite global setting and survives replay",
async (enabled) => {
await h.db
.insertInto("settings")
.values({ key: "organize", value: JSON.stringify({ scrapeAfterImport: !enabled }) })
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ scrapeAfterImport: !enabled }) }))
.execute();
const s = await source("TMDB");
const r = await organize(s.items, { scrapeAfterImport: enabled });
const rid = r.exec.recordIds[0];
expect((await executeRecord(h.module.deps().ao, rid)).status).toBe("succeeded");
const records = () =>
h.db.selectFrom("scrape_record").selectAll().where("media_entity_id", "=", s.entity).execute();
const scraped = await records();
expect(scraped).toHaveLength(enabled ? 1 : 0);
const byJob = await h.api("GET", `/files/scrape-records?organizationRecordId=${rid}`);
expect(byJob.status).toBe(200);
expect(byJob.body.data.records.map((r: { id: string }) => r.id)).toEqual(scraped.map((r) => r.id));
if (enabled) {
const id = scraped[0]!.id;
const oldQuery = await h.api("GET", `/files/scrape-records?ids=${id}`);
expect(oldQuery.body.data.records[0].id).toBe(id);
const both = await h.api("GET", `/files/scrape-records?ids=${id}&organizationRecordId=${rid}`);
expect(both.body.data.records[0].id).toBe(id);
const differentJob = await h.api("GET", `/files/scrape-records?ids=${id}&organizationRecordId=${randomUUID()}`);
expect(differentJob.body.data.records).toEqual([]);
}
await executeRecord(h.module.deps().ao, rid);
expect(await records()).toHaveLength(enabled ? 1 : 0);
},
);
it.each([false, true])("Local per-import scrape=%s is respected", async (enabled) => {
await h.db
.insertInto("settings")
.values({ key: "organize", value: JSON.stringify({ scrapeAfterImport: false }) })
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ scrapeAfterImport: false }) }))
.execute();
const s = await source("Local", true);
const r = await organize(s.items, { scrapeAfterImport: enabled });
const rid = r.exec.recordIds[0];
const record = await h.db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", rid)
.executeTakeFirstOrThrow();
expect(record.plan).toMatchObject({ metadataMode: "local", scrapeAfterImport: enabled });
await executeRecord(h.module.deps().ao, rid);
expect(
await h.db.selectFrom("scrape_record").select("id").where("media_entity_id", "=", s.entity).execute(),
).toHaveLength(enabled ? 1 : 0);
});
it("scrape-records requires an ID list or an organization record", async () => {
expect((await h.api("GET", "/files/scrape-records")).status).toBe(422);
});
});
@@ -0,0 +1,121 @@
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "./harness.ts";
const skip = !(await pgAvailable());
describe.skipIf(skip)("files 整理人工集号校验", () => {
let h: FmHarness;
let show: string;
let film: string;
const path = "Some.Show.S01E02.mkv";
const bytes = "x".repeat(64);
beforeAll(async () => {
h = await createFmHarness("overrides");
show = await seedEntity(h.db, { kind: "series", title: "Some Show", year: 2022 });
film = await seedEntity(h.db, { kind: "movie", title: "Film", year: 2021 });
await writeFile(join(h.dl, path), bytes);
await mkdir(join(h.dl, "Season"));
await writeFile(join(h.dl, "Season", path), bytes);
});
afterAll(async () => h?.close());
const facts = async () => ({
entities: await h.db.selectFrom("media_entity").select("id").orderBy("id").execute(),
identities: await h.db.selectFrom("external_identity").select("id").orderBy("id").execute(),
plans: await h.db.selectFrom("artifact").select("id").where("title", "=", "files_plan").orderBy("id").execute(),
records: await h.db.selectFrom("organization_record").select("id").orderBy("id").execute(),
tasks: await h.db.selectFrom("task").select("id").orderBy("id").execute(),
jobs: h.engine.enqueued.length,
library: await readdir(h.lib, { recursive: true }),
});
it.each([true, false])("非连续集号在 dryRun=%s 时整批拒绝,不创建作品、计划、记录或文件", async (dryRun) => {
const before = await facts();
const r = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun,
items: [
// 排在坏输入前的远端候选也不能创建作品或发起 TMDB 请求。
{ root: h.dlRoot, path, external: { provider: "tmdb", externalId: "136315", kind: "tv" } },
{ root: h.dlRoot, path, mediaEntityId: show, season: 2, episodes: [5, 7] },
],
});
expect(r.status).toBe(400);
expect(r.body.error.message).toMatch(/bad_request:.*集号 5,7 不连续/);
expect(await facts()).toEqual(before);
expect(await readFile(join(h.dl, path), "utf8")).toBe(bytes);
});
it("远端剧集候选的不连续集号也在建档前拒绝", async () => {
const before = await facts();
const r = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun: true,
items: [
{ root: h.dlRoot, path, external: { provider: "tmdb", externalId: "136315", kind: "tv" }, episodes: [2, 4] },
],
});
expect(r.status).toBe(400);
expect(r.body.error.message).toContain("不连续");
expect(await facts()).toEqual(before);
});
it.each(["local", "external"])("%s 剧集目录指定集号时明确拒绝,不保存计划", async (source) => {
const before = await facts();
const entity =
source === "local"
? { mediaEntityId: show }
: { external: { provider: "tmdb", externalId: "136315", kind: "tv" } };
const r = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun: true,
items: [{ root: h.dlRoot, path: "Season", ...entity, season: 1, episodes: [2] }],
});
expect(r.status).toBe(400);
expect(r.body.error.message).toContain("目录项不能指定集号");
expect(await facts()).toEqual(before);
});
it("连续人工季集覆盖原解析,预览与执行路径一致", async () => {
const items = [{ root: h.dlRoot, path, mediaEntityId: show, season: 2, episodes: [4, 3, 4] }];
const dry = await h.api("POST", "/files/organize", { idempotencyKey: key(), action: "copy", dryRun: true, items });
expect(dry.status).toBe(200);
const group = dry.body.data.groups[0];
expect(group.error).toBeNull();
expect(group.warnings).toEqual([]);
expect(group.files).toHaveLength(1);
expect(group.files[0].target).toMatch(/Season 2\/Some Show - S02E03-E04 - 第 3-4 集\.mkv$/);
const exec = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun: false,
items,
planIds: [group.planId],
});
expect(exec.status).toBe(200);
expect(exec.body.data.groups[0].error).toBeNull();
expect(exec.body.data.groups[0].files).toEqual(group.files);
expect(exec.body.data.recordIds).toHaveLength(1);
const result = await executeRecord(h.module.deps().ao, exec.body.data.recordIds[0]);
expect(result.status).toBe("succeeded");
expect(await readFile(group.files[0].target, "utf8")).toBe(bytes);
});
it("电影仍忽略季集覆写并提示", async () => {
const r = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun: true,
items: [{ root: h.dlRoot, path, mediaEntityId: film, season: 1, episodes: [2, 4] }],
});
expect(r.status).toBe(200);
expect(r.body.data.groups[0].warnings).toEqual([expect.stringMatching(/电影不适用季\/集覆写/)]);
});
});
@@ -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();
});
});
+3 -3
View File
@@ -224,7 +224,7 @@ describe.skipIf(skip)("files W5-A2", () => {
dryRun: true,
items: [
{ root: h.dlRoot, path: "weird-a.mkv", mediaEntityId: series, season: 2, episodes: [3, 4] },
{ root: h.dlRoot, path: "weird-b.mkv", mediaEntityId: series, season: 2, episodes: [5, 7] },
{ root: h.dlRoot, path: "weird-b.mkv", mediaEntityId: series, season: 2, episodes: [5, 6] },
{
root: h.dlRoot,
path: "Film.2019.1080p.mkv",
@@ -243,8 +243,8 @@ describe.skipIf(skip)("files W5-A2", () => {
const mv = groups.find((g: { mediaEntityId: string }) => g.mediaEntityId === film);
const tvTargets = tv.files.map((f: { target: string | null }) => f.target);
expect(tvTargets.some((t: string | null) => t?.endsWith("Season 2/剧 - S02E03-E04 - 第 3-4 集.mkv"))).toBe(true);
// 非连续集号不应用(只按季覆写,文件名无集号 → 计划排除),其余覆写不进 warnings
expect(tv.warnings).toEqual([expect.stringMatching(/weird-b\.mkv:集号 5,7 不连续/)]);
expect(tvTargets.some((t: string | null) => t?.endsWith("Season 2/剧 - S02E05-E06 - 第 5-6 集.mkv"))).toBe(true);
expect(tv.warnings).toEqual([]);
expect(mv.warnings).toEqual([expect.stringMatching(/电影不适用季\/集覆写/)]);
expect(mv).toMatchObject({ action: "copy", slotId: slot.id, targetDir: join(h.lib, "picked") });
expect(mv.files[0].target).toBe(join(h.lib, "picked", "Film (2019) - 1080p.mkv"));
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
// 不挂载 HTTP 路由;隔离 Deno 静态文件适配器,以便本单元测试在 Node 下执行。
vi.mock("hono/deno", () => ({ serveStatic: vi.fn() }));
import { verifyInPlace } from "../src/modules/acquisition-organize/services/organize.ts";
const path = "/media/unchanged.mkv";
const frozen = {
rootPath: "/media",
sentinel: ".uhd-root",
libraryRootId: "root",
entityId: "work",
sourceFacts: { [path]: { size: 100, mtimeMs: 200 } },
} as unknown as Parameters<typeof verifyInPlace>[2];
async function verify(observed: unknown, owner: string | null = null) {
const executeTakeFirst = vi.fn().mockResolvedValue(owner ? { media_entity_id: owner } : undefined);
const query = { innerJoin: () => query, select: () => query, where: () => query, executeTakeFirst };
const d = { ctx: { db: { selectFrom: () => query } } } as unknown as Parameters<typeof verifyInPlace>[0];
const statSafe = vi.fn().mockResolvedValue(observed);
// 仅提供 statSafe;若意外调用复制、移动、清理或写入,本测试立即失败。
const fsr = { proxy: { statSafe } } as unknown as Parameters<typeof verifyInPlace>[1];
return { result: await verifyInPlace(d, fsr, frozen, path, 100), statSafe, executeTakeFirst };
}
const observed = (extra = {}) => ({
kind: "ok",
facts: { isFile: true, isSymbolicLink: false, size: 100, mtimeMs: 200, ...extra },
});
describe("原地登记执行核验", () => {
it("只读核验,不复制或移动文件;同作品重复登记可继续", async () => {
const r = await verify(observed(), "work");
expect(r.result).toEqual({ status: "succeeded", bytes: 0 });
expect(r.statSafe).toHaveBeenCalledWith(path, { root: "/media", sentinel: ".uhd-root" });
});
it("文件消失、链接、变为目录或计划后的大小/时间变化均不登记", async () => {
for (const value of [
{ kind: "missing" },
observed({ isSymbolicLink: true }),
observed({ isFile: false }),
observed({ size: 101 }),
observed({ mtimeMs: 201 }),
]) {
const r = await verify(value);
expect(r.result.status).toBe("failed");
expect(r.executeTakeFirst).not.toHaveBeenCalled();
}
});
it("不会把已有视频悄悄登记为另一个作品", async () => {
expect((await verify(observed(), "other")).result).toMatchObject({
status: "failed",
error: { code: "ASSET_ENTITY_CONFLICT" },
});
});
});
+1 -1
View File
@@ -102,7 +102,7 @@ describe.skipIf(!hasPg)("Pi 上游(真实 PG)", () => {
.plaintextKey as string;
expect((await bearer(h.app, readKey)("GET", "/pi/upstreams")).status).toBe(403);
const cat = await json(await key("GET", "/pi/providers?models=false"));
expect(cat.data.providers).toHaveLength(43);
expect(cat.data.providers).toHaveLength(44);
const anthropic = cat.data.providers.find((p: { id: string }) => p.id === "anthropic");
expect(anthropic.termsNote).toMatch(/封禁/);
});
+39 -6
View File
@@ -15,6 +15,7 @@ import { createOutboxPort } from "../../src/context.ts";
import { createApp } from "../../src/http/app.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { createAgentModule } from "../../src/modules/agent/index.ts";
import { createSearchModule } from "../../src/modules/search/index.ts";
import { type BaseRelease, filterReleases, quickFilter } from "../../src/modules/search/releases.ts";
@@ -149,7 +150,7 @@ describe.skipIf(!up)("全局搜索", () => {
outbox: createOutboxPort(),
secrets,
settings: plainSettings(() => db),
capabilities: {} as never,
capabilities: createCapabilityRegistry({ logger }),
} as ServerContext;
// 挂起的站点:请求一直不返回,直到被中止(单站超时)
const fetchImpl: typeof fetch = (input, init) => {
@@ -208,6 +209,28 @@ describe.skipIf(!up)("全局搜索", () => {
await addAccount(sites.mteam, "api_key", mteam.apiKey);
sites.slow = await addSite("hdsky", "天空", "nexusphp", `http://${SLOW_HOST}/`);
await addAccount(sites.slow, "cookie", "uid=1; pass=x");
const downloader = await db
.insertInto("downloader_instance")
.values({
name: "search-download",
base_url: "http://downloader.invalid",
cache_volume_key: "search-cache",
settings: JSON.stringify({ savePath: "/downloads" }),
})
.returning("id")
.executeTakeFirstOrThrow();
await db
.insertInto("path_mapping")
.values({
downloader_instance_id: downloader.id,
view_kind: "downloader",
remote_prefix: "/downloads",
local_prefix: "/tmp/umt-search-cache",
media_server_instance_id: null,
library_root_id: null,
verified_at: null,
})
.execute();
// 已关闭的站点不参与搜索
await db
.insertInto("site")
@@ -561,12 +584,16 @@ describe.skipIf(!up)("全局搜索", () => {
});
it("从搜索结果下载:转调 /acquisition-plans(同一鉴权、同一拒绝原因)", async () => {
// 未识别的资源 → 与 POST /acquisition-plans 相同的 412
// 未识别资源先返回一次结构化确认,不阻断后续手动下载
const unresolved = await req(`/search/releases/${sites.nexus}/900001/plan`, { body: {} });
expect(unresolved.status).toBe(412);
expect(unresolved.body.error.code).toBe("precondition_failed");
expect(unresolved.status, JSON.stringify(unresolved.body)).toBe(200);
expect(unresolved.body.data).toMatchObject({
submitted: false,
requiresConfirmation: true,
rejected: [{ code: "unrecognized" }],
});
// 已识别但作品没有收录版本 → submitted=false + no_slot
// 已识别资源无需订阅或收录版本,独立计划提交
const other = (
await db
.insertInto("media_entity")
@@ -582,7 +609,13 @@ describe.skipIf(!up)("全局搜索", () => {
.execute();
const ok = await req(`/search/releases/${sites.mteam}/912234/plan`, { body: { note: "from search" } });
expect(ok.status).toBe(200);
expect(ok.body.data).toMatchObject({ submitted: false, plan: null, rejected: [{ code: "no_slot" }] });
expect(ok.body.data).toMatchObject({ submitted: true, slotId: null, plan: { slotId: null }, rejected: [] });
expect(await db.selectFrom("subscription").selectAll().where("media_entity_id", "=", other).execute()).toHaveLength(
0,
);
expect(
await db.selectFrom("acquisition_slot").selectAll().where("media_entity_id", "=", other).execute(),
).toHaveLength(0);
// 不在搜索结果里
const missing = await req(`/search/releases/${sites.mteam}/nope/plan`, { body: {} });
@@ -316,6 +316,28 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
expect(siteRequests.some((x) => x.includes("douban"))).toBe(false);
});
it("升级链没认出(豆瓣召回无匹配)时保留原有歧义:候选不丢,照常开有歧义的事项", async () => {
await saveDoubanSubject(h.ctx, {
id: "36000097",
title: "查无此片",
aliases: [],
// 年份对不上:按豆瓣条目召回 TMDB 拿不到候选
year: 1990,
source: "web_snippet",
ref: "x",
at: new Date().toISOString(),
});
const r = await release(audiences, "725859", "Rose 2026 2160p WEB-DL H265-ADWeb", {
_detail: { doubanId: "36000097" },
});
await h.db.updateTable("source_release").set({ detail_fetched_at: new Date() }).where("id", "=", r.id).execute();
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
expect(res.outcome).toBe("ambiguous");
expect(res.decisionId).not.toBeNull();
const d = await h.db.selectFrom("decision").select("kind").where("id", "=", res.decisionId!).executeTakeFirst();
expect(d?.kind).toBe("identity_ambiguous");
});
it("池条目补全前先走升级链:详情页给了 ID 就直接认出,返回 409,不做网页补全", async () => {
const r = await release(hhan, "91007", "Example Show S01 2026 2160p YK WEB-DL H.265 DV HQ AAC2.0-HHWEB");
const first = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual", escalate: false });
@@ -0,0 +1,223 @@
import { workNfo } from "@umt/domain";
import { createMockTmdb } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { imagePathsOf, nfoWork } from "../../src/modules/acquisition-organize/services/scrape-input.ts";
import { saveDetailPage } from "../../src/modules/sources-identity/service/detail-page.ts";
import {
ptDetailFacts,
resolveOrganizeMetadata,
} from "../../src/modules/sources-identity/service/organize-metadata.ts";
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
const ptText =
"◎译 名 未收录的故事\n◎原 名 Unlisted Story\n◎年 代 2025\n◎产 地 中国大陆\n◎语 言 汉语普通话\n◎类 别 剧情\n◎导 演 张三\n◎主 演 李四 / 王五\n◎简 介 一位主人公在旅途中发现了一段不为人知的故事,并开始寻找失踪的朋友。";
const up = await pgAvailable();
describe("PT detail extraction", () => {
it("reads evidenced labels and leaves missing fields empty", () => {
const facts = ptDetailFacts(ptText, "https://pt.example/details.php?id=1");
expect(facts.names).toEqual(["未收录的故事", "Unlisted Story"]);
expect(facts.year).toBe(2025);
expect(facts.extras).toContainEqual({ field: "originCountry", value: ["CN"] });
expect(facts.extras).toContainEqual({ field: "originalLanguage", value: "zh" });
expect(ptDetailFacts("Unknown.2025.1080p", "https://pt.example/").candidates).toEqual([]);
});
it("local final fields reuse NFO input and external poster", () => {
const metadata = {
overview: { value: "简介" },
posterUrl: { value: "https://img.example/cover.jpg" },
originCountry: { value: ["CN"] },
cast: { value: [{ id: -1, name: "本地主演", role: null, profilePath: null }] },
crew: { value: [{ id: -2, name: "本地导演", role: "Director", profilePath: null }] },
};
expect(imagePathsOf(metadata).posterPath).toBe("https://img.example/cover.jpg");
const nfo = nfoWork({ kind: "movie", title: "本地作品", year: 2025, original_title: null, metadata }, {});
expect(nfo.ids.tmdb).toBeNull();
expect(nfo.overview).toBe("简介");
expect(nfo.countries).toEqual(["中国"]);
expect(nfo.actors?.[0]?.tmdbId).toBeNull();
expect(nfo.directors?.[0]?.tmdbId).toBeNull();
const xml = workNfo(nfo);
expect(xml).toContain("本地主演");
expect(xml).toContain("本地导演");
expect(xml).not.toContain("-1");
expect(xml).not.toContain("-2");
});
});
describe.skipIf(!up)("organize metadata local recovery (isolated PG)", () => {
let h: Harness;
let siteId: string;
beforeAll(async () => {
h = await createHarness("local_metadata", { tmdb: () => null });
siteId = (await seedSite(h, { baseUrl: "https://pt.example/" })).site.id;
});
afterAll(async () => {
await h?.close();
});
const add = async (n: string, text: string, title?: string) => {
const release = await h.db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: n,
title: title ?? `Unlisted Story ${n} 2025 1080p`,
detail_fetched_at: new Date(),
})
.returning("id")
.executeTakeFirstOrThrow();
await saveDetailPage(
h.rt,
h.db,
release.id,
{
descriptionText: text,
mediaInfo: null,
screenshots: ["https://img.example/cover.jpg"],
imdbId: null,
doubanId: null,
tmdb: null,
files: [],
tags: [],
uploader: null,
probe: { labels: [], hasDescription: true, imageCount: 1 },
},
3600,
);
return release.id;
};
const deps = {
search: async () => [],
fetch: async () => {
throw new Error("unexpected fetch");
},
complete: async () => null,
};
it("creates Local with sourced fields, remains revisitable and reuses identity", async () => {
const id = await add("1", ptText);
const out = await resolveOrganizeMetadata(h.rt, id, deps, { actor: { type: "system", id: "system:organize" } });
expect(out.mode).toBe("local");
expect(out.mediaEntityId).toBeTruthy();
const entity = await h.db
.selectFrom("media_entity")
.selectAll()
.where("id", "=", out.mediaEntityId!)
.executeTakeFirstOrThrow();
expect(entity.status).toBe("provisional");
expect(entity.title).toBe("未收录的故事");
expect((entity.metadata as Record<string, { value: unknown }>).originCountry?.value).toEqual(["CN"]);
expect((entity.metadata as Record<string, { value: unknown }>).posterUrl?.value).toBe(
"https://img.example/cover.jpg",
);
const release = await h.db.selectFrom("source_release").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
expect(release.resolution_locked).toBe(false);
const again = await resolveOrganizeMetadata(h.rt, id, deps, { actor: { type: "system", id: "system:organize" } });
expect(again.mediaEntityId).toBe(out.mediaEntityId);
});
it("series fallback preserves actual season and episode instead of fabricating season one", async () => {
const id = await add(
"season",
"◎译 名 独立剧集\n◎年 代 2025\n◎集 数 12\n◎类 别 剧情",
"Distinct Local Series S02E03 2025 1080p",
);
const out = await resolveOrganizeMetadata(h.rt, id, deps, { actor: { type: "system", id: "system:organize" } });
expect(out.mode).toBe("local");
const units = await h.db
.selectFrom("content_unit")
.select(["season_number", "episode_number"])
.where("media_entity_id", "=", out.mediaEntityId!)
.execute();
expect(units.some((u) => u.season_number === 1)).toBe(false);
expect(units.some((u) => u.season_number === 2 && u.episode_number === 3)).toBe(true);
});
it("does not create a filename placeholder when facts are insufficient", async () => {
const id = await add("2", "no metadata available");
const out = await resolveOrganizeMetadata(h.rt, id, deps, { actor: { type: "system", id: "system:organize" } });
expect(out.mediaEntityId).toBeNull();
});
});
describe.skipIf(!up)("organize metadata translated name recovery", () => {
it("retries TMDB using evidenced PT aliases after normal title lookup fails", async () => {
const tmdb = await createMockTmdb({
fixtures: [
{
type: "movie",
id: 990087,
original_title: "True Original",
original_language: "en",
origin_country: ["US"],
date: "2025-01-01",
titles: { "zh-CN": "正确译名", "en-US": "True Original" },
popularity: 10,
alternative_titles: [],
external_ids: { imdb_id: null },
},
],
});
const h = await createHarness("local_alias", { tmdbUrl: tmdb.url });
try {
const siteId = (await seedSite(h, { baseUrl: "https://pt.example/" })).site.id;
const release = await h.db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: "alias",
title: "Unknown Translation 2025 1080p",
detail_fetched_at: new Date(),
})
.returning("id")
.executeTakeFirstOrThrow();
await saveDetailPage(
h.rt,
h.db,
release.id,
{
descriptionText: "◎译 名 另类译名\n◎原 名 True Original\n◎年 代 2025\n◎类 别 剧情",
mediaInfo: null,
screenshots: [],
imdbId: null,
doubanId: null,
tmdb: null,
files: [],
tags: [],
uploader: null,
probe: { labels: [], hasDescription: true, imageCount: 0 },
},
3600,
);
let completions = 0;
const out = await resolveOrganizeMetadata(
h.rt,
release.id,
{
search: async () => [],
fetch: async () => {
throw new Error("unexpected fetch");
},
complete: async () => {
completions++;
return { text: "{}", model: "fixture-agent", inputTokens: 100, outputTokens: 5 };
},
},
{ actor: { type: "system", id: "system:organize" } },
);
expect(completions).toBe(1);
expect(out.mode).toBe("tmdb");
const identity = await h.db
.selectFrom("external_identity")
.selectAll()
.where("media_entity_id", "=", out.mediaEntityId!)
.where("provider", "=", "tmdb")
.executeTakeFirstOrThrow();
expect(identity.external_id).toBe("990087");
const enrichment = await h.db
.selectFrom("metadata_enrichment")
.selectAll()
.where("id", "=", out.enrichmentId!)
.executeTakeFirstOrThrow();
expect((enrichment.usage as { inputTokens: number }).inputTokens).toBe(100);
} finally {
await h.close();
await tmdb.close();
}
});
});
@@ -15,8 +15,9 @@ const TiB = 1024 ** 4;
const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const;
describe("站点能力规格", () => {
it("进入首批能力目录(17 + 5 只读摘要 + 4 站点 + 10 浏览器 + 2 会话交互 + 2 网页搜索 + 1 元数据补全 = 41);类别 / 风险 / 重放按约定", () => {
expect(CAPABILITY_IDS).toHaveLength(41);
it("进入能力目录(41 个已有能力 + 整理元数据补全);类别 / 风险 / 重放按约定", () => {
expect(CAPABILITY_IDS).toHaveLength(42);
expect(capabilitySpec("metadata.resolve_organize")).toMatchObject({ kind: "execute", replay: "never" });
for (const id of SITE_IDS) expect(CAPABILITY_IDS).toContain(id);
expect(capabilitySpec("site.list")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" });
expect(capabilitySpec("site.user_data")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" });
+1
View File
@@ -8,6 +8,7 @@ import { defineConfig } from "vitest/config";
// 新测试文件缺省归入 pg(串行,安全);确认不碰 PG 的才加进 UNIT。
const UNIT = [
"test/api-registry.test.ts",
"test/organize-in-place-verify.test.ts",
"test/file-manager/preview-parse.test.ts",
"test/auth.test.ts",
"test/health.test.ts",