merge: fix/q-inline-handling

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei

# Conflicts:
#	apps/server/src/modules/sources-identity/service/episode-resolution.ts
This commit is contained in:
root committed 2026-10-06 17:09:59 +08:00
commit c465b533fd
80 files changed
+2766 -1016

No files matched your search

@@ -0,0 +1,227 @@
import { capabilitySpec } from "@umt/contracts";
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
import { sql } from "kysely";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { assignOrganizeIdentity } from "../../src/modules/acquisition-organize/services/takeover.ts";
import { createAuthorizer } from "../../src/modules/agent/authorize.ts";
import { autoInvestigate } from "../../src/modules/agent/investigate.ts";
import { ORGANIZE_IDENTIFY_CAPABILITIES } from "../../src/modules/agent/runner.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
// 去掉待处理中心后的整理侧(2026-10-06):
// - 下载完识别不到 → 整理记录「需要处理」(attention 口径:未被后来的整理取代,且有未关闭事项或失败 / 部分完成未忽略);
// - 识别失败自动派一次智能体识别(agent.autoIdentifyOrganize,缺省开;关掉不派),
// 智能体 organize.assign_identity 指定作品后重新整理、事项关闭,旧记录不再算需要处理;
// - 整理记录里「让智能体识别」入队一次识别会话;「忽略」清掉需要处理;
// - /attention:整理需要处理的条数;铃铛只放等你批准的智能体操作与系统健康,整理侧的审批不进铃铛。
const hasPg = await pgAvailable();
const SIZE = 300_000;
describe.skipIf(!hasPg)("整理记录需要处理 + 识别失败自动叫智能体识别", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
let n = 0;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
cache = await createTempTree({}, { prefix: "umt-cache-" });
h = await createHarness("organize_attention", {}, { withAgent: true });
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 adoptCompleted(name: string, file: string) {
n++;
const hash = n.toString(16).padStart(40, "b");
qb.seed({ hash, name, tags: ["umt"], save_path: cache.root });
await qb.complete(hash, { files: [{ path: `${name}/${file}`, size: SIZE }] });
await syncDownloads(h.d);
return h.db.selectFrom("download_task").selectAll().where("torrent_hash", "=", hash).executeTakeFirstOrThrow();
}
/** 接管任务识别不到 → 失败记录 + organize_identity 事项 */
async function unidentified(name: string) {
const dt = await adoptCompleted(name, `${name}.mkv`);
const plan = await planImport(h.d, { downloadTaskId: dt.id, trigger: "auto", actorType: "system" });
expect(plan).toMatchObject({ status: "failed", reason: "identity_unknown" });
const dec = await h.db
.selectFrom("decision")
.selectAll()
.where("dedupe_key", "=", `organize_identity:${dt.id}`)
.executeTakeFirstOrThrow();
return { dt, recordId: plan.recordId!, dec };
}
const attentionIds = async () => {
const r = await h.api("GET", "/organization-records?attention=true&organizeOnly=true");
expect(r.status).toBe(200);
return (r.body.data.items as { id: string }[]).map((x) => x.id);
};
it("识别不到:记录进「需要处理」,行内带原因与可做的动作;统计同口径", async () => {
const { recordId, dec } = await unidentified("Vqzt.Nowhere.2019.1080p.WEB-DL");
expect(await attentionIds()).toContain(recordId);
const one = await h.api("GET", `/organization-records/${recordId}`);
expect(one.body.data.attention).toMatchObject({
topic: "identity",
agentIdentify: true,
decision: { id: dec.id, kind: "identity_ambiguous", status: "open" },
});
const stats = await h.api("GET", "/organization-records/stats?organizeOnly=true");
expect(stats.body.data.attention).toBeGreaterThanOrEqual(1);
const summary = await h.api("GET", "/attention");
expect(summary.status).toBe(200);
expect(summary.body.data.organize).toBe(stats.body.data.attention);
// 整理侧事项不进铃铛(铃铛只放等你批准的智能体操作与系统健康)
const bell = [...summary.body.data.approvals.items, ...summary.body.data.health.items].map(
(x: { id: string }) => x.id,
);
expect(bell).not.toContain(dec.id);
});
it("开关开(缺省):识别失败自动派一次智能体识别;同一事项不重复派", async () => {
const { dec } = await unidentified("Wqxx.Elsewhere.2020.1080p.WEB-DL");
const out = await autoInvestigate(h.ctx, dec.id);
expect(out).toMatchObject({ created: true, mode: "recover" });
const run = await h.db.selectFrom("agent_run").selectAll().where("id", "=", out.runId!).executeTakeFirstOrThrow();
expect(run).toMatchObject({
dedupe_key: `investigate:${dec.id}`,
created_by: "system:organize-identify",
trigger: "event",
mode: "execute",
});
expect((run.scope as { capabilities: string[] }).capabilities.sort()).toEqual(
[...ORGANIZE_IDENTIFY_CAPABILITIES].sort(),
);
expect(run.goal).toContain("organize.assign_identity");
expect(run.goal).toContain(dec.id);
const after = await h.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow();
expect(after).toMatchObject({ status: "investigating", agent_run_id: run.id });
expect((await autoInvestigate(h.ctx, dec.id)).created).toBe(false);
});
it("开关关:不派智能体,事项留在整理记录里", async () => {
await h.setSettings("agent", { autoIdentifyOrganize: false });
try {
const { dec } = await unidentified("Xqyy.Nomatch.2021.1080p.WEB-DL");
expect(await autoInvestigate(h.ctx, dec.id)).toMatchObject({ runId: null, created: false, skipped: "disabled" });
const runs = await h.db
.selectFrom("agent_run")
.select("id")
.where("dedupe_key", "like", `investigate:${dec.id}%`)
.execute();
expect(runs).toHaveLength(0);
// 用户在整理记录里点「让智能体识别」不看开关:入队一次识别
const rec = await h.db
.selectFrom("decision")
.select("subject_id")
.where("id", "=", dec.id)
.executeTakeFirstOrThrow();
const r = await h.api("POST", `/organization-records/${rec.subject_id}/agent-identify`);
expect(r.status).toBe(200);
expect(r.body.data).toEqual({ decisionId: dec.id, queued: true });
expect(h.engine.enqueued.at(-1)).toMatchObject({
task: "resolveDecision",
input: { decisionId: dec.id, trigger: "identify" },
});
const manual = await autoInvestigate(h.ctx, dec.id, { manual: true });
expect(manual.created).toBe(true);
const run = await h.db
.selectFrom("agent_run")
.select("dedupe_key")
.where("id", "=", manual.runId!)
.executeTakeFirstOrThrow();
expect(run.dedupe_key).toMatch(new RegExp(`^investigate:${dec.id}#\\d+$`));
} finally {
await h.setSettings("agent", {});
}
});
it("智能体识别成功:assign_identity 只放行本事项的会话;指定作品后自动重新整理,事项关闭,旧记录不再需要处理", async () => {
await h.setSettings("automation", { enabled: true });
const target = await seedEntity(h.db, { kind: "movie", title: "Silent Meridian", year: 2018 });
const { recordId, dec } = await unidentified("Smrd.2018.1080p.BluRay.x264-GRP");
const other = await unidentified("Qqqq.Other.2017.1080p.WEB-DL");
const out = await autoInvestigate(h.ctx, dec.id);
const authorize = createAuthorizer({ db: h.db, settings: h.ctx.settings });
const spec = capabilitySpec("organize.assign_identity");
const ctx = {
actorId: "agent",
taskId: "t",
scopeIds: [],
signal: new AbortController().signal,
source: "agent" as const,
agentRunId: out.runId!,
};
const input = (decisionId: string) => ({ decisionId, mediaEntityId: target, reasons: ["标题年份一致"] });
const allowed = await authorize({ descriptor: spec, input: input(dec.id), ctx });
expect(allowed.allowed).toBe(true);
const denied = await authorize({ descriptor: spec, input: input(other.dec.id), ctx });
expect(denied).toMatchObject({ allowed: false });
const r = await assignOrganizeIdentity(
h.d,
dec.id,
{ mediaEntityId: target },
{ type: "agent", id: out.runId! },
"标题年份一致",
);
expect(r).toMatchObject({ mediaEntityId: target, status: "running", decisionStatus: "resolved" });
const closed = await h.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow();
expect(closed.status).toBe("resolved");
expect((await executeRecord(h.d, r.recordId!)).status).toBe("succeeded");
const ids = await attentionIds();
expect(ids).not.toContain(recordId);
expect(ids).not.toContain(r.recordId);
expect(ids).toContain(other.recordId);
// 同一事项再指定:已处理过
await expect(
assignOrganizeIdentity(h.d, dec.id, { mediaEntityId: target }, { type: "agent", id: null }, "x"),
).rejects.toThrow(/已经处理过/);
});
it("忽略:失败记录不再需要处理,关联事项一并关闭", async () => {
const { recordId, dec } = await unidentified("Zzyy.Ignore.2016.1080p.WEB-DL");
const r = await h.api("POST", `/organization-records/${recordId}/dismiss-attention`);
expect(r.status).toBe(200);
expect(r.body.data.attention).toBeNull();
expect(await attentionIds()).not.toContain(recordId);
const d = await h.db.selectFrom("decision").select("status").where("id", "=", dec.id).executeTakeFirstOrThrow();
expect(d.status).toBe("dismissed");
});
it("/attention:系统健康与等你批准的智能体操作进铃铛,整理记录上的审批不进", async () => {
const run = await h.db
.insertInto("agent_run")
.values({ trigger: "user", goal: "g", status: "waiting", scope: "{}", budget: "{}", created_by: "user:x" })
.returning("id")
.executeTakeFirstOrThrow();
const rec = await h.db.selectFrom("organization_record").select("id").limit(1).executeTakeFirstOrThrow();
const ins = (kind: string, key: string, subjectType: string | null, subjectId: string | null) =>
sql`insert into decision (kind, severity, title, subject_type, subject_id, dedupe_key)
values (${kind}, 'warning', ${key}, ${subjectType}, ${subjectId}, ${key})`.execute(h.db);
await ins("cache_volume_unknown", "cache_volume_unknown:vol-1", "downloader_instance", null);
await ins("approval_required", "approval:run", "agent_run", run.id);
await ins("approval_required", "organize_blocked:hnr", "organization_record", rec.id);
await ins("episode_mapping", "episode_mapping:x", "media_entity", null);
const s = (await h.api("GET", "/attention")).body.data;
const keys = (g: { items: { dedupeKey: string }[] }) => g.items.map((x) => x.dedupeKey);
expect(keys(s.health)).toEqual(["cache_volume_unknown:vol-1"]);
expect(keys(s.approvals)).toEqual(["approval:run"]);
expect(s.health.total).toBe(1);
expect(s.approvals.total).toBe(1);
});
});
@@ -107,7 +107,15 @@ describe.skipIf(skip)("整理记录筛选与统计", () => {
it("统计与列表同口径,不受状态筛选影响", async () => {
const all = await h.api("GET", "/organization-records/stats");
expect(all.status).toBe(200);
expect(all.body.data).toEqual({ total: 4, succeeded: 2, partial: 0, failed: 2, running: 0, cancelled: 0 });
expect(all.body.data).toEqual({
total: 4,
succeeded: 2,
partial: 0,
failed: 2,
running: 0,
cancelled: 0,
attention: 2,
});
const movies = await h.api("GET", "/organization-records/stats?kind=movie&organizeOnly=true");
expect(movies.body.data).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
});
@@ -0,0 +1,110 @@
import { DEFAULT_SETTINGS, DEPRECATED_SETTINGS_FIELDS, type GlobalSettings } from "@umt/contracts";
import type { PiUpstreamRow } from "@umt/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createAgentModule } from "../../src/modules/agent/index.ts";
import { agentSettings } from "../../src/modules/agent/runner.ts";
import { ORGANIZE_IDENTIFY_ACTOR } from "../../src/modules/agent/usage.ts";
import { createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
import { DEV_KEY, json } from "../helpers.ts";
// 智能体不设硬性预算上限(2026-10-06):每天次数 / 每天花费 / 单次花费 / 单次 token 四个上限不再拦截(旧值兼容读取、不生效),
// 只保留单次工具调用次数与单次最长时长两个防跑飞限制(缺省调宽);花费改为可见:/agent/usage 按会话类型统计今天与最近 7 天。
const upstream = { row: { provider: "fake", default_model: null, thinking_level: "off" } as unknown as PiUpstreamRow };
describe("预算上限移除,防跑飞限制保留", () => {
it("旧设置里的 token / 费用上限不进运行参数;工具调用次数与时长照用", () => {
const legacy = {
...DEFAULT_SETTINGS,
agent: { ...DEFAULT_SETTINGS.agent, maxTokensPerRun: 1000, maxCostUsdPerRun: 0.01 },
budget: { ...DEFAULT_SETTINGS.budget, agentMaxRunsPerDay: 0, agentMaxCostUsdPerDay: 0 },
} as GlobalSettings;
// biome-ignore lint/suspicious/noExplicitAny: 只取用到的上游字段
const s = agentSettings(legacy, {}, true, upstream as any);
expect(s).not.toHaveProperty("costBudget");
expect(s.maxToolCalls).toBe(DEFAULT_SETTINGS.agent.maxToolCalls);
expect(s.maxDurationMs).toBe(DEFAULT_SETTINGS.agent.maxDurationSec * 1000);
// 调用方显式划拨(子会话、巡检预算)仍然生效
// biome-ignore lint/suspicious/noExplicitAny: 同上
const sub = agentSettings(legacy, { maxToolCalls: 3, maxCostUsd: 0.5 }, true, upstream as any);
expect(sub.maxToolCalls).toBe(3);
expect(sub.costBudget).toEqual({ maxCostUsd: 0.5 });
});
it("缺省调宽;四个上限登记为弃用字段", () => {
expect(DEFAULT_SETTINGS.agent.maxToolCalls).toBeGreaterThanOrEqual(100);
expect(DEFAULT_SETTINGS.agent.maxDurationSec).toBeGreaterThanOrEqual(1800);
expect(DEFAULT_SETTINGS.agent.investigateMaxToolCalls).toBeGreaterThanOrEqual(20);
expect(DEFAULT_SETTINGS.agent.autoIdentifyOrganize).toBe(true);
expect(DEFAULT_SETTINGS.budget).not.toHaveProperty("agentMaxRunsPerDay");
expect(DEPRECATED_SETTINGS_FIELDS).toEqual(
expect.arrayContaining([
"budget.agentMaxRunsPerDay",
"budget.agentMaxCostUsdPerDay",
"agent.maxTokensPerRun",
"agent.maxCostUsdPerRun",
]),
);
});
});
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("花费统计 /agent/usage(真实 PG)", () => {
let h: Harness;
beforeAll(async () => {
h = await createHarness({ extraModules: [createAgentModule()] });
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("今天与最近 7 天按会话类型统计次数、token 与费用;超过 7 天的不算", async () => {
const day = 86_400_000;
const now = Date.now();
const run = async (
trigger: string,
at: number,
usage: { totalTokens: number; costUsd: number },
extra: Record<string, unknown> = {},
) =>
(
await h.db
.insertInto("agent_run")
.values({
trigger,
goal: "g",
status: "succeeded",
mode: "execute",
usage: JSON.stringify(usage),
started_at: new Date(at),
created_at: new Date(at),
...extra,
} as never)
.returning("id")
.executeTakeFirstOrThrow()
).id;
const chat = await run("user", now, { totalTokens: 1000, costUsd: 0.1 });
await run("subagent", now, { totalTokens: 500, costUsd: 0.05 }, { parent_run_id: chat });
await run("event", now, { totalTokens: 200, costUsd: 0.02 }, { created_by: ORGANIZE_IDENTIFY_ACTOR });
await run("event", now - 3 * day, { totalTokens: 300, costUsd: 0.03 });
await run("schedule", now - 3 * day, { totalTokens: 400, costUsd: 0.04 });
await run("user", now - 10 * day, { totalTokens: 9999, costUsd: 9 });
const res = await h.app.request("/api/v1/agent/usage", { headers: { authorization: `Bearer ${DEV_KEY}` } });
expect(res.status).toBe(200);
const { today, last7d } = (await json(res)).data;
expect(today.runs).toBe(3);
expect(today.tokens).toBe(1700);
expect(today.costUsd).toBeCloseTo(0.17);
expect(today.byKind.map((k: { kind: string }) => k.kind)).toEqual(["chat", "organize_identify", "subagent"]);
expect(last7d.runs).toBe(5);
expect(last7d.costUsd).toBeCloseTo(0.24);
expect(last7d.byKind).toEqual(
expect.arrayContaining([
{ kind: "patrol", runs: 1, tokens: 400, costUsd: 0.04 },
{ kind: "investigate", runs: 1, tokens: 300, costUsd: 0.03 },
]),
);
});
});
@@ -0,0 +1,121 @@
import { createHash } from "node:crypto";
import type { FakeScript } from "@umt/agent";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createE2E, type E2E, pgAvailable, runEvents, seedBase, waitFor } from "./harness.ts";
// 整理识别不到作品时自动叫智能体识别(2026-10-06,agent.autoIdentifyOrganize 缺省开):
// 用户手动加进 qB 的任务(贴管理标签)下完 → 整理按文件名认不出 → 整理记录失败 + 内部事项 organize_identity
// → decision.created → resolveDecision → 识别会话(白名单只有查询类 + organize.assign_identity,不需要持续授权)
// → 智能体查到本地作品后 organize.assign_identity → 按该作品自动重新整理入库,事项关闭,记录不再需要处理。
const hasPg = await pgAvailable();
/** 识别会话目标里带着事项 id(脚本从目标里取) */
const cur: { decisionId?: string; entityId?: string } = {};
const identifyScript: FakeScript = {
name: "organize-identify",
match: (goal) => {
const m = /待处理事项 ([0-9a-f-]{36})/.exec(goal);
if (m && goal.includes("识别整理记录里的作品")) cur.decisionId = m[1];
return goal.includes("识别整理记录里的作品");
},
steps: [
{
text: "先看媒体库里有没有这部。",
toolCalls: [{ capability: "inventory.query", input: { query: "Silent Meridian" } }],
},
{
text: "标题、年份都对得上,指定为媒体库里的《Silent Meridian》。",
toolCalls: [
{
capability: "organize.assign_identity",
input: () => ({
decisionId: cur.decisionId ?? "",
mediaEntityId: cur.entityId ?? "",
reasons: ["种子名缩写 Smrd 对应 Silent Meridian,年份 2018 一致"],
}),
},
],
},
],
finalText: "已指定作品并重新整理。",
};
describe.skipIf(!hasPg)("整理识别不到作品 → 智能体识别 → 自动重新整理", () => {
let e: E2E;
beforeAll(async () => {
e = await createE2E("orgid", { fakeScripts: [identifyScript] });
await seedBase(e);
const ent = await e.db
.insertInto("media_entity")
.values({ kind: "movie", title: "Silent Meridian", year: 2018, status: "active" })
.returning("id")
.executeTakeFirstOrThrow();
cur.entityId = ent.id;
await e.settings({ retention: { seedRetentionDays: 0 } });
await e.startWorker();
}, 120_000);
afterAll(async () => {
await e?.close();
}, 60_000);
it("识别失败自动派一次识别会话;指定作品后自动重新整理入库,事项关闭,记录不再需要处理", async () => {
const name = "Smrd.2018.1080p.BluRay.x264-QQQ";
const hash = createHash("sha1").update("orgid-1").digest("hex");
e.qb.seed({ hash, name, size: 400_000, progress: 1, tags: ["umt"], save_path: e.cache.root });
await e.qb.complete(hash, { files: [{ path: `${name}/${name}.mkv`, size: 400_000 }], writeFiles: true });
const dec = await waitFor(
() =>
e.db.selectFrom("decision").selectAll().where("dedupe_key", "like", "organize_identity:%").executeTakeFirst(),
60_000,
"整理识别不到 → organize_identity 事项",
);
const run = await waitFor(
() =>
e.db.selectFrom("agent_run").selectAll().where("dedupe_key", "=", `investigate:${dec.id}`).executeTakeFirst(),
60_000,
"识别会话",
);
expect(run).toMatchObject({ trigger: "event", mode: "execute", created_by: "system:organize-identify" });
const done = await waitFor(
async () => {
const r = await e.db.selectFrom("agent_run").selectAll().where("id", "=", run.id).executeTakeFirstOrThrow();
return r.finished_at ? r : null;
},
120_000,
"识别会话结束",
);
const starts = (await runEvents(e, run.id))
.filter((x) => x.type === "tool_call_start")
.map((x) => (x.payload as { capabilityId: string }).capabilityId);
expect(starts).toEqual(["inventory.query", "organize.assign_identity"]);
expect(done.status).toBe("succeeded");
expect(done.summary).toContain("已恢复");
const closed = await e.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow();
expect(closed.status).toBe("resolved");
// 自动重新整理:同一下载任务的新整理记录成功入库,作品是智能体指定的
const rec = await waitFor(
async () => {
const r = await e.db
.selectFrom("organization_record")
.selectAll()
.where("status", "=", "succeeded")
.where("media_entity_id", "=", cur.entityId!)
.executeTakeFirst();
return r ?? null;
},
90_000,
"重新整理入库",
);
expect(rec.download_task_id).toBeTruthy();
const attention = await e.api("GET", "/organization-records?attention=true");
expect(attention.body.data.items).toEqual([]);
}, 300_000);
});
+2 -2
View File
@@ -217,8 +217,8 @@ describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => {
// 子超预算:budget_exceeded,不影响父会话
expect(oppie.status).toBe("budget_exceeded");
expect((oppie.budget as Any).maxToolCalls).toBe(1);
// 缺省划拨:父剩余预算的一半(设置 maxToolCalls 40,派生时父已用 1 次)
expect((dune.budget as Any).maxToolCalls).toBeLessThanOrEqual(20);
// 缺省划拨:父剩余预算的一半(设置 maxToolCalls 缺省 120,派生时父已用 1 次)
expect((dune.budget as Any).maxToolCalls).toBeLessThanOrEqual(60);
// maxConcurrent=1:串行(谁先占到名额不定,但两个运行区间不重叠)
const [first, second] = [dune, oppie].sort((a, b) => a.started_at!.getTime() - b.started_at!.getTime()) as [
@@ -0,0 +1,150 @@
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit";
import { sql } from "kysely";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { enqueueWatchedRefresh, isEntityWatched } from "../../src/modules/sources-identity/service/watched.ts";
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
// 集数识别分两层(2026-10-06):
// 确定性校验 / 换算对所有已绑定作品的资源照做;拉 TMDB(自动建档后的元数据刷新)、开集数待确认只对关注中的作品做。
// RSS 扫进来自动建档、没人关心的作品:不排刷新、不开事项;进入关注范围(建订阅等)补排刷新,刷新重算后才开内部事项。
const pg = await pgAvailable();
const season = (n: number, eps: number, start: string) => ({
season_number: n,
air_date: start,
names: { "zh-CN": `第 ${n} 季`, "en-US": `Season ${n}` },
episodes: Array.from({ length: eps }, (_, k) => ({
episode_number: k + 1,
air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10),
names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` },
})),
});
// 已完结:S1 = 12、S2 = 13;绝对集号 40 超出全部季,刷新后换算不了 → 集数待确认
const SHOW: TmdbFixture = {
type: "tv",
id: 990301,
original_title: "Yoru no Kurage wa Oyogenai",
original_language: "ja",
origin_country: ["JP"],
date: "2024-04-06",
titles: { "zh-CN": "夜晚的水母不会游泳", "en-US": "Yoru no Kurage wa Oyogenai" },
popularity: 30,
alternative_titles: [],
external_ids: {},
rich: { status: "Ended" },
seasons: [season(1, 12, "2024-04-06"), season(2, 13, "2025-04-05")],
};
describe.skipIf(!pg)("集数识别两层:只对关注中的作品拉元数据、开集数待确认", () => {
let h: Harness;
let tmdb: MockTmdb;
let siteId: string;
let n = 0;
beforeAll(async () => {
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SHOW] });
h = await createHarness("episode_watch_scope", { tmdbUrl: tmdb.url });
siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id;
// 不靠订阅也查 TMDB(旧行为):RSS 扫进来的资源会自动建档,但作品不在关注范围
await sql`insert into settings (key, value) values ('identity', ${JSON.stringify({ eagerTmdbLookup: true })}::jsonb)
on conflict (key) do update set value = excluded.value`.execute(h.db);
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
const observe = async (title: string) => {
const r = await h.db
.insertInto("source_release")
.values({ site_id: siteId, site_release_id: String(830000 + ++n), title, subtitle: "夜晚的水母不会游泳" })
.returningAll()
.executeTakeFirstOrThrow();
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "observed" });
expect(res.outcome, JSON.stringify(res)).toBe("auto");
return { id: r.id, entityId: res.mediaEntityId as string };
};
const method = async (id: string) =>
(
(
await h.db
.selectFrom("source_release")
.select("episode_resolution")
.where("id", "=", id)
.executeTakeFirstOrThrow()
).episode_resolution as { method: string }
).method;
const refreshJobs = () => h.engine.enqueued.filter((e) => e.task === "refreshMetadata");
const episodeDecisions = () =>
h.db
.selectFrom("decision")
.selectAll()
.where("kind", "=", "episode_mapping")
.where("status", "in", ["open", "investigating", "awaiting_user"])
.execute();
let entityId: string;
let r40: string;
it("RSS 扫进来自动建档:确定性换算照做,但不排元数据刷新、不开集数待确认", async () => {
const a = await observe("[ANi] Yoru no Kurage wa Oyogenai - 03 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]");
entityId = a.entityId;
expect(await method(a.id)).toBe("unverified");
expect(await isEntityWatched(h.db, entityId)).toBe(false);
const b = await observe("[Lilith-Raws] Yoru no Kurage wa Oyogenai - 40 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]");
r40 = b.id;
expect(b.entityId).toBe(entityId);
expect(refreshJobs()).toEqual([]);
expect(await episodeDecisions()).toEqual([]);
// 季详情一次都没拉(识别只用搜索与详情)
expect(tmdb.callsTo(/\/season\//)).toHaveLength(0);
});
it("即使季表已知、资源换算不了,非关注作品也不开事项", async () => {
// 直接刷新一次(模拟别处拉过季表),非关注作品的待确认资源照样只记在资源上
expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed");
expect(await method(r40)).toBe("unresolved");
expect(await episodeDecisions()).toEqual([]);
await h.db.updateTable("media_entity").set({ last_metadata_refresh_at: null }).where("id", "=", entityId).execute();
});
it("进入关注范围(建订阅):补排一次 full 刷新;刷新重算后对换算不了的资源开内部事项", async () => {
await h.db
.insertInto("subscription")
.values({ media_entity_id: entityId, media_kind: "series", status: "active" })
.execute();
expect(await isEntityWatched(h.db, entityId)).toBe(true);
expect(await enqueueWatchedRefresh(h.ctx, entityId, "subscription")).toBe(true);
expect(refreshJobs()).toEqual([
{
task: "refreshMetadata",
input: { mediaEntityId: entityId, full: true },
key: `refreshMetadata:watched:${entityId}`,
},
]);
// 执行入队的刷新(同 refreshMetadata 任务处理器)
expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed");
const ds = await episodeDecisions();
expect(ds).toHaveLength(1);
expect((ds[0]!.evidence as { sourceReleaseIds: string[] }).sourceReleaseIds).toEqual([r40]);
// 已刷新过的不再补排
expect(await enqueueWatchedRefresh(h.ctx, entityId, "download")).toBe(false);
});
it("用户搜索 / 手动识别出的新作品照常排刷新", async () => {
const r = await h.db
.insertInto("source_release")
.values({ site_id: siteId, site_release_id: String(830000 + ++n), title: "Oppenheimer.2023.2160p.UHD.BluRay" })
.returningAll()
.executeTakeFirstOrThrow();
const before = refreshJobs().length;
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" });
expect(res.outcome, JSON.stringify(res)).toBe("auto");
expect(await isEntityWatched(h.db, res.mediaEntityId as string)).toBe(true);
expect(refreshJobs().length).toBe(before + 1);
});
});
@@ -15,8 +15,8 @@ const TiB = 1024 ** 4;
const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const;
describe("站点能力规格", () => {
it("进入能力目录(41 个已有能力 + 整理元数据补全 + 起草巡检 + 集数映射 3 个);类别 / 风险 / 重放按约定", () => {
expect(CAPABILITY_IDS).toHaveLength(46);
it("进入能力目录(41 个已有能力 + 整理元数据补全 + 起草巡检 + 集数映射 3 个 + 指定整理作品);类别 / 风险 / 重放按约定", () => {
expect(CAPABILITY_IDS).toHaveLength(47);
expect(capabilitySpec("episode_mapping.inspect")).toMatchObject({ kind: "read", replay: "safe" });
expect(capabilitySpec("episode_mapping.simulate")).toMatchObject({ kind: "preview", replay: "safe" });
expect(capabilitySpec("episode_mapping.apply_rule")).toMatchObject({