diff --git a/apps/server/src/modules/search/releases.ts b/apps/server/src/modules/search/releases.ts index 8ff26f65..220019f7 100644 --- a/apps/server/src/modules/search/releases.ts +++ b/apps/server/src/modules/search/releases.ts @@ -16,6 +16,7 @@ import type { SourcesRuntime } from "../sources-identity/runtime.ts"; import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts"; import { siteCan } from "../sources-identity/site-kinds.ts"; import { raceAbort } from "./media.ts"; +import { ruleVerdicts } from "./rule-verdict.ts"; // 跨站资源搜索:选中的已启用站点逐站调适配器 search(每站共享限流器 + 单站超时 + 站点级并发上限); // 按(站点, 查询词)并发合并、短缓存 —— 换站点选择时已查过的站点直接复用;结果 upsert 为资源 @@ -23,7 +24,7 @@ import { raceAbort } from "./media.ts"; // 作品关联与库存叠加在每次请求时现算(不进缓存),识别完成后刷新即可看到。 /** 缓存里的资源条目(不含作品关联与库存叠加) */ -export type BaseRelease = Omit; +export type BaseRelease = Omit; export interface Fanout { items: BaseRelease[]; @@ -502,6 +503,18 @@ export async function decorate( entities.set(e.id, e); } const inv = entityIds.length ? await loadInventory(entityIds) : new Map(); + // 规则判定:失败只记警告,不影响结果列表 + 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(); + }); return items.map((it) => { const link = links.get(it.key); const e = link ? entities.get(link.id) : undefined; @@ -512,6 +525,7 @@ export async function decorate( entityMatch: e ? link!.match : 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, + ruleVerdict: verdicts.get(it.key) ?? null, }; }); } diff --git a/apps/server/src/modules/search/rule-verdict.ts b/apps/server/src/modules/search/rule-verdict.ts new file mode 100644 index 00000000..bac74c60 --- /dev/null +++ b/apps/server/src/modules/search/rule-verdict.ts @@ -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 = { + 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; + slot: AcquisitionSlotRow; + spec: SlotSpec; + ev: SlotEvaluation; + pri: number; +} + +/** 一个收录版本的评估 → 判定(不含库存比较) */ +export function verdictOf(slot: Pick, 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, + items: readonly VerdictInput[], + now: Date = new Date(), +): Promise> { + const out = new Map(); + 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[]), + 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(); + for (const s of slots) slotsByEntity.set(s.media_entity_id, [...(slotsByEntity.get(s.media_entity_id) ?? []), s]); + + const langs = new Map>(); + const langOf = (id: string) => { + if (!langs.has(id)) langs.set(id, entityOriginalLanguage(db, id)); + return langs.get(id)!; + }; + const currentPriCache = new Map>(); + + 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, + spec: SlotSpec, + assetId: string, + entityId: string, + langOf: (id: string) => Promise, + 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, + 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; +} diff --git a/apps/server/test/search/release-rule-verdict.test.ts b/apps/server/test/search/release-rule-verdict.test.ts new file mode 100644 index 00000000..98bff20b --- /dev/null +++ b/apps/server/test/search/release-rule-verdict.test.ts @@ -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; + let ts: TestSchema; + let rt: SourcesRuntime; + let siteId = ""; + let entity = ""; + let bare = ""; + const rel: Record = {}; + + 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; + 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(); + }); +}); diff --git a/packages/contracts/src/search.ts b/packages/contracts/src/search.ts index 8f237361..72ef85b5 100644 --- a/packages/contracts/src/search.ts +++ b/packages/contracts/src/search.ts @@ -104,6 +104,35 @@ export const SearchReleaseInventory = InventoryEntry.extend({ }); export type SearchReleaseInventory = z.infer; +/** + * 规则判定(只在关联到本地作品、且作品的收录版本配置了条件或规则组时给出): + * 按收录版本的评估口径(硬条件 + 规则组链 + 阶梯)判断这条资源,多个收录版本时取最好的一个。 + */ +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; + export const SearchRelease = z.object({ /** `${siteId}:${siteReleaseId}`,前端列表 key */ 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(), /** 库存叠加;candidateReasons 只保留针对本条资源的评估 */ inventory: SearchReleaseInventory.nullable(), + /** 与本地作品收录规则的关系;没关联作品或没有规则时为 null */ + ruleVerdict: SearchRuleVerdict.nullable(), }); export type SearchRelease = z.infer;