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; }