feat(search): 资源搜索结果附带规则判定

关联到本地作品的资源按作品收录版本的评估口径(硬条件 + 规则组链 + 阶梯,同自动获取的
evaluateCandidate)给出 ruleVerdict:命中第几档 / 不满足哪些维度 / 与库里当前版本相比高几档。
只调用 acquisition-organize 导出的 loadRuleCatalog、slotSpecWithRules、parsedOf、contextOf、
currentFacts;现有版本 pri 按 rulePriOfAsset 同口径在 search 模块里组合。一页最多判定 100 条、
20 个作品,收录版本 / 订阅 / 资源行 / 规则目录批量查一次;没关联作品或没有规则时为 null。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 11:59:07 +08:00
1 parent fb6c76875c
commit a7d20ca370
4 files changed
+556 -1

No files matched your search

@@ -0,0 +1,244 @@
import type { DB, Kysely } from "@umt/db";
import { parseRelease } from "@umt/domain";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { type BaseRelease, decorate } from "../../src/modules/search/releases.ts";
import { compareWithLibrary, specHasRules } from "../../src/modules/search/rule-verdict.ts";
import type { SourcesRuntime } from "../../src/modules/sources-identity/runtime.ts";
import { memoryLogger } from "../helpers.ts";
import { createTestSchema, pgAvailable, type TestSchema, uniqueSchema } from "../support/pg.ts";
// 搜索结果的规则判定:关联到本地作品的资源按收录版本评估口径给出「命中第 N 档 / 不满足 / 比库里版本高几档」;
// 没关联作品、作品没有收录版本、收录版本没配置条件时为 null。
const up = await pgAvailable();
describe("规则判定口径", () => {
it("种子默认规则组(空层)不算规则;硬条件或带层规则组才算", () => {
expect(specHasRules({ name: "x", hard: {} })).toBe(false);
expect(
specHasRules({
name: "x",
hard: {},
rules: { groups: [{ name: "订阅默认", layers: [], hardExcludes: [] }], atoms: [] },
}),
).toBe(false);
expect(specHasRules({ name: "x", hard: { resolutions: ["2160p"] } })).toBe(true);
expect(specHasRules({ name: "x", hard: {}, soft: { tiers: [{ resolutions: ["2160p"] }] } })).toBe(true);
});
it("不分档时按来源等级与分辨率比较,两项不同向不下结论", () => {
const spec = { name: "x", hard: {} };
expect(
compareWithLibrary(
spec,
{ resolution: "2160p", source: "BluRay" },
100,
{ resolution: "1080p", source: "WEB-DL" },
undefined,
),
).toEqual({ cmp: "higher", steps: null });
expect(
compareWithLibrary(
spec,
{ resolution: "1080p", source: "WEB-DL" },
100,
{ resolution: "1080p", source: "WEB-DL" },
undefined,
),
).toEqual({ cmp: "same", steps: null });
expect(
compareWithLibrary(
spec,
{ resolution: "720p", source: "HDTV" },
100,
{ resolution: "1080p", source: "WEB-DL" },
undefined,
),
).toEqual({ cmp: "lower", steps: null });
expect(
compareWithLibrary(
spec,
{ resolution: "2160p", source: "WEB-DL" },
100,
{ resolution: "1080p", source: "BluRay" },
undefined,
),
).toBeNull();
});
});
describe.skipIf(!up)("搜索结果规则判定", () => {
let db: Kysely<DB>;
let ts: TestSchema;
let rt: SourcesRuntime;
let siteId = "";
let entity = "";
let bare = "";
const rel: Record<string, string> = {};
const TITLES = {
uhd: "Dune.Part.Two.2024.2160p.BluRay.REMUX.HEVC.DTS-HD.MA-FRDS",
fhd: "Dune.Part.Two.2024.1080p.WEB-DL.H264-GRP",
sd: "Dune.Part.Two.2024.720p.HDTV.x264-GRP",
other: "Some.Other.Movie.2023.1080p.WEB-DL.H264-GRP",
loose: "Unrelated.Thing.2020.1080p.WEB-DL-X",
} as const;
const base = (k: keyof typeof TITLES): BaseRelease => {
const p = parseRelease(TITLES[k]);
return {
key: `${siteId}:${k}`,
sourceReleaseId: rel[k]!,
siteId,
siteName: "站",
siteReleaseId: k,
title: TITLES[k],
subtitle: null,
category: { id: null, name: null },
sizeBytes: null,
seeders: 10,
leechers: 0,
snatched: null,
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
free: false,
hr: false,
uploadedAt: null,
detailUrl: "http://x/",
observedAt: "2026-10-01T00:00:00.000Z",
parsed: {
titleCn: null,
titleEn: p.titleEn ?? null,
year: p.year ?? null,
season: null,
episodes: null,
resolution: p.resolution ?? null,
source: p.source ?? null,
videoCodec: p.videoCodec ?? null,
hdr: [],
releaseGroup: null,
},
};
};
beforeAll(async () => {
ts = await createTestSchema(uniqueSchema("rule_verdict"), { max: 4 });
db = ts.db as unknown as Kysely<DB>;
rt = { ctx: { db, logger: memoryLogger().logger } } as unknown as SourcesRuntime;
siteId = (
await db
.insertInto("site")
.values({ key: "s1", name: "站", adapter: "nexusphp", base_url: "http://s1.invalid/" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
const addEntity = async (title: string) =>
(
await db
.insertInto("media_entity")
.values({ kind: "movie", title, year: 2024, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
entity = await addEntity("沙丘2");
bare = await addEntity("没有收录版本的作品");
for (const k of Object.keys(TITLES) as (keyof typeof TITLES)[]) {
const linked = k === "other" ? bare : k === "loose" ? null : entity;
rel[k] = (
await db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: k,
title: TITLES[k],
seeders: 10,
media_entity_id: linked,
resolution_status: linked ? "resolved" : "unresolved",
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
// 规则组:第 1 档 4K,第 2 档 1080P
const group = (
await db
.insertInto("rule_group")
.values({
name: "画质",
kind: "acquisition",
fingerprint: "test:quality",
layers: JSON.stringify([{ expr: { op: "atom", id: "4K" } }, { expr: { op: "atom", id: "1080P" } }]),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
// 库里现有版本来自 1080p 那条资源(第 2 档)
const asset = (
await db
.insertInto("media_asset")
.values({ media_entity_id: entity, kind: "video", source_release_id: rel.fhd!, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
await db
.insertInto("acquisition_slot")
.values({
media_entity_id: entity,
name: "主版本",
status: "satisfied",
hard_conditions: JSON.stringify({ resolutions: ["2160p", "1080p"] }),
rule_group_ids: JSON.stringify([group]),
current_asset_id: asset,
})
.execute();
}, 60_000);
afterAll(async () => {
await ts?.drop();
});
it("命中档位 / 不满足原因 / 与库里版本比较;无作品或作品没有收录版本时为 null", async () => {
const items = await decorate(
rt,
(["uhd", "fhd", "sd", "other", "loose"] as const).map(base),
async () => new Map(),
);
const by = Object.fromEntries(items.map((i) => [i.siteReleaseId, i]));
expect(by.uhd!.ruleVerdict).toMatchObject({
slotName: "主版本",
slotCount: 1,
outcome: "hit",
tier: 1,
tierName: "画质·第1层",
failed: [],
vsLibrary: { cmp: "higher", steps: 1 },
});
expect(by.fhd!.ruleVerdict).toMatchObject({ outcome: "hit", tier: 2, vsLibrary: { cmp: "same", steps: 0 } });
const sd = by.sd!.ruleVerdict!;
expect(sd).toMatchObject({ outcome: "miss", tier: null, vsLibrary: null });
expect(sd.failed).toContain("分辨率");
expect(sd.failed).toContain("规则组「画质」");
expect(sd.details.length).toBeGreaterThan(0);
// 关联到没有收录版本的作品
expect(by.other!.entity?.id).toBe(bare);
expect(by.other!.ruleVerdict).toBeNull();
// 没关联作品
expect(by.loose!.entity).toBeNull();
expect(by.loose!.ruleVerdict).toBeNull();
});
it("收录版本只有种子默认规则组(没有条件)时不给判定", async () => {
const e = (
await db
.insertInto("media_entity")
.values({ kind: "movie", title: "空规则作品", year: 2024, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
await db.insertInto("acquisition_slot").values({ media_entity_id: e, name: "默认版本" }).execute();
await db.updateTable("source_release").set({ media_entity_id: e }).where("id", "=", rel.loose!).execute();
const [item] = await decorate(rt, [base("loose")], async () => new Map());
expect(item!.entity?.id).toBe(e);
expect(item!.ruleVerdict).toBeNull();
});
});