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

+15 -1
View File
@@ -16,6 +16,7 @@ import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts"; import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
import { siteCan } from "../sources-identity/site-kinds.ts"; import { siteCan } from "../sources-identity/site-kinds.ts";
import { raceAbort } from "./media.ts"; import { raceAbort } from "./media.ts";
import { ruleVerdicts } from "./rule-verdict.ts";
// 跨站资源搜索:选中的已启用站点逐站调适配器 search(每站共享限流器 + 单站超时 + 站点级并发上限); // 跨站资源搜索:选中的已启用站点逐站调适配器 search(每站共享限流器 + 单站超时 + 站点级并发上限);
// 按(站点, 查询词)并发合并、短缓存 —— 换站点选择时已查过的站点直接复用;结果 upsert 为资源 // 按(站点, 查询词)并发合并、短缓存 —— 换站点选择时已查过的站点直接复用;结果 upsert 为资源
@@ -23,7 +24,7 @@ import { raceAbort } from "./media.ts";
// 作品关联与库存叠加在每次请求时现算(不进缓存),识别完成后刷新即可看到。 // 作品关联与库存叠加在每次请求时现算(不进缓存),识别完成后刷新即可看到。
/** 缓存里的资源条目(不含作品关联与库存叠加) */ /** 缓存里的资源条目(不含作品关联与库存叠加) */
export type BaseRelease = Omit<SearchRelease, "mediaEntityId" | "entityMatch" | "entity" | "inventory">; export type BaseRelease = Omit<SearchRelease, "mediaEntityId" | "entityMatch" | "entity" | "inventory" | "ruleVerdict">;
export interface Fanout { export interface Fanout {
items: BaseRelease[]; items: BaseRelease[];
@@ -502,6 +503,18 @@ export async function decorate(
entities.set(e.id, e); entities.set(e.id, e);
} }
const inv = entityIds.length ? await loadInventory(entityIds) : new Map<string, InventoryEntry>(); const inv = entityIds.length ? await loadInventory(entityIds) : new Map<string, InventoryEntry>();
// 规则判定:失败只记警告,不影响结果列表
const verdicts = await ruleVerdicts(
db,
items.map((it) => {
const link = links.get(it.key);
const e = link ? entities.get(link.id) : undefined;
return { key: it.key, sourceReleaseId: it.sourceReleaseId, entity: e ? { id: e.id, kind: e.kind } : null };
}),
).catch((err: Error) => {
rt.ctx.logger.warn({ err: { message: err.message } }, "搜索结果规则判定失败");
return new Map<string, never>();
});
return items.map((it) => { return items.map((it) => {
const link = links.get(it.key); const link = links.get(it.key);
const e = link ? entities.get(link.id) : undefined; const e = link ? entities.get(link.id) : undefined;
@@ -512,6 +525,7 @@ export async function decorate(
entityMatch: e ? link!.match : null, entityMatch: e ? link!.match : null,
entity: e ? { id: e.id, title: e.title, year: e.year, kind: e.kind as never } : null, entity: e ? { id: e.id, title: e.title, year: e.year, kind: e.kind as never } : null,
inventory: entry ? overlay(entry, it.sourceReleaseId) : null, inventory: entry ? overlay(entry, it.sourceReleaseId) : null,
ruleVerdict: verdicts.get(it.key) ?? null,
}; };
}); });
} }
@@ -0,0 +1,266 @@
import type { SearchRuleVerdict } from "@umt/contracts";
import type { AcquisitionSlotRow, DB, Kysely, SourceReleaseRow, SubscriptionRow } from "@umt/db";
import {
currentPri,
describeViolation,
effectiveLadder,
evaluateCandidate,
type QualityFacts,
qualityTier,
ruleChainOf,
type SlotEvaluation,
type SlotSpec,
} from "@umt/domain";
import { contextOf, currentFacts, parsedOf } from "../acquisition-organize/services/acquisition.ts";
import { entityOriginalLanguage, loadRuleCatalog } from "../acquisition-organize/services/rules.ts";
import { slotSpecWithRules } from "../acquisition-organize/services/subscriptions.ts";
// 搜索结果的规则判定:关联到本地作品的资源,按作品各收录版本的评估口径(硬条件 + 规则组链 + 阶梯,
// 同自动获取的 evaluateCandidate)判断「命中第几档 / 不满足什么 / 比库里版本高几档」。
// 只调用 acquisition-organize 导出的函数(规则目录、收录版本规格、资源 → 评估输入、当前版本特征);
// 现有版本的 pri 重算(acquisition.ts 的 rulePriOfAsset 未导出)在这里按同一口径组合:
// 有来源资源 → 按当前规则重算;否则取下载计划评估时记下的 pri;再否则按交付特征算。
// 性能:一页最多判定 MAX_ITEMS 条、MAX_ENTITIES 个作品;收录版本、订阅、资源行、规则目录都是批量查一次。
export const MAX_VERDICT_ITEMS = 100;
export const MAX_VERDICT_ENTITIES = 20;
export interface VerdictInput {
key: string;
sourceReleaseId: string | null;
entity: { id: string; kind: string } | null;
}
const FIELD_LABEL: Record<string, string> = {
resolution: "分辨率",
source: "来源",
size: "体积",
subtitles: "字幕",
cut: "版本",
group: "制作组",
contentType: "内容类型",
hdr: "HDR",
codec: "编码",
keywords: "关键字",
site: "站点",
seeders: "做种数",
promotion: "促销",
tier: "分档",
rule: "规则组",
};
const fieldLabel = (f: string) => FIELD_LABEL[f] ?? f;
/** 收录版本有没有可判定的东西:硬条件、阶梯、带层或带硬排除的规则组(种子的默认组是空的,不算) */
export function specHasRules(spec: SlotSpec): boolean {
const hard = Object.values(spec.hard ?? {}).some((v) =>
Array.isArray(v) ? v.length > 0 : v !== undefined && v !== null && v !== false,
);
const groups = !!spec.rules?.groups.some((g) => g.layers.length > 0 || (g.hardExcludes?.length ?? 0) > 0);
return hard || groups || !!spec.soft?.tiers?.length;
}
const tieredSpec = (spec: SlotSpec) =>
!!spec.soft?.tiers?.length || !!spec.rules?.groups.some((g) => g.layers.length > 0);
interface Candidate {
verdict: Omit<SearchRuleVerdict, "slotCount" | "vsLibrary">;
slot: AcquisitionSlotRow;
spec: SlotSpec;
ev: SlotEvaluation;
pri: number;
}
/** 一个收录版本的评估 → 判定(不含库存比较) */
export function verdictOf(slot: Pick<AcquisitionSlotRow, "id" | "name">, ev: SlotEvaluation) {
const base = { slotId: slot.id, slotName: slot.name };
if (ev.decision === "unknown_field") {
const missing = (ev.missing as string[]).filter((f) => f !== "current_asset");
return {
...base,
outcome: "unknown" as const,
tier: null,
tierName: null,
failed: [...new Set(missing.map(fieldLabel))],
details: missing.map((f) => `标题里看不出${fieldLabel(f)}`),
};
}
if (ev.decision === "reject") {
const failed = ev.violations.map((v) =>
v.field === "rule" && typeof v.expected === "string" ? `规则组「${v.expected}」` : fieldLabel(v.field),
);
return {
...base,
outcome: "miss" as const,
tier: null,
tierName: null,
failed: [...new Set(failed)],
details: ev.violations.map(describeViolation),
};
}
const level = ev.ladder?.level ?? null;
return {
...base,
outcome: "hit" as const,
tier: level === null ? null : level + 1,
tierName: level === null ? null : (ev.ladder?.levelName ?? null),
failed: [],
details: [],
};
}
const RANK = { hit: 0, unknown: 1, miss: 2 } as const;
/**
* 批量判定:返回 key → 判定。没关联作品、没有资源行、作品没有收录版本或收录版本都没配置规则的不出现在结果里。
*/
export async function ruleVerdicts(
db: Kysely<DB>,
items: readonly VerdictInput[],
now: Date = new Date(),
): Promise<Map<string, SearchRuleVerdict>> {
const out = new Map<string, SearchRuleVerdict>();
const entityIds: string[] = [];
for (const it of items) {
if (!it.entity || !it.sourceReleaseId) continue;
if (!entityIds.includes(it.entity.id) && entityIds.length < MAX_VERDICT_ENTITIES) entityIds.push(it.entity.id);
}
const todo = items
.filter((it) => it.entity && it.sourceReleaseId && entityIds.includes(it.entity.id))
.slice(0, MAX_VERDICT_ITEMS);
if (todo.length === 0) return out;
const slots = await db
.selectFrom("acquisition_slot")
.selectAll()
.where("media_entity_id", "in", entityIds)
.orderBy("position")
.orderBy("created_at")
.execute();
if (slots.length === 0) return out;
const subIds = [...new Set(slots.map((s) => s.subscription_id).filter((x): x is string => !!x))];
const [subs, rels, cat] = await Promise.all([
subIds.length
? db.selectFrom("subscription").select(["id", "params"]).where("id", "in", subIds).execute()
: Promise.resolve([] as Pick<SubscriptionRow, "id" | "params">[]),
db
.selectFrom("source_release")
.selectAll()
.where(
"id",
"in",
todo.map((t) => t.sourceReleaseId!),
)
.execute(),
loadRuleCatalog(db),
]);
const subById = new Map(subs.map((s) => [s.id, s]));
const relById = new Map(rels.map((r) => [r.id, r]));
const slotsByEntity = new Map<string, AcquisitionSlotRow[]>();
for (const s of slots) slotsByEntity.set(s.media_entity_id, [...(slotsByEntity.get(s.media_entity_id) ?? []), s]);
const langs = new Map<string, Promise<string | undefined>>();
const langOf = (id: string) => {
if (!langs.has(id)) langs.set(id, entityOriginalLanguage(db, id));
return langs.get(id)!;
};
const currentPriCache = new Map<string, Promise<{ facts: QualityFacts; pri: number | undefined } | null>>();
for (const it of todo) {
const rel = relById.get(it.sourceReleaseId!);
const entity = it.entity!;
const entitySlots = slotsByEntity.get(entity.id) ?? [];
if (!rel || entitySlots.length === 0) continue;
const parsed = parsedOf(rel);
const cands: Candidate[] = [];
let ruled = 0;
for (const slot of entitySlots) {
const sub = slot.subscription_id ? subById.get(slot.subscription_id) : null;
const spec = slotSpecWithRules(cat, slot, sub, entity, rel.site_id);
if (!specHasRules(spec)) continue;
ruled++;
const ctx = contextOf(rel, now);
if (spec.rules) {
const lang = await langOf(entity.id);
if (lang) ctx.originalLanguage = lang;
}
const ev = evaluateCandidate(spec, parsed, null, ctx);
const pri = ev.decision === "accept" || ev.decision === "upgrade" ? (ev.pri ?? ev.ladder?.pri ?? 100) : -1;
cands.push({ verdict: verdictOf(slot, ev), slot, spec, ev, pri });
}
if (cands.length === 0) continue;
// 多个收录版本:命中 > 缺字段 > 不满足;同为命中取档位高的;再按收录版本顺序
const best = [...cands].sort((a, b) => RANK[a.verdict.outcome] - RANK[b.verdict.outcome] || b.pri - a.pri)[0]!;
let vsLibrary: SearchRuleVerdict["vsLibrary"] = null;
if (best.verdict.outcome === "hit" && best.slot.current_asset_id) {
const key = `${best.slot.id}:${rel.site_id}`;
if (!currentPriCache.has(key))
currentPriCache.set(key, currentVersion(db, best.spec, best.slot.current_asset_id, entity.id, langOf, now));
const cur = await currentPriCache.get(key)!;
if (cur) vsLibrary = compareWithLibrary(best.spec, parsed, best.pri, cur.facts, cur.pri);
}
out.set(it.key, { ...best.verdict, slotCount: ruled, vsLibrary });
}
return out;
}
/** 当前版本的交付特征 + 规则 pri(同 acquisition.ts rulePriOfAsset 的口径,只在分档时算) */
async function currentVersion(
db: Kysely<DB>,
spec: SlotSpec,
assetId: string,
entityId: string,
langOf: (id: string) => Promise<string | undefined>,
now: Date,
): Promise<{ facts: QualityFacts; pri: number | undefined } | null> {
const facts = await currentFacts(db, assetId);
if (!facts) return null;
if (!tieredSpec(spec)) return { facts, pri: undefined };
const a = await db
.selectFrom("media_asset")
.select(["source_release_id", "download_task_id"])
.where("id", "=", assetId)
.executeTakeFirst();
let rel: SourceReleaseRow | undefined;
if (a?.source_release_id)
rel = await db.selectFrom("source_release").selectAll().where("id", "=", a.source_release_id).executeTakeFirst();
if (rel) {
const p = parsedOf(rel);
const lang = spec.rules ? await langOf(entityId) : undefined;
const ctx = { ...contextOf(rel, now), ...(lang ? { originalLanguage: lang } : {}) };
return { facts, pri: effectiveLadder(spec, p, ctx, ruleChainOf(spec, p, ctx)).pri };
}
if (a?.download_task_id) {
const p = await db
.selectFrom("download_task as t")
.innerJoin("acquisition_plan as p", "p.id", "t.acquisition_plan_id")
.select("p.evaluation")
.where("t.id", "=", a.download_task_id)
.executeTakeFirst();
const v = (p?.evaluation as { pri?: unknown } | undefined)?.pri;
if (typeof v === "number") return { facts, pri: v };
}
return { facts, pri: currentPri(spec, facts) };
}
/** 分档时比 pri(相差几档);不分档时比来源等级与分辨率(两项同向才下结论) */
export function compareWithLibrary(
spec: SlotSpec,
candidate: Pick<QualityFacts, "resolution" | "source">,
candidatePri: number,
current: QualityFacts,
currentRulePri: number | undefined,
): SearchRuleVerdict["vsLibrary"] {
if (tieredSpec(spec) && currentRulePri !== undefined) {
const d = candidatePri - currentRulePri;
return { cmp: d > 0 ? "higher" : d < 0 ? "lower" : "same", steps: Math.abs(d) };
}
const a = qualityTier(candidate, spec);
const b = qualityTier(current, spec);
if (a.source < 0 || a.resolution < 0 || b.source < 0 || b.resolution < 0) return null;
const ds = a.source - b.source;
const dr = a.resolution - b.resolution;
if (ds === 0 && dr === 0) return { cmp: "same", steps: null };
if (ds >= 0 && dr >= 0) return { cmp: "higher", steps: null };
if (ds <= 0 && dr <= 0) return { cmp: "lower", steps: null };
return null;
}
@@ -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();
});
});
+31
View File
@@ -104,6 +104,35 @@ export const SearchReleaseInventory = InventoryEntry.extend({
}); });
export type SearchReleaseInventory = z.infer<typeof SearchReleaseInventory>; export type SearchReleaseInventory = z.infer<typeof SearchReleaseInventory>;
/**
* 规则判定(只在关联到本地作品、且作品的收录版本配置了条件或规则组时给出):
* 按收录版本的评估口径(硬条件 + 规则组链 + 阶梯)判断这条资源,多个收录版本时取最好的一个。
*/
export const SearchRuleVerdict = z.object({
slotId: Id,
slotName: z.string(),
/** 作品共有几个收录版本参与判定;>1 时界面写出收录版本名 */
slotCount: z.number().int().positive(),
/** hit = 满足;miss = 不满足;unknown = 标题缺少判定需要的字段 */
outcome: z.enum(["hit", "miss", "unknown"]),
/** 命中的档位(1 起);没有分档或未命中为 null */
tier: z.number().int().positive().nullable(),
/** 档位名(规则组名·第 N 层 / 阶梯层名) */
tierName: z.string().nullable(),
/** 不满足(或缺字段)的维度:分辨率 / 来源 / 编码 / HDR / 字幕 / 体积 / 制作组 / 关键字 / 规则组…,去重 */
failed: z.array(z.string()),
/** 逐条说明(悬停提示) */
details: z.array(z.string()),
/**
* 与库里该收录版本当前版本相比:higher / same / lower;steps = 相差几档(按档位比较时给出)。
* 没有当前版本或无法比较时为 null。
*/
vsLibrary: z
.object({ cmp: z.enum(["higher", "same", "lower"]), steps: z.number().int().nonnegative().nullable() })
.nullable(),
});
export type SearchRuleVerdict = z.infer<typeof SearchRuleVerdict>;
export const SearchRelease = z.object({ export const SearchRelease = z.object({
/** `${siteId}:${siteReleaseId}`,前端列表 key */ /** `${siteId}:${siteReleaseId}`,前端列表 key */
key: z.string(), key: z.string(),
@@ -140,6 +169,8 @@ export const SearchRelease = z.object({
entity: z.object({ id: Id, title: z.string(), year: z.number().int().nullable(), kind: MediaKind }).nullable(), entity: z.object({ id: Id, title: z.string(), year: z.number().int().nullable(), kind: MediaKind }).nullable(),
/** 库存叠加;candidateReasons 只保留针对本条资源的评估 */ /** 库存叠加;candidateReasons 只保留针对本条资源的评估 */
inventory: SearchReleaseInventory.nullable(), inventory: SearchReleaseInventory.nullable(),
/** 与本地作品收录规则的关系;没关联作品或没有规则时为 null */
ruleVerdict: SearchRuleVerdict.nullable(),
}); });
export type SearchRelease = z.infer<typeof SearchRelease>; export type SearchRelease = z.infer<typeof SearchRelease>;