feat(server): W2-C rules, downloads, import, organization records, library and dashboard

- Rules API: subscriptions, acquisition slots (revision lock), policy revisions
  (draft -> publish = automation_grant, rollback as new revision), preference
  tags with suppression records (agent cannot re-add suppressed fingerprints).
- Tasks: evaluateAcquisition (mode/evaluate/budget/cache volume -> frozen plan
  with slot reservation, candidate reasons as evidence artifacts),
  submitDownload (withAttemptToken fencing, already_exists adoption),
  verifyAndImport (frozen organization_record + file results, resumable
  copy/move/cross-volume steps, assets/locations/bindings in one tx),
  cleanupDownloadCache (verified+retention+H&R only, assertSafeToDeleteData),
  reconcileLibrary (root sentinel, suspected -> confirmed missing).
- Worker loop: qB sync with tag adoption, no-progress evidence, hard threshold
  pause / resume-watermark resume, sweepers for deferred plans and imports.
- Public organization records API incl. changes cursor (change_xid guard),
  media library list/detail, inventory overlay, Emby inventory, dashboard.
- Contracts: SlotTemplate, AcquisitionPolicyRules, CandidateEvaluation,
  library/inventory/dashboard read models.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 20:08:47 +08:00
1 parent 1cd523d3e6
commit 829ca4f7ac
24 files changed
+7488 -9

No files matched your search

@@ -0,0 +1,347 @@
import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import type { DiskUsage } from "@umt/adapters";
import { createQbClient } from "@umt/adapters";
import { DEFAULT_SETTINGS, GlobalSettings, type SettingsKey } from "@umt/contracts";
import { type DB, migrateToLatest } from "@umt/db";
import type { TaskEngine } from "@umt/tasks";
import { Kysely, PostgresDialect, sql } from "kysely";
import pg from "pg";
import { ConfigSchema } from "../../../config.ts";
import { createOutboxPort } from "../../../context.ts";
import { createApp } from "../../../http/app.ts";
import { hashApiKey } from "../../../http/auth.ts";
import { EventBus } from "../../../http/sse.ts";
import { createLogger } from "../../../logger.ts";
import { createCapabilityRegistry } from "../../agent/capability-registry.ts";
import type { ServerContext } from "../../types.ts";
import { type AoOverrides, createAcquisitionOrganizeModule } from "../index.ts";
import type { AoDeps } from "../services/common.ts";
// 真实 PG(独立 schema)+ mock qB + 临时目录 的测试装配;引擎用记录入队的假实现,测试直接驱动服务函数。
export const DB_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
export const DEV_KEY = "dev-admin-ao-test-key";
/** 只读整理记录 / 可发起整理 的 API Key */
export const READ_KEY = "ao-organize-read-key";
export const EXEC_KEY = "ao-organize-exec-key";
const KEYS = [
{ key: READ_KEY, scopes: ["organize.read"] },
{ key: EXEC_KEY, scopes: ["organize.execute"] },
];
export async function pgAvailable() {
if (process.env.UMT_SKIP_DB_TESTS === "1") return false;
const c = new pg.Client({ connectionString: DB_URL, connectionTimeoutMillis: 2000 });
try {
await c.connect();
await c.end();
return true;
} catch {
return false;
}
}
export interface Enqueued {
task: string;
input: unknown;
key: string;
}
/** 记录入队、不执行的引擎(处理器由测试直接调服务函数) */
export function fakeEngine(): TaskEngine & { enqueued: Enqueued[] } {
const enqueued: Enqueued[] = [];
let n = 0;
return {
enqueued,
async enqueue(task, input, opts) {
enqueued.push({ task, input, key: opts.idempotencyKey });
n++;
return { runId: `run-${n}`, deduplicated: false };
},
async signal() {
return { delivered: 0 };
},
async cancel() {
return false;
},
async get() {
return null;
},
async list() {
return [];
},
async startWorker() {},
async stop() {},
};
}
/** 可调的磁盘用量:used = base + 目录下文件总大小(清理删文件后自然下降) */
export function fakeDisk(total: number) {
const state = { total, base: 0, watch: [] as string[], fail: false };
const du = (dir: string): number => {
let s = 0;
try {
for (const n of readdirSync(dir)) {
const p = join(dir, n);
const st = statSync(p);
s += st.isDirectory() ? du(p) : st.size;
}
} catch {
// 不存在
}
return s;
};
const fn = async (path: string): Promise<DiskUsage> => {
if (state.fail) return { status: "unknown", path, code: "EIO", message: "fake" };
const used = state.base + state.watch.reduce((a, d) => a + du(d), 0);
return {
status: "ok",
path,
totalBytes: state.total,
freeBytes: state.total - used,
availableBytes: state.total - used,
usedBytes: used,
usedRatio: used / state.total,
};
};
/** 把使用率设为 pct(按当前目录内容反推 base) */
const setPct = (pct: number) => {
const files = state.watch.reduce((a, d) => a + du(d), 0);
state.base = Math.round((pct / 100) * state.total) - files;
};
return { state, fn, setPct };
}
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读响应/事件 JSON
type Loose = any;
export interface Harness {
db: Kysely<DB>;
ctx: ServerContext;
d: AoDeps;
engine: ReturnType<typeof fakeEngine>;
app: ReturnType<typeof createApp>;
api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>;
setSettings(key: string, value: Record<string, unknown>): Promise<void>;
outbox(type?: string): Promise<{ type: string; payload: Loose; idempotency_key: string | null }[]>;
close(): Promise<void>;
}
export async function createHarness(name: string, overrides: AoOverrides = {}): Promise<Harness> {
const schema = `ao_test_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const admin = new pg.Client({ connectionString: DB_URL });
await admin.connect();
await admin.query(`create schema ${schema}`);
await admin.end();
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: DB_URL, max: 6, options: `-c search_path=${schema},public` }),
}),
});
await migrateToLatest(db, { migrationTableSchema: schema });
const logger = createLogger({ level: "silent" });
const engine = fakeEngine();
const config = ConfigSchema.parse({
DATABASE_URL: DB_URL,
SECRET_MASTER_KEY: "test-master-key-0123456789",
DEV_ADMIN_KEY: DEV_KEY,
});
const outbox = createOutboxPort();
const bus = new EventBus(50);
const ctx: ServerContext = {
role: "all",
db,
config,
logger,
events: bus,
engine,
outbox,
secrets: {
resolve: async () => "adminadmin",
create: async () => "x",
replace: async () => {},
remove: async () => {},
},
// 不缓存的设置读取(测试里改 settings 行立即生效)
settings: {
async all() {
const rows = await db.selectFrom("settings").select(["key", "value"]).execute();
const stored = new Map(rows.map((r) => [r.key, r.value]));
const out: Record<string, unknown> = {};
for (const k of Object.keys(DEFAULT_SETTINGS) as SettingsKey[]) {
const v = stored.get(k);
out[k] = GlobalSettings.shape[k].parse({ ...DEFAULT_SETTINGS[k], ...((v as object) ?? {}) });
}
return out as GlobalSettings;
},
async get(k) {
return (await this.all())[k];
},
},
capabilities: createCapabilityRegistry({ logger }),
};
const module = createAcquisitionOrganizeModule({
downloader: async (inst) =>
createQbClient({
instanceId: inst.id,
baseUrl: inst.base_url,
username: "admin",
password: "adminadmin",
lookup: { attempts: 3, intervalMs: 50 },
}),
probe: async () => ({ status: "unknown", reason: "ffprobe_unavailable" }),
...overrides,
options: { backgroundLoops: false, minVideoBytes: 100_000, verifyHash: "sha256", ...(overrides.options ?? {}) },
});
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
bus,
role: "all",
version: "test",
db,
modules: [module],
ctx,
apiKeyStore: {
async findByHash(hash) {
const k = KEYS.find((x) => hashApiKey(x.key) === hash);
return k ? { id: k.key, name: k.key, scopes: k.scopes, revokedAt: null } : null;
},
async touch() {},
},
});
const d = module.deps();
return {
db,
ctx,
d,
engine,
app,
async api(method, path, body, key = DEV_KEY) {
const res = await app.request(`/api/v1${path}`, {
method,
headers: {
authorization: `Bearer ${key}`,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
return { status: res.status, body: await res.json() };
},
async setSettings(key, value) {
await sql`insert into settings (key, value) values (${key}, ${JSON.stringify(value)}::jsonb)
on conflict (key) do update set value = excluded.value`.execute(db);
},
async outbox(type) {
const r = await sql<{ type: string; payload: Loose; idempotency_key: string | null }>`
select type, payload, idempotency_key from outbox_event ${type ? sql`where type = ${type}` : sql``} order by at, id`.execute(
db,
);
return r.rows;
},
async close() {
await db.destroy();
const c = new pg.Client({ connectionString: DB_URL });
await c.connect();
await c.query(`drop schema if exists ${schema} cascade`);
await c.end();
},
};
}
// ── 种子数据 ──
export async function seedBase(
db: Kysely<DB>,
opts: { libraryPath: string; cachePath: string; qbUrl: string; mediaKind?: "movie" | "series" },
) {
const root = await db
.insertInto("library_root")
.values({
name: `lib-${Math.random().toString(36).slice(2, 6)}`,
media_kind: "mixed",
local_path: opts.libraryPath,
management_mode: "managed",
})
.returning("id")
.executeTakeFirstOrThrow();
const inst = await db
.insertInto("downloader_instance")
.values({
name: `qb-${Math.random().toString(36).slice(2, 6)}`,
base_url: opts.qbUrl,
username: "admin",
settings: JSON.stringify({ savePath: opts.cachePath }),
})
.returning("id")
.executeTakeFirstOrThrow();
const site = await db
.insertInto("site")
.values({
key: `site${Math.random().toString(36).slice(2, 6)}`,
name: "测试站",
adapter: "nexusphp",
base_url: "https://pt.example.test",
config: JSON.stringify({ hitAndRun: { minSeedingSec: 86_400 } }),
})
.returning("id")
.executeTakeFirstOrThrow();
return { rootId: root.id, instanceId: inst.id, siteId: site.id };
}
export async function seedEntity(db: Kysely<DB>, e: { kind: "movie" | "series"; title: string; year: number }) {
return (
await db
.insertInto("media_entity")
.values({ kind: e.kind, title: e.title, year: e.year, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
export async function seedRelease(
db: Kysely<DB>,
r: { siteId: string; entityId: string; title: string; size: number; hash: string; hr?: boolean | "unknown" },
) {
return (
await db
.insertInto("source_release")
.values({
site_id: r.siteId,
site_release_id: `${Math.floor(Math.random() * 1e9)}`,
title: r.title,
size_bytes: r.size,
infohash_v1: r.hash,
media_entity_id: r.entityId,
resolution_status: "resolved",
raw: JSON.stringify({ _site: { hr: r.hr ?? false } }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
export async function seedSlot(
db: Kysely<DB>,
s: { entityId: string; name?: string; hard?: Record<string, unknown>; upgrade?: Record<string, unknown> },
) {
const sub = await db
.insertInto("subscription")
.values({ media_entity_id: s.entityId, media_kind: "movie", mode: "continuous" })
.returning("id")
.executeTakeFirstOrThrow();
return (
await db
.insertInto("acquisition_slot")
.values({
media_entity_id: s.entityId,
subscription_id: sub.id,
name: s.name ?? "main",
hard_conditions: JSON.stringify(s.hard ?? {}),
upgrade_threshold: JSON.stringify(s.upgrade ?? {}),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
@@ -0,0 +1,246 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { copyFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { sql } from "@umt/db";
import {
createMockQb,
createTempTree,
findCrossDeviceBase,
type MockQb,
makeTorrent,
type TempTree,
} from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { evaluateAcquisition } from "../services/acquisition.ts";
import { submitDownload, syncDownloads } from "../services/downloads.ts";
import { executeRecord, type FrozenPlan, planImport } from "../services/organize.ts";
import {
createHarness,
EXEC_KEY,
fakeDisk,
type Harness,
pgAvailable,
READ_KEY,
seedBase,
seedEntity,
seedRelease,
seedSlot,
} from "./harness.ts";
// 跨卷 Move 分步可恢复(kill 后 resumed 先对账目标)、路径绑定不因译名变化改变、手动整理 API 与 scope。
const hasPg = await pgAvailable();
const xdev = findCrossDeviceBase();
const SIZE = 250_000;
describe.skipIf(!hasPg)("整理:跨卷 Move 恢复 / 路径绑定固定 / 手动整理", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
let src: TempTree;
const disk = fakeDisk(100_000_000);
const torrents = new Map<string, Uint8Array>();
let base: Awaited<ReturnType<typeof seedBase>>;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
// 库根放到另一个设备(/dev/shm)上,缓存在 tmpdir:入库 Move 必然跨卷
lib = await createTempTree({}, { sentinel: true, prefix: "umt-xlib-", ...(xdev ? { base: xdev } : {}) });
cache = await createTempTree({}, { prefix: "umt-xcache-" });
src = await createTempTree(
{
"Show.S01E01.1080p.WEB-DL.x264-GRP.mkv": SIZE,
"Show.S01E02.1080p.WEB-DL.x264-GRP.mkv": SIZE,
"Show.S02E01.1080p.WEB-DL.x264-GRP.mkv": SIZE,
"Show.S01E01.1080p.WEB-DL.x264-GRP.chs.srt": 2_000,
},
{ prefix: "umt-src-" },
);
disk.state.watch = [cache.root];
disk.setPct(30);
h = await createHarness("move", {
diskUsage: disk.fn,
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
});
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
for (const t of [lib, cache, src]) await t?.cleanup();
});
it.skipIf(!xdev)(
"跨卷 Move:copy 已完成后进程被 kill → 续跑时 resumed 先对账目标,不重复复制,核验后删源并移除 qB 任务记录",
async () => {
await h.setSettings("organize", { defaultMode: "move", allowCrossVolumeMove: true });
const e = await seedEntity(h.db, { kind: "movie", title: "跨卷片", year: 2021 });
await seedSlot(h.db, { entityId: e });
const t = makeTorrent({ name: "Cross.Volume.2021.1080p.BluRay-XV.mkv", length: SIZE, pieceSeed: 7 });
const relId = await seedRelease(h.db, {
siteId: base.siteId,
entityId: e,
title: "Cross.Volume.2021.1080p.BluRay-XV",
size: SIZE,
hash: t.hash,
hr: false,
});
torrents.set(relId, t.bytes);
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relId });
const sub = await submitDownload(h.d, ev.plans[0]!);
await qb.complete(t.hash, { writeFiles: true });
await syncDownloads(h.d);
const plan = await planImport(h.d, { downloadTaskId: sub.downloadTaskId, trigger: "auto", actorType: "system" });
expect(plan.status).toBe("running");
const rec = await h.db
.selectFrom("organization_record")
.selectAll()
.where("id", "=", plan.recordId!)
.executeTakeFirstOrThrow();
const frozen = rec.plan as unknown as FrozenPlan;
const f = frozen.organize.files[0]!;
expect(f.crossDevice).toBe(true);
// 模拟上一次 attempt:copy 步骤已把目标写完并校验,删源前 worker 被 kill(账本停在 started)
await mkdir(join(lib.root, "跨卷片 (2021)"), { recursive: true });
await copyFile(f.src, f.dst);
await sql`insert into tasks_op_ledger (op_key, attempt_token, attempts, status) values (${`org:${plan.recordId}:0`}, ${randomUUID()}, 1, 'started')`.execute(
h.db,
);
const ex = await executeRecord(h.d, plan.recordId!);
expect(ex.status).toBe("succeeded");
expect(existsSync(f.src)).toBe(false);
expect(existsSync(f.dst)).toBe(true);
const op = await h.db
.selectFrom("asset_operation")
.selectAll()
.where("operation_id", "=", `org:${plan.recordId}:0`)
.executeTakeFirstOrThrow();
const result = op.result as { resumed?: boolean; steps?: { step: string; status: string }[] };
expect(result.resumed).toBe(true);
expect(result.steps?.find((s) => s.step === "copy")?.status).toBe("skipped");
expect(result.steps?.find((s) => s.step === "delete_source")?.status).toBe("done");
// Move 成功:移除 qB 任务记录(deleteFiles=false),结束下载侧管理
expect(qb.get(t.hash)).toBeUndefined();
const del = qb.callsTo("torrents/delete").at(-1)!;
expect(del.form.deleteFiles).toBe("false");
const dt = await h.db
.selectFrom("download_task")
.selectAll()
.where("id", "=", sub.downloadTaskId!)
.executeTakeFirstOrThrow();
expect(dt.removed_at).not.toBeNull();
expect(dt.status).toBe("imported");
await h.setSettings("organize", { defaultMode: "copy" });
},
60_000,
);
it("路径绑定固定:译名变化后新集仍进旧目录;新季在旧作品目录下建季绑定;手动整理与 scope", async () => {
const e = await seedEntity(h.db, { kind: "series", title: "旧译名", year: 2020 });
// 只读 key 不能发起整理
const denied = await h.api(
"POST",
"/organization-records",
{ sourcePaths: [src.path("Show.S01E01.1080p.WEB-DL.x264-GRP.mkv")], mediaEntityId: e, action: "copy" },
READ_KEY,
);
expect(denied.status).toBe(403);
// dryRun:只出计划,不写记录
const dry = await h.api(
"POST",
"/organization-records",
{
sourcePaths: [src.path("Show.S01E01.1080p.WEB-DL.x264-GRP.mkv")],
mediaEntityId: e,
action: "copy",
dryRun: true,
},
EXEC_KEY,
);
expect(dry.status).toBe(201);
expect(dry.body.data).toMatchObject({ recordId: null });
expect(dry.body.data.planId).toBeTruthy();
expect(
await h.db.selectFrom("organization_record").select("id").where("media_entity_id", "=", e).execute(),
).toHaveLength(0);
const r1 = await h.api(
"POST",
"/organization-records",
{
sourcePaths: [
src.path("Show.S01E01.1080p.WEB-DL.x264-GRP.mkv"),
src.path("Show.S01E01.1080p.WEB-DL.x264-GRP.chs.srt"),
],
mediaEntityId: e,
action: "copy",
},
EXEC_KEY,
);
expect(r1.status).toBe(201);
expect(h.engine.enqueued.at(-1)).toMatchObject({
task: "verifyAndImport",
input: { recordId: r1.body.data.recordId },
});
await executeRecord(h.d, r1.body.data.recordId);
const ep1 = join(lib.root, "旧译名 (2020)", "Season 1", "旧译名 - S01E01.mkv");
expect(existsSync(ep1)).toBe(true);
expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 1", "旧译名 - S01E01.chi.zh-cn.srt"))).toBe(true);
const rec1 = await h.api("GET", `/organization-records/${r1.body.data.recordId}`, undefined, READ_KEY);
expect(rec1.body.data).toMatchObject({ origin: "manual", status: "succeeded", counts: { total: 2, succeeded: 2 } });
expect(rec1.body.data.actor.type).toBe("api_key");
// TMDB 改名:只改展示,不改目录
await h.db.updateTable("media_entity").set({ title: "新译名" }).where("id", "=", e).execute();
const r2 = await h.api(
"POST",
"/organization-records",
{ sourcePaths: [src.path("Show.S01E02.1080p.WEB-DL.x264-GRP.mkv")], mediaEntityId: e, action: "copy" },
EXEC_KEY,
);
await executeRecord(h.d, r2.body.data.recordId);
expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 1", "新译名 - S01E02.mkv"))).toBe(true);
expect(existsSync(join(lib.root, "新译名 (2020)"))).toBe(false);
const r3 = await h.api(
"POST",
"/organization-records",
{ sourcePaths: [src.path("Show.S02E01.1080p.WEB-DL.x264-GRP.mkv")], mediaEntityId: e, action: "copy" },
EXEC_KEY,
);
await executeRecord(h.d, r3.body.data.recordId);
expect(existsSync(join(lib.root, "旧译名 (2020)", "Season 2", "新译名 - S02E01.mkv"))).toBe(true);
const bindings = await h.db
.selectFrom("path_binding")
.select(["binding_kind", "season_number", "relative_path", "status"])
.where("media_entity_id", "=", e)
.execute();
bindings.sort((a, b) => (a.season_number ?? -1) - (b.season_number ?? -1));
expect(bindings).toEqual([
{ binding_kind: "entity", season_number: null, relative_path: "旧译名 (2020)", status: "active" },
{ binding_kind: "season", season_number: 1, relative_path: "旧译名 (2020)/Season 1", status: "active" },
{ binding_kind: "season", season_number: 2, relative_path: "旧译名 (2020)/Season 2", status: "active" },
]);
// 同目标已存在且内容相同:重复整理判 already_in_library 跳过,不覆盖
const r4 = await h.api(
"POST",
"/organization-records",
{ sourcePaths: [src.path("Show.S01E02.1080p.WEB-DL.x264-GRP.mkv")], mediaEntityId: e, action: "copy" },
EXEC_KEY,
);
const rec4 = await h.db
.selectFrom("organization_file_result")
.selectAll()
.where("organization_record_id", "=", r4.body.data.recordId)
.executeTakeFirstOrThrow();
expect(rec4).toMatchObject({ status: "skipped", skip_reason: "already_in_library" });
}, 60_000);
});
@@ -0,0 +1,252 @@
import { makeTorrent } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createAcquisitionOrganizeModule } from "../index.ts";
import { evaluateAcquisition } from "../services/acquisition.ts";
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
// 规则 API:订阅/Slot/策略版本(发布=持续授权、回滚)/偏好标签抑制;新作品收录走已发布策略;outbox 路由。
const hasPg = await pgAvailable();
describe("outbox 路由", () => {
const routes = createAcquisitionOrganizeModule().outboxRoutes!({} as never);
it("release.resolved → evaluateAcquisition;只有下载完成才建 verifyAndImport", () => {
const ev = (type: string, payload: unknown) => ({ id: "1", type, payload, at: new Date() });
expect(
routes["release.resolved"]!(ev("release.resolved", { sourceReleaseId: "a", contentUnitIds: ["u"] })),
).toEqual({
task: "evaluateAcquisition",
input: { sourceReleaseId: "a", contentUnitIds: ["u"] },
});
expect(
routes["download.status_changed"]!(ev("download.status_changed", { downloadTaskId: "d", to: "completed" })),
).toEqual({
task: "verifyAndImport",
input: { downloadTaskId: "d" },
});
expect(
routes["download.status_changed"]!(ev("download.status_changed", { downloadTaskId: "d", to: "paused" })),
).toBeNull();
});
});
describe.skipIf(!hasPg)("规则 API(真实 PG)", () => {
let h: Harness;
const disk = fakeDisk(1_000_000_000);
beforeAll(async () => {
disk.setPct(10);
h = await createHarness("rules", { diskUsage: disk.fn });
}, 60_000);
afterAll(async () => {
await h?.close();
});
it("订阅与 Slot:缺省季范围、同名冲突、revision 乐观锁", async () => {
const e = await seedEntity(h.db, { kind: "series", title: "追更剧", year: 2025 });
const sub = await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "series" });
expect(sub.status).toBe(201);
expect(sub.body.data).toMatchObject({
mode: "continuous",
seasonScope: "following_seasons",
origin: "user",
status: "active",
});
const once = await h.api("PATCH", `/subscriptions/${sub.body.data.id}`, {
seasonScope: "current_season",
params: { stopAfter: "2030-01-01T00:00:00Z" },
});
expect(once.body.data.seasonScope).toBe("current_season");
expect((await h.api("POST", "/subscriptions", { mediaKind: "movie" })).status).toBe(422);
const s = await h.api("POST", "/acquisition-slots", {
mediaEntityId: e,
subscriptionId: sub.body.data.id,
name: "1080p",
hardConditions: { resolutions: ["1080p"] },
});
expect(s.status).toBe(201);
expect(s.body.data).toMatchObject({ status: "empty", revision: 1 });
expect((await h.api("POST", "/acquisition-slots", { mediaEntityId: e, name: "1080p" })).status).toBe(409);
expect(
(
await h.api("PATCH", `/acquisition-slots/${s.body.data.id}`, {
expectedRevision: 9,
softPreferences: { hdr: ["DV"] },
})
).status,
).toBe(409);
const ok = await h.api("PATCH", `/acquisition-slots/${s.body.data.id}`, {
expectedRevision: 1,
softPreferences: { hdr: ["DV"] },
});
expect(ok.body.data).toMatchObject({ revision: 2, softPreferences: { hdr: ["DV"] } });
const list = await h.api("GET", `/acquisition-slots?mediaEntityId=${e}`);
expect(list.body.data.items).toHaveLength(1);
expect(h.engine.enqueued.some((q) => q.task === "evaluateAcquisition")).toBe(true);
});
it("策略版本:草案 → 发布(持续授权)→ 新版本发布(旧授权吊销)→ 回滚(新版本号,内容同目标)", async () => {
const bad = await h.api("POST", "/policy-revisions", {
policyKey: "acq",
kind: "acquisition",
title: "x",
rules: { newTitle: { enabled: "yes" } },
});
expect(bad.status).toBe(422);
const rules = {
defaultSlots: [{ name: "1080p", hardConditions: { resolutions: ["1080p"] } }],
newTitle: {
enabled: true,
windowDays: 60,
mediaKinds: ["movie"],
slots: [{ name: "新片", hardConditions: { resolutions: ["1080p", "2160p"] } }],
},
};
const r1 = await h.api("POST", "/policy-revisions", {
policyKey: "acq",
kind: "acquisition",
title: "默认收录",
rules,
});
expect(r1.body.data).toMatchObject({ revision: 1, status: "draft" });
expect((await h.api("POST", `/policy-revisions/${r1.body.data.id}/preview`)).body.data.preview).toMatchObject({
valid: true,
templates: 2,
});
expect((await h.api("POST", `/policy-revisions/${r1.body.data.id}/publish`, { expectedRevision: 2 })).status).toBe(
409,
);
const p1 = await h.api("POST", `/policy-revisions/${r1.body.data.id}/publish`, { expectedRevision: 1 });
expect(p1.body.data.status).toBe("published");
expect((await h.api("PATCH", `/policy-revisions/${r1.body.data.id}`, { title: "改" })).status).toBe(409);
const g1 = await h.db
.selectFrom("automation_grant")
.selectAll()
.where("policy_revision_id", "=", r1.body.data.id)
.executeTakeFirstOrThrow();
expect(g1).toMatchObject({ status: "active", action: "policy.acquisition" });
const r2 = await h.api("POST", "/policy-revisions", {
policyKey: "acq",
kind: "acquisition",
title: "只要 4K",
rules: { defaultSlots: [{ name: "4k", hardConditions: { resolutions: ["2160p"] } }] },
});
expect(r2.body.data.revision).toBe(2);
await h.api("POST", `/policy-revisions/${r2.body.data.id}/publish`, { expectedRevision: 2 });
expect((await h.api("GET", `/policy-revisions/${r1.body.data.id}`)).body.data.status).toBe("superseded");
expect(
(await h.db.selectFrom("automation_grant").select("status").where("id", "=", g1.id).executeTakeFirstOrThrow())
.status,
).toBe("revoked");
const rb = await h.api("POST", `/policy-revisions/${r1.body.data.id}/rollback`);
expect(rb.body.data).toMatchObject({ revision: 3, status: "published", basedOnRevisionId: r1.body.data.id, rules });
expect((await h.api("GET", `/policy-revisions/${r2.body.data.id}`)).body.data.status).toBe("rolled_back");
const active = await h.db
.selectFrom("automation_grant")
.select("policy_revision_id")
.where("status", "=", "active")
.execute();
expect(active.map((a) => a.policy_revision_id)).toEqual([rb.body.data.id]);
// 订阅没有 Slot 时套用已发布策略的缺省 Slot
const e = await seedEntity(h.db, { kind: "movie", title: "套模板", year: 2025 });
await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "movie" });
const slots = await h.db.selectFrom("acquisition_slot").selectAll().where("media_entity_id", "=", e).execute();
expect(slots.map((s) => s.name)).toEqual(["1080p"]);
});
it("新作品收录:库里没有、首映在窗口内 → 按已发布策略建订阅与 Slot 并预留计划;旧片不算新片", async () => {
const base = await seedBase(h.db, {
libraryPath: "/tmp/nonexistent-lib",
cachePath: "/tmp",
qbUrl: "http://127.0.0.1:9",
});
const recent = new Date(Date.now() - 5 * 86_400_000).toISOString().slice(0, 10);
const fresh = (
await h.db
.insertInto("media_entity")
.values({
kind: "movie",
title: "新片",
year: 2026,
status: "active",
metadata: JSON.stringify({ releaseDate: recent }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const t = makeTorrent({ name: "Fresh.2026.2160p.WEB-DL.mkv", length: 1000, pieceSeed: 3 });
const rel = await seedRelease(h.db, {
siteId: base.siteId,
entityId: fresh,
title: "Fresh.2026.2160p.WEB-DL.H265-NEW",
size: 1000,
hash: t.hash,
});
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel });
expect(ev.plans).toHaveLength(1);
const sub = await h.db
.selectFrom("subscription")
.selectAll()
.where("media_entity_id", "=", fresh)
.executeTakeFirstOrThrow();
expect(sub).toMatchObject({ origin: "policy", mode: "once", priority: "new_work" });
const plan = await h.db
.selectFrom("acquisition_plan")
.selectAll()
.where("id", "=", ev.plans[0]!)
.executeTakeFirstOrThrow();
expect(plan).toMatchObject({ status: "reserved", priority: "new_work", purpose: "fill" });
expect((plan.evaluation as { mode: string }).mode).toBe("new_title");
const old = await seedEntity(h.db, { kind: "movie", title: "老片", year: 1999 });
const t2 = makeTorrent({ name: "Old.1999.1080p.mkv", length: 1000, pieceSeed: 4 });
const rel2 = await seedRelease(h.db, {
siteId: base.siteId,
entityId: old,
title: "Old.1999.1080p.BluRay-OLD",
size: 1000,
hash: t2.hash,
});
const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: rel2 });
expect(ev2.plans).toHaveLength(0);
expect(ev2.skipped[0]?.reason).toBe("new_title:not_new_release");
// 总开关关闭:不建计划
await h.setSettings("automation", { enabled: false });
const t3 = makeTorrent({ name: "Fresh2.mkv", length: 1000, pieceSeed: 5 });
const rel3 = await seedRelease(h.db, {
siteId: base.siteId,
entityId: fresh,
title: "Fresh.2026.1080p.WEB-DL.H264-NEW",
size: 1000,
hash: t3.hash,
});
const ev3 = await evaluateAcquisition(h.d, { sourceReleaseId: rel3 });
expect(ev3.plans).toHaveLength(0);
await h.setSettings("automation", { enabled: true });
});
it("偏好标签:删除/停用写抑制记录;Agent 不能据旧证据加回;用户重新添加解除抑制", async () => {
const tag = { label: "不收恐怖片", kind: "hard", scope: { genres: ["horror"] }, rule: { exclude: true } };
const c = await h.api("POST", "/preference-tags", tag);
expect(c.status).toBe(201);
expect((await h.api("POST", "/preference-tags", tag)).status).toBe(409);
expect((await h.api("DELETE", `/preference-tags/${c.body.data.id}`)).status).toBe(200);
const sup = await h.api("GET", "/preference-suppressions?active=true");
expect(sup.body.data.items).toHaveLength(1);
expect(sup.body.data.items[0].fingerprint).toBe(c.body.data.fingerprint);
expect((await h.api("POST", "/preference-tags", { ...tag, origin: "agent_inferred" })).status).toBe(409);
const again = await h.api("POST", "/preference-tags", { ...tag, origin: "manual" });
expect(again.status).toBe(201);
expect((await h.api("GET", "/preference-suppressions?active=true")).body.data.items).toHaveLength(0);
const off = await h.api("PATCH", `/preference-tags/${again.body.data.id}`, { status: "disabled" });
expect(off.body.data).toMatchObject({ status: "disabled", revision: 2 });
expect((await h.api("GET", "/preference-suppressions?active=true")).body.data.items).toHaveLength(1);
const list = await h.api("GET", "/preference-tags");
expect(list.body.data.items.map((t: { id: string }) => t.id)).toEqual([again.body.data.id]);
});
});
@@ -0,0 +1,462 @@
import { existsSync, readFileSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { sql } from "@umt/db";
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { evaluateAcquisition } from "../services/acquisition.ts";
import { cleanupDownloadCache, submitDownload, syncDownloads } from "../services/downloads.ts";
import { executeRecord, planImport } from "../services/organize.ts";
import { reconcileLibrary } from "../services/reconcile.ts";
import {
createHarness,
fakeDisk,
type Harness,
pgAvailable,
seedBase,
seedEntity,
seedRelease,
seedSlot,
} from "./harness.ts";
// 纵切:release.resolved → 计划 → 提交 mock qB → 完成(落盘)→ Copy 入库 → 整理记录 API(含 changes 游标);
// 以及硬阈值暂停/恢复、already_exists 接管、缓存清理安全边界、资产对账。
const hasPg = await pgAvailable();
const SIZE = 300_000;
describe.skipIf(!hasPg)("acquisition-organize 纵切(真实 PG + mock qB + 临时目录)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
const disk = fakeDisk(10_000_000);
const torrents = new Map<string, Uint8Array>();
let base: Awaited<ReturnType<typeof seedBase>>;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
cache = await createTempTree({}, { prefix: "umt-cache-" });
disk.state.watch = [cache.root];
disk.setPct(40);
h = await createHarness("vertical", {
diskUsage: disk.fn,
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
});
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
await h.setSettings("retention", { seedRetentionDays: 0 });
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
await lib?.cleanup();
await cache?.cleanup();
});
async function release(entityId: string, title: string, opts: { hr?: boolean } = {}) {
const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
const id = await seedRelease(h.db, {
siteId: base.siteId,
entityId,
title,
size: SIZE,
hash: t.hash,
...(opts.hr !== undefined ? { hr: opts.hr } : {}),
});
torrents.set(id, t.bytes);
return { id, hash: t.hash, name: t.name };
}
async function runToCompleted(entityId: string, title: string, opts: { hr?: boolean } = {}) {
const rel = await release(entityId, title, opts);
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
expect(ev.plans).toHaveLength(1);
const sub = await submitDownload(h.d, ev.plans[0]!);
expect(sub.status).toBe("submitted");
await qb.complete(rel.hash, { writeFiles: true });
const sync = await syncDownloads(h.d);
expect(sync.completed).toContain(sub.downloadTaskId);
return { rel, planId: ev.plans[0]!, dtId: sub.downloadTaskId! };
}
let movieId: string;
let slotId: string;
let firstRecord: string;
it("release → 计划(预留 Slot)→ 提交 qB(管理标签)→ 完成 → Copy 入库 → 登记资产与绑定", async () => {
movieId = await seedEntity(h.db, { kind: "movie", title: "沙丘2", year: 2024 });
slotId = await seedSlot(h.db, { entityId: movieId, hard: { resolutions: ["1080p"] } });
// 不满足硬条件的候选:写拒绝理由,不建计划
const bad = await release(movieId, "Dune.Part.Two.2024.720p.WEB-DL.x264-AAA");
const r0 = await evaluateAcquisition(h.d, { sourceReleaseId: bad.id });
expect(r0.plans).toHaveLength(0);
const rel = await release(movieId, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP");
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
expect(ev.plans).toHaveLength(1);
const slot1 = await h.db
.selectFrom("acquisition_slot")
.selectAll()
.where("id", "=", slotId)
.executeTakeFirstOrThrow();
expect(slot1.status).toBe("reserved");
expect(h.engine.enqueued.map((e) => e.task)).toContain("submitDownload");
const sub = await submitDownload(h.d, ev.plans[0]!);
expect(sub).toMatchObject({ status: "submitted", reason: "added" });
const t = qb.get(rel.hash)!;
expect(t.tags).toContain("umt");
expect(t.save_path).toBe(cache.root);
// 重放:账本已有结果,不重复提交
expect((await submitDownload(h.d, ev.plans[0]!)).status).toBe("skipped");
expect(qb.callsTo("torrents/add")).toHaveLength(1);
expect((await h.outbox("download.submitted")).length).toBe(1);
expect(
(await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slotId).executeTakeFirstOrThrow())
.status,
).toBe("downloading");
// 未完成:同步只更新进度
qb.setProgress(rel.hash, 0.5);
const s1 = await syncDownloads(h.d);
expect(s1.completed).toHaveLength(0);
await qb.complete(rel.hash, { writeFiles: true });
const s2 = await syncDownloads(h.d);
expect(s2.completed).toEqual([sub.downloadTaskId]);
const done = await h.outbox("download.status_changed");
expect(done.filter((e) => e.payload.to === "completed")).toHaveLength(1);
expect(
(await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slotId).executeTakeFirstOrThrow())
.status,
).toBe("verifying");
const plan = await planImport(h.d, { downloadTaskId: sub.downloadTaskId, trigger: "auto", actorType: "system" });
expect(plan.status).toBe("running");
firstRecord = plan.recordId!;
const ex = await executeRecord(h.d, firstRecord);
expect(ex).toMatchObject({ status: "succeeded", succeeded: 1, failed: 0 });
const target = join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv");
expect(existsSync(target)).toBe(true);
// Copy:源继续做种
expect(existsSync(join(cache.root, `${rel.name}`))).toBe(true);
const slot = await h.db
.selectFrom("acquisition_slot")
.selectAll()
.where("id", "=", slotId)
.executeTakeFirstOrThrow();
expect(slot.status).toBe("satisfied");
expect(slot.current_asset_id).not.toBeNull();
const binding = await h.db.selectFrom("path_binding").selectAll().where("media_entity_id", "=", movieId).execute();
expect(binding.map((b) => b.relative_path)).toEqual(["沙丘2 (2024)"]);
const loc = await h.db
.selectFrom("asset_location")
.selectAll()
.where("media_asset_id", "=", slot.current_asset_id!)
.executeTakeFirstOrThrow();
expect(loc.relative_path).toBe("沙丘2 (2024)/沙丘2 (2024) - 1080p.mkv");
const dt = await h.db
.selectFrom("download_task")
.selectAll()
.where("id", "=", sub.downloadTaskId!)
.executeTakeFirstOrThrow();
expect(dt.status).toBe("imported");
expect(
(
await h.db
.selectFrom("acquisition_plan")
.select("status")
.where("id", "=", ev.plans[0]!)
.executeTakeFirstOrThrow()
).status,
).toBe("completed");
// 重跑执行:幂等,不重复登记
await executeRecord(h.d, firstRecord);
expect(
await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", movieId).execute(),
).toHaveLength(1);
}, 60_000);
it("整理记录 API:详情含文件明细;changes 游标增量(含既有记录状态更新);changes 先于 {id}", async () => {
const det = await h.api("GET", `/organization-records/${firstRecord}`);
expect(det.status).toBe(200);
expect(det.body.data).toMatchObject({
id: firstRecord,
status: "succeeded",
action: "copy",
origin: "auto",
counts: { total: 1, succeeded: 1 },
});
expect(det.body.data.files[0]).toMatchObject({
role: "main",
status: "succeeded",
target: { path: "沙丘2 (2024)/沙丘2 (2024) - 1080p.mkv", view: "library" },
});
expect(det.body.data.media).toMatchObject({ entityId: movieId, title: "沙丘2" });
const poll = async (after: string) => {
for (let i = 0; i < 50; i++) {
const r = await h.api("GET", `/organization-records/changes?after=${after}&limit=100`);
if (r.body.data.items.length > 0) return r;
await new Promise((res) => setTimeout(res, 100));
}
return h.api("GET", `/organization-records/changes?after=${after}&limit=100`);
};
const first = await poll("0");
expect(first.status).toBe(200);
expect(first.body.data.items.map((i: { id: string }) => i.id)).toContain(firstRecord);
const cursor = first.body.data.nextCursor as string;
const again = await h.api("GET", `/organization-records/changes?after=${cursor}`);
expect(again.body.data).toMatchObject({ items: [], nextCursor: cursor, hasMore: false });
// 既有记录状态更新 → 以更大的 changeSeq 再次出现
await sql`update organization_record set reason = 'touched' where id = ${firstRecord}`.execute(h.db);
const upd = await poll(cursor);
expect(upd.body.data.items.map((i: { id: string }) => i.id)).toEqual([firstRecord]);
expect(BigInt(upd.body.data.nextCursor)).toBeGreaterThan(BigInt(cursor));
const list = await h.api("GET", "/organization-records?limit=10");
expect(list.body.data.items[0].files).toBeNull();
expect((await h.api("GET", "/organization-records/changes?after=abc")).status).toBe(422);
});
it("硬阈值:达到时暂停纳管下载中任务并阻止新计划;低于恢复水位只恢复阈值暂停项", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "阈值片", year: 2024 });
await seedSlot(h.db, { entityId: e, hard: {} });
// 一个下载中的纳管任务 + 一个用户手动暂停的纳管任务(贴标签接管)
const a = makeTorrent({ name: "busy.mkv", length: SIZE, pieceSeed: 11 });
const b = makeTorrent({ name: "user-paused.mkv", length: SIZE, pieceSeed: 12 });
qb.seed({ hash: a.hash, name: "busy.mkv", tags: ["umt"], progress: 0.2, save_path: cache.root, size: SIZE });
qb.seed({
hash: b.hash,
name: "user-paused.mkv",
tags: ["umt"],
progress: 0.2,
save_path: cache.root,
size: SIZE,
state: "pausedDL",
});
const s0 = await syncDownloads(h.d);
expect(s0.adopted).toBe(2);
const rowB = await h.db
.selectFrom("download_task")
.selectAll()
.where("torrent_hash", "=", b.hash)
.executeTakeFirstOrThrow();
expect(rowB).toMatchObject({ status: "paused", pause_reason: "user", origin: "adopted" });
disk.setPct(95);
const s1 = await syncDownloads(h.d);
expect(s1.thresholdPaused).toBe(1);
expect(qb.get(a.hash)!.state).toMatch(/^paused|^stopped/i);
expect(
(await h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", a.hash).executeTakeFirstOrThrow())
.pause_reason,
).toBe("threshold");
const tripped = await h.outbox("download.threshold");
expect(tripped.at(-1)?.payload.state).toBe("tripped");
const rel = await release(e, "Threshold.Movie.2024.1080p.WEB-DL-X");
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
expect(ev.plans).toHaveLength(0);
expect(ev.skipped[0]?.reason).toMatch(/^cache:/);
// 手动恢复阈值暂停项被拒绝(不绕过阈值)
const rowA = await h.db
.selectFrom("download_task")
.select("id")
.where("torrent_hash", "=", a.hash)
.executeTakeFirstOrThrow();
expect((await h.api("POST", `/download-tasks/${rowA.id}/resume`)).status).toBe(412);
// 下一轮观察:暂停中不计入无进展,原因保持 threshold
await syncDownloads(h.d);
expect(
(await h.db.selectFrom("download_task").selectAll().where("id", "=", rowA.id).executeTakeFirstOrThrow())
.pause_reason,
).toBe("threshold");
disk.setPct(88); // 高于恢复水位:保持暂停
expect((await syncDownloads(h.d)).thresholdResumed).toBe(0);
disk.setPct(70);
const s3 = await syncDownloads(h.d);
expect(s3.thresholdResumed).toBe(1);
expect(qb.get(a.hash)!.state).not.toMatch(/^paused|^stopped/i);
expect(qb.get(b.hash)!.state).toMatch(/^paused|^stopped/i);
expect(
(await h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", b.hash).executeTakeFirstOrThrow())
.pause_reason,
).toBe("user");
// 读不到磁盘:保守阻止
disk.state.fail = true;
const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
expect(ev2.plans).toHaveLength(0);
disk.state.fail = false;
disk.setPct(40);
qb.setProgress(a.hash, 0);
}, 60_000);
it("already_exists:种子原本在 qB 里 → 接管,只补管理标签,不改保存路径和其它标签", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "旧种", year: 2023 });
await seedSlot(h.db, { entityId: e });
const rel = await release(e, "Old.Seed.2023.1080p.BluRay-OLD");
qb.seed({ hash: rel.hash, name: rel.name, tags: ["keep"], save_path: "/elsewhere", progress: 0.1, size: SIZE });
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
const sub = await submitDownload(h.d, ev.plans[0]!);
expect(sub).toMatchObject({ status: "submitted", reason: "already_exists" });
const t = qb.get(rel.hash)!;
expect(t.save_path).toBe("/elsewhere");
expect(t.tags.sort()).toEqual(["keep", "umt"]);
const row = await h.db
.selectFrom("download_task")
.selectAll()
.where("id", "=", sub.downloadTaskId!)
.executeTakeFirstOrThrow();
expect(row).toMatchObject({ origin: "adopted", managed: true, acquisition_plan_id: ev.plans[0] });
expect(qb.callsTo("torrents/add").length).toBe(1);
}, 30_000);
it("H&R 未满足时 Move 变冲突:记录失败 + 待处理,不搬源、不静默改 Copy", async () => {
await h.setSettings("organize", { defaultMode: "move" });
const e = await seedEntity(h.db, { kind: "movie", title: "做种片", year: 2022 });
const slot = await seedSlot(h.db, { entityId: e });
const { dtId, rel } = await runToCompleted(e, "Seeding.Movie.2022.1080p.BluRay-HR", { hr: true });
const plan = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
expect(plan.status).toBe("failed");
expect(plan.reason).toContain("hnr_unsatisfied");
const rec = await h.db
.selectFrom("organization_record")
.selectAll()
.where("id", "=", plan.recordId!)
.executeTakeFirstOrThrow();
expect(rec.action).toBe("move");
expect(existsSync(join(cache.root, rel.name))).toBe(true);
expect(existsSync(join(lib.root, "做种片 (2022)"))).toBe(false);
const dec = await h.db
.selectFrom("decision")
.selectAll()
.where("subject_id", "=", plan.recordId!)
.executeTakeFirstOrThrow();
expect(dec).toMatchObject({ kind: "approval_required", status: "open" });
expect(
(await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slot).executeTakeFirstOrThrow())
.status,
).toBe("attention");
await h.setSettings("organize", { defaultMode: "copy" });
}, 30_000);
it("缓存清理:低于阈值不动;超阈值只删已验证入库且满足保留/H&R 的任务,删数据必经安全检查", async () => {
// 已有:第一部片(已入库、hr=false、保留 0 天)→ 可清理;做种片(hr 未满足,未入库)→ 锁定
disk.setPct(50);
const r0 = await cleanupDownloadCache(h.d);
expect(r0.volumes[0]!.action).toBe("none");
const firstDt = await h.db.selectFrom("download_task").selectAll().where("status", "=", "imported").execute();
expect(firstDt.length).toBeGreaterThanOrEqual(1);
// 另造一个"已入库"但 content_path 落在媒体库内的任务:安全检查必须拒绝删数据
const trap = await h.db
.insertInto("download_task")
.values({
instance_id: base.instanceId,
torrent_hash: "f".repeat(40),
name: "trap",
source_release_id: firstDt[0]!.source_release_id,
save_path: lib.root,
content_path: join(lib.root, "沙丘2 (2024)"),
status: "imported",
managed: true,
completed_at: new Date(0),
size_bytes: 1,
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("organization_record")
.values({ trigger: "auto", actor_type: "system", action: "copy", status: "succeeded", download_task_id: trap.id })
.execute();
qb.seed({ hash: "f".repeat(40), name: "trap", tags: ["umt"], progress: 1, save_path: lib.root, size: 1 });
disk.setPct(95);
const r = await cleanupDownloadCache(h.d);
const v = r.volumes[0]!;
expect(v.removed).toContain(firstDt[0]!.id);
expect(v.removed).not.toContain(trap.id);
expect(v.skipped.find((s) => s.downloadTaskId === trap.id)?.reason).toMatch(/拒绝删除|媒体库/);
expect(qb.get(firstDt[0]!.torrent_hash)).toBeUndefined();
// 库内副本不受影响
expect(existsSync(join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv"))).toBe(true);
const locked = v.skipped.map((s) => s.reason);
expect(locked.some((x) => x === "hnr_unsatisfied" || x === "import_not_verified")).toBe(false); // 做种片未入库,不在候选里
const op = await h.db
.selectFrom("asset_operation")
.selectAll()
.where("operation_id", "=", `cleanup:${firstDt[0]!.id}`)
.executeTakeFirstOrThrow();
expect(op).toMatchObject({ kind: "qb_remove", status: "succeeded" });
disk.setPct(40);
}, 30_000);
it("资产对账:库根离线只标 unavailable;缺失按 suspected → confirmed 推进,不删记录不补下载", async () => {
const file = join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv");
const content = readFileSync(file);
const loc = await h.db
.selectFrom("asset_location")
.selectAll()
.where("relative_path", "=", "沙丘2 (2024)/沙丘2 (2024) - 1080p.mkv")
.executeTakeFirstOrThrow();
const { rm } = await import("node:fs/promises");
await rm(join(lib.root, ".uhd-root"));
const off = await reconcileLibrary(h.d);
expect(off.stats.rootsOffline).toHaveLength(1);
expect(
(await h.db.selectFrom("asset_location").select("status").where("id", "=", loc.id).executeTakeFirstOrThrow())
.status,
).toBe("unavailable");
await writeFile(join(lib.root, ".uhd-root"), "x");
await rm(file);
for (const want of ["suspected_missing", "suspected_missing", "confirmed_missing"]) {
await reconcileLibrary(h.d);
expect(
(await h.db.selectFrom("asset_location").select("status").where("id", "=", loc.id).executeTakeFirstOrThrow())
.status,
).toBe(want);
}
expect(
(await h.db.selectFrom("acquisition_slot").select("status").where("id", "=", slotId).executeTakeFirstOrThrow())
.status,
).toBe("attention");
expect(
await h.db.selectFrom("media_asset").select("id").where("id", "=", loc.media_asset_id).executeTakeFirst(),
).toBeDefined();
await writeFile(file, content);
await reconcileLibrary(h.d);
expect(
(await h.db.selectFrom("asset_location").select("status").where("id", "=", loc.id).executeTakeFirstOrThrow())
.status,
).toBe("available");
const runs = await h.db.selectFrom("reconcile_run").select(["kind", "status"]).execute();
expect(runs.every((r) => r.kind === "asset")).toBe(true);
}, 30_000);
it("首页、媒体库、库存叠加 API", async () => {
const dash = await h.api("GET", "/dashboard");
expect(dash.status).toBe(200);
expect(dash.body.data.recentOutput.length).toBeGreaterThan(0);
expect(dash.body.data.cache.volumes[0]).toMatchObject({ hardThresholdPercent: 90, resumePercent: 85 });
expect(dash.body.data.needsAttention.total).toBeGreaterThan(0);
const libList = await h.api("GET", "/library?q=沙丘");
expect(libList.body.data.items[0]).toMatchObject({ entity: { id: movieId }, assetCount: 1 });
const det = await h.api("GET", `/library/${movieId}`);
expect(det.status).toBe(200);
expect(det.body.data.assets[0].locations[0].absolutePath).toBe(
join(lib.root, "沙丘2 (2024)", "沙丘2 (2024) - 1080p.mkv"),
);
expect(det.body.data.candidates.some((c: { decision: string }) => c.decision === "reject")).toBe(true);
expect(det.body.data.downloads[0].organizationRecordIds.length).toBeGreaterThan(0);
const inv = await h.api("GET", `/inventory?entityIds=${movieId}`);
expect(inv.body.data.items[0].versions[0]).toMatchObject({ resolution: "1080p", available: true });
const ms = await h.api("GET", `/inventory/media-servers?mediaEntityId=${movieId}`);
expect(ms.body.data).toEqual({ mediaEntityId: movieId, instances: [] });
});
});
@@ -0,0 +1,197 @@
import { defineTask, type TaskRegistry } from "@umt/tasks";
import { z } from "zod";
import { evaluateAcquisition } from "./services/acquisition.ts";
import { type AoDeps, automationBlock, ensureTaskRow, markTask } from "./services/common.ts";
import { cleanupDownloadCache, submitDownload } from "./services/downloads.ts";
import { executeRecord, planImport } from "./services/organize.ts";
import { reconcileLibrary } from "./services/reconcile.ts";
import { errMessage } from "./util.ts";
// 持久任务处理器。输入只放业务 id;业务状态写业务表;副作用经 withAttemptToken(在 services 里)。
// 步骤体幂等:worker 被 kill 后正在执行的步骤会在新 worker 上从头重跑。
const id = z.string().min(1);
const NETWORK_RETRY = {
maximumAttempts: 5,
initialInterval: "1s",
backoffCoefficient: 2,
maximumInterval: "60s",
} as const;
const FILE_RETRY = {
maximumAttempts: 3,
initialInterval: "2s",
backoffCoefficient: 2,
maximumInterval: "60s",
} as const;
const DB_RETRY = { maximumAttempts: 3, initialInterval: "1s", backoffCoefficient: 2, maximumInterval: "30s" } as const;
/** 业务任务行:手动发起的带 taskId,事件触发的按语义幂等键补建 */
async function taskRow(
d: AoDeps,
kind: "submitDownload" | "verifyAndImport" | "cleanupDownloadCache" | "reconcileLibrary",
given: string | undefined,
key: string,
input: Record<string, unknown>,
runId: string,
subject?: { type: string; id: string },
) {
const taskId =
given ??
(await ensureTaskRow(d.ctx.db, {
kind,
idempotencyKey: key,
input,
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();
await markTask(d.ctx.db, taskId, "running");
return taskId;
}
async function tracked<T>(
d: AoDeps,
taskId: string,
fn: () => Promise<T>,
done: (r: T) => "succeeded" | "failed" | "waiting" = () => "succeeded",
): Promise<T> {
try {
const r = await fn();
await markTask(d.ctx.db, taskId, done(r), { output: r });
return r;
} catch (err) {
await markTask(d.ctx.db, taskId, "failed", { error: errMessage(err) }).catch(() => {});
throw err;
}
}
export function registerAoTasks(registry: TaskRegistry, d: AoDeps) {
registry.register(
defineTask({
name: "evaluateAcquisition",
version: 1,
inputSchema: z.object({
subscriptionId: id.optional(),
acquisitionSlotId: id.optional(),
sourceReleaseId: id.optional(),
contentUnitIds: z.array(id).optional(),
}),
retryPolicy: DB_RETRY,
handler: async (t) =>
t.step.run("evaluate", async () => JSON.parse(JSON.stringify(await evaluateAcquisition(d, t.input)))),
}),
defineTask({
name: "submitDownload",
version: 1,
inputSchema: z.object({ acquisitionPlanId: id, taskId: id.optional() }),
retryPolicy: NETWORK_RETRY,
handler: async (t) => {
const taskId = await taskRow(
d,
"submitDownload",
t.input.taskId,
`plan:${t.input.acquisitionPlanId}`,
t.input,
t.runId,
{ type: "acquisition_plan", id: t.input.acquisitionPlanId },
);
return tracked(
d,
taskId,
() =>
t.step.run("submit", async () => ({
...(await submitDownload(d, t.input.acquisitionPlanId, { runId: t.runId })),
})),
(r) => (r.status === "failed" ? "failed" : r.status === "deferred" ? "waiting" : "succeeded"),
);
},
}),
defineTask({
name: "verifyAndImport",
version: 1,
inputSchema: z.object({ downloadTaskId: id.optional(), recordId: id.optional(), taskId: id.optional() }),
retryPolicy: FILE_RETRY,
handler: async (t) => {
const key = t.input.recordId ? `record:${t.input.recordId}` : `import:${t.input.downloadTaskId ?? t.runId}`;
const taskId = await taskRow(
d,
"verifyAndImport",
t.input.taskId,
key,
t.input,
t.runId,
t.input.downloadTaskId ? { type: "download_task", id: t.input.downloadTaskId } : undefined,
);
return tracked(
d,
taskId,
async () => {
const planned = await t.step.run("plan", async () => {
if (t.input.recordId)
return {
recordId: t.input.recordId as string | null,
status: "running",
reason: null as string | null,
};
if (!t.input.downloadTaskId) return { recordId: null, status: "skipped", reason: "no_input" };
const dt = await d.ctx.db
.selectFrom("download_task")
.select(["status", "managed"])
.where("id", "=", t.input.downloadTaskId)
.executeTakeFirst();
if (!dt || !["completed", "importing"].includes(dt.status))
return { recordId: null, status: "skipped", reason: `download_${dt?.status ?? "missing"}` };
if (!dt.managed) return { recordId: null, status: "skipped", reason: "unmanaged" };
const blocked = await automationBlock(d);
if (blocked) return { recordId: null, status: "deferred", reason: blocked };
const r = await planImport(d, {
downloadTaskId: t.input.downloadTaskId,
trigger: "auto",
actorType: "system",
actorId: null,
taskId,
});
return { recordId: r.recordId, status: r.status as string, reason: r.reason };
});
if (!planned.recordId || (planned.status !== "running" && planned.status !== "queued")) return planned;
const recordId = planned.recordId;
return t.step.run("execute", async () => ({
...(await executeRecord(d, recordId, { runId: t.runId, abortSignal: t.abortSignal })),
}));
},
(r) => (r.status === "failed" ? "failed" : r.status === "deferred" ? "waiting" : "succeeded"),
);
},
}),
defineTask({
name: "cleanupDownloadCache",
version: 1,
inputSchema: z.object({ downloaderInstanceId: id.optional(), taskId: id.optional() }),
retryPolicy: FILE_RETRY,
handler: async (t) => {
const taskId = await taskRow(d, "cleanupDownloadCache", t.input.taskId, `cleanup:${t.runId}`, t.input, t.runId);
return tracked(d, taskId, () =>
t.step.run("cleanup", async () => JSON.parse(JSON.stringify(await cleanupDownloadCache(d, t.input)))),
);
},
}),
defineTask({
name: "reconcileLibrary",
version: 1,
inputSchema: z.object({ libraryRootId: id.optional(), reconcileRunId: id.optional(), taskId: id.optional() }),
retryPolicy: FILE_RETRY,
handler: async (t) => {
const taskId = await taskRow(d, "reconcileLibrary", t.input.taskId, `reconcile:${t.runId}`, t.input, t.runId);
return tracked(d, taskId, () =>
t.step.run("reconcile", async () => JSON.parse(JSON.stringify(await reconcileLibrary(d, t.input)))),
);
},
}),
);
}
@@ -1,4 +1,18 @@
import type { ServerModule } from "../types.ts";
import { type EventPayload, EventType } from "@umt/contracts";
import type { ServerContext, ServerModule } from "../types.ts";
import { registerAoTasks } from "./handlers.ts";
import { type AoOptions, type AoPorts, createPorts } from "./ports.ts";
import { registerDashboardRoutes } from "./routes/dashboard.ts";
import { registerDownloadRoutes } from "./routes/downloads.ts";
import { registerLibraryRoutes } from "./routes/library.ts";
import { registerOrganizationRoutes } from "./routes/organization.ts";
import { registerRuleRoutes } from "./routes/rules.ts";
import { type AoDeps, automationBlock } from "./services/common.ts";
import { cleanupDownloadCache, sweepReservedPlans, syncDownloads } from "./services/downloads.ts";
import { executeRecord, planImport } from "./services/organize.ts";
import { reconcileLibrary } from "./services/reconcile.ts";
import { loadVolumes, usedPercent } from "./services/volumes.ts";
import { errMessage } from "./util.ts";
/**
* W2-C 规则/下载/入库/整理记录:subscription、acquisition_slot、policy_revision、preference_tag;
@@ -6,8 +20,126 @@ import type { ServerModule } from "../types.ts";
* qB 同步与缓存阈值、Copy/Move、path_binding、organization_record 与公开增量 API、媒体库与首页聚合。
* 消费 release.resolved(outboxRoutes);删种带数据一律经 adapters 的 assertSafeToDeleteData。
*/
export const acquisitionOrganizeModule: ServerModule = {
name: "acquisition-organize",
registerRoutes() {},
registerTasks() {},
};
export type AoOverrides = Partial<Omit<AoPorts, "options">> & { options?: Partial<AoOptions> };
/** 模块工厂:测试注入 mock qB / 固定磁盘用量 / 假种子来源;正式装配用 acquisitionOrganizeModule */
export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): ServerModule & { deps(): AoDeps } {
let deps: AoDeps | null = null;
let loop: ReturnType<typeof setTimeout> | null = null;
let stopped = false;
let lastReconcile = 0;
// register* 阶段只保存 ctx;端口在首次使用时组装(ctx 其它字段此时才完整)
const lazy = (ctx: ServerContext): AoDeps => {
deps ??= { ctx, ports: createPorts(ctx, overrides) };
return deps;
};
/** worker 后台一轮:同步 qB → 阈值 → 必要时清理 → 补提交 → 补入库 → 低频资产对账 */
const tick = async (d: AoDeps) => {
await syncDownloads(d);
const blocked = await automationBlock(d);
if (!blocked) {
const cache = await d.ctx.settings.get("cache");
const over = (await loadVolumes(d)).some((v) => (usedPercent(v) ?? 0) >= cache.hardThresholdPercent);
if (over) await cleanupDownloadCache(d);
await sweepReservedPlans(d);
await sweepPendingImports(d);
}
const every = d.ports.options.reconcileIntervalMs;
if (every > 0 && Date.now() - lastReconcile >= every) {
lastReconcile = Date.now();
await reconcileLibrary(d);
}
};
const module: ServerModule & { deps(): AoDeps } = {
name: "acquisition-organize",
deps() {
if (!deps) throw new Error("acquisition-organize 尚未装配");
return deps;
},
registerRoutes(app, ctx) {
const d = lazy(ctx);
registerRuleRoutes(app, d);
registerDownloadRoutes(app, d);
registerOrganizationRoutes(app, d);
registerLibraryRoutes(app, d);
registerDashboardRoutes(app, d);
},
registerTasks(registry, ctx) {
registerAoTasks(registry, lazy(ctx));
},
outboxRoutes: () => ({
[EventType.releaseResolved]: (ev) => {
const p = ev.payload as EventPayload<"release.resolved">;
return {
task: "evaluateAcquisition",
input: { sourceReleaseId: p.sourceReleaseId, contentUnitIds: p.contentUnitIds },
};
},
// 下载完成 → 核验入库(其余状态变化只给 SSE/时间线,不建任务)
[EventType.downloadStatusChanged]: (ev) => {
const p = ev.payload as EventPayload<"download.status_changed">;
return p.to === "completed" ? { task: "verifyAndImport", input: { downloadTaskId: p.downloadTaskId } } : null;
},
}),
async start(ctx) {
const d = lazy(ctx);
if (!d.ports.options.backgroundLoops) return;
stopped = false;
const schedule = async () => {
if (stopped) return;
const cache = await ctx.settings.get("cache").catch(() => null);
const ms = d.ports.options.syncIntervalMs ?? (cache?.checkIntervalSec ?? 60) * 1000;
loop = setTimeout(async () => {
try {
await tick(d);
} catch (err) {
ctx.logger.error({ err: { message: errMessage(err) } }, "acquisition-organize 后台轮询失败");
}
void schedule();
}, ms);
};
void schedule();
},
stop() {
stopped = true;
if (loop) clearTimeout(loop);
},
};
return module;
}
/** 下载已完成、没有整理记录(入库事件被总开关延后等)的纳管任务:直接补做入库 */
async function sweepPendingImports(d: AoDeps): Promise<number> {
const rows = await d.ctx.db
.selectFrom("download_task as dt")
.select("dt.id")
.where("dt.status", "=", "completed")
.where("dt.managed", "=", true)
.where("dt.removed_at", "is", null)
.where("dt.updated_at", "<", new Date(d.ports.now().getTime() - 5 * 60_000))
.where((eb) =>
eb.not(
eb.exists(
eb.selectFrom("organization_record as r").select("r.id").whereRef("r.download_task_id", "=", "dt.id"),
),
),
)
.limit(10)
.execute();
let n = 0;
for (const r of rows) {
try {
const p = await planImport(d, { downloadTaskId: r.id, trigger: "auto", actorType: "system", actorId: null });
if (p.recordId && (p.status === "running" || p.status === "queued")) await executeRecord(d, p.recordId);
n++;
} catch (err) {
d.ctx.logger.warn({ downloadTaskId: r.id, err: { message: errMessage(err) } }, "补入库失败");
}
}
return n;
}
export const acquisitionOrganizeModule = createAcquisitionOrganizeModule();
@@ -0,0 +1,144 @@
import {
createEmbyClient,
createQbClient,
type DiskUsage,
type DownloaderClient,
diskUsage,
type EmbyClient,
ffprobe,
getSiteAdapter,
getSiteDefinition,
type ProbeResult,
type SecretResolver,
type TorrentSource,
type VerifyMethod,
} from "@umt/adapters";
import type { DownloaderInstanceRow, MediaServerInstanceRow, SiteRow, SourceReleaseRow } from "@umt/db";
import type { ServerContext } from "../types.ts";
// 外部依赖端口:默认实现接真实 qB / 站点 / 文件系统 / Emby;测试注入 fake(mock qB、临时目录、固定磁盘用量)。
export type { DownloaderInstanceRow, MediaServerInstanceRow, SiteRow, SourceReleaseRow };
export interface AoOptions {
/** 下载同步/阈值巡检周期;缺省按 settings.cache.checkIntervalSec */
syncIntervalMs?: number;
/** worker 启动时是否跑后台循环(测试里手动调用) */
backgroundLoops: boolean;
/** 下载中且字节不增长超过该时长 → 记证据 + 待处理(不换源) */
noProgressMs: number;
/** 主视频最小体积(低于视为样例) */
minVideoBytes: number;
/** 入库复制的校验方式 */
verifyHash: VerifyMethod;
/** 新作品窗口(天),策略未给时的缺省 */
newTitleWindowDays: number;
/** 资产对账:连续多少次观察缺失才从 suspected_missing 进 confirmed_missing */
missingConfirmAfter: number;
/** 后台资产对账周期(0 = 不自动跑) */
reconcileIntervalMs: number;
}
export interface AoPorts {
now(): Date;
/** 按实例取 qB 客户端(凭证现场经 secrets 解出) */
downloader(inst: DownloaderInstanceRow): Promise<DownloaderClient>;
/** 取种子来源(站点适配器现场取 .torrent / 磁力;不入库、不打日志) */
torrentSource(input: { release: SourceReleaseRow; site: SiteRow | null }): Promise<TorrentSource>;
diskUsage(path: string): Promise<DiskUsage>;
probe(path: string): Promise<ProbeResult>;
mediaServer(inst: MediaServerInstanceRow): Promise<EmbyClient>;
options: AoOptions;
}
export const DEFAULT_OPTIONS: AoOptions = {
backgroundLoops: true,
noProgressMs: 6 * 3600_000,
minVideoBytes: 50 * 1024 * 1024,
verifyHash: "sample",
newTitleWindowDays: 30,
missingConfirmAfter: 3,
reconcileIntervalMs: 6 * 3600_000,
};
const str = (v: unknown) => (typeof v === "string" && v.length > 0 ? v : undefined);
/** 用 ctx 组装默认端口;overrides 逐项覆盖(测试) */
export function createPorts(
ctx: ServerContext,
overrides: Partial<Omit<AoPorts, "options">> & { options?: Partial<AoOptions> } = {},
): AoPorts {
const clients = new Map<string, { stamp: string; client: DownloaderClient }>();
const resolveSecret = async (ref: string | null): Promise<string | undefined> => {
if (!ref) return undefined;
try {
return await ctx.secrets.resolve(ref);
} catch (err) {
ctx.logger.warn({ err: { message: (err as Error).message } }, "凭证解析失败");
return undefined;
}
};
const ports: AoPorts = {
now: () => new Date(),
async downloader(inst) {
const stamp = `${inst.updated_at.toISOString()}|${inst.base_url}`;
const hit = clients.get(inst.id);
if (hit && hit.stamp === stamp) return hit.client;
const settings = (inst.settings ?? {}) as Record<string, unknown>;
const secret = await resolveSecret(inst.secret_ref);
const useApiKey = settings.authKind === "api_key";
const client = createQbClient({
instanceId: inst.id,
baseUrl: inst.base_url,
...(inst.username ? { username: inst.username } : {}),
...(secret ? (useApiKey ? { apiKey: secret } : { password: secret }) : {}),
});
clients.set(inst.id, { stamp, client });
return client;
},
async torrentSource({ release, site }) {
if (!site) throw new Error("来源站点不存在,无法取种子");
const raw = (release.raw ?? {}) as Record<string, unknown>;
const siteMeta = (raw._site ?? {}) as Record<string, unknown>;
const defId = [str(siteMeta.adapterSiteId), site.key].find((k) => k && getSiteDefinition(k));
if (!defId) throw new Error(`站点 ${site.key} 没有可用的适配器定义`);
const accounts = await ctx.db
.selectFrom("site_account")
.select(["auth_kind", "secret_ref"])
.where("site_id", "=", site.id)
.where("enabled", "=", true)
.execute();
const secrets: SecretResolver = {
async resolve({ kind }) {
const want = kind === "apiKey" ? "api_key" : kind;
const acc = accounts.find((a) => a.auth_kind === want);
return (await resolveSecret(acc?.secret_ref ?? null)) ?? null;
},
};
const adapter = getSiteAdapter(defId, { secrets, baseUrl: site.base_url });
const t = await adapter.downloadTarget(release.site_release_id);
if (t.kind === "torrent")
return { kind: "file", bytes: t.bytes, ...(t.fileName ? { filename: t.fileName } : {}) };
if (t.kind === "magnet") return { kind: "magnet", uri: t.uri };
return { kind: "url", url: t.url };
},
diskUsage: (p) => diskUsage(p),
probe: (p) => ffprobe(p),
async mediaServer(inst) {
const settings = (inst.settings ?? {}) as Record<string, unknown>;
const apiKey = (await resolveSecret(inst.secret_ref)) ?? "";
return createEmbyClient({
instanceId: inst.id,
baseUrl: inst.base_url,
apiKey,
flavor: inst.kind,
...(inst.user_id ? { userId: inst.user_id } : {}),
...(str(settings.username) ? { username: str(settings.username)! } : {}),
});
},
options: { ...DEFAULT_OPTIONS },
};
const { options, ...rest } = overrides;
return { ...ports, ...rest, options: { ...ports.options, ...options } };
}
@@ -0,0 +1,288 @@
import { Dashboard, type DecisionKind, type SlotStatusCounts } from "@umt/contracts";
import { sql } from "@umt/db";
import type { ModuleApp } from "../../types.ts";
import type { AoDeps } from "../services/common.ts";
import { cleanupCandidates } from "../services/downloads.ts";
import { decideVolume, loadVolumes, usedPercent } from "../services/volumes.ts";
import { startOfDay } from "../util.ts";
import { mount } from "./helpers.ts";
// 首页六区块(REQ §13.4.3):只读投影,统计未知时给 null 并在 notes 说明。
const iso = (v: Date | null | undefined) => (v ? v.toISOString() : null);
export function registerDashboardRoutes(app: ModuleApp, d: AoDeps) {
mount(
app,
{
method: "get",
path: "/dashboard",
scope: "read",
summary: "首页聚合:自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出",
response: Dashboard,
},
async () => {
const db = d.ctx.db;
const now = d.ports.now();
const day = startOfDay(now);
const [automation, cache] = await Promise.all([d.ctx.settings.get("automation"), d.ctx.settings.get("cache")]);
const today = (
await sql<{
plans: number;
submitted: number;
files: number;
ok: number;
bad: number;
upgrades: number;
subs: number;
}>`
select
(select count(*) from acquisition_plan where created_at >= ${day})::int as plans,
(select count(*) from download_task where created_at >= ${day})::int as submitted,
(select count(*) from organization_file_result f join organization_record r on r.id = f.organization_record_id
where f.status = 'succeeded' and f.role = 'main' and f.finished_at >= ${day})::int as files,
(select count(*) from organization_record where status = 'succeeded' and finished_at >= ${day})::int as ok,
(select count(*) from organization_record where status in ('failed','partial') and finished_at >= ${day})::int as bad,
(select count(*) from acquisition_plan where purpose = 'upgrade' and status = 'completed' and updated_at >= ${day})::int as upgrades,
(select count(*) from subscription where status = 'active')::int as subs`.execute(db)
).rows[0]!;
const slotRows = await db
.selectFrom("acquisition_slot")
.select(["status"])
.select(sql<number>`count(*)::int`.as("n"))
.groupBy("status")
.execute();
const slotCounts: SlotStatusCounts = {
empty: 0,
reserved: 0,
downloading: 0,
verifying: 0,
satisfied: 0,
upgrade_pending: 0,
attention: 0,
};
for (const r of slotRows) slotCounts[r.status] = r.n;
const dlRows = await db
.selectFrom("download_task")
.select(["id", "name", "status", "progress", "pause_reason", "no_progress_since"])
.where("removed_at", "is", null)
.where("status", "in", ["queued", "submitting", "downloading", "stalled", "paused", "importing"])
.orderBy("updated_at", "desc")
.limit(200)
.execute();
const count = (s: string) => dlRows.filter((r) => r.status === s).length;
const reservedPlans = Number(
(
await db
.selectFrom("acquisition_plan")
.select(sql<number>`count(*)::int`.as("n"))
.where("status", "=", "reserved")
.executeTakeFirst()
)?.n ?? 0,
);
const runningRecords = Number(
(
await db
.selectFrom("organization_record")
.select(sql<number>`count(*)::int`.as("n"))
.where("status", "in", ["queued", "running"])
.executeTakeFirst()
)?.n ?? 0,
);
const agentRuns = await db
.selectFrom("agent_run")
.select(["id", "goal", "status"])
.where("status", "in", ["queued", "running", "waiting"])
.orderBy("created_at", "desc")
.limit(10)
.execute();
const decisions = await db
.selectFrom("decision")
.select(["kind"])
.select(sql<number>`count(*)::int`.as("n"))
.where("status", "in", ["open", "investigating", "awaiting_user"])
.groupBy("kind")
.execute();
const thr = await db
.selectFrom("download_task")
.select(["instance_id"])
.select(sql<number>`count(*)::int`.as("n"))
.where("status", "=", "paused")
.where("pause_reason", "=", "threshold")
.where("removed_at", "is", null)
.groupBy("instance_id")
.execute();
const volumes: Dashboard["cache"]["volumes"] = [];
for (const v of await loadVolumes(d)) {
const dv = decideVolume(v, cache);
const cands = await cleanupCandidates(d, v);
const notes: string[] = [];
if (!v.snapshot) notes.push(v.path ? "磁盘用量读取失败,已保守阻止新增下载" : "实例未配置保存路径,无法统计");
if (dv.unknownSizeTaskIds.length) notes.push(`${dv.unknownSizeTaskIds.length} 个在途任务体积未知`);
if (dv.unmanagedGrowthRisk) notes.push("存在未纳管任务仍在增长,暂停纳管任务后磁盘仍可能增长");
const pct = usedPercent(v);
volumes.push({
volumeKey: v.volumeKey,
instanceIds: v.instances.map((t) => t.inst.id),
usedPercent: pct === null ? null : Math.min(100, Math.max(0, pct)),
totalBytes: v.snapshot?.totalBytes ?? null,
usedBytes: v.snapshot?.usedBytes ?? null,
pendingBytes: dv.pendingBytes,
reservedBytes: dv.reservedBytes,
hardThresholdPercent: cache.hardThresholdPercent,
resumePercent: cache.resumePercent,
pausedByThreshold: thr
.filter((t) => v.instances.some((i) => i.inst.id === t.instance_id))
.reduce((s, t) => s + t.n, 0),
cleanableBytes: cands.filter((c) => c.cleanable).reduce((s, c) => s + c.bytes, 0),
retentionLockedBytes: cands.filter((c) => !c.cleanable).reduce((s, c) => s + c.bytes, 0),
notes,
});
}
const [sites, feeds, downloaders, servers] = await Promise.all([
db
.selectFrom("site")
.select(["id", "name", "enabled", "health_status", "last_snapshot_at"])
.orderBy("name")
.execute(),
db
.selectFrom("feed_source")
.select(["id", "name", "enabled", "health_status", "last_success_at", "consecutive_failures", "last_error"])
.orderBy("name")
.execute(),
db
.selectFrom("downloader_instance")
.select(["id", "name", "enabled", "health_status", "last_seen_at"])
.orderBy("name")
.execute(),
db
.selectFrom("media_server_instance")
.select(["id", "name", "enabled", "health_status", "last_seen_at"])
.orderBy("name")
.execute(),
]);
const recent = await db
.selectFrom("organization_record as r")
.leftJoin("media_entity as e", "e.id", "r.media_entity_id")
.select([
"r.id",
"r.media_entity_id",
"e.title",
"r.action",
"r.status",
"r.target_path",
"r.file_total",
"r.file_succeeded",
"r.file_failed",
"r.file_skipped",
"r.finished_at",
])
.where("r.status", "not in", ["queued", "running"])
.orderBy("r.change_seq", "desc")
.limit(10)
.execute();
const out: Dashboard = {
generatedAt: now.toISOString(),
automation: {
enabled: automation.enabled,
readOnlyReconcile: automation.readOnlyReconcile,
today: {
plansCreated: today.plans,
downloadsSubmitted: today.submitted,
filesImported: today.files,
recordsSucceeded: today.ok,
recordsFailed: today.bad,
upgrades: today.upgrades,
},
activeSubscriptions: today.subs,
slotCounts,
},
currentWork: {
downloading: count("downloading"),
paused: count("paused"),
stalled: count("stalled"),
importing: count("importing"),
reservedPlans,
runningRecords,
agentRuns: agentRuns.map((r) => ({ id: r.id, goal: r.goal, status: r.status })),
items: dlRows.slice(0, 20).map((r) => ({
downloadTaskId: r.id,
name: r.name,
status: r.status,
progress: r.progress,
waitReason:
r.status === "paused"
? `paused:${r.pause_reason ?? "user"}`
: r.status === "stalled"
? "no_progress"
: r.status === "queued"
? "queued_in_downloader"
: null,
})),
},
needsAttention: {
total: decisions.reduce((s, r) => s + r.n, 0),
byKind: decisions.map((r) => ({ kind: r.kind as DecisionKind, count: r.n })),
},
cache: { volumes },
sources: {
sites: sites.map((s) => ({
id: s.id,
name: s.name,
enabled: s.enabled,
health: s.health_status,
lastSnapshotAt: iso(s.last_snapshot_at),
})),
feeds: feeds.map((f) => ({
id: f.id,
name: f.name,
enabled: f.enabled,
health: f.health_status,
lastSuccessAt: iso(f.last_success_at),
consecutiveFailures: f.consecutive_failures,
lastError: f.last_error
? String(
(f.last_error as { message?: unknown }).message ??
(f.last_error as { kind?: unknown }).kind ??
"error",
)
: null,
})),
downloaders: downloaders.map((x) => ({
id: x.id,
name: x.name,
enabled: x.enabled,
health: x.health_status,
lastSeenAt: iso(x.last_seen_at),
})),
mediaServers: servers.map((x) => ({
id: x.id,
name: x.name,
enabled: x.enabled,
health: x.health_status,
lastSeenAt: iso(x.last_seen_at),
})),
},
recentOutput: recent.map((r) => ({
recordId: r.id,
mediaEntityId: r.media_entity_id,
title: r.title,
action: r.action,
status: r.status,
targetPath: r.target_path,
counts: { total: r.file_total, succeeded: r.file_succeeded, failed: r.file_failed, skipped: r.file_skipped },
finishedAt: iso(r.finished_at),
})),
};
return out;
},
{ tag: "dashboard" },
);
}
@@ -0,0 +1,115 @@
import { type AcquisitionPlan, Api, type DownloadTask } from "@umt/contracts";
import { z } from "zod";
import { ApiException } from "../../../http/errors.ts";
import type { ModuleApp } from "../../types.ts";
import type { AoDeps } from "../services/common.ts";
import { cleanupDownloadCache, userPause, userResume } from "../services/downloads.ts";
import { reconcileLibrary } from "../services/reconcile.ts";
import { notFound, toApi, toApiList } from "../util.ts";
import { mount, paginate } from "./helpers.ts";
// 下载计划 / 下载任务(含用户暂停与恢复)/ 手动触发缓存清理与资产对账。
const TAG = "downloads";
export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
const db = () => d.ctx.db;
mount(
app,
Api.acquisitionPlans.list,
async (_c, { query }) => {
let q = db().selectFrom("acquisition_plan").selectAll();
if (query.slotId) q = q.where("slot_id", "=", query.slotId);
if (query.status) q = q.where("status", "=", query.status);
const p = await paginate(q, query);
return { items: toApiList<AcquisitionPlan>(p.rows), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
mount(
app,
Api.acquisitionPlans.get,
async (_c, { params }) => {
const r = await db().selectFrom("acquisition_plan").selectAll().where("id", "=", params.id).executeTakeFirst();
return toApi<AcquisitionPlan>(r ?? notFound("下载计划"));
},
{ tag: TAG },
);
mount(
app,
Api.downloadTasks.list,
async (_c, { query }) => {
let q = db().selectFrom("download_task").selectAll();
if (query.instanceId) q = q.where("instance_id", "=", query.instanceId);
if (query.status) q = q.where("status", "=", query.status);
if (query.managed) q = q.where("managed", "=", query.managed === "true");
const p = await paginate(q, query);
return { items: toApiList<DownloadTask>(p.rows), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
mount(
app,
Api.downloadTasks.get,
async (_c, { params }) => {
const r = await db().selectFrom("download_task").selectAll().where("id", "=", params.id).executeTakeFirst();
return toApi<DownloadTask>(r ?? notFound("下载任务"));
},
{ tag: TAG },
);
mount(
app,
Api.downloadTasks.pause,
async (_c, { params }) => {
const exists = await db().selectFrom("download_task").select("id").where("id", "=", params.id).executeTakeFirst();
if (!exists) notFound("下载任务");
return toApi<DownloadTask>(await userPause(d, params.id));
},
{ tag: TAG },
);
mount(
app,
Api.downloadTasks.resume,
async (_c, { params }) => {
const exists = await db().selectFrom("download_task").select("id").where("id", "=", params.id).executeTakeFirst();
if (!exists) notFound("下载任务");
const r = await userResume(d, params.id);
if (r === "threshold_active")
throw new ApiException("precondition_failed", "缓存卷仍高于恢复水位,阈值暂停不能被手动绕过");
return toApi<DownloadTask>(r);
},
{ tag: TAG },
);
mount(
app,
{
method: "post",
path: "/download-cache/cleanup",
scope: "admin",
summary: "立即按阈值清理下载缓存(只清已验证入库且满足保留/H&R 的纳管任务)",
body: z.object({ downloaderInstanceId: z.uuid().optional() }),
response: z.object({ report: z.record(z.string(), z.unknown()) }),
},
async (_c, { body }) => ({ report: await cleanupDownloadCache(d, body) }),
{ tag: TAG, status: 200 },
);
mount(
app,
{
method: "post",
path: "/library/reconcile",
scope: "admin",
summary: "立即做一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载)",
body: z.object({ libraryRootId: z.uuid().optional() }),
response: z.object({ runId: z.uuid(), stats: z.record(z.string(), z.unknown()) }),
},
async (_c, { body }) => {
const r = await reconcileLibrary(d, body);
return { runId: r.runId, stats: r.stats };
},
{ tag: TAG, status: 200 },
);
}
@@ -0,0 +1,93 @@
import { createRoute } from "@hono/zod-openapi";
import { type ApiScope, apiResponse } from "@umt/contracts";
import { sql } from "@umt/db";
import type { Context } from "hono";
import type { SelectQueryBuilder } from "kysely";
import type { z } from "zod";
import { type AuthEnv, requireScope } from "../../../http/auth.ts";
import type { ModuleApp } from "../../types.ts";
import { decodeCursor, encodeCursor } from "../util.ts";
// 路由注册:直接吃 contracts 的 EndpointDef(Api.xxx),scope 走 requireScope 中间件,入参按 schema 校验。
export interface RouteDef {
method: "get" | "post" | "patch" | "put" | "delete";
path: string;
scope: ApiScope;
summary: string;
params?: z.ZodObject;
query?: z.ZodType;
body?: z.ZodType;
response: z.ZodType;
}
export interface RouteInput {
// biome-ignore lint/suspicious/noExplicitAny: 入参已按各自 schema 校验,处理器按需收窄
params: any;
// biome-ignore lint/suspicious/noExplicitAny: 同上
query: any;
// biome-ignore lint/suspicious/noExplicitAny: 同上
body: any;
}
export type Handler = (c: Context<AuthEnv>, input: RouteInput) => Promise<unknown>;
export function mount(
app: ModuleApp,
maybe: RouteDef | undefined,
handler: Handler,
opts: { tag?: string; status?: 200 | 201 } = {},
) {
if (!maybe) throw new Error("contracts 未定义该端点");
const def = maybe;
const status = opts.status ?? (def.method === "post" && !def.path.includes("}/") ? 201 : 200);
const route = createRoute({
method: def.method,
path: def.path,
tags: [opts.tag ?? "acquisition-organize"],
summary: def.summary,
middleware: [requireScope(def.scope)] as const,
request: {
...(def.params ? { params: def.params } : {}),
...(def.query ? { query: def.query as z.ZodObject } : {}),
...(def.body ? { body: { content: { "application/json": { schema: def.body } }, required: true } } : {}),
},
responses: {
[status]: { description: "成功", content: { "application/json": { schema: apiResponse(def.response) } } },
},
});
app.openapi(route, (async (c: Context<AuthEnv>) => {
const v = c.req as unknown as { valid(t: string): unknown };
const input: RouteInput = {
params: def.params ? v.valid("param") : {},
query: def.query ? v.valid("query") : {},
body: def.body ? v.valid("json") : undefined,
};
const data = await handler(c, input);
return c.json({ ok: true as const, data }, status);
}) as never);
}
/** (created_at desc, id desc) 游标翻页;col 前缀用于带别名的查询 */
export async function paginate<R extends { id: string; created_at: Date }>(
// biome-ignore lint/suspicious/noExplicitAny: 通用翻页,表类型由调用方决定
qb: SelectQueryBuilder<any, any, R>,
page: { cursor?: string | undefined; limit: number },
prefix = "",
): Promise<{ rows: R[]; nextCursor: string | null }> {
const c = decodeCursor(page.cursor);
let q = qb;
if (c)
q = q.where(
sql<boolean>`(${sql.ref(`${prefix}created_at`)}, ${sql.ref(`${prefix}id`)}) < (${c.at}::timestamptz, ${c.id}::uuid)`,
);
const rows = await q
.orderBy(sql.ref(`${prefix}created_at`), "desc")
.orderBy(sql.ref(`${prefix}id`), "desc")
.limit(page.limit + 1)
.execute();
const more = rows.length > page.limit;
const items = rows.slice(0, page.limit);
const last = items[items.length - 1];
return { rows: items, nextCursor: more && last ? encodeCursor(last.created_at, last.id) : null };
}
@@ -0,0 +1,430 @@
import { join } from "node:path";
import {
type AcquisitionPlan,
type AcquisitionSlot,
type AssetLocation,
type CandidateEvaluation,
cursorPage,
type DownloadTask,
type ExternalIdentity,
IdParam,
InventoryQuery,
InventoryResponse,
LibraryDetail,
type LibraryItem,
LibraryItem as LibraryItemSchema,
LibraryListQuery,
type MediaAsset,
type MediaEntity,
MediaServerInventory,
MediaServerInventoryQuery,
type PathBinding,
type SlotStatusCounts,
} from "@umt/contracts";
import { sql } from "@umt/db";
import { ApiException } from "../../../http/errors.ts";
import type { ModuleApp } from "../../types.ts";
import { type AoDeps, CANDIDATE_ARTIFACT_TITLE } from "../services/common.ts";
import { notFound, toApi, toApiList } from "../util.ts";
import { mount, paginate } from "./helpers.ts";
// 媒体库(列表/详情)、站点工作台库存叠加、Emby 兼容库存查询(多实例,未知 ≠ 缺失)。
const TAG = "library";
const ACTIVE_DL = ["queued", "submitting", "downloading", "stalled", "paused", "completed", "importing"] as const;
const emptyCounts = (): SlotStatusCounts => ({
empty: 0,
reserved: 0,
downloading: 0,
verifying: 0,
satisfied: 0,
upgrade_pending: 0,
attention: 0,
});
export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
const db = () => d.ctx.db;
mount(
app,
{
method: "get",
path: "/library",
scope: "read",
summary: "媒体库列表(已登记资产或正在追踪的作品)",
query: LibraryListQuery,
response: cursorPage(LibraryItemSchema),
},
async (_c, { query }) => {
let q = db()
.selectFrom("media_entity as e")
.select(["e.id", "e.kind", "e.title", "e.original_title", "e.year", "e.status", "e.created_at"])
.select(
sql<number>`(select count(*) from media_asset a where a.media_entity_id = e.id and a.status = 'active' and a.parent_asset_id is null)::int`.as(
"asset_count",
),
)
.select(
sql<number>`(select count(*) from download_task dt left join acquisition_plan p on p.id = dt.acquisition_plan_id
left join acquisition_slot s on s.id = p.slot_id left join source_release sr on sr.id = dt.source_release_id
where (s.media_entity_id = e.id or sr.media_entity_id = e.id) and dt.removed_at is null
and dt.status in ('queued','submitting','downloading','stalled','paused'))::int`.as("active_downloads"),
)
.select(
sql<Date | null>`(select max(r.finished_at) from organization_record r where r.media_entity_id = e.id and r.status in ('succeeded','partial'))`.as(
"last_org",
),
)
.select(
sql<
Record<string, number>
>`(select coalesce(json_object_agg(x.status, x.n), '{}'::json) from (select status, count(*)::int n from acquisition_slot where media_entity_id = e.id group by status) x)`.as(
"slot_counts",
),
)
.where("e.status", "!=", "merged")
.where((eb) =>
eb.or([
eb.exists(eb.selectFrom("media_asset as a").select("a.id").whereRef("a.media_entity_id", "=", "e.id")),
eb.exists(eb.selectFrom("acquisition_slot as s").select("s.id").whereRef("s.media_entity_id", "=", "e.id")),
eb.exists(
eb.selectFrom("subscription as sub").select("sub.id").whereRef("sub.media_entity_id", "=", "e.id"),
),
]),
);
if (query.kind) q = q.where("e.kind", "=", query.kind);
if (query.q) {
const like = `%${String(query.q).replace(/[\\%_]/g, (m) => `\\${m}`)}%`;
q = q.where((eb) =>
eb.or([
eb("e.title", "ilike", like),
eb("e.original_title", "ilike", like),
eb.exists(
eb
.selectFrom("title_alias as t")
.select("t.id")
.whereRef("t.media_entity_id", "=", "e.id")
.where("t.alias", "ilike", like),
),
]),
);
}
if (query.filter === "gap")
q = q.where(
sql<boolean>`exists (select 1 from acquisition_slot s where s.media_entity_id = e.id and s.status in ('empty','attention','upgrade_pending'))`,
);
if (query.filter === "attention")
q = q.where(
sql<boolean>`exists (select 1 from acquisition_slot s where s.media_entity_id = e.id and s.status = 'attention')`,
);
if (query.filter === "satisfied")
q = q.where(
sql<boolean>`exists (select 1 from acquisition_slot s where s.media_entity_id = e.id) and not exists (select 1 from acquisition_slot s where s.media_entity_id = e.id and s.status <> 'satisfied')`,
);
if (query.filter === "downloading")
q = q.where(
sql<boolean>`exists (select 1 from download_task dt left join acquisition_plan p on p.id = dt.acquisition_plan_id left join acquisition_slot s on s.id = p.slot_id
where s.media_entity_id = e.id and dt.removed_at is null and dt.status in ('queued','submitting','downloading','stalled','paused'))`,
);
const p = await paginate(q, query, "e.");
const items: LibraryItem[] = p.rows.map((r) => ({
entity: {
id: r.id,
kind: r.kind,
title: r.title,
originalTitle: r.original_title,
year: r.year,
status: r.status,
},
assetCount: r.asset_count,
slotCounts: { ...emptyCounts(), ...(r.slot_counts ?? {}) },
activeDownloads: r.active_downloads,
lastOrganizedAt: r.last_org ? new Date(r.last_org).toISOString() : null,
}));
return { items, nextCursor: p.nextCursor };
},
{ tag: TAG },
);
mount(
app,
{
method: "get",
path: "/library/{id}",
scope: "read",
summary: "作品详情:实际路径、身份映射、元数据来源、各 Slot、候选拒绝理由、下载历史与校验结果",
params: IdParam,
response: LibraryDetail,
},
async (_c, { params }) => {
const e = await db().selectFrom("media_entity").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!e) notFound("作品");
const id = e!.id;
const [identities, bindings, assets, slots, roots] = await Promise.all([
db()
.selectFrom("external_identity")
.selectAll()
.where("media_entity_id", "=", id)
.orderBy("created_at")
.execute(),
db().selectFrom("path_binding").selectAll().where("media_entity_id", "=", id).orderBy("bound_at").execute(),
db().selectFrom("media_asset").selectAll().where("media_entity_id", "=", id).orderBy("created_at").execute(),
db()
.selectFrom("acquisition_slot")
.selectAll()
.where("media_entity_id", "=", id)
.orderBy("created_at")
.execute(),
db().selectFrom("library_root").select(["id", "local_path"]).execute(),
]);
const assetIds = assets.map((a) => a.id);
const [locs, cov] = assetIds.length
? await Promise.all([
db().selectFrom("asset_location").selectAll().where("media_asset_id", "in", assetIds).execute(),
db()
.selectFrom("asset_unit_coverage")
.select(["media_asset_id", "content_unit_id"])
.where("media_asset_id", "in", assetIds)
.execute(),
])
: [[], []];
const slotIds = slots.map((s) => s.id);
const cands = slotIds.length
? await db()
.selectFrom("artifact")
.select(["content"])
.where("subject_type", "=", "acquisition_slot")
.where("subject_id", "in", slotIds)
.where("title", "=", CANDIDATE_ARTIFACT_TITLE)
.orderBy("updated_at", "desc")
.limit(100)
.execute()
: [];
const dts = await db()
.selectFrom("download_task as dt")
.leftJoin("acquisition_plan as p", "p.id", "dt.acquisition_plan_id")
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
.leftJoin("source_release as sr", "sr.id", "dt.source_release_id")
.selectAll("dt")
.where((eb) => eb.or([eb("s.media_entity_id", "=", id), eb("sr.media_entity_id", "=", id)]))
.orderBy("dt.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
? await db().selectFrom("acquisition_plan").selectAll().where("id", "in", planIds).execute()
: [];
const recs = dts.length
? await db()
.selectFrom("organization_record")
.select(["id", "download_task_id", "status", "created_at"])
.where(
"download_task_id",
"in",
dts.map((t) => t.id),
)
.orderBy("created_at")
.execute()
: [];
const rootPath = new Map(roots.map((r) => [r.id, r.local_path]));
const detail: LibraryDetail = {
entity: toApi<MediaEntity>(e!),
identities: toApiList<ExternalIdentity>(identities),
metadataSources: { fields: (e!.metadata ?? {}) as Record<string, unknown>, fieldLocks: e!.field_locks },
pathBindings: toApiList<PathBinding>(bindings),
assets: assets.map((a) => ({
asset: toApi<MediaAsset>(a),
locations: locs
.filter((l) => l.media_asset_id === a.id)
.map((l) => ({
...toApi<AssetLocation>(l),
absolutePath:
l.library_root_id && rootPath.has(l.library_root_id)
? join(rootPath.get(l.library_root_id)!, l.relative_path)
: null,
})),
contentUnitIds: cov.filter((c) => c.media_asset_id === a.id).map((c) => c.content_unit_id),
})),
slots: toApiList<AcquisitionSlot>(slots),
candidates: cands.map((c) => c.content as CandidateEvaluation),
downloads: dts.map((t) => {
const mine = recs.filter((r) => r.download_task_id === t.id);
const plan = plans.find((p) => p.id === t.acquisition_plan_id);
return {
downloadTask: toApi<DownloadTask>(t),
plan: plan ? toApi<AcquisitionPlan>(plan) : null,
organizationRecordIds: mine.map((r) => r.id),
lastOrganizationStatus: mine[mine.length - 1]?.status ?? null,
};
}),
};
return detail;
},
{ tag: TAG },
);
mount(
app,
{
method: "get",
path: "/inventory",
scope: "read",
summary: "库存叠加(站点工作台):按作品返回已有版本 / Slot 缺口 / 下载中",
query: InventoryQuery,
response: InventoryResponse,
},
async (_c, { query }) => {
const ids = [
...new Set(
String(query.entityIds)
.split(",")
.map((s) => s.trim())
.filter(Boolean),
),
];
if (ids.length > 200 || ids.some((i) => !/^[0-9a-f-]{36}$/i.test(i)))
throw new ApiException("validation_failed", "entityIds 必须是最多 200 个 UUID");
if (ids.length === 0) return { items: [] };
const [assets, slots, dts] = await Promise.all([
db()
.selectFrom("media_asset as a")
.select(["a.id", "a.media_entity_id", "a.delivery", "a.content_edition", "a.size_bytes"])
.select(
sql<boolean>`exists (select 1 from asset_location l where l.media_asset_id = a.id and l.status = 'available')`.as(
"available",
),
)
.where("a.media_entity_id", "in", ids)
.where("a.status", "=", "active")
.where("a.parent_asset_id", "is", null)
.execute(),
db()
.selectFrom("acquisition_slot")
.select(["id", "media_entity_id", "name", "status"])
.where("media_entity_id", "in", ids)
.where("status", "in", ["empty", "attention", "upgrade_pending"])
.execute(),
db()
.selectFrom("download_task as dt")
.leftJoin("acquisition_plan as p", "p.id", "dt.acquisition_plan_id")
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
.leftJoin("source_release as sr", "sr.id", "dt.source_release_id")
.select(["dt.id", "dt.name", "dt.status", "dt.progress"])
.select(sql<string>`coalesce(s.media_entity_id, sr.media_entity_id)`.as("entity_id"))
.where((eb) => eb.or([eb("s.media_entity_id", "in", ids), eb("sr.media_entity_id", "in", ids)]))
.where("dt.removed_at", "is", null)
.where("dt.status", "in", [...ACTIVE_DL])
.execute(),
]);
return {
items: ids.map((entityId) => ({
entityId,
versions: assets
.filter((a) => a.media_entity_id === entityId)
.map((a) => {
const dv = (a.delivery ?? {}) as { resolution?: string; sourceType?: string; releaseGroup?: string };
return {
assetId: a.id,
resolution: dv.resolution ?? null,
sourceType: dv.sourceType ?? null,
releaseGroup: dv.releaseGroup ?? null,
contentEdition: a.content_edition,
sizeBytes: a.size_bytes,
available: a.available,
};
}),
gaps: slots
.filter((s) => s.media_entity_id === entityId)
.map((s) => ({ slotId: s.id, name: s.name, status: s.status })),
downloading: dts
.filter((t) => t.entity_id === entityId)
.map((t) => ({ downloadTaskId: t.id, name: t.name, status: t.status, progress: t.progress })),
})),
};
},
{ tag: TAG },
);
mount(
app,
{
method: "get",
path: "/inventory/media-servers",
scope: "read",
summary: "Emby 兼容库存查询(多实例;接口失败/截断 = unknown,不等于缺失)",
query: MediaServerInventoryQuery,
response: MediaServerInventory,
},
async (_c, { query }) => {
const e = await db()
.selectFrom("media_entity")
.select(["id", "kind", "title", "year"])
.where("id", "=", query.mediaEntityId)
.executeTakeFirst();
if (!e) notFound("作品");
const idents = await db()
.selectFrom("external_identity")
.select(["provider", "entity_type", "external_id"])
.where("media_entity_id", "=", e!.id)
.where("status", "=", "accepted")
.execute();
const insts = await db()
.selectFrom("media_server_instance")
.selectAll()
.where("enabled", "=", true)
.orderBy("name")
.execute();
const type = e!.kind === "movie" ? "Movie" : "Series";
const KEY: Record<string, string> = { tmdb: "Tmdb", imdb: "Imdb", tvdb: "Tvdb", douban: "Douban" };
const out: MediaServerInventory = { mediaEntityId: e!.id, instances: [] };
for (const inst of insts) {
const observedAt = new Date().toISOString();
const usable = idents.filter((i) => KEY[i.provider]);
if (usable.length === 0) {
out.instances.push({
instanceId: inst.id,
name: inst.name,
status: "unknown",
reason: "no_external_id",
items: [],
observedAt,
});
continue;
}
let status: "present" | "not_found" | "unknown" = "not_found";
let reason: string | null = null;
const items: MediaServerInventory["instances"][number]["items"] = [];
try {
const client = await d.ports.mediaServer(inst);
for (const i of usable) {
const r = await client.findByProviderId(KEY[i.provider]!, i.external_id, type);
if (r.status === "present") {
status = "present";
for (const it of r.items) {
if (items.some((x) => x.itemId === it.itemId)) continue;
items.push({
itemId: it.itemId,
type: it.type,
name: it.name,
year: it.year ?? null,
path: it.path ?? null,
versions: it.versions.map((v) => JSON.parse(JSON.stringify(v)) as Record<string, unknown>),
});
}
} else if (r.status === "unknown" && status !== "present") {
status = "unknown";
reason = r.reason;
}
}
} catch (err) {
status = "unknown";
reason = (err as Error).message;
}
out.instances.push({ instanceId: inst.id, name: inst.name, status, reason, items, observedAt });
}
return out;
},
{ tag: TAG },
);
}
@@ -0,0 +1,170 @@
import { randomUUID } from "node:crypto";
import { Api, type OrganizationChangesResponse } from "@umt/contracts";
import { sql } from "@umt/db";
import { ApiException } from "../../../http/errors.ts";
import type { ModuleApp } from "../../types.ts";
import { type AoDeps, ensureTaskRow, markTask } from "../services/common.ts";
import { loadRecordsPublic, planImport } from "../services/organize.ts";
import { actorOf, errMessage, jsonb, notFound } from "../util.ts";
import { mount, paginate } from "./helpers.ts";
// 整理记录公开 API(organize.read / organize.execute)。changes 必须先于 {id} 注册。
const TAG = "organization";
export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) {
const db = () => d.ctx.db;
mount(
app,
Api.organization.list,
async (_c, { query }) => {
let q = db().selectFrom("organization_record").selectAll();
if (query.status) q = q.where("status", "=", query.status);
if (query.trigger) q = q.where("trigger", "=", query.trigger);
if (query.mediaEntityId) q = q.where("media_entity_id", "=", query.mediaEntityId);
if (query.q) {
const like = `%${String(query.q).replace(/[\\%_]/g, (m) => `\\${m}`)}%`;
q = q.where((eb) =>
eb.or([
eb("source_path", "ilike", like),
eb("target_path", "ilike", like),
eb("media_entity_id", "in", eb.selectFrom("media_entity").select("id").where("title", "ilike", like)),
]),
);
}
const p = await paginate(q, query);
return { items: await loadRecordsPublic(db(), p.rows, false), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
// 增量读取:change_xid 早于当前快照最小活跃事务,避免"序号小但提交晚"被跳过
mount(
app,
Api.organization.changes,
async (_c, { query }): Promise<OrganizationChangesResponse> => {
const after = Number(query.after ?? "0");
let q = db()
.selectFrom("organization_record")
.selectAll()
.where("change_seq", ">", after)
.where(sql<boolean>`change_xid < pg_snapshot_xmin(pg_current_snapshot())`);
if (query.status) q = q.where("status", "=", query.status);
if (query.mediaEntityId) q = q.where("media_entity_id", "=", query.mediaEntityId);
const rows = await q
.orderBy("change_seq")
.limit(query.limit + 1)
.execute();
const hasMore = rows.length > query.limit;
const page = rows.slice(0, query.limit);
const last = page[page.length - 1];
return {
items: await loadRecordsPublic(db(), page, query.includeFiles),
nextCursor: last ? String(last.change_seq) : String(after),
hasMore,
};
},
{ tag: TAG },
);
mount(
app,
Api.organization.get,
async (_c, { params }) => {
const r = await db().selectFrom("organization_record").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!r) notFound("整理记录");
return (await loadRecordsPublic(db(), [r!], true))[0]!;
},
{ tag: TAG },
);
// 手动发起整理:与自动整理走同一冻结计划流程;返回 record/task ID,由任务页追踪
mount(
app,
Api.organization.create,
async (c, { body }) => {
const principal = c.get("principal");
const actorType = principal?.kind === "api_key" ? "api_key" : "user";
const idem =
body.downloadTaskId && !body.dryRun
? `manual:${body.downloadTaskId}:${randomUUID()}`
: `manual:${randomUUID()}`;
const taskId = await ensureTaskRow(db(), {
kind: "verifyAndImport",
idempotencyKey: idem,
input: { ...body },
subjectType: body.downloadTaskId ? "download_task" : "organize_request",
subjectId: body.downloadTaskId ?? null,
triggeredBy: actorOf(c),
});
if (body.downloadTaskId) {
const dt = await db()
.selectFrom("download_task")
.select(["status"])
.where("id", "=", body.downloadTaskId)
.executeTakeFirst();
if (!dt) throw new ApiException("not_found", "下载任务不存在");
if (!["completed", "importing", "imported"].includes(dt.status))
throw new ApiException("precondition_failed", `下载任务尚未完成(${dt.status})`);
}
let plan: Awaited<ReturnType<typeof planImport>>;
try {
plan = await planImport(d, {
downloadTaskId: body.downloadTaskId,
sourcePaths: body.sourcePaths,
mediaEntityId: body.mediaEntityId,
contentUnitId: body.contentUnitId,
action: body.action,
targetLibraryRootId: body.targetLibraryRootId,
trigger: "manual",
actorType,
actorId: actorOf(c),
taskId,
dryRun: body.dryRun,
});
} catch (err) {
await markTask(db(), taskId, "failed", { error: errMessage(err) });
throw new ApiException("bad_request", errMessage(err));
}
if (body.dryRun) {
// 只生成计划:冻结为 artifact(kind=plan),planId 供 organize.execute 使用
const art = await db()
.insertInto("artifact")
.values({
kind: "plan",
task_id: taskId,
subject_type: "organize_request",
title: "organize_plan",
content: jsonb({ request: body, plan: plan.plan ?? null, reason: plan.reason, status: plan.status }),
content_hash: randomUUID(),
})
.returning("id")
.executeTakeFirstOrThrow();
await markTask(db(), taskId, "succeeded", {
output: { planId: art.id, status: plan.status, reason: plan.reason },
});
return { recordId: null, taskId, planId: art.id };
}
if (plan.status === "running" || plan.status === "queued") {
const { runId } = await d.ctx.engine.enqueue(
"verifyAndImport",
{ recordId: plan.recordId, downloadTaskId: body.downloadTaskId, taskId },
{ idempotencyKey: `verifyAndImport:${idem}`, traceId: taskId },
);
await db().updateTable("task").set({ engine_run_id: runId }).where("id", "=", taskId).execute();
if (plan.recordId)
await db()
.updateTable("organization_record")
.set({ task_id: taskId })
.where("id", "=", plan.recordId)
.where("task_id", "is", null)
.execute();
} else {
await markTask(db(), taskId, "failed", { error: plan.reason ?? plan.status });
}
return { recordId: plan.recordId, taskId, planId: null };
},
{ tag: TAG, status: 201 },
);
}
@@ -0,0 +1,730 @@
import {
AcquisitionPolicyRules,
type AcquisitionSlot,
Api,
type PolicyRevision,
type PreferenceSuppression,
type PreferenceTag,
type Subscription,
} from "@umt/contracts";
import { type DB, sql, type Transaction } from "@umt/db";
import { evaluateCandidate, parseRelease } from "@umt/domain";
import { slotSpecFromRow } from "@umt/mapping";
import { ApiException } from "../../../http/errors.ts";
import type { ModuleApp } from "../../types.ts";
import { createSlotsFromTemplates, publishedAcquisitionPolicies } from "../services/acquisition.ts";
import type { AoDeps } from "../services/common.ts";
import { actorOf, conflict, errMessage, hashJson, jsonb, notFound, toApi, toApiList } from "../util.ts";
import { mount, paginate } from "./helpers.ts";
// 规则 API:订阅、收录 Slot、策略版本(草案→发布→回滚,发布即持续授权)、偏好标签与抑制记录。
const TAG = "rules";
/** 规则变化后按需重评(引擎未就绪/入队失败不影响写入) */
async function kickEvaluate(d: AoDeps, input: { subscriptionId?: string; acquisitionSlotId?: string }) {
try {
const key = input.acquisitionSlotId ?? input.subscriptionId ?? "x";
await d.ctx.engine.enqueue("evaluateAcquisition", input, {
idempotencyKey: `reeval:${key}:${Date.now()}`,
traceId: key,
});
} catch (err) {
d.ctx.logger.debug({ err: { message: errMessage(err) } }, "重评入队失败");
}
}
export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
const db = () => d.ctx.db;
// ── 订阅 ──
mount(
app,
Api.subscriptions.list,
async (_c, { query }) => {
let q = db().selectFrom("subscription").selectAll();
if (query.status) q = q.where("status", "=", query.status);
if (query.mediaEntityId) q = q.where("media_entity_id", "=", query.mediaEntityId);
const p = await paginate(q, query);
return { items: toApiList<Subscription>(p.rows), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
mount(
app,
Api.subscriptions.get,
async (_c, { params }) => {
const r = await db().selectFrom("subscription").selectAll().where("id", "=", params.id).executeTakeFirst();
return toApi<Subscription>(r ?? notFound("订阅"));
},
{ tag: TAG },
);
mount(
app,
Api.subscriptions.create,
async (c, { body }) => {
if (body.mediaEntityId) {
const e = await db()
.selectFrom("media_entity")
.select("id")
.where("id", "=", body.mediaEntityId)
.executeTakeFirst();
if (!e) throw new ApiException("bad_request", "mediaEntityId 不存在");
}
const row = await db()
.transaction()
.execute(async (trx) => {
const r = await trx
.insertInto("subscription")
.values({
media_entity_id: body.mediaEntityId ?? null,
season_unit_id: body.seasonUnitId ?? null,
title_query: body.titleQuery ?? null,
media_kind: body.mediaKind,
mode: body.mode ?? "continuous",
// 用户主动添加的剧集默认持续追踪并接续后续季;电影无季概念
season_scope: body.seasonScope ?? (body.mediaKind === "movie" ? "current_season" : "following_seasons"),
origin: body.origin ?? "user",
priority: body.priority ?? "tracking",
policy_revision_id: body.policyRevisionId ?? null,
params: jsonb(body.params),
created_by: actorOf(c),
})
.returningAll()
.executeTakeFirstOrThrow();
if (r.media_entity_id) {
const has = await trx
.selectFrom("acquisition_slot")
.select("id")
.where("media_entity_id", "=", r.media_entity_id)
.executeTakeFirst();
if (!has) {
const pols = await publishedAcquisitionPolicies(trx);
const pol =
(body.policyRevisionId ? pols.find((p) => p.row.id === body.policyRevisionId) : undefined) ??
pols.find((p) => p.rules.defaultSlots?.length);
if (pol?.rules.defaultSlots?.length) {
await createSlotsFromTemplates(trx, {
entityId: r.media_entity_id,
subscriptionId: r.id,
policyRevisionId: pol.row.id,
templates: pol.rules.defaultSlots,
});
}
}
}
return r;
});
if (row.media_entity_id) await kickEvaluate(d, { subscriptionId: row.id });
return toApi<Subscription>(row);
},
{ tag: TAG },
);
mount(
app,
Api.subscriptions.update,
async (_c, { params, body }) => {
const r = await db()
.updateTable("subscription")
.set({
...(body.mode !== undefined ? { mode: body.mode } : {}),
...(body.seasonScope !== undefined ? { season_scope: body.seasonScope } : {}),
...(body.priority !== undefined ? { priority: body.priority } : {}),
...(body.status !== undefined ? { status: body.status } : {}),
...(body.policyRevisionId !== undefined ? { policy_revision_id: body.policyRevisionId } : {}),
...(body.params !== undefined ? { params: jsonb(body.params) } : {}),
...(body.statusReason !== undefined ? { status_reason: body.statusReason } : {}),
updated_at: new Date(),
})
.where("id", "=", params.id)
.returningAll()
.executeTakeFirst();
return toApi<Subscription>(r ?? notFound("订阅"));
},
{ tag: TAG },
);
// ── 收录 Slot ──
mount(
app,
Api.acquisitionSlots.list,
async (_c, { query }) => {
let q = db().selectFrom("acquisition_slot").selectAll();
if (query.mediaEntityId) q = q.where("media_entity_id", "=", query.mediaEntityId);
if (query.status) q = q.where("status", "=", query.status);
const p = await paginate(q, query);
return { items: toApiList<AcquisitionSlot>(p.rows), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
mount(
app,
Api.acquisitionSlots.get,
async (_c, { params }) => {
const r = await db().selectFrom("acquisition_slot").selectAll().where("id", "=", params.id).executeTakeFirst();
return toApi<AcquisitionSlot>(r ?? notFound("收录目标"));
},
{ tag: TAG },
);
mount(
app,
Api.acquisitionSlots.create,
async (_c, { body }) => {
const e = await db()
.selectFrom("media_entity")
.select("id")
.where("id", "=", body.mediaEntityId)
.executeTakeFirst();
if (!e) throw new ApiException("bad_request", "mediaEntityId 不存在");
const r = await db()
.insertInto("acquisition_slot")
.values({
media_entity_id: body.mediaEntityId,
content_unit_id: body.contentUnitId ?? null,
subscription_id: body.subscriptionId ?? null,
policy_revision_id: body.policyRevisionId ?? null,
name: body.name,
hard_conditions: jsonb(body.hardConditions),
soft_preferences: jsonb(body.softPreferences),
fallback: jsonb(body.fallback),
upgrade_threshold: jsonb(body.upgradeThreshold),
completion: jsonb(body.completion),
distinct_group: body.distinctGroup ?? null,
require_distinct_asset: body.requireDistinctAsset ?? false,
})
.onConflict((oc) => oc.doNothing())
.returningAll()
.executeTakeFirst();
if (!r) conflict("同一作品/内容单元下已有同名 Slot");
await kickEvaluate(d, { acquisitionSlotId: r!.id });
return toApi<AcquisitionSlot>(r!);
},
{ tag: TAG },
);
mount(
app,
Api.acquisitionSlots.update,
async (_c, { params, body }) => {
const { expectedRevision, ...b } = body;
const r = await db()
.updateTable("acquisition_slot")
.set({
...(b.subscriptionId !== undefined ? { subscription_id: b.subscriptionId } : {}),
...(b.policyRevisionId !== undefined ? { policy_revision_id: b.policyRevisionId } : {}),
...(b.hardConditions !== undefined ? { hard_conditions: jsonb(b.hardConditions) } : {}),
...(b.softPreferences !== undefined ? { soft_preferences: jsonb(b.softPreferences) } : {}),
...(b.fallback !== undefined ? { fallback: jsonb(b.fallback) } : {}),
...(b.upgradeThreshold !== undefined ? { upgrade_threshold: jsonb(b.upgradeThreshold) } : {}),
...(b.completion !== undefined ? { completion: jsonb(b.completion) } : {}),
...(b.distinctGroup !== undefined ? { distinct_group: b.distinctGroup } : {}),
...(b.requireDistinctAsset !== undefined ? { require_distinct_asset: b.requireDistinctAsset } : {}),
revision: sql`revision + 1`,
})
.where("id", "=", params.id)
.where("revision", "=", expectedRevision)
.returningAll()
.executeTakeFirst();
if (!r) {
const exists = await db()
.selectFrom("acquisition_slot")
.select("revision")
.where("id", "=", params.id)
.executeTakeFirst();
if (!exists) notFound("收录目标");
conflict(`revision 不匹配(当前 ${exists!.revision})`);
}
await kickEvaluate(d, { acquisitionSlotId: params.id });
return toApi<AcquisitionSlot>(r!);
},
{ tag: TAG },
);
// ── 策略版本 ──
mount(
app,
Api.policyRevisions.list,
async (_c, { query }) => {
let q = db().selectFrom("policy_revision").selectAll();
if (query.policyKey) q = q.where("policy_key", "=", query.policyKey);
if (query.status) q = q.where("status", "=", query.status);
const p = await paginate(q, query);
return { items: toApiList<PolicyRevision>(p.rows), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
mount(
app,
Api.policyRevisions.get,
async (_c, { params }) => {
const r = await db().selectFrom("policy_revision").selectAll().where("id", "=", params.id).executeTakeFirst();
return toApi<PolicyRevision>(r ?? notFound("策略版本"));
},
{ tag: TAG },
);
const checkRules = (kind: string, rules: unknown) => {
if (kind !== "acquisition") return;
const r = AcquisitionPolicyRules.safeParse(rules);
if (!r.success)
throw new ApiException(
"validation_failed",
`rules 不符合收录策略结构: ${r.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`,
);
};
mount(
app,
Api.policyRevisions.create,
async (c, { body }) => {
checkRules(body.kind, body.rules);
const r = await db()
.transaction()
.execute(async (trx) => {
await sql`select pg_advisory_xact_lock(hashtext(${`policy:${body.policyKey}`}))`.execute(trx);
const prev = await trx
.selectFrom("policy_revision")
.select(["kind"])
.select(sql<number>`max(revision)`.as("max"))
.where("policy_key", "=", body.policyKey)
.groupBy("kind")
.executeTakeFirst();
if (prev && prev.kind !== body.kind) conflict(`policyKey ${body.policyKey} 已属于 ${prev.kind}`);
return trx
.insertInto("policy_revision")
.values({
policy_key: body.policyKey,
revision: (prev?.max ?? 0) + 1,
kind: body.kind,
status: "draft",
title: body.title,
summary: body.summary ?? null,
scope: jsonb(body.scope),
rules: jsonb(body.rules),
based_on_revision_id: body.basedOnRevisionId ?? null,
created_by: actorOf(c),
})
.returningAll()
.executeTakeFirstOrThrow();
});
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
mount(
app,
Api.policyRevisions.update,
async (_c, { params, body }) => {
const cur = await db().selectFrom("policy_revision").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!cur) notFound("策略版本");
if (cur!.status !== "draft") conflict("只有草案可以修改;已发布版本请新建草案");
if (body.rules !== undefined) checkRules(cur!.kind, body.rules);
const r = await db()
.updateTable("policy_revision")
.set({
...(body.title !== undefined ? { title: body.title } : {}),
...(body.summary !== undefined ? { summary: body.summary } : {}),
...(body.scope !== undefined ? { scope: jsonb(body.scope) } : {}),
...(body.rules !== undefined ? { rules: jsonb(body.rules) } : {}),
...(body.basedOnRevisionId !== undefined ? { based_on_revision_id: body.basedOnRevisionId } : {}),
preview: null,
})
.where("id", "=", params.id)
.where("status", "=", "draft")
.returningAll()
.executeTakeFirstOrThrow();
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
mount(
app,
Api.policyRevisions.preview,
async (_c, { params }) => {
const cur = await db().selectFrom("policy_revision").selectAll().where("id", "=", params.id).executeTakeFirst();
if (!cur) notFound("策略版本");
const preview = await previewPolicy(d, cur!.rules);
const r = await db()
.updateTable("policy_revision")
.set({ preview: jsonb(preview) })
.where("id", "=", params.id)
.returningAll()
.executeTakeFirstOrThrow();
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
mount(
app,
Api.policyRevisions.publish,
async (c, { params, body }) => {
const r = await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("policy_revision")
.selectAll()
.where("id", "=", params.id)
.forUpdate()
.executeTakeFirst();
if (!cur) notFound("策略版本");
if (cur!.revision !== body.expectedRevision) conflict(`revision 不匹配(当前 ${cur!.revision})`);
if (cur!.status !== "draft") conflict(`只能发布草案(当前 ${cur!.status})`);
checkRules(cur!.kind, cur!.rules);
return publishRevision(trx, cur!.id, cur!.policy_key, actorOf(c), "superseded");
});
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
mount(
app,
Api.policyRevisions.rollback,
async (c, { params }) => {
const r = await db()
.transaction()
.execute(async (trx) => {
const target = await trx
.selectFrom("policy_revision")
.selectAll()
.where("id", "=", params.id)
.executeTakeFirst();
if (!target) notFound("策略版本");
if (target!.status === "published") conflict("该版本已是当前发布版本");
if (target!.status === "draft") conflict("草案不能作为回滚目标");
await sql`select pg_advisory_xact_lock(hashtext(${`policy:${target!.policy_key}`}))`.execute(trx);
const max = await trx
.selectFrom("policy_revision")
.select(sql<number>`max(revision)`.as("m"))
.where("policy_key", "=", target!.policy_key)
.executeTakeFirstOrThrow();
// 回滚 = 以目标版本内容发布一个新版本(历史单调递增,可审计)
const clone = await trx
.insertInto("policy_revision")
.values({
policy_key: target!.policy_key,
revision: max.m + 1,
kind: target!.kind,
status: "draft",
title: target!.title,
summary: `回滚到 r${target!.revision}`,
scope: jsonb(target!.scope),
rules: jsonb(target!.rules),
based_on_revision_id: target!.id,
created_by: actorOf(c),
})
.returning("id")
.executeTakeFirstOrThrow();
return publishRevision(trx, clone.id, target!.policy_key, actorOf(c), "rolled_back");
});
return toApi<PolicyRevision>(r);
},
{ tag: TAG },
);
// ── 偏好标签 ──
const fingerprint = (b: { kind: string; scope?: unknown; rule?: unknown }) =>
hashJson({ kind: b.kind, scope: b.scope ?? {}, rule: b.rule ?? {} });
mount(
app,
Api.preferenceTags.list,
async (_c, { query }) => {
let q = db().selectFrom("preference_tag").selectAll();
if (query.status) q = q.where("status", "=", query.status);
else q = q.where("status", "!=", "deleted");
if (query.kind) q = q.where("kind", "=", query.kind);
const p = await paginate(q, query);
return { items: toApiList<PreferenceTag>(p.rows), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
mount(
app,
Api.preferenceTags.get,
async (_c, { params }) => {
const r = await db().selectFrom("preference_tag").selectAll().where("id", "=", params.id).executeTakeFirst();
return toApi<PreferenceTag>(r ?? notFound("偏好标签"));
},
{ tag: TAG },
);
mount(
app,
Api.preferenceTags.create,
async (c, { body }) => {
const fp = fingerprint(body);
const origin = body.origin ?? "manual";
const r = await db()
.transaction()
.execute(async (trx) => {
const sup = await trx
.selectFrom("preference_suppression")
.selectAll()
.where("fingerprint", "=", fp)
.where("active", "=", true)
.executeTakeFirst();
if (sup) {
// Agent 不得据旧证据自动加回;用户手动(或对话确认)重新添加即解除抑制
if (origin === "agent_inferred") conflict("该偏好已被用户取消(存在抑制记录),Agent 不能自动加回");
await trx
.updateTable("preference_suppression")
.set({ active: false, lifted_at: new Date(), lifted_by: actorOf(c) })
.where("id", "=", sup.id)
.execute();
}
const ins = await trx
.insertInto("preference_tag")
.values({
label: body.label,
kind: body.kind,
scope: jsonb(body.scope),
rule: jsonb(body.rule),
fingerprint: fp,
origin,
description: body.description ?? null,
evidence: jsonb(body.evidence),
})
.onConflict((oc) => oc.doNothing())
.returningAll()
.executeTakeFirst();
if (!ins) conflict("已存在同一语义的偏好标签");
return ins!;
});
return toApi<PreferenceTag>(r);
},
{ tag: TAG },
);
mount(
app,
Api.preferenceTags.update,
async (c, { params, body }) => {
const r = await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("preference_tag")
.selectAll()
.where("id", "=", params.id)
.forUpdate()
.executeTakeFirst();
if (!cur || cur.status === "deleted") notFound("偏好标签");
const next = { kind: cur!.kind, scope: body.scope ?? cur!.scope, rule: body.rule ?? cur!.rule };
const fp = fingerprint(next);
const status = body.status ?? cur!.status;
if (status !== "active" && cur!.status === "active")
await suppress(trx, cur!.id, cur!.fingerprint, actorOf(c), `status:${status}`);
if (status === "active" && cur!.status !== "active") {
await trx
.updateTable("preference_suppression")
.set({ active: false, lifted_at: new Date(), lifted_by: actorOf(c) })
.where("fingerprint", "=", fp)
.where("active", "=", true)
.execute();
}
if (fp !== cur!.fingerprint && cur!.status === "active")
await suppress(trx, cur!.id, cur!.fingerprint, actorOf(c), "edited");
return trx
.updateTable("preference_tag")
.set({
...(body.label !== undefined ? { label: body.label } : {}),
...(body.kind !== undefined ? { kind: body.kind } : {}),
...(body.scope !== undefined ? { scope: jsonb(body.scope) } : {}),
...(body.rule !== undefined ? { rule: jsonb(body.rule) } : {}),
...(body.description !== undefined ? { description: body.description } : {}),
...(body.evidence !== undefined ? { evidence: jsonb(body.evidence) } : {}),
fingerprint: fp,
status,
revision: cur!.revision + 1,
})
.where("id", "=", params.id)
.returningAll()
.executeTakeFirstOrThrow();
});
return toApi<PreferenceTag>(r);
},
{ tag: TAG },
);
mount(
app,
Api.preferenceTags.remove,
async (c, { params }) => {
await db()
.transaction()
.execute(async (trx) => {
const cur = await trx
.selectFrom("preference_tag")
.selectAll()
.where("id", "=", params.id)
.forUpdate()
.executeTakeFirst();
if (!cur || cur.status === "deleted") notFound("偏好标签");
await trx
.updateTable("preference_tag")
.set({ status: "deleted", revision: cur!.revision + 1 })
.where("id", "=", params.id)
.execute();
await suppress(trx, cur!.id, cur!.fingerprint, actorOf(c), "deleted");
});
return { ok: true as const };
},
{ tag: TAG, status: 200 },
);
// ── 抑制记录 ──
mount(
app,
Api.preferenceSuppressions.list,
async (_c, { query }) => {
let q = db().selectFrom("preference_suppression").selectAll();
if (query.active) q = q.where("active", "=", query.active === "true");
const p = await paginate(q, query);
return { items: toApiList<PreferenceSuppression>(p.rows), nextCursor: p.nextCursor };
},
{ tag: TAG },
);
mount(
app,
Api.preferenceSuppressions.get,
async (_c, { params }) => {
const r = await db()
.selectFrom("preference_suppression")
.selectAll()
.where("id", "=", params.id)
.executeTakeFirst();
return toApi<PreferenceSuppression>(r ?? notFound("抑制记录"));
},
{ tag: TAG },
);
mount(
app,
Api.preferenceSuppressions.create,
async (c, { body }) => {
const r = await db()
.insertInto("preference_suppression")
.values({
preference_tag_id: body.preferenceTagId ?? null,
fingerprint: body.fingerprint,
reason: body.reason ?? null,
evidence: jsonb(body.evidence),
suppressed_by: actorOf(c),
})
.returningAll()
.executeTakeFirstOrThrow();
return toApi<PreferenceSuppression>(r);
},
{ tag: TAG },
);
mount(
app,
Api.preferenceSuppressions.update,
async (c, { params, body }) => {
const r = await db()
.updateTable("preference_suppression")
.set({
active: false,
lifted_at: new Date(),
lifted_by: actorOf(c),
...(body.reason ? { reason: body.reason } : {}),
})
.where("id", "=", params.id)
.returningAll()
.executeTakeFirst();
return toApi<PreferenceSuppression>(r ?? notFound("抑制记录"));
},
{ tag: TAG },
);
}
async function suppress(trx: Transaction<DB>, tagId: string, fingerprint: string, by: string, reason: string) {
await trx
.insertInto("preference_suppression")
.values({ preference_tag_id: tagId, fingerprint, reason, evidence: jsonb({ tagId }), suppressed_by: by })
.execute();
}
/** 发布:旧 published → superseded/rolled_back;吊销旧授权;新版本生成持续授权 automation_grant */
async function publishRevision(
trx: Transaction<DB>,
id: string,
policyKey: string,
by: string,
oldStatus: "superseded" | "rolled_back",
) {
const old = await trx
.selectFrom("policy_revision")
.select(["id"])
.where("policy_key", "=", policyKey)
.where("status", "=", "published")
.execute();
const now = new Date();
for (const o of old as { id: string }[]) {
await trx.updateTable("policy_revision").set({ status: oldStatus }).where("id", "=", o.id).execute();
await trx
.updateTable("automation_grant")
.set({ status: "revoked", revoked_at: now, revoked_by: by })
.where("policy_revision_id", "=", o.id)
.where("status", "=", "active")
.execute();
}
const row = await trx
.updateTable("policy_revision")
.set({ status: "published", published_at: now, published_by: by })
.where("id", "=", id)
.returningAll()
.executeTakeFirstOrThrow();
const rules = (row.rules ?? {}) as { budget?: unknown };
await trx
.insertInto("automation_grant")
.values({
action: `policy.${row.kind}`,
policy_revision_id: row.id,
scope: jsonb(row.scope),
budget: jsonb(rules.budget ?? {}),
granted_by: by,
reason: `发布策略 ${policyKey} r${row.revision}`,
})
.execute();
return row;
}
/** 预览:用规则里的 Slot 模板评估最近已识别的发布(确定性,不产生副作用) */
async function previewPolicy(d: AoDeps, rules: unknown) {
const r = AcquisitionPolicyRules.safeParse(rules);
const templates = r.success ? [...(r.data.defaultSlots ?? []), ...(r.data.newTitle?.slots ?? [])] : [];
const rels = await d.ctx.db
.selectFrom("source_release")
.select(["id", "title", "subtitle", "size_bytes"])
.where("resolution_status", "=", "resolved")
.orderBy("first_seen_at", "desc")
.limit(200)
.execute();
let matched = 0;
let excluded = 0;
const samples: { sourceReleaseId: string; template: string; decision: string }[] = [];
for (const rel of rels) {
const parsed = {
...parseRelease(rel.title, rel.subtitle ? { subtitle: rel.subtitle } : {}),
...(rel.size_bytes !== null ? { sizeBytes: rel.size_bytes } : {}),
};
for (const t of templates) {
const spec = slotSpecFromRow({
id: "preview",
name: t.name,
hardConditions: t.hardConditions ?? {},
softPreferences: t.softPreferences ?? {},
upgradeThreshold: t.upgradeThreshold ?? {},
completion: t.completion ?? {},
fallback: t.fallback ?? {},
});
const ev = evaluateCandidate(spec, parsed, null);
if (ev.decision === "accept") matched++;
else excluded++;
if (samples.length < 20) samples.push({ sourceReleaseId: rel.id, template: t.name, decision: ev.decision });
}
}
return {
evaluatedAt: new Date().toISOString(),
sampleSize: rels.length,
templates: templates.length,
matched,
excluded,
samples,
valid: r.success,
};
}
@@ -0,0 +1,655 @@
import type {
AcquisitionPolicyRules,
AcquisitionPriority,
AcquisitionSlot,
CandidateEvaluation,
SlotTemplate,
} from "@umt/contracts";
import type { AcquisitionSlotRow, MediaEntityRow, SourceReleaseRow, SubscriptionRow } from "@umt/db";
import { sql } from "@umt/db";
import {
type AcquisitionMode,
checkBudget,
determineMode,
evaluateCandidate,
type ParsedRelease,
parseRelease,
priorityOf,
type QualityFacts,
type SlotEvaluation,
type SlotState,
transition,
} from "@umt/domain";
import { fromParsedColumns, slotSpecFromRow, toContractSlotEvaluation } from "@umt/mapping";
import { hashJson, jsonb, startOfDay, toApi } from "../util.ts";
import { type AoDeps, automationBlock, type Exec, enqueueTracked, loadInstances, recordCandidate } from "./common.ts";
import { decideVolume, loadVolumes } from "./volumes.ts";
// evaluateAcquisition:release.resolved → 匹配订阅/Slot → 模式 → 确定性评估 → 预算/缓存卷 → 冻结计划并预留。
// 拒绝与被挡住的原因都写成候选评估记录(artifact),供作品详情"为什么选/不选"。
export interface EvaluateInput {
sourceReleaseId?: string | undefined;
subscriptionId?: string | undefined;
acquisitionSlotId?: string | undefined;
/** release.resolved 带来的内容单元 */
contentUnitIds?: string[] | undefined;
}
export interface EvaluateOutcome {
evaluated: number;
plans: string[];
skipped: { sourceReleaseId: string; reason: string }[];
}
const PRIORITY: Record<ReturnType<typeof priorityOf>, AcquisitionPriority> = {
immediate: "immediate",
catch_up: "tracking",
new_title: "new_work",
upgrade: "upgrade",
};
const BUSY: readonly string[] = ["reserved", "downloading", "verifying"];
/** source_release → ParsedRelease(解析版本过期时按原标题重解析;站点体积覆盖解析体积) */
export function parsedOf(r: SourceReleaseRow): ParsedRelease {
const p =
fromParsedColumns({ parsed: (r.parsed ?? {}) as Record<string, unknown>, parserVersion: r.parser_version }) ??
parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
return r.size_bytes !== null ? { ...p, sizeBytes: r.size_bytes } : p;
}
/** 当前资产的交付特征:入库时记在 media_info._umt.quality */
export async function currentFacts(exec: Exec, assetId: string | null): Promise<QualityFacts | null> {
if (!assetId) return null;
const a = await exec
.selectFrom("media_asset")
.select(["media_info", "delivery", "size_bytes"])
.where("id", "=", assetId)
.executeTakeFirst();
if (!a) return null;
const q = ((a.media_info as { _umt?: { quality?: QualityFacts } })?._umt?.quality ?? {}) as QualityFacts;
return { ...q, ...(a.size_bytes !== null ? { sizeBytes: a.size_bytes } : {}) };
}
/** 单元键:有 content_unit 用其 id,没有时用 s{季}e{集} 合成键(追更判定两侧同口径) */
export function unitKeysOf(p: Pick<ParsedRelease, "season" | "episodes" | "airDate">): string[] {
if (p.airDate) return [`d${p.airDate}`];
if (!p.episodes) return [];
const s = p.season ?? 1;
const out: string[] = [];
for (let e = p.episodes.start; e <= p.episodes.end && out.length < 500; e++) out.push(`s${s}e${e}`);
return out;
}
async function releaseUnits(
exec: Exec,
entityId: string,
parsed: ParsedRelease,
fromEvent: readonly string[],
): Promise<string[]> {
const keys = unitKeysOf(parsed);
const ids = new Set(fromEvent);
if (parsed.episodes) {
const rows = await exec
.selectFrom("content_unit")
.select(["id", "season_number", "episode_number"])
.where("media_entity_id", "=", entityId)
.where("kind", "=", "episode")
.where("season_number", "=", parsed.season ?? 1)
.where("episode_number", ">=", parsed.episodes.start)
.where("episode_number", "<=", parsed.episodes.end)
.execute();
for (const r of rows) ids.add(r.id);
}
return [...ids, ...keys];
}
/** Slot 已满足的单元:该 Slot 的计划带来的资产 + 当前资产的覆盖(content_unit id 与合成键) */
async function satisfiedUnits(exec: Exec, slot: AcquisitionSlotRow): Promise<string[]> {
const assets = await sql<{ id: string; media_info: unknown }>`
select distinct a.id, a.media_info from media_asset a
left join download_task dt on dt.id = a.download_task_id
left join acquisition_plan p on p.id = dt.acquisition_plan_id
where a.status = 'active' and a.parent_asset_id is null
and (p.slot_id = ${slot.id} or a.id = ${slot.current_asset_id})`.execute(exec);
if (assets.rows.length === 0) return [];
const ids = assets.rows.map((a) => a.id);
const cov = await exec
.selectFrom("asset_unit_coverage")
.select("content_unit_id")
.where("media_asset_id", "in", ids)
.execute();
const keys = assets.rows.flatMap((a) => (a.media_info as { _umt?: { unitKeys?: string[] } })?._umt?.unitKeys ?? []);
return [...cov.map((c) => c.content_unit_id), ...keys];
}
async function resolveEntity(exec: Exec, id: string): Promise<MediaEntityRow | null> {
let cur = await exec.selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirst();
for (let i = 0; cur?.merged_into_id && i < 10; i++) {
cur = await exec.selectFrom("media_entity").selectAll().where("id", "=", cur.merged_into_id).executeTakeFirst();
}
return cur ?? null;
}
const firstReleaseDate = (e: MediaEntityRow): string | undefined => {
const m = (e.metadata ?? {}) as Record<string, unknown>;
for (const k of ["releaseDate", "firstAirDate", "release_date", "first_air_date"]) {
const v = m[k];
const s =
typeof v === "string"
? v
: typeof (v as { value?: unknown })?.value === "string"
? (v as { value: string }).value
: undefined;
if (s && /^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
}
return e.year ? `${e.year}-01-01` : undefined;
};
/** 已发布的收录策略(kind=acquisition) */
export async function publishedAcquisitionPolicies(exec: Exec) {
const rows = await exec
.selectFrom("policy_revision")
.selectAll()
.where("kind", "=", "acquisition")
.where("status", "=", "published")
.execute();
return rows.map((r) => ({
row: r,
rules: (r.rules ?? {}) as AcquisitionPolicyRules,
scope: (r.scope ?? {}) as { mediaKinds?: string[] },
}));
}
const subKindOf = (k: MediaEntityRow["kind"]): "movie" | "series" | "variety" =>
k === "movie" ? "movie" : k === "variety" ? "variety" : "series";
export async function createSlotsFromTemplates(
exec: Exec,
input: {
entityId: string;
subscriptionId: string | null;
policyRevisionId: string | null;
templates: readonly SlotTemplate[];
},
): Promise<void> {
for (const t of input.templates) {
await exec
.insertInto("acquisition_slot")
.values({
media_entity_id: input.entityId,
subscription_id: input.subscriptionId,
policy_revision_id: input.policyRevisionId,
name: t.name,
hard_conditions: jsonb(t.hardConditions),
soft_preferences: jsonb(t.softPreferences),
fallback: jsonb(t.fallback),
upgrade_threshold: jsonb(t.upgradeThreshold),
completion: jsonb(t.completion),
})
.onConflict((oc) => oc.doNothing())
.execute();
}
}
async function budgetUsage(exec: Exec, now: Date) {
const day = startOfDay(now);
const r = await sql<{ new_titles: number; bytes: number; reserved: number; active: number; reserved_count: number }>`
select
(select count(*) from acquisition_plan where created_at >= ${day} and status not in ('cancelled','failed','expired')
and evaluation->>'mode' = 'new_title')::int as new_titles,
(select coalesce(sum(reserved_bytes),0) from acquisition_plan where created_at >= ${day} and status in ('submitted','completed'))::bigint as bytes,
(select coalesce(sum(reserved_bytes),0) from acquisition_plan where created_at >= ${day} and status = 'reserved')::bigint as reserved,
(select count(*) from download_task where managed and removed_at is null and status in ('queued','submitting','downloading','stalled'))::int as active,
(select count(*) from acquisition_plan where status = 'reserved')::int as reserved_count`.execute(exec);
const row = r.rows[0]!;
return {
newTitlesToday: row.new_titles,
bytesToday: Number(row.bytes),
reservedBytesToday: Number(row.reserved),
activeDownloads: row.active + row.reserved_count,
};
}
interface Ctx {
d: AoDeps;
now: Date;
out: EvaluateOutcome;
}
async function note(c: Ctx, base: Omit<CandidateEvaluation, "evaluatedAt">) {
await recordCandidate(c.d.ctx.db, { ...base, evaluatedAt: c.now.toISOString() });
}
export async function evaluateAcquisition(d: AoDeps, input: EvaluateInput): Promise<EvaluateOutcome> {
const c: Ctx = { d, now: d.ports.now(), out: { evaluated: 0, plans: [], skipped: [] } };
const db = d.ctx.db;
if (input.sourceReleaseId) {
const rel = await db
.selectFrom("source_release")
.selectAll()
.where("id", "=", input.sourceReleaseId)
.executeTakeFirst();
if (!rel) {
c.out.skipped.push({ sourceReleaseId: input.sourceReleaseId, reason: "release_not_found" });
return c.out;
}
await evaluateRelease(c, rel, input.contentUnitIds ?? [], input.acquisitionSlotId);
return c.out;
}
// 按 Slot / 订阅重评:取该作品最近已识别的发布
let entityId: string | null = null;
if (input.acquisitionSlotId) {
entityId =
(
await db
.selectFrom("acquisition_slot")
.select("media_entity_id")
.where("id", "=", input.acquisitionSlotId)
.executeTakeFirst()
)?.media_entity_id ?? null;
} else if (input.subscriptionId) {
entityId =
(
await db
.selectFrom("subscription")
.select("media_entity_id")
.where("id", "=", input.subscriptionId)
.executeTakeFirst()
)?.media_entity_id ?? null;
}
if (!entityId) return c.out;
const rels = await db
.selectFrom("source_release")
.selectAll()
.where("media_entity_id", "=", entityId)
.where("resolution_status", "=", "resolved")
.where("availability", "=", "active")
.orderBy("first_seen_at", "desc")
.limit(50)
.execute();
for (const r of rels) await evaluateRelease(c, r, [], input.acquisitionSlotId);
return c.out;
}
async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readonly string[], onlySlotId?: string) {
const { d, now } = c;
const db = d.ctx.db;
const skip = (reason: string) => c.out.skipped.push({ sourceReleaseId: rel.id, reason });
if (!rel.media_entity_id || rel.resolution_status !== "resolved") return skip("release_unresolved");
if (rel.availability !== "active") return skip(`release_${rel.availability}`);
const entity = await resolveEntity(db, rel.media_entity_id);
if (!entity || entity.status === "archived") return skip("entity_unavailable");
const parsed = parsedOf(rel);
const units = await releaseUnits(
db,
entity.id,
parsed,
rel.content_unit_id ? [...eventUnits, rel.content_unit_id] : eventUnits,
);
const isMovie = entity.kind === "movie";
let subs = await db.selectFrom("subscription").selectAll().where("media_entity_id", "=", entity.id).execute();
let slots = await db
.selectFrom("acquisition_slot")
.selectAll()
.where("media_entity_id", "=", entity.id)
.orderBy("created_at")
.execute();
let forcedMode: AcquisitionMode | null = null;
// 新作品收录:库里没有(无订阅、无 Slot、无资产)且策略开启
if (subs.length === 0 && slots.length === 0) {
const hasAsset = await db
.selectFrom("media_asset")
.select("id")
.where("media_entity_id", "=", entity.id)
.where("status", "=", "active")
.executeTakeFirst();
if (hasAsset) return skip("not_tracked");
const pol = (await publishedAcquisitionPolicies(db)).find(
(p) =>
p.rules.newTitle?.enabled &&
(!p.rules.newTitle.mediaKinds?.length || p.rules.newTitle.mediaKinds.includes(subKindOf(entity.kind))),
);
if (!pol?.rules.newTitle) return skip("not_tracked");
const m = determineMode({
entity: null,
releaseUnits: units,
satisfiedUnits: [],
slotSatisfied: false,
...(firstReleaseDate(entity) ? { firstReleaseDate: firstReleaseDate(entity)! } : {}),
now: now.toISOString(),
newTitleWindowDays: pol.rules.newTitle.windowDays ?? d.ports.options.newTitleWindowDays,
});
if (m.mode !== "new_title") return skip(`new_title:${m.reason}`);
const grant = await db
.selectFrom("automation_grant")
.select("id")
.where("policy_revision_id", "=", pol.row.id)
.where("status", "=", "active")
.executeTakeFirst();
if (!grant) return skip("no_automation_grant");
await db.transaction().execute(async (trx) => {
const sub = await trx
.insertInto("subscription")
.values({
media_entity_id: entity.id,
media_kind: subKindOf(entity.kind),
mode: "once",
season_scope: "current_season",
origin: "policy",
priority: "new_work",
policy_revision_id: pol.row.id,
created_by: `policy:${pol.row.policy_key}`,
})
.returning("id")
.executeTakeFirstOrThrow();
await createSlotsFromTemplates(trx, {
entityId: entity.id,
subscriptionId: sub.id,
policyRevisionId: pol.row.id,
templates: pol.rules.newTitle!.slots,
});
});
subs = await db.selectFrom("subscription").selectAll().where("media_entity_id", "=", entity.id).execute();
slots = await db
.selectFrom("acquisition_slot")
.selectAll()
.where("media_entity_id", "=", entity.id)
.orderBy("created_at")
.execute();
forcedMode = "new_title";
}
// 订阅停止条件
for (const s of subs) {
const stopAfter = (s.params as { stopAfter?: string } | null)?.stopAfter;
if (s.status === "active" && stopAfter && Date.parse(stopAfter) < now.getTime()) {
await db
.updateTable("subscription")
.set({ status: "completed", status_reason: "stop_after_reached" })
.where("id", "=", s.id)
.execute();
s.status = "completed";
}
}
const activeSubs = new Map<string, SubscriptionRow>(subs.filter((s) => s.status === "active").map((s) => [s.id, s]));
const tracked = activeSubs.size > 0 || slots.some((s) => s.subscription_id === null);
const releaseSeason = parsed.season ?? (parsed.episodes ? 1 : undefined);
let planned: string | null = null;
for (const slot of slots) {
if (onlySlotId && slot.id !== onlySlotId) continue;
const sub = slot.subscription_id ? activeSubs.get(slot.subscription_id) : undefined;
if (slot.subscription_id && !sub) continue; // 订阅已暂停/完成/取消
if (slot.content_unit_id && !units.includes(slot.content_unit_id)) {
// Slot 限定某季:季单元是否是本发布单元的父级
const seasonOk = await db
.selectFrom("content_unit")
.select("id")
.where("parent_unit_id", "=", slot.content_unit_id)
.where(
"id",
"in",
units.filter((u) => /^[0-9a-f-]{36}$/.test(u)).length
? units.filter((u) => /^[0-9a-f-]{36}$/.test(u))
: ["00000000-0000-0000-0000-000000000000"],
)
.executeTakeFirst();
if (!seasonOk) continue;
}
// 仅本季:发布季号与订阅季不一致则跳过
if (sub?.season_scope === "current_season" && sub.season_unit_id && releaseSeason !== undefined) {
const su = await db
.selectFrom("content_unit")
.select("season_number")
.where("id", "=", sub.season_unit_id)
.executeTakeFirst();
if (su?.season_number !== null && su?.season_number !== undefined && su.season_number !== releaseSeason) continue;
}
c.out.evaluated++;
const base = { slotId: slot.id, sourceReleaseId: rel.id, title: rel.title, planId: null as string | null };
if (BUSY.includes(slot.status)) {
await note(c, { ...base, decision: "skipped", mode: null, rejected: [], reasons: [`slot_busy:${slot.status}`] });
continue;
}
const spec = slotSpecFromRow(toApi<AcquisitionSlot>(slot));
const satisfied = await satisfiedUnits(db, slot);
const slotSatisfied = slot.status === "satisfied" || slot.status === "upgrade_pending";
const gaps = units.filter((u) => !satisfied.includes(u));
const current = await currentFacts(db, slot.current_asset_id);
const fill = isMovie ? !slotSatisfied : gaps.length > 0 || !current;
const ev: SlotEvaluation = evaluateCandidate(spec, parsed, fill ? null : current);
const flat = toContractSlotEvaluation(ev);
if (!flat.acceptable) {
await note(c, {
...base,
decision: ev.decision as "reject" | "unknown_field",
mode: null,
rejected: flat.rejected,
reasons: flat.reasons,
});
continue;
}
const mode: AcquisitionMode | null =
forcedMode ??
determineMode({
entity: { tracked, kind: isMovie ? "movie" : "tv" },
releaseUnits: units,
satisfiedUnits: satisfied,
slotSatisfied,
now: now.toISOString(),
newTitleWindowDays: d.ports.options.newTitleWindowDays,
upgradeCandidate: ev.decision === "upgrade",
}).mode;
if (!mode) {
await note(c, {
...base,
decision: "skipped",
mode: null,
rejected: [],
reasons: ["mode:none", ...flat.reasons],
});
continue;
}
if (planned) {
await note(c, { ...base, decision: "skipped", mode, rejected: [], reasons: [`covered_by_plan:${planned}`] });
continue;
}
const gate = await gates(c, rel, mode, sub);
if (!gate.ok) {
await note(c, { ...base, decision: "skipped", mode, rejected: gate.rejected, reasons: gate.reasons });
skip(gate.reasons[0] ?? "gated");
continue;
}
const planId = await reserve(c, {
slot,
rel,
mode,
ev,
sub,
instanceId: gate.instanceId,
volumeKey: gate.volumeKey,
savePath: gate.savePath,
});
if (!planId) {
await note(c, { ...base, decision: "skipped", mode, rejected: [], reasons: ["slot_changed_or_reserved"] });
continue;
}
planned = planId;
c.out.plans.push(planId);
await note(c, {
...base,
planId,
decision: ev.decision as "accept" | "upgrade",
mode,
rejected: [],
reasons: flat.reasons,
});
await enqueueTracked(d, {
kind: "submitDownload",
idempotencyKey: `plan:${planId}`,
input: { acquisitionPlanId: planId },
subjectType: "acquisition_plan",
subjectId: planId,
}).then(
(taskId) => db.updateTable("acquisition_plan").set({ task_id: taskId }).where("id", "=", planId).execute(),
(err) =>
d.ctx.logger.warn({ err: { message: (err as Error).message }, planId }, "submitDownload 入队失败,留给巡检补投"),
);
}
}
interface GateOk {
ok: true;
instanceId: string;
volumeKey: string;
savePath: string;
}
interface GateNo {
ok: false;
reasons: string[];
rejected: { code: string; message: string }[];
}
/** 自动化总开关 → 下载器 → 预算 → 缓存卷阈值;任一不过都不建计划 */
async function gates(
c: Ctx,
rel: SourceReleaseRow,
mode: AcquisitionMode,
sub: SubscriptionRow | undefined,
): Promise<GateOk | GateNo> {
const { d } = c;
const blocked = await automationBlock(d);
if (blocked) return { ok: false, reasons: [blocked], rejected: [] };
const wanted = (sub?.params as { downloaderInstanceId?: string } | null)?.downloaderInstanceId;
const insts = (await loadInstances(d.ctx.db)).filter((t) => t.savePath && (!wanted || t.inst.id === wanted));
const inst = insts[0];
if (!inst?.savePath) return { ok: false, reasons: ["no_downloader_instance"], rejected: [] };
const budget = await d.ctx.settings.get("budget");
const b = checkBudget(
{
maxNewTitlesPerDay: budget.maxNewTitlesPerDay,
maxBytesPerDay: budget.maxDownloadBytesPerDay,
maxConcurrent: budget.maxConcurrentDownloads,
},
await budgetUsage(d.ctx.db, c.now),
{ countsAsNewTitle: mode === "new_title", ...(rel.size_bytes !== null ? { bytes: rel.size_bytes } : {}) },
);
if (b.decision !== "allow") {
return {
ok: false,
reasons: [b.decision === "unknown" ? "budget_unknown_size" : "budget_exceeded"],
rejected: b.violations.map((v) => ({
code: `budget.${v.kind}`,
message: `${v.kind}: 上限 ${v.limit},当前 ${v.current},请求 ${v.requested}`,
})),
};
}
const cache = await d.ctx.settings.get("cache");
const vol = (await loadVolumes(d, d.ctx.db, await loadInstances(d.ctx.db))).find(
(v) => v.volumeKey === inst.volumeKey,
);
if (!vol) return { ok: false, reasons: ["volume_state_unknown"], rejected: [] };
const dv = decideVolume(vol, cache, rel.size_bytes !== null ? { bytes: rel.size_bytes } : {});
if (dv.decision !== "allow") {
return {
ok: false,
reasons: dv.reasons.length ? dv.reasons.map((r) => `cache:${r}`) : ["cache:blocked"],
rejected: [
{
code: `cache.${dv.decision}`,
message: `缓存卷 ${vol.volumeKey} 使用率 ${dv.usedPct?.toFixed(1) ?? "未知"}%,预测 ${dv.projectedPct?.toFixed(1) ?? "未知"}%`,
},
],
};
}
return { ok: true, instanceId: inst.inst.id, volumeKey: inst.volumeKey, savePath: inst.savePath };
}
async function reserve(
c: Ctx,
p: {
slot: AcquisitionSlotRow;
rel: SourceReleaseRow;
mode: AcquisitionMode;
ev: SlotEvaluation;
sub: SubscriptionRow | undefined;
instanceId: string;
volumeKey: string;
savePath: string;
},
): Promise<string | null> {
const { d, now } = c;
const immediate = p.sub?.priority === "immediate";
const priority = PRIORITY[priorityOf(p.mode, immediate)];
const payload = {
slotId: p.slot.id,
sourceReleaseId: p.rel.id,
instanceId: p.instanceId,
savePath: p.savePath,
mode: p.mode,
};
try {
return await d.ctx.db.transaction().execute(async (trx) => {
const slot = await trx
.selectFrom("acquisition_slot")
.selectAll()
.where("id", "=", p.slot.id)
.forUpdate()
.executeTakeFirst();
if (!slot || slot.revision !== p.slot.revision || BUSY.includes(slot.status)) return null;
let st: SlotState = { status: slot.status, hasAsset: slot.current_asset_id !== null };
if (st.status === "satisfied") st = transition(st, { type: "upgrade_found" });
st = transition(st, { type: "reserve" });
const plan = await trx
.insertInto("acquisition_plan")
.values({
slot_id: slot.id,
source_release_id: p.rel.id,
purpose: p.mode === "upgrade" ? "upgrade" : "fill",
status: "reserved",
priority,
evaluation: jsonb({
...toContractSlotEvaluation(p.ev),
mode: p.mode,
instanceId: p.instanceId,
volumeKey: p.volumeKey,
savePath: p.savePath,
}),
reserved_bytes: p.rel.size_bytes ?? 0,
budget_reservation: jsonb({
bytes: p.rel.size_bytes,
countsAsNewTitle: p.mode === "new_title",
day: startOfDay(now).toISOString().slice(0, 10),
}),
payload_hash: hashJson(payload),
expected_revision: slot.revision + 1,
idempotency_key: `acq:${slot.id}:${p.rel.id}`,
})
.onConflict((oc) => oc.column("idempotency_key").doNothing())
.returning("id")
.executeTakeFirst();
if (!plan) return null;
await trx
.updateTable("acquisition_slot")
.set({
status: st.status,
reserved_plan_id: plan.id,
revision: slot.revision + 1,
last_evaluated_at: now,
status_reason: `reserved:${p.mode}`,
})
.where("id", "=", slot.id)
.execute();
return plan.id;
});
} catch (err) {
// 部分唯一索引:同一 Slot 同时只能有一个有效预留
if ((err as { code?: string }).code === "23505") return null;
throw err;
}
}
@@ -0,0 +1,237 @@
import type { CandidateEvaluation, DecisionKind, Severity, TaskKind } from "@umt/contracts";
import { type DB, type DownloaderInstanceRow, type Kysely, type LibraryRootRow, sql, type Transaction } from "@umt/db";
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";
// 本模块各服务共用:决策(待处理)、候选评估记录、业务任务行、实例拓扑(路径映射/缓存卷)。
export type Exec = Kysely<DB> | Transaction<DB>;
export interface AoDeps {
ctx: ServerContext;
ports: AoPorts;
}
// ── 待处理中心 ──
export interface DecisionInput {
kind: DecisionKind;
severity?: Severity;
title: string;
summary?: string;
subjectType?: string;
subjectId?: string | null;
/** 同一 dedupeKey 同时只有一个未关闭条目;重复写入只累加 attempts、刷新证据 */
dedupeKey: string;
evidence?: Record<string, unknown>;
options?: { key: string; label: string; description?: string }[];
taskId?: string | null;
}
export async function upsertDecision(d: AoDeps, exec: Exec, input: DecisionInput): Promise<string> {
const r = await sql<{ id: string; inserted: boolean }>`
insert into decision (kind, severity, title, summary, subject_type, subject_id, dedupe_key, evidence, options, task_id)
values (${input.kind}, ${input.severity ?? "warning"}, ${input.title}, ${input.summary ?? null},
${input.subjectType ?? null}, ${input.subjectId ?? null}, ${input.dedupeKey},
${jsonb(input.evidence)}::jsonb, ${JSON.stringify(input.options ?? [])}::jsonb, ${input.taskId ?? null})
on conflict (dedupe_key) where status in ('open', 'investigating', 'awaiting_user')
do update set attempts = decision.attempts + 1, evidence = excluded.evidence, summary = excluded.summary,
updated_at = now()
returning id, (xmax = 0) as inserted`.execute(exec);
const row = r.rows[0]!;
if (row.inserted) {
await d.ctx.outbox.emit(
exec,
"decision.created",
{ decisionId: row.id, kind: input.kind, severity: input.severity ?? "warning", title: input.title },
{ idempotencyKey: `decision.created:${row.id}` },
);
}
return row.id;
}
// ── 候选评估记录(artifact kind=evidence,subject=acquisition_slot) ──
export const CANDIDATE_ARTIFACT_TITLE = "candidate_evaluation";
export async function recordCandidate(exec: Exec, ev: CandidateEvaluation): Promise<void> {
const content = { ...ev };
const contentHash = hashJson({
slotId: ev.slotId,
sourceReleaseId: ev.sourceReleaseId,
decision: ev.decision,
rejected: ev.rejected,
reasons: ev.reasons,
planId: ev.planId,
});
const dup = await exec
.selectFrom("artifact")
.select("id")
.where("subject_type", "=", "acquisition_slot")
.where("subject_id", "=", ev.slotId)
.where("content_hash", "=", contentHash)
.executeTakeFirst();
if (dup) {
await exec
.updateTable("artifact")
.set({ content: jsonb(content), updated_at: new Date() })
.where("id", "=", dup.id)
.execute();
return;
}
await exec
.insertInto("artifact")
.values({
kind: "evidence",
subject_type: "acquisition_slot",
subject_id: ev.slotId,
title: CANDIDATE_ARTIFACT_TITLE,
content: jsonb(content),
content_hash: contentHash,
})
.execute();
}
// ── 业务任务行(task 表:时间线投影;引擎只管推进) ──
export interface TaskRowInput {
kind: TaskKind;
idempotencyKey: string;
input: Record<string, unknown>;
subjectType?: string;
subjectId?: string | null;
triggeredBy?: string;
runId?: string;
}
/** 按 (kind, idempotency_key) 幂等建行,返回 id;给了 runId 时回填 engine_run_id */
export async function ensureTaskRow(exec: Exec, t: TaskRowInput): Promise<string> {
const r = await sql<{ id: string }>`
insert into task (kind, idempotency_key, input, subject_type, subject_id, triggered_by, engine_run_id)
values (${t.kind}, ${t.idempotencyKey}, ${jsonb(t.input)}::jsonb, ${t.subjectType ?? null}, ${t.subjectId ?? null},
${t.triggeredBy ?? "system"}, ${t.runId ?? null})
on conflict (kind, idempotency_key)
do update set engine_run_id = coalesce(excluded.engine_run_id, task.engine_run_id), updated_at = now()
returning id`.execute(exec);
return r.rows[0]!.id;
}
export async function markTask(
exec: Exec,
taskId: string,
status: "running" | "succeeded" | "failed" | "waiting",
extra: { output?: unknown; error?: string; waitReason?: string } = {},
): Promise<void> {
const now = new Date();
await exec
.updateTable("task")
.set({
status,
...(status === "running" ? { started_at: now, attempts: sql`attempts + 1`, last_error: null } : {}),
...(status === "succeeded" || status === "failed" ? { finished_at: now } : {}),
...(extra.output !== undefined ? { output: jsonb(extra.output) } : {}),
...(extra.error !== undefined ? { last_error: jsonb({ message: extra.error }) } : {}),
...(extra.waitReason !== undefined ? { wait_reason: extra.waitReason } : {}),
})
.where("id", "=", taskId)
.execute();
}
/** 建业务任务行 + 引擎入队(幂等键一致);返回 task.id */
export async function enqueueTracked(d: AoDeps, t: Omit<TaskRowInput, "runId">): Promise<string> {
const taskId = await ensureTaskRow(d.ctx.db, t);
const { runId } = await d.ctx.engine.enqueue(
t.kind,
{ ...t.input, taskId },
{
idempotencyKey: `${t.kind}:${t.idempotencyKey}`,
traceId: taskId,
},
);
await d.ctx.db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", taskId).execute();
return taskId;
}
// ── 实例拓扑:路径映射、保存路径、缓存卷 ──
export interface InstanceTopology {
inst: DownloaderInstanceRow;
mappings: PathMapping[];
/** 下载器视角的保存路径(settings.savePath) */
savePath: string | null;
/** 本机视角的保存路径(用于 statfs / 文件访问) */
localSavePath: string | null;
volumeKey: string;
category: string | null;
}
export async function loadInstances(
exec: Exec,
opts: { enabledOnly?: boolean; ids?: string[] } = {},
): Promise<InstanceTopology[]> {
let q = exec.selectFrom("downloader_instance").selectAll();
if (opts.enabledOnly !== false) q = q.where("enabled", "=", true);
if (opts.ids?.length) q = q.where("id", "in", opts.ids);
const insts = await q.orderBy("name").execute();
if (insts.length === 0) return [];
const maps = await exec
.selectFrom("path_mapping")
.select(["downloader_instance_id", "remote_prefix", "local_prefix"])
.where("view_kind", "=", "downloader")
.where("enabled", "=", true)
.where(
"downloader_instance_id",
"in",
insts.map((i) => i.id),
)
.execute();
return insts.map((inst) => {
const mappings = maps
.filter((m) => m.downloader_instance_id === inst.id)
.map((m) => ({ instanceId: inst.id, downloaderPath: m.remote_prefix, hostPath: m.local_prefix }));
const settings = (inst.settings ?? {}) as Record<string, unknown>;
const savePath =
typeof settings.savePath === "string" && settings.savePath
? settings.savePath
: (mappings[0]?.downloaderPath ?? null);
const topo: InstanceTopology = {
inst,
mappings,
savePath,
localSavePath: null,
volumeKey: inst.cache_volume_key ?? `instance:${inst.id}`,
category: typeof settings.category === "string" && settings.category ? settings.category : null,
};
topo.localSavePath = savePath ? hostPathOf(topo, savePath) : null;
return topo;
});
}
/** 下载器路径 → 本机路径;实例没有配置任何映射时视为同机同路径,配置了但不匹配返回 null(不猜测) */
export function hostPathOf(t: Pick<InstanceTopology, "inst" | "mappings">, downloaderPath: string): string | null {
if (t.mappings.length === 0) return downloaderPath;
const r = toHostPath(downloaderPath, t.inst.id, t.mappings);
return r.ok ? r.path : null;
}
export function downloaderPathOf(t: Pick<InstanceTopology, "inst" | "mappings">, hostPath: string): string | null {
if (t.mappings.length === 0) return hostPath;
const r = toDownloaderPath(hostPath, t.inst.id, t.mappings);
return r.ok ? r.path : null;
}
export async function loadLibraryRoots(exec: Exec, enabledOnly = false): Promise<LibraryRootRow[]> {
let q = exec.selectFrom("library_root").selectAll();
if (enabledOnly) q = q.where("enabled", "=", true);
return q.orderBy("name").execute();
}
/** 自动化总开关 / 只读对账:返回阻止原因,null 表示可写 */
export async function automationBlock(d: AoDeps): Promise<string | null> {
const a = await d.ctx.settings.get("automation");
if (!a.enabled) return "automation_disabled";
if (a.readOnlyReconcile) return "read_only_reconcile";
return null;
}
@@ -0,0 +1,917 @@
import { assertSafeToDeleteData, DownloaderError, type QbTorrent, SiteError, type TorrentSource } from "@umt/adapters";
import type { DownloadStatus, PauseReason } from "@umt/contracts";
import { type DB, type DownloadTaskRow, sql, type Transaction } from "@umt/db";
import { mapQbState, type SlotEvent, transition } from "@umt/domain";
import { mergeObservedDownloadState, toDomainDownloadStatus } from "@umt/mapping";
import { withAttemptToken } from "@umt/tasks";
import { errMessage, jsonb } from "../util.ts";
import {
type AoDeps,
automationBlock,
downloaderPathOf,
type Exec,
hostPathOf,
type InstanceTopology,
loadInstances,
loadLibraryRoots,
upsertDecision,
} from "./common.ts";
import { hnrSatisfied, retentionElapsed } from "./retention.ts";
import { decideVolume, loadVolumes, usedPercent, type VolumeState } from "./volumes.ts";
// 下载:提交(幂等 + fencing)、qB 同步与标签接管、无进展证据、缓存卷硬阈值、缓存清理。
/** Slot 状态推进(非法迁移只记日志不抛:并发下 Slot 可能已被人工处理) */
export async function applySlotEvent(
d: AoDeps,
trx: Exec,
slotId: string,
event: SlotEvent,
extra: { currentAssetId?: string | null; reason?: string } = {},
): Promise<boolean> {
const slot = await trx
.selectFrom("acquisition_slot")
.selectAll()
.where("id", "=", slotId)
.forUpdate()
.executeTakeFirst();
if (!slot) return false;
try {
const next = transition({ status: slot.status, hasAsset: slot.current_asset_id !== null }, event);
await trx
.updateTable("acquisition_slot")
.set({
status: next.status,
revision: slot.revision + 1,
...(extra.currentAssetId !== undefined ? { current_asset_id: extra.currentAssetId } : {}),
...(next.status !== "reserved" && next.status !== "downloading" && next.status !== "verifying"
? { reserved_plan_id: null }
: {}),
status_reason: extra.reason ?? event.type,
})
.where("id", "=", slotId)
.execute();
return true;
} catch (err) {
d.ctx.logger.warn({ slotId, event: event.type, err: { message: errMessage(err) } }, "Slot 状态迁移被拒绝");
return false;
}
}
async function emitStatus(
d: AoDeps,
exec: Exec,
id: string,
from: DownloadStatus | null,
to: DownloadStatus,
pauseReason: PauseReason | null,
) {
await d.ctx.outbox.emit(
exec,
"download.status_changed",
{ downloadTaskId: id, from, to, pauseReason },
// 完成事件幂等:只触发一次入库;其它状态变化不去重
to === "completed" ? { idempotencyKey: `download.completed:${id}` } : {},
);
}
// ── 提交 ──
type SubmitResult =
| {
kind: "added" | "already_exists" | "accepted_unconfirmed";
hash: string;
name: string;
savePath: string | null;
contentPath: string | null;
tags: string[];
}
| { kind: "rejected" | "source_failed"; reason: string };
export interface SubmitOutcome {
status: "submitted" | "deferred" | "failed" | "skipped";
reason?: string;
downloadTaskId?: string;
}
export async function submitDownload(d: AoDeps, planId: string, opts: { runId?: string } = {}): Promise<SubmitOutcome> {
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}` };
const blocked = await automationBlock(d);
if (blocked) return { status: "deferred", reason: blocked };
const ev = (plan.evaluation ?? {}) as { instanceId?: string; volumeKey?: 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");
// 阈值复核:计划自身已作为预留计入,这里不再追加请求量
const cache = await d.ctx.settings.get("cache");
const vol = (await loadVolumes(d, db, [topo]))[0];
if (!vol) return { status: "deferred", reason: "volume_state_unknown" };
const dv = decideVolume(vol, cache);
if (dv.decision !== "allow") return { status: "deferred", reason: `cache:${dv.reasons.join(",") || dv.decision}` };
const rel = await db
.selectFrom("source_release")
.selectAll()
.where("id", "=", plan.source_release_id)
.executeTakeFirstOrThrow();
const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst();
const client = await d.ports.downloader(topo.inst);
const tag = topo.inst.managed_tag;
const savePath = topo.savePath;
const view = (t: QbTorrent) => ({
hash: t.hash,
name: t.name,
savePath: t.savePath,
contentPath: t.contentPath,
tags: t.tags,
});
let dtId: string | undefined;
const result = await withAttemptToken<SubmitResult>(
db,
`submit:${plan.id}`,
async (info) => {
const known = rel.infohash_v1 ?? rel.infohash_v2;
if (info.resumed && known) {
// 上次 attempt 没留下结果:先按 hash 对账,已在 qB 里就不重提
const existing = await client.findByHash(known);
if (existing) return { kind: "added", ...view(existing) };
}
let source: TorrentSource;
try {
source = await d.ports.torrentSource({ release: rel, site: site ?? null });
} catch (err) {
if (err instanceof SiteError && (err.isAuthError || err.kind === "not_found" || err.kind === "unsupported")) {
return { kind: "source_failed", reason: `${err.kind}: ${err.message}` };
}
throw err;
}
const out = await client.submit({
source,
savePath,
tags: [tag],
...(topo.category ? { category: topo.category } : {}),
});
switch (out.kind) {
case "added": {
const t = await client.findByHash(out.hash).catch(() => null);
return t
? { kind: "added", ...view(t) }
: { kind: "added", hash: out.hash, name: rel.title, savePath, contentPath: null, tags: [tag] };
}
case "already_exists": {
// 原本已存在:接管,只补管理标签,不改保存路径/其它标签/文件选择
if (!out.existing.tags.includes(tag)) await client.addTags([out.hash], [tag]);
return { kind: "already_exists", ...view(out.existing), tags: [...new Set([...out.existing.tags, tag])] };
}
case "accepted_unconfirmed":
if (!out.hash) throw new Error("qB 已收单但未能确认任务(无 hash),稍后按探针标签对账");
return {
kind: "accepted_unconfirmed",
hash: out.hash,
name: rel.title,
savePath,
contentPath: null,
tags: [tag],
};
case "rejected":
return { kind: "rejected", reason: out.reason };
case "unreachable":
throw out.error;
}
},
{
...(opts.runId ? { runId: opts.runId } : {}),
commit: async (trx, res) => {
if (res.kind === "rejected" || res.kind === "source_failed") {
const r = res;
await failPlanTx(d, trx, plan.id, plan.slot_id, `${r.kind}: ${r.reason}`);
return;
}
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, 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}, ${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),
source_release_id = coalesce(download_task.source_release_id, excluded.source_release_id),
managed = true, tags = excluded.tags, updated_at = now()
returning id, (xmax = 0) as inserted`.execute(trx);
dtId = ins.rows[0]!.id;
await trx
.updateTable("acquisition_plan")
.set({ status: "submitted", status_reason: r.kind })
.where("id", "=", plan.id)
.execute();
await applySlotEvent(d, trx, plan.slot_id, { type: "download_started" }, { reason: `download:${r.kind}` });
await d.ctx.outbox.emit(
trx,
"download.submitted",
{ downloadTaskId: dtId, instanceId: topo.inst.id, torrentHash: r.hash, planId: plan.id, name: r.name },
{ idempotencyKey: `download.submitted:${plan.id}` },
);
},
},
);
if (result.kind === "rejected" || result.kind === "source_failed") return { status: "failed", reason: result.reason };
const hash = (result as Extract<SubmitResult, { hash: string }>).hash;
if (!dtId) {
// 账本已有结果(重放):按 hash 找回任务行
dtId = (
await db
.selectFrom("download_task")
.select("id")
.where("instance_id", "=", topo.inst.id)
.where("torrent_hash", "=", hash)
.executeTakeFirst()
)?.id;
}
return { status: "submitted", reason: result.kind, ...(dtId ? { downloadTaskId: dtId } : {}) };
}
async function failPlanTx(d: AoDeps, trx: Exec, planId: string, slotId: string, reason: string) {
await trx
.updateTable("acquisition_plan")
.set({ status: "failed", status_reason: reason.slice(0, 500) })
.where("id", "=", planId)
.execute();
await applySlotEvent(d, trx, slotId, { type: "release" }, { reason: `submit_failed` });
await upsertDecision(d, trx, {
kind: "download_failed",
title: "下载提交失败",
summary: reason.slice(0, 500),
subjectType: "acquisition_plan",
subjectId: planId,
dedupeKey: `submit_failed:${planId}`,
evidence: { planId, reason },
});
}
async function failPlan(d: AoDeps, planId: string, slotId: string, reason: string): Promise<SubmitOutcome> {
await d.ctx.db.transaction().execute((trx) => failPlanTx(d, trx, planId, slotId, reason));
return { status: "failed", reason };
}
/** 巡检:reserved 但没有下载任务的计划(入队失败、被阈值/总开关延后)→ 条件满足时直接补提交 */
export async function sweepReservedPlans(d: AoDeps): Promise<number> {
if (await automationBlock(d)) return 0;
const stale = await d.ctx.db
.selectFrom("acquisition_plan as p")
.leftJoin("download_task as dt", "dt.acquisition_plan_id", "p.id")
.select("p.id")
.where("p.status", "=", "reserved")
.where("dt.id", "is", null)
.where("p.updated_at", "<", new Date(d.ports.now().getTime() - 60_000))
.limit(20)
.execute();
let n = 0;
for (const p of stale) {
const r = await submitDownload(d, p.id).catch((err) => {
d.ctx.logger.warn({ planId: p.id, err: { message: errMessage(err) } }, "补提交失败");
return null;
});
if (r?.status === "submitted") n++;
}
return n;
}
// ── 同步 ──
export interface SyncStats {
instances: number;
unreachable: string[];
updated: number;
adopted: number;
completed: string[];
removed: number;
thresholdPaused: number;
thresholdResumed: number;
}
export async function syncDownloads(d: AoDeps): Promise<SyncStats> {
const stats: SyncStats = {
instances: 0,
unreachable: [],
updated: 0,
adopted: 0,
completed: [],
removed: 0,
thresholdPaused: 0,
thresholdResumed: 0,
};
const insts = await loadInstances(d.ctx.db);
for (const topo of insts) {
stats.instances++;
await syncInstance(d, topo, stats);
}
await enforceThresholds(d, insts, stats);
return stats;
}
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);
let list: QbTorrent[];
try {
list = await client.list({ tag: topo.inst.managed_tag });
} catch (err) {
// 下载器失联:不改任何任务状态(失败 ≠ 不存在)
stats.unreachable.push(topo.inst.id);
await db
.updateTable("downloader_instance")
.set({ health_status: "offline" })
.where("id", "=", topo.inst.id)
.execute();
d.ctx.logger.warn({ instanceId: topo.inst.id, err: { message: errMessage(err) } }, "qB 同步失败");
return;
}
await db
.updateTable("downloader_instance")
.set({ health_status: "healthy", last_seen_at: now })
.where("id", "=", topo.inst.id)
.execute();
const rows = await db
.selectFrom("download_task")
.selectAll()
.where("instance_id", "=", topo.inst.id)
.where("removed_at", "is", null)
.where("status", "!=", "removed")
.execute();
const byHash = new Map(rows.map((r) => [r.torrent_hash, r]));
const seen = new Set<string>();
for (const t of list) {
seen.add(t.hash);
const row = byHash.get(t.hash);
if (row) {
if (await observe(d, row, t, now, stats)) stats.updated++;
} else {
await adopt(d, topo, t, now, stats);
}
}
for (const row of rows) {
if (seen.has(row.torrent_hash) || !row.managed) continue;
let still: QbTorrent | null;
try {
still = await client.findByHash(row.torrent_hash);
} catch {
continue;
}
await db.transaction().execute(async (trx) => {
if (still) {
// 管理标签被移除:停止后续自动动作,保留历史与资产
await trx
.updateTable("download_task")
.set({ managed: false, tags: still.tags, last_synced_at: now })
.where("id", "=", row.id)
.execute();
} else {
await trx
.updateTable("download_task")
.set({ status: "removed", removed_at: now, last_synced_at: now })
.where("id", "=", row.id)
.execute();
await emitStatus(d, trx, row.id, row.status, "removed", null);
stats.removed++;
}
});
}
}
const sizeOf = (t: QbTorrent) => (t.totalSize ?? t.size) || null;
const downloadedOf = (t: QbTorrent) => Math.max(0, Math.round((sizeOf(t) ?? 0) - t.amountLeft));
async function observe(d: AoDeps, row: DownloadTaskRow, t: QbTorrent, now: Date, stats: SyncStats): Promise<boolean> {
const cur = { status: row.status, pauseReason: row.pause_reason };
const obs = mapQbState(
{ state: t.rawState, progress: t.progress, amountLeft: t.amountLeft },
{
pausedByThreshold: row.pause_reason === "threshold",
previous: toDomainDownloadStatus(row.status, row.pause_reason),
},
);
const merged = mergeObservedDownloadState(cur, obs.status);
const downloaded = downloadedOf(t);
let noProgressSince = row.no_progress_since;
let lastProgressAt = row.last_progress_at;
let stalledNow = false;
if (obs.status === "downloading") {
if (downloaded > row.downloaded_bytes || !lastProgressAt) {
lastProgressAt = now;
noProgressSince = null;
if (merged.status === "stalled") merged.status = "downloading";
} else {
noProgressSince ??= now;
if (now.getTime() - noProgressSince.getTime() >= d.ports.options.noProgressMs && merged.status !== "stalled") {
merged.status = "stalled";
stalledNow = true;
}
}
} else {
// 暂停/排队/完成不计入无进展
noProgressSince = null;
}
const becameComplete = merged.status === "completed" && !["completed", "importing", "imported"].includes(row.status);
const changed = merged.status !== row.status || merged.pauseReason !== row.pause_reason;
await d.ctx.db.transaction().execute(async (trx) => {
await trx
.updateTable("download_task")
.set({
status: merged.status,
pause_reason: merged.status === "paused" ? merged.pauseReason : null,
raw_state: t.rawState,
progress: Math.min(1, Math.max(0, t.progress)),
size_bytes: sizeOf(t),
downloaded_bytes: downloaded,
remaining_bytes: Math.max(0, Math.round(t.amountLeft)),
ratio: t.ratio,
seeding_time_sec: t.seedingTimeSec,
save_path: t.savePath,
content_path: t.contentPath,
name: t.name,
tags: t.tags,
last_progress_at: lastProgressAt,
no_progress_since: noProgressSince,
last_synced_at: now,
...(becameComplete ? { completed_at: now } : {}),
})
.where("id", "=", row.id)
.execute();
if (changed)
await emitStatus(
d,
trx,
row.id,
row.status,
merged.status,
merged.status === "paused" ? merged.pauseReason : null,
);
if (becameComplete) {
stats.completed.push(row.id);
const plan = row.acquisition_plan_id
? await trx
.selectFrom("acquisition_plan")
.select(["slot_id"])
.where("id", "=", row.acquisition_plan_id)
.executeTakeFirst()
: null;
if (plan) await applySlotEvent(d, trx, plan.slot_id, { type: "download_completed" });
}
if (stalledNow) {
// 无进展:只记证据 + 待处理,保留原任务,不换源(REQ §8.4.2)
await upsertDecision(d, trx, {
kind: "download_stalled",
title: `下载无进展:${t.name}`,
summary: `已 ${Math.round((now.getTime() - (noProgressSince?.getTime() ?? now.getTime())) / 60000)} 分钟没有新增字节`,
subjectType: "download_task",
subjectId: row.id,
dedupeKey: `download_stalled:${row.id}`,
evidence: {
rawState: t.rawState,
progress: t.progress,
downloadedBytes: downloaded,
noProgressSince: noProgressSince?.toISOString(),
action: "record_evidence",
},
});
}
if ((merged.status === "failed" || merged.status === "missing") && changed) {
await upsertDecision(d, trx, {
kind: "download_failed",
title: `下载异常:${t.name}`,
summary: `qB 状态 ${t.rawState}`,
subjectType: "download_task",
subjectId: row.id,
dedupeKey: `download_failed:${row.id}`,
evidence: { rawState: t.rawState, progress: t.progress },
});
}
});
return changed;
}
/** 用户给已有任务贴了管理标签:登记接管,不改保存路径与其它标签 */
async function adopt(d: AoDeps, topo: InstanceTopology, t: QbTorrent, now: Date, stats: SyncStats) {
const db = d.ctx.db;
const rel = await db
.selectFrom("source_release")
.select("id")
.where((eb) => eb.or([eb("infohash_v1", "=", t.hash), eb("infohash_v2", "=", t.hash)]))
.executeTakeFirst();
const obs = mapQbState(
{ state: t.rawState, progress: t.progress, amountLeft: t.amountLeft },
{ pausedByThreshold: false },
);
const st = mergeObservedDownloadState({ status: "queued", pauseReason: null }, obs.status);
await db.transaction().execute(async (trx) => {
const ins = await trx
.insertInto("download_task")
.values({
instance_id: topo.inst.id,
torrent_hash: t.hash,
infohash_v1: t.infohashV1,
infohash_v2: t.infohashV2,
source_release_id: rel?.id ?? null,
name: t.name,
save_path: t.savePath,
content_path: t.contentPath,
category: t.category || null,
tags: t.tags,
managed: true,
origin: "adopted",
status: st.status,
pause_reason: st.status === "paused" ? st.pauseReason : null,
raw_state: t.rawState,
progress: Math.min(1, Math.max(0, t.progress)),
size_bytes: sizeOf(t),
downloaded_bytes: downloadedOf(t),
remaining_bytes: Math.max(0, Math.round(t.amountLeft)),
ratio: t.ratio,
seeding_time_sec: t.seedingTimeSec,
last_synced_at: now,
last_progress_at: now,
...(st.status === "completed" ? { completed_at: now } : {}),
})
.onConflict((oc) => oc.columns(["instance_id", "torrent_hash"]).doNothing())
.returning("id")
.executeTakeFirst();
if (!ins) return;
stats.adopted++;
await emitStatus(d, trx, ins.id, null, st.status, st.status === "paused" ? st.pauseReason : null);
if (st.status === "completed") stats.completed.push(ins.id);
});
}
// ── 缓存卷硬阈值 ──
async function enforceThresholds(d: AoDeps, insts: InstanceTopology[], stats: SyncStats) {
if (insts.length === 0) return;
// 只读对账模式禁止自动写入(含对 qB 的暂停/恢复)
if ((await d.ctx.settings.get("automation")).readOnlyReconcile) return;
const cache = await d.ctx.settings.get("cache");
const vols = await loadVolumes(d, d.ctx.db, insts);
for (const v of vols) {
const dv = decideVolume(v, cache);
if (dv.decision === "pause_managed" && dv.pauseTaskIds.length > 0) {
const n = await pauseForThreshold(d, v, dv.pauseTaskIds);
stats.thresholdPaused += n;
await d.ctx.outbox.emit(d.ctx.db, "download.threshold", {
volumeKey: v.volumeKey,
state: "tripped",
usedPercent: dv.usedPct === undefined ? null : Math.min(100, dv.usedPct),
pausedTaskIds: dv.pauseTaskIds,
});
} else if (dv.resumeThresholdPaused) {
const ids = await resumeThresholdPaused(d, v);
stats.thresholdResumed += ids.length;
await d.ctx.outbox.emit(d.ctx.db, "download.threshold", {
volumeKey: v.volumeKey,
state: "recovered",
usedPercent: dv.usedPct === undefined ? null : Math.min(100, dv.usedPct),
pausedTaskIds: ids,
});
}
}
}
async function pauseForThreshold(d: AoDeps, v: VolumeState, taskIds: string[]): Promise<number> {
const rows = await d.ctx.db
.selectFrom("download_task")
.select(["id", "instance_id", "torrent_hash", "status"])
.where("id", "in", taskIds)
.execute();
let n = 0;
for (const topo of v.instances) {
const mine = rows.filter((r) => r.instance_id === topo.inst.id);
if (mine.length === 0) continue;
const client = await d.ports.downloader(topo.inst);
try {
await client.pause(mine.map((r) => r.torrent_hash));
} catch (err) {
d.ctx.logger.error({ instanceId: topo.inst.id, err: { message: errMessage(err) } }, "阈值暂停失败");
continue;
}
await d.ctx.db.transaction().execute(async (trx) => {
for (const r of mine) {
await trx
.updateTable("download_task")
.set({ status: "paused", pause_reason: "threshold", no_progress_since: null })
.where("id", "=", r.id)
.execute();
await emitStatus(d, trx, r.id, r.status, "paused", "threshold");
n++;
}
});
}
return n;
}
/** 恢复水位:只恢复因本阈值暂停的纳管任务,不动用户手动暂停项 */
async function resumeThresholdPaused(d: AoDeps, v: VolumeState): Promise<string[]> {
const out: string[] = [];
for (const topo of v.instances) {
const rows = await d.ctx.db
.selectFrom("download_task")
.select(["id", "torrent_hash"])
.where("instance_id", "=", topo.inst.id)
.where("status", "=", "paused")
.where("pause_reason", "=", "threshold")
.where("managed", "=", true)
.where("removed_at", "is", null)
.execute();
if (rows.length === 0) continue;
const client = await d.ports.downloader(topo.inst);
try {
await client.resume(rows.map((r) => r.torrent_hash));
} catch (err) {
d.ctx.logger.error({ instanceId: topo.inst.id, err: { message: errMessage(err) } }, "阈值恢复失败");
continue;
}
await d.ctx.db.transaction().execute(async (trx) => {
for (const r of rows) {
await trx
.updateTable("download_task")
.set({ status: "downloading", pause_reason: null })
.where("id", "=", r.id)
.execute();
await emitStatus(d, trx, r.id, "paused", "downloading", null);
out.push(r.id);
}
});
}
return out;
}
// ── 用户暂停/恢复 ──
export async function userPause(d: AoDeps, id: string): Promise<DownloadTaskRow> {
const row = await d.ctx.db.selectFrom("download_task").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new DownloaderError("notfound", "下载任务不存在");
const [topo] = await loadInstances(d.ctx.db, { enabledOnly: false, ids: [row.instance_id] });
const client = await d.ports.downloader(topo!.inst);
await client.pause([row.torrent_hash]);
return d.ctx.db.transaction().execute(async (trx) => {
const r = await trx
.updateTable("download_task")
.set({ status: "paused", pause_reason: "user", no_progress_since: null })
.where("id", "=", id)
.returningAll()
.executeTakeFirstOrThrow();
if (row.status !== "paused" || row.pause_reason !== "user")
await emitStatus(d, trx, id, row.status, "paused", "user");
return r;
});
}
export async function userResume(d: AoDeps, id: string): Promise<DownloadTaskRow | "threshold_active"> {
const row = await d.ctx.db.selectFrom("download_task").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new DownloaderError("notfound", "下载任务不存在");
const [topo] = await loadInstances(d.ctx.db, { enabledOnly: false, ids: [row.instance_id] });
if (row.pause_reason === "threshold") {
// 阈值不被手动操作绕过:卷仍在阈值之上时拒绝
const cache = await d.ctx.settings.get("cache");
const v = (await loadVolumes(d, d.ctx.db, [topo!]))[0];
const pct = v ? usedPercent(v) : null;
if (pct === null || pct > cache.resumePercent) return "threshold_active";
}
const client = await d.ports.downloader(topo!.inst);
await client.resume([row.torrent_hash]);
return d.ctx.db.transaction().execute(async (trx) => {
const r = await trx
.updateTable("download_task")
.set({ status: "downloading", pause_reason: null })
.where("id", "=", id)
.returningAll()
.executeTakeFirstOrThrow();
await emitStatus(d, trx, id, row.status, "downloading", null);
return r;
});
}
// ── 缓存清理 ──
export interface CleanupCandidate {
row: DownloadTaskRow;
topo: InstanceTopology;
bytes: number;
/** 可清理;否则 lockReason 说明为什么锁定 */
cleanable: boolean;
lockReason: string | null;
}
/** 挑选已验证入库、满足用户保留天数与 H&R、无其它引用的纳管任务(按完成时间先旧后新) */
export async function cleanupCandidates(d: AoDeps, v: VolumeState): Promise<CleanupCandidate[]> {
const db = d.ctx.db;
const retention = await d.ctx.settings.get("retention");
const now = d.ports.now();
const out: CleanupCandidate[] = [];
for (const topo of v.instances) {
const rows = await db
.selectFrom("download_task")
.selectAll()
.where("instance_id", "=", topo.inst.id)
.where("managed", "=", true)
.where("removed_at", "is", null)
.where("status", "=", "imported")
.orderBy("completed_at", "asc")
.execute();
for (const row of rows) {
const rel = row.source_release_id
? await db
.selectFrom("source_release")
.select(["raw", "site_id"])
.where("id", "=", row.source_release_id)
.executeTakeFirst()
: null;
const site = rel
? await db.selectFrom("site").select("config").where("id", "=", rel.site_id).executeTakeFirst()
: null;
const lock = await lockReason(d, row, rel ?? null, site ?? null, retention.seedRetentionDays, now);
out.push({ row, topo, bytes: row.size_bytes ?? 0, cleanable: lock === null, lockReason: lock });
}
}
return out;
}
async function lockReason(
d: AoDeps,
row: DownloadTaskRow,
rel: { raw: unknown } | null,
site: { config: unknown } | null,
days: number,
now: Date,
): Promise<string | null> {
if (!retentionElapsed(row, days, now)) return "retention_days";
const hnr = hnrSatisfied(row, rel as never, site as never);
if (hnr !== true) return hnr === false ? "hnr_unsatisfied" : "hnr_unknown";
// 已验证入库:最近一次整理记录成功,且没有失败/排队的文件(部分入库不能整种清理)
const rec = await d.ctx.db
.selectFrom("organization_record")
.select(["id", "status"])
.where("download_task_id", "=", row.id)
.orderBy("created_at", "desc")
.executeTakeFirst();
if (rec?.status !== "succeeded") return "import_not_verified";
const bad = await d.ctx.db
.selectFrom("organization_file_result")
.select("id")
.where("organization_record_id", "=", rec.id)
.where("status", "in", ["failed", "queued", "running", "cancelled"])
.executeTakeFirst();
if (bad) return "import_partial";
// 仍被资产位置引用(缓存内直接做种/硬链接)
const ref = await d.ctx.db
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select("l.id")
.where("a.download_task_id", "=", row.id)
.where("l.location_kind", "in", ["download_cache", "hardlink"])
.where("l.status", "!=", "removed")
.executeTakeFirst();
if (ref) return "referenced_by_asset";
return null;
}
export interface CleanupReport {
volumes: {
volumeKey: string;
action: "none" | "cleaned" | "insufficient" | "unknown";
usedPercentBefore: number | null;
usedPercentAfter: number | null;
removed: string[];
skipped: { downloadTaskId: string; reason: string }[];
}[];
}
export async function cleanupDownloadCache(
d: AoDeps,
input: { downloaderInstanceId?: string | undefined } = {},
): Promise<CleanupReport> {
const db = d.ctx.db;
const cache = await d.ctx.settings.get("cache");
const insts = await loadInstances(db);
const vols = (await loadVolumes(d, db, insts)).filter(
(v) => !input.downloaderInstanceId || v.instances.some((t) => t.inst.id === input.downloaderInstanceId),
);
const roots = await loadLibraryRoots(db);
const report: CleanupReport = { volumes: [] };
for (const v of vols) {
const before = usedPercent(v);
const entry: CleanupReport["volumes"][number] = {
volumeKey: v.volumeKey,
action: "none",
usedPercentBefore: before,
usedPercentAfter: before,
removed: [],
skipped: [],
};
report.volumes.push(entry);
if (before === null) {
// 读不到磁盘状态:不清理(不能把"未知"当"需要清理")
entry.action = "unknown";
continue;
}
if (before < cache.hardThresholdPercent) continue;
let pct = before;
const cands = (await cleanupCandidates(d, v)).filter((c) => {
if (!c.cleanable) entry.skipped.push({ downloadTaskId: c.row.id, reason: c.lockReason ?? "locked" });
return c.cleanable;
});
for (const cand of cands) {
if (pct <= cache.cleanupTargetPercent) break;
const ok = await removeWithData(
d,
cand,
roots.map((r) => r.local_path),
).catch((err) => {
entry.skipped.push({ downloadTaskId: cand.row.id, reason: errMessage(err) });
return false;
});
if (!ok) continue;
entry.removed.push(cand.row.id);
const u = v.path ? await d.ports.diskUsage(v.path) : null;
pct = u?.status === "ok" ? (u.usedBytes / (u.usedBytes + u.availableBytes)) * 100 : pct;
}
entry.usedPercentAfter = pct;
entry.action = entry.removed.length > 0 ? "cleaned" : "none";
if (pct > cache.cleanupTargetPercent && pct >= cache.hardThresholdPercent) {
entry.action = entry.removed.length > 0 ? "cleaned" : "insufficient";
// 没有可清理对象:待处理 + 阈值继续阻止新下载(不突破保留要求)
await upsertDecision(d, db, {
kind: "space_insufficient",
severity: "critical",
title: `下载缓存卷 ${v.volumeKey} 空间不足`,
summary: `使用率 ${pct.toFixed(1)}%,可清理任务已用尽;新下载已暂停`,
subjectType: "cache_volume",
dedupeKey: `space_insufficient:${v.volumeKey}`,
evidence: { usedPercent: pct, locked: entry.skipped },
});
}
}
return report;
}
/** qB 删种 + 删数据:必经 assertSafeToDeleteData(本机视图与下载器视图各查一次) */
async function removeWithData(d: AoDeps, cand: CleanupCandidate, libraryRootsLocal: string[]): Promise<boolean> {
const { row, topo } = cand;
if (!row.content_path) throw new Error("content_path 未知,不能删数据");
const localContent = hostPathOf(topo, row.content_path);
if (!localContent) throw new Error("content_path 无法映射到本机路径");
assertSafeToDeleteData(
{ contentPath: localContent, libraryRoots: libraryRootsLocal, importVerified: true },
topo.inst.id,
);
const rootsDl = libraryRootsLocal.map((r) => downloaderPathOf(topo, r) ?? r);
const client = await d.ports.downloader(topo.inst);
const opId = `cleanup:${row.id}`;
await withAttemptToken(
d.ctx.db,
opId,
async () => {
await client.delete([row.torrent_hash], {
deleteFiles: true,
safety: { [row.torrent_hash]: { contentPath: row.content_path!, libraryRoots: rootsDl, importVerified: true } },
});
return { deleted: true };
},
{
commit: async (trx: Transaction<DB>) => {
const now = d.ports.now();
await trx
.updateTable("download_task")
.set({ status: "removed", removed_at: now, managed: false })
.where("id", "=", row.id)
.execute();
await trx
.insertInto("asset_operation")
.values({
operation_id: opId,
phase: "cleanup",
kind: "qb_remove",
status: "succeeded",
intent: jsonb({
torrentHash: row.torrent_hash,
instanceId: topo.inst.id,
deleteFiles: true,
contentPath: row.content_path,
}),
download_task_id: row.id,
started_at: now,
finished_at: now,
attempts: 1,
})
.onConflict((oc) => oc.column("operation_id").doNothing())
.execute();
await emitStatus(d, trx, row.id, row.status, "removed", null);
},
},
);
return true;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,228 @@
import { join } from "node:path";
import { checkRoot, DEFAULT_ROOT_SENTINEL, statSafe } from "@umt/adapters";
import { errMessage, jsonb } from "../util.ts";
import type { AoDeps } from "./common.ts";
import { applySlotEvent } from "./downloads.ts";
// 资产对账(Asset Reconciler,REQ §8.1/§8.2):先查库根哨兵再增量 stat;
// 状态按 available → unavailable / suspected_missing → confirmed_missing 推进,不因单次失败删记录或补下载。
export interface ReconcileInput {
libraryRootId?: string | undefined;
reconcileRunId?: string | undefined;
/** 每个库根每轮最多检查的位置数(续扫靠 resume_cursor) */
batch?: number;
}
export interface ReconcileStats {
checked: number;
available: number;
unavailable: number;
suspectedMissing: number;
confirmedMissing: number;
rootsOffline: string[];
}
export async function reconcileLibrary(
d: AoDeps,
input: ReconcileInput = {},
): Promise<{ runId: string; stats: ReconcileStats }> {
const db = d.ctx.db;
const automation = await d.ctx.settings.get("automation");
const readOnly = automation.readOnlyReconcile;
const now = d.ports.now();
let run = input.reconcileRunId
? await db.selectFrom("reconcile_run").selectAll().where("id", "=", input.reconcileRunId).executeTakeFirst()
: undefined;
if (!run) {
run = await db
.insertInto("reconcile_run")
.values({
kind: "asset",
mode: readOnly ? "read_only" : "normal",
status: "running",
scope: jsonb({ libraryRootId: input.libraryRootId ?? null }),
started_at: now,
})
.returningAll()
.executeTakeFirstOrThrow();
} else {
await db
.updateTable("reconcile_run")
.set({ status: "running", started_at: run.started_at ?? now })
.where("id", "=", run.id)
.execute();
}
const cursor = (run.resume_cursor ?? {}) as Record<string, string>;
const stats: ReconcileStats = {
checked: 0,
available: 0,
unavailable: 0,
suspectedMissing: 0,
confirmedMissing: 0,
rootsOffline: [],
};
const batch = input.batch ?? 2000;
const confirmAfter = d.ports.options.missingConfirmAfter;
let partial = false;
try {
let rq = db.selectFrom("library_root").selectAll().where("enabled", "=", true);
const scopeRoot = input.libraryRootId ?? ((run.scope ?? {}) as { libraryRootId?: string }).libraryRootId;
if (scopeRoot) rq = rq.where("id", "=", scopeRoot);
for (const root of await rq.execute()) {
const health = await checkRoot(root.local_path, root.sentinel_file ?? DEFAULT_ROOT_SENTINEL);
await db
.updateTable("library_root")
.set({
health_status: health.kind === "online" ? "healthy" : "offline",
last_health_at: now,
health_detail: jsonb(health),
})
.where("id", "=", root.id)
.execute();
if (health.kind === "offline") {
// 根离线:只标记不可用,绝不据此判定缺失
stats.rootsOffline.push(root.id);
const r = await db
.updateTable("asset_location")
.set({
status: "unavailable",
last_checked_at: now,
check_detail: jsonb({ reason: `root_offline:${health.reason}` }),
})
.where("library_root_id", "=", root.id)
.where("status", "=", "available")
.executeTakeFirst();
stats.unavailable += Number(r.numUpdatedRows);
continue;
}
let q = db
.selectFrom("asset_location")
.selectAll()
.where("library_root_id", "=", root.id)
.where("status", "!=", "removed")
.orderBy("id")
.limit(batch + 1);
if (cursor[root.id]) q = q.where("id", ">", cursor[root.id]!);
const locs = await q.execute();
const more = locs.length > batch;
for (const loc of locs.slice(0, batch)) {
stats.checked++;
const st = await statSafe(join(root.local_path, loc.relative_path), {
root: root.local_path,
sentinel: root.sentinel_file ?? DEFAULT_ROOT_SENTINEL,
});
const detail = (loc.check_detail ?? {}) as { missCount?: number };
if (st.kind === "ok") {
stats.available++;
await db
.updateTable("asset_location")
.set({
status: "available",
last_seen_at: now,
last_checked_at: now,
check_detail: jsonb({
missCount: 0,
...(loc.size_bytes !== null && loc.size_bytes !== st.facts.size
? { sizeChanged: { from: loc.size_bytes, to: st.facts.size } }
: {}),
}),
})
.where("id", "=", loc.id)
.execute();
// 文件回来了:资产恢复 active(Slot 的 attention 仍留给人工确认)
await db
.updateTable("media_asset")
.set({ status: "active" })
.where("id", "=", loc.media_asset_id)
.where("status", "=", "missing")
.execute();
} else if (st.kind === "missing") {
const missCount = (detail.missCount ?? 0) + 1;
const confirmed = missCount >= confirmAfter;
if (confirmed) stats.confirmedMissing++;
else stats.suspectedMissing++;
await db.transaction().execute(async (trx) => {
await trx
.updateTable("asset_location")
.set({
status: confirmed ? "confirmed_missing" : "suspected_missing",
last_checked_at: now,
check_detail: jsonb({ missCount }),
})
.where("id", "=", loc.id)
.execute();
if (confirmed && loc.status !== "confirmed_missing" && !readOnly) {
const other = await trx
.selectFrom("asset_location")
.select("id")
.where("media_asset_id", "=", loc.media_asset_id)
.where("id", "!=", loc.id)
.where("status", "=", "available")
.executeTakeFirst();
if (!other) {
await trx
.updateTable("media_asset")
.set({ status: "missing" })
.where("id", "=", loc.media_asset_id)
.where("status", "=", "active")
.execute();
const slots = await trx
.selectFrom("acquisition_slot")
.select("id")
.where("current_asset_id", "=", loc.media_asset_id)
.execute();
// 只推进到 attention 等人处理;不自动补下载(确认缺失仍需遵循恢复策略)
for (const s of slots)
await applySlotEvent(
d,
trx,
s.id,
{ type: "asset_missing_confirmed" },
{ reason: "asset_confirmed_missing" },
);
}
}
});
} else {
stats.unavailable++;
await db
.updateTable("asset_location")
.set({ status: "unavailable", last_checked_at: now, check_detail: jsonb({ ...detail, reason: st.kind }) })
.where("id", "=", loc.id)
.execute();
}
}
if (more) {
partial = true;
cursor[root.id] = locs[batch - 1]!.id;
} else {
delete cursor[root.id];
}
}
await db
.updateTable("reconcile_run")
.set({
status: partial ? "partial" : "succeeded",
stats: jsonb(stats),
resume_cursor: jsonb(cursor),
finished_at: d.ports.now(),
})
.where("id", "=", run.id)
.execute();
} catch (err) {
await db
.updateTable("reconcile_run")
.set({
status: "failed",
stats: jsonb(stats),
resume_cursor: jsonb(cursor),
error: jsonb({ message: errMessage(err) }),
finished_at: d.ports.now(),
})
.where("id", "=", run.id)
.execute();
throw err;
}
return { runId: run.id, stats };
}
@@ -0,0 +1,36 @@
import type { DownloadTaskRow, SiteRow, SourceReleaseRow } from "@umt/db";
// H&R / 做种保留判断:与用户保留天数分开(REQ §8.3)。未知一律按"未满足"对待,不据此删除或 Move。
export interface HnrRequirement {
/** 站点 config.hitAndRun:{ minSeedingSec?, minRatio? };任一满足即解除 */
minSeedingSec?: number;
minRatio?: number;
}
export function releaseHnr(rel: Pick<SourceReleaseRow, "raw"> | null | undefined): boolean | "unknown" {
const site = ((rel?.raw ?? {}) as { _site?: { hr?: unknown } })._site;
const hr = site?.hr;
return hr === true ? true : hr === false ? false : "unknown";
}
export function hnrSatisfied(
task: Pick<DownloadTaskRow, "seeding_time_sec" | "ratio">,
rel: Pick<SourceReleaseRow, "raw"> | null | undefined,
site: Pick<SiteRow, "config"> | null | undefined,
): boolean | "unknown" {
const hr = releaseHnr(rel);
if (hr === false) return true;
if (hr === "unknown") return "unknown";
const req = ((site?.config ?? {}) as { hitAndRun?: HnrRequirement }).hitAndRun;
if (!req || (req.minSeedingSec === undefined && req.minRatio === undefined)) return "unknown";
const bySeed = req.minSeedingSec !== undefined && (task.seeding_time_sec ?? 0) >= req.minSeedingSec;
const byRatio = req.minRatio !== undefined && (task.ratio ?? 0) >= req.minRatio;
return bySeed || byRatio;
}
/** 用户保留天数:从下载完成起算 */
export function retentionElapsed(task: Pick<DownloadTaskRow, "completed_at">, days: number, now: Date): boolean {
if (!task.completed_at) return false;
return now.getTime() - task.completed_at.getTime() >= days * 86_400_000;
}
@@ -0,0 +1,121 @@
import type { DiskUsage } from "@umt/adapters";
import type { CacheSettings } from "@umt/contracts";
import {
type CacheVolumeDecision,
evaluateCacheVolume,
type InflightTask,
type Reservation,
type VolumeSnapshot,
} from "@umt/domain";
import type { AoDeps, Exec, InstanceTopology } from "./common.ts";
import { loadInstances } from "./common.ts";
// 缓存卷:多个 qB 共用同一卷时按 cache_volume_key 汇总在途需求与预留(REQ §8.4.1)。
export interface VolumeState {
volumeKey: string;
instances: InstanceTopology[];
/** statfs 用的本机路径(第一个有保存路径的实例) */
path: string | null;
usage: DiskUsage | null;
snapshot: VolumeSnapshot | null;
inflight: InflightTask[];
reservations: Reservation[];
/** 该卷上存在因阈值暂停的纳管任务 */
pausedByThreshold: boolean;
}
const INFLIGHT_STATUS: Record<string, InflightTask["status"]> = {
queued: "queued",
submitting: "queued",
downloading: "downloading",
stalled: "downloading",
paused: "paused",
completed: "completed",
importing: "completed",
imported: "completed",
};
export async function loadVolumes(
d: AoDeps,
exec: Exec = d.ctx.db,
instances?: InstanceTopology[],
): Promise<VolumeState[]> {
const insts = instances ?? (await loadInstances(exec));
const byKey = new Map<string, InstanceTopology[]>();
for (const t of insts) byKey.set(t.volumeKey, [...(byKey.get(t.volumeKey) ?? []), t]);
if (byKey.size === 0) return [];
const tasks = await exec
.selectFrom("download_task")
.select([
"id",
"instance_id",
"acquisition_plan_id",
"size_bytes",
"downloaded_bytes",
"managed",
"status",
"pause_reason",
])
.where(
"instance_id",
"in",
insts.map((i) => i.inst.id),
)
.where("removed_at", "is", null)
.where("status", "not in", ["removed", "failed", "missing"])
.execute();
const plans = await exec
.selectFrom("acquisition_plan")
.select(["id", "reserved_bytes", "evaluation"])
.where("status", "=", "reserved")
.execute();
const out: VolumeState[] = [];
for (const [volumeKey, members] of byKey) {
const ids = new Set(members.map((m) => m.inst.id));
const mine = tasks.filter((t) => ids.has(t.instance_id));
const path = members.find((m) => m.localSavePath)?.localSavePath ?? null;
const usage = path ? await d.ports.diskUsage(path) : null;
const snapshot: VolumeSnapshot | null =
usage?.status === "ok"
? { volumeId: volumeKey, totalBytes: usage.usedBytes + usage.availableBytes, usedBytes: usage.usedBytes }
: null;
out.push({
volumeKey,
instances: members,
path,
usage,
snapshot,
inflight: mine.map((t) => ({
taskId: t.id,
...(t.acquisition_plan_id ? { planId: t.acquisition_plan_id } : {}),
volumeId: volumeKey,
...(t.size_bytes !== null ? { sizeBytes: t.size_bytes } : {}),
onDiskBytes: t.downloaded_bytes,
managed: t.managed,
status: INFLIGHT_STATUS[t.status] ?? "other",
})),
reservations: plans
.filter((p) => (p.evaluation as { volumeKey?: string } | null)?.volumeKey === volumeKey)
.map((p) => ({ planId: p.id, volumeId: volumeKey, bytes: p.reserved_bytes })),
pausedByThreshold: mine.some((t) => t.managed && t.status === "paused" && t.pause_reason === "threshold"),
});
}
return out;
}
export function decideVolume(v: VolumeState, cache: CacheSettings, request?: { bytes?: number }): CacheVolumeDecision {
return evaluateCacheVolume({
volume: v.snapshot,
config: { hardPct: cache.hardThresholdPercent, resumePct: cache.resumePercent },
inflight: v.inflight,
reservations: v.reservations,
...(request ? { request } : {}),
pausedByThreshold: v.pausedByThreshold,
});
}
export const usedPercent = (v: VolumeState): number | null =>
v.snapshot ? (v.snapshot.usedBytes / v.snapshot.totalBytes) * 100 : null;
@@ -0,0 +1,89 @@
import { createHash } from "node:crypto";
import type { Context } from "hono";
import type { AuthEnv } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
// 模块内通用小工具:行转换、哈希、游标、错误。
const camel = (s: string) => s.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
/**
* db 行(snake_case、Date)→ contracts 形状(camelCase、ISO 串)。
* contracts 的 Row schema 与表列一一对应(db-sync 测试保证),因此可以通用转换。
*/
export function toApi<T>(row: object): T {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(row)) out[camel(k)] = v instanceof Date ? v.toISOString() : v;
return out as T;
}
export const toApiList = <T>(rows: readonly object[]): T[] => rows.map((r) => toApi<T>(r));
/** 规范化 JSON(键排序)后 sha256,用于幂等键 / payloadHash / 指纹 */
export function canonicalJson(v: unknown): string {
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
if (v && typeof v === "object" && !(v instanceof Date)) {
const o = v as Record<string, unknown>;
return `{${Object.keys(o)
.filter((k) => o[k] !== undefined)
.sort()
.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`)
.join(",")}}`;
}
return JSON.stringify(v instanceof Date ? v.toISOString() : v) ?? "null";
}
export const sha256 = (s: string) => createHash("sha256").update(s, "utf8").digest("hex");
export const hashJson = (v: unknown) => sha256(canonicalJson(v));
/** jsonb 写入:一律 stringify(pg 会把 JS 数组当 PG 数组) */
export const jsonb = (v: unknown) => JSON.stringify(v ?? {});
/** 按 (created_at desc, id desc) 翻页的不透明游标 */
export function encodeCursor(at: Date | string, id: string): string {
const t = at instanceof Date ? at.toISOString() : at;
return Buffer.from(`${t}|${id}`, "utf8").toString("base64url");
}
export function decodeCursor(cursor: string | undefined): { at: string; id: string } | null {
if (!cursor) return null;
const raw = Buffer.from(cursor, "base64url").toString("utf8");
const i = raw.lastIndexOf("|");
const at = raw.slice(0, i);
const id = raw.slice(i + 1);
if (i <= 0 || Number.isNaN(Date.parse(at)) || !/^[0-9a-f-]{36}$/i.test(id)) {
throw new ApiException("bad_request", "cursor 无效");
}
return { at, id };
}
/** 统一成功信封 */
export function ok<T>(c: Context<AuthEnv>, data: T, status: 200 | 201 = 200) {
return c.json({ ok: true as const, data }, status);
}
export const notFound = (what: string): never => {
throw new ApiException("not_found", `${what}不存在`);
};
export const conflict = (msg: string): never => {
throw new ApiException("conflict", msg);
};
/** 调用者标识(写 created_by / granted_by 等) */
export function actorOf(c: Context<AuthEnv>): string {
const p = c.get("principal");
return p ? `${p.kind}:${p.id}` : "system";
}
export const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e));
/** 当天 00:00(UTC)起点,用于每日预算与今日统计 */
export function startOfDay(now: Date): Date {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
export const posixJoin = (...parts: string[]) =>
parts
.filter((p) => p.length > 0)
.join("/")
.replace(/\/{2,}/g, "/");