- rules/: RuleExpr tree, '>' layers with ! & | and parens, position-aware errors (unknown atom with suggestions, unsupported music atoms, parens, empty layer) - builtin keyword table aligned with MP BUILTIN_RULE_SET; field-based judgement where parsed fields exist, MP regex otherwise; custom atoms (MP 7 fields) - chain evaluation: hard excludes, ladder pri 100-k, groups AND-ed, pri from last laddered group, readable per-atom trace - slot: SlotSpec.rules, rule.hard_exclude / rule.no_layer_matched violations, pri basis switches to priority when a rule ladder is present - differential: 66 rule chains x 32 torrents vs MP python path (parse 64/64, pri 2090/2112, 22 registered mp_bug) Co-Authored-By: Claude Opus 5.5 <[email protected]>
645 lines
26 KiB
TypeScript
645 lines
26 KiB
TypeScript
import type { ParsedRelease, ReleaseSource, Resolution } from "../release-parser/types.ts";
|
|
import { catalogOf, evaluateRuleChain } from "../rules/evaluate.ts";
|
|
import type { RuleChainResult } from "../rules/types.ts";
|
|
import type {
|
|
EvaluationContext,
|
|
HardField,
|
|
LadderResult,
|
|
QualityFacts,
|
|
QualityTier,
|
|
SlotEvaluation,
|
|
SlotSpec,
|
|
SlotTierRule,
|
|
UnknownPolicy,
|
|
Violation,
|
|
} from "./types.ts";
|
|
|
|
export const DEFAULT_SOURCE_TIERS: Readonly<Record<ReleaseSource, number>> = {
|
|
REMUX: 3,
|
|
"BluRay-Disc": 3,
|
|
BluRay: 3,
|
|
"WEB-DL": 2,
|
|
WEBRip: 1,
|
|
HDTV: 1,
|
|
HDRip: 1,
|
|
DVDRip: 0,
|
|
DVD: 0,
|
|
};
|
|
|
|
export const RESOLUTION_RANK: Readonly<Record<Resolution, number>> = {
|
|
"4320p": 7,
|
|
"2160p": 6,
|
|
"1440p": 5,
|
|
"1080p": 4,
|
|
"1080i": 3,
|
|
"720p": 2,
|
|
"576p": 1,
|
|
"480p": 0,
|
|
};
|
|
|
|
export function qualityTier(f: QualityFacts, spec?: SlotSpec): QualityTier {
|
|
const tiers = { ...DEFAULT_SOURCE_TIERS, ...spec?.upgrade?.sourceTiers };
|
|
return {
|
|
source: f.source ? tiers[f.source] : -1,
|
|
resolution: f.resolution ? RESOLUTION_RANK[f.resolution] : -1,
|
|
};
|
|
}
|
|
|
|
function policyFor(spec: SlotSpec, field: HardField): UnknownPolicy {
|
|
const p = spec.unknownPolicy;
|
|
if (p === undefined) return "hold";
|
|
if (typeof p === "string") return p;
|
|
return p[field] ?? "hold";
|
|
}
|
|
|
|
const lc = (s: string) => s.toLowerCase();
|
|
|
|
function subtitleSatisfied(have: string[], need: string): boolean {
|
|
return have.some((h) => h === need || (need === "zh" && h.startsWith("zh")));
|
|
}
|
|
|
|
/** 硬条件检查:区分"缺字段"(missing)和"字段不满足"(violations) */
|
|
export function checkHardConditions(
|
|
spec: SlotSpec,
|
|
r: ParsedRelease,
|
|
ctx: EvaluationContext = {},
|
|
): { violations: Violation[]; missing: HardField[]; accepted: HardField[] } {
|
|
const h = spec.hard;
|
|
const violations: Violation[] = [];
|
|
const missing: HardField[] = [];
|
|
const accepted: HardField[] = [];
|
|
const unknown = (f: HardField) => {
|
|
const p = policyFor(spec, f);
|
|
if (p === "hold") missing.push(f);
|
|
else if (p === "reject") violations.push({ field: f, code: "unknown_rejected_by_policy" });
|
|
else accepted.push(f);
|
|
};
|
|
|
|
if (h.resolutions?.length) {
|
|
if (!r.resolution) unknown("resolution");
|
|
else if (!h.resolutions.includes(r.resolution))
|
|
violations.push({ field: "resolution", code: "not_allowed", expected: h.resolutions, actual: r.resolution });
|
|
}
|
|
if (h.sources?.length || h.requireEncode) {
|
|
if (!r.source) unknown("source");
|
|
else {
|
|
if (h.sources?.length && !h.sources.includes(r.source))
|
|
violations.push({ field: "source", code: "not_allowed", expected: h.sources, actual: r.source });
|
|
if (h.requireEncode && !r.isEncode)
|
|
violations.push({ field: "source", code: "not_encode", expected: "BluRay encode", actual: r.source });
|
|
}
|
|
}
|
|
if (h.maxSizeBytes !== undefined || h.minSizeBytes !== undefined) {
|
|
if (r.sizeBytes === undefined) unknown("size");
|
|
else {
|
|
if (h.maxSizeBytes !== undefined && r.sizeBytes > h.maxSizeBytes)
|
|
violations.push({ field: "size", code: "too_large", expected: h.maxSizeBytes, actual: r.sizeBytes });
|
|
if (h.minSizeBytes !== undefined && r.sizeBytes < h.minSizeBytes)
|
|
violations.push({ field: "size", code: "too_small", expected: h.minSizeBytes, actual: r.sizeBytes });
|
|
}
|
|
}
|
|
if (h.requiredSubtitles?.length) {
|
|
if (r.subtitles === undefined) unknown("subtitles");
|
|
else {
|
|
const lack = h.requiredSubtitles.filter((s) => !subtitleSatisfied(r.subtitles!, s));
|
|
if (lack.length > 0)
|
|
violations.push({ field: "subtitles", code: "missing_language", expected: lack, actual: r.subtitles });
|
|
}
|
|
}
|
|
if (h.cuts?.length) {
|
|
const cut = r.cut ?? "default";
|
|
if (!h.cuts.includes(cut)) violations.push({ field: "cut", code: "not_allowed", expected: h.cuts, actual: cut });
|
|
}
|
|
if (h.excludeGroups?.length && r.releaseGroup) {
|
|
const g = lc(r.releaseGroup.name);
|
|
if (h.excludeGroups.some((x) => g.split("@").includes(lc(x))))
|
|
violations.push({ field: "group", code: "excluded", actual: r.releaseGroup.name });
|
|
}
|
|
if (h.contentTypes?.length) {
|
|
if (r.contentType === "unknown" && !h.contentTypes.includes("unknown")) unknown("contentType");
|
|
else if (!h.contentTypes.includes(r.contentType))
|
|
violations.push({ field: "contentType", code: "not_allowed", expected: h.contentTypes, actual: r.contentType });
|
|
}
|
|
if (h.excludeSources?.length) {
|
|
if (!r.source) unknown("source");
|
|
else if (h.excludeSources.includes(r.source))
|
|
violations.push({ field: "source", code: "excluded", expected: h.excludeSources, actual: r.source });
|
|
}
|
|
if (h.hdr) {
|
|
const have = r.hdr ?? [];
|
|
const { anyOf, noneOf, requireSdr } = h.hdr;
|
|
if (anyOf?.length && !anyOf.some((x) => have.includes(x)))
|
|
violations.push({ field: "hdr", code: "missing_required", expected: anyOf, actual: have });
|
|
const hit = (noneOf ?? []).filter((x) => have.includes(x));
|
|
if (hit.length) violations.push({ field: "hdr", code: "excluded", expected: noneOf, actual: hit });
|
|
if (requireSdr && have.length) violations.push({ field: "hdr", code: "not_sdr", actual: have });
|
|
}
|
|
if (h.codecs?.length || h.excludeCodecs?.length) {
|
|
if (!r.videoCodec) unknown("codec");
|
|
else {
|
|
if (h.codecs?.length && !h.codecs.includes(r.videoCodec))
|
|
violations.push({ field: "codec", code: "not_allowed", expected: h.codecs, actual: r.videoCodec });
|
|
if (h.excludeCodecs?.includes(r.videoCodec))
|
|
violations.push({ field: "codec", code: "excluded", expected: h.excludeCodecs, actual: r.videoCodec });
|
|
}
|
|
}
|
|
if (h.minUnitSizeBytes !== undefined || h.maxUnitSizeBytes !== undefined) {
|
|
const n = unitCountOf(r, ctx);
|
|
if (r.sizeBytes === undefined || n === undefined) unknown("size");
|
|
else {
|
|
const per = Math.round(r.sizeBytes / n);
|
|
if (h.maxUnitSizeBytes !== undefined && per > h.maxUnitSizeBytes)
|
|
violations.push({ field: "size", code: "unit_too_large", expected: h.maxUnitSizeBytes, actual: per });
|
|
if (h.minUnitSizeBytes !== undefined && per < h.minUnitSizeBytes)
|
|
violations.push({ field: "size", code: "unit_too_small", expected: h.minUnitSizeBytes, actual: per });
|
|
}
|
|
}
|
|
if (h.include?.length || h.exclude?.length) {
|
|
const text = matchText(r, ctx);
|
|
const bad = [...(h.include ?? []), ...(h.exclude ?? [])].filter((p) => validateKeywordPattern(p) !== null);
|
|
if (bad.length) violations.push({ field: "keywords", code: "invalid_pattern", actual: bad });
|
|
else {
|
|
if (h.include?.length && !h.include.some((p) => keywordRe(p).test(text)))
|
|
violations.push({ field: "keywords", code: "include_missed", expected: h.include });
|
|
const hits = (h.exclude ?? []).filter((p) => keywordRe(p).test(text));
|
|
if (hits.length) violations.push({ field: "keywords", code: "exclude_hit", actual: hits });
|
|
}
|
|
}
|
|
if (h.sites?.length || h.excludeSites?.length) {
|
|
if (!ctx.siteId) unknown("site");
|
|
else {
|
|
if (h.sites?.length && !h.sites.includes(ctx.siteId))
|
|
violations.push({ field: "site", code: "not_in_scope", expected: h.sites, actual: ctx.siteId });
|
|
if (h.excludeSites?.includes(ctx.siteId))
|
|
violations.push({ field: "site", code: "excluded", actual: ctx.siteId });
|
|
}
|
|
}
|
|
if (h.minSeeders !== undefined) {
|
|
if (ctx.seeders === undefined) unknown("seeders");
|
|
else if (ctx.seeders < h.minSeeders)
|
|
violations.push({ field: "seeders", code: "too_few", expected: h.minSeeders, actual: ctx.seeders });
|
|
}
|
|
if (h.freeOnly) {
|
|
if (ctx.downloadVolumeFactor === undefined) unknown("promotion");
|
|
else if (ctx.downloadVolumeFactor > 0)
|
|
violations.push({ field: "promotion", code: "not_free", actual: ctx.downloadVolumeFactor });
|
|
}
|
|
return { violations, missing, accepted };
|
|
}
|
|
|
|
/** 候选覆盖的单元数:上下文给定 > 解析出的集范围 > 标题里的"全 N 集" */
|
|
export function unitCountOf(r: Pick<ParsedRelease, "episodes" | "totalEpisodes">, ctx: EvaluationContext = {}) {
|
|
if (ctx.unitCount !== undefined && ctx.unitCount > 0) return ctx.unitCount;
|
|
if (r.episodes) return Math.max(1, r.episodes.end - r.episodes.start + 1);
|
|
if (r.totalEpisodes && r.totalEpisodes > 0) return r.totalEpisodes;
|
|
return undefined;
|
|
}
|
|
|
|
const MAX_PATTERN = 200;
|
|
|
|
/**
|
|
* 关键词正则白名单校验:长度上限、禁止反向引用/后行断言/嵌套量词(防灾难性回溯),必须能编译。
|
|
* 返回 null 表示合法,否则为原因。
|
|
*/
|
|
export function validateKeywordPattern(p: string): string | null {
|
|
if (typeof p !== "string" || p.length === 0) return "empty";
|
|
if (p.length > MAX_PATTERN) return "too_long";
|
|
if (/\\[1-9]|\\k</.test(p)) return "backreference";
|
|
if (/\(\?<[=!]/.test(p)) return "lookbehind";
|
|
if (/\([^()]*[+*][^()]*\)[+*{]/.test(p)) return "nested_quantifier";
|
|
try {
|
|
new RegExp(p, "i");
|
|
} catch {
|
|
return "invalid";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const reCache = new Map<string, RegExp>();
|
|
function keywordRe(p: string): RegExp {
|
|
let re = reCache.get(p);
|
|
if (!re) {
|
|
re = new RegExp(p, "i");
|
|
if (reCache.size > 500) reCache.clear();
|
|
reCache.set(p, re);
|
|
}
|
|
return re;
|
|
}
|
|
|
|
function matchText(r: Pick<ParsedRelease, "raw">, ctx: EvaluationContext): string {
|
|
return [r.raw ?? "", ctx.subtitle ?? "", ...(ctx.labels ?? [])].join("\n");
|
|
}
|
|
|
|
const GiB = 1024 ** 3;
|
|
const fmtBytes = (v: unknown) =>
|
|
typeof v === "number" ? (v >= GiB ? `${(v / GiB).toFixed(2)} GiB` : `${Math.round(v / 1024 ** 2)} MiB`) : String(v);
|
|
const fmtList = (v: unknown) => (Array.isArray(v) ? `[${v.join(", ")}]` : String(v ?? "未知"));
|
|
|
|
/** 违规 → 可读说明(对齐 MP trace 的可读性,但字段结构化保留在 violations) */
|
|
export function describeViolation(v: Violation): string {
|
|
const e = v.expected;
|
|
const a = v.actual;
|
|
switch (`${v.field}.${v.code}`) {
|
|
case "resolution.not_allowed":
|
|
return `分辨率 ${a} 不在允许列表 ${fmtList(e)}`;
|
|
case "source.not_allowed":
|
|
return `来源 ${a} 不在允许列表 ${fmtList(e)}`;
|
|
case "source.excluded":
|
|
return `来源 ${a} 被排除`;
|
|
case "source.not_encode":
|
|
return `要求蓝光压制,实际 ${a}`;
|
|
case "size.too_large":
|
|
return `体积 ${fmtBytes(a)} 超过上限 ${fmtBytes(e)}`;
|
|
case "size.too_small":
|
|
return `体积 ${fmtBytes(a)} 低于下限 ${fmtBytes(e)}`;
|
|
case "size.unit_too_large":
|
|
return `每集体积 ${fmtBytes(a)} 超过上限 ${fmtBytes(e)}`;
|
|
case "size.unit_too_small":
|
|
return `每集体积 ${fmtBytes(a)} 低于下限 ${fmtBytes(e)}`;
|
|
case "subtitles.missing_language":
|
|
return `缺少字幕 ${fmtList(e)}(实际 ${fmtList(a)})`;
|
|
case "cut.not_allowed":
|
|
return `版本 ${a} 不在允许列表 ${fmtList(e)}`;
|
|
case "group.excluded":
|
|
return `发布组 ${a} 被排除`;
|
|
case "contentType.not_allowed":
|
|
return `内容类型 ${a} 不在允许列表 ${fmtList(e)}`;
|
|
case "hdr.missing_required":
|
|
return `HDR 需含 ${fmtList(e)} 之一(实际 ${fmtList(a)})`;
|
|
case "hdr.excluded":
|
|
return `HDR ${fmtList(a)} 被排除`;
|
|
case "hdr.not_sdr":
|
|
return `要求 SDR,实际带 ${fmtList(a)}`;
|
|
case "codec.not_allowed":
|
|
return `编码 ${a} 不在允许列表 ${fmtList(e)}`;
|
|
case "codec.excluded":
|
|
return `编码 ${a} 被排除`;
|
|
case "keywords.include_missed":
|
|
return `不包含任何关键词 ${fmtList(e)}`;
|
|
case "keywords.exclude_hit":
|
|
return `命中排除关键词 ${fmtList(a)}`;
|
|
case "keywords.invalid_pattern":
|
|
return `关键词正则不合法 ${fmtList(a)}`;
|
|
case "site.not_in_scope":
|
|
return "站点不在范围内";
|
|
case "site.excluded":
|
|
return "站点被排除";
|
|
case "seeders.too_few":
|
|
return `做种数 ${a} 小于 ${e}`;
|
|
case "promotion.not_free":
|
|
return `非免费(下载系数 ${a})`;
|
|
case "tier.no_tier_matched":
|
|
return "不命中优先级阶梯的任何一层";
|
|
case "rule.hard_exclude":
|
|
return `命中规则组「${e}」的硬排除 ${fmtList(a)}`;
|
|
case "rule.no_layer_matched":
|
|
return `不命中规则组「${e}」的任何一层`;
|
|
case "upgrade.upgrade_disabled":
|
|
return "已有版本且未开启升级";
|
|
case "upgrade.at_ceiling":
|
|
return "当前版本已达顶档,不再升级";
|
|
case "upgrade.same_tier":
|
|
return "与当前版本同档,不反复更换";
|
|
case "upgrade.lower_tier":
|
|
return "低于当前版本";
|
|
case "upgrade.below_min_improvement":
|
|
return "提升幅度不足最小改进阈值";
|
|
case "upgrade.encode_not_qualified":
|
|
return `压制组 ${a ?? "未知"} 不在合格名单`;
|
|
case "upgrade.not_higher_priority":
|
|
return `阶梯 pri 未严格提升(当前 ${JSON.stringify(e)},候选 ${JSON.stringify(a)})`;
|
|
case "upgrade.not_full_pack":
|
|
return "全集洗版只接受整季包";
|
|
case "upgrade.no_improvable_units":
|
|
return "候选所含单元都没有可提升的";
|
|
default:
|
|
return e === undefined
|
|
? `${v.field}: ${v.code}`
|
|
: `${v.field}: ${v.code}(期望 ${JSON.stringify(e)},实际 ${JSON.stringify(a ?? null)})`;
|
|
}
|
|
}
|
|
|
|
/** 阶梯层匹配:返回未满足的条件说明(空 = 命中) */
|
|
function tierFailures(rule: SlotTierRule, r: ParsedRelease, ctx: EvaluationContext): string[] {
|
|
const out: string[] = [];
|
|
if (rule.resolutions?.length && !(r.resolution && rule.resolutions.includes(r.resolution)))
|
|
out.push(`分辨率 ${r.resolution ?? "未知"} 不在 ${fmtList(rule.resolutions)}`);
|
|
if (rule.sources?.length && !(r.source && rule.sources.includes(r.source)))
|
|
out.push(`来源 ${r.source ?? "未知"} 不在 ${fmtList(rule.sources)}`);
|
|
if (rule.excludeSources?.length && (!r.source || rule.excludeSources.includes(r.source)))
|
|
out.push(`来源 ${r.source ?? "未知"} 被本层排除`);
|
|
if (rule.requireEncode && !r.isEncode) out.push("不是蓝光压制");
|
|
if (rule.hdr?.length && !rule.hdr.some((x) => (r.hdr ?? []).includes(x)))
|
|
out.push(`HDR ${fmtList(r.hdr ?? [])} 不含 ${fmtList(rule.hdr)}`);
|
|
if (rule.codecs?.length && !(r.videoCodec && rule.codecs.includes(r.videoCodec)))
|
|
out.push(`编码 ${r.videoCodec ?? "未知"} 不在 ${fmtList(rule.codecs)}`);
|
|
if (rule.groups?.length) {
|
|
const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : [];
|
|
if (!rule.groups.some((g) => groups.includes(lc(g)))) out.push(`发布组 ${r.releaseGroup?.name ?? "未知"} 不在名单`);
|
|
}
|
|
if (rule.include?.length || rule.exclude?.length) {
|
|
const text = matchText(r, ctx);
|
|
const ok = (p: string) => validateKeywordPattern(p) === null;
|
|
if (rule.include?.length && !rule.include.some((p) => ok(p) && keywordRe(p).test(text)))
|
|
out.push(`不含关键词 ${fmtList(rule.include)}`);
|
|
const hit = (rule.exclude ?? []).filter((p) => ok(p) && keywordRe(p).test(text));
|
|
if (hit.length) out.push(`命中排除词 ${fmtList(hit)}`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 优先级阶梯(对应 MP 规则组 `A > B > C`):逐层匹配,第 k 层(0 起)命中 → pri = 100 - k;
|
|
* 未配置阶梯 → pri = 100、level = null(不参与排序差异);配置了但都不命中 → pri = 0。
|
|
*/
|
|
export function priorityLadder(spec: SlotSpec, r: ParsedRelease, ctx: EvaluationContext = {}): LadderResult {
|
|
const tiers = spec.soft?.tiers ?? [];
|
|
if (tiers.length === 0) return { pri: 100, level: null, levelName: null, trace: [] };
|
|
const trace: string[] = [];
|
|
for (let k = 0; k < tiers.length; k++) {
|
|
const t = tiers[k]!;
|
|
const name = t.name ?? `第${k + 1}层`;
|
|
const fail = tierFailures(t, r, ctx);
|
|
if (fail.length === 0) {
|
|
trace.push(`${name}:命中,pri=${100 - k}`);
|
|
return { pri: 100 - k, level: k, levelName: name, trace };
|
|
}
|
|
trace.push(`${name}:${fail.join(";")}`);
|
|
}
|
|
return { pri: 0, level: null, levelName: null, trace };
|
|
}
|
|
|
|
/** 当前资产的阶梯 pri:入库时记下的优先;否则按交付特征重算(关键词条件对已入库资产视为不命中) */
|
|
export function currentPri(spec: SlotSpec, current: QualityFacts): number {
|
|
if (typeof current.pri === "number") return current.pri;
|
|
const pseudo = { raw: "", hdr: [], audio: [], isEncode: false, ...current } as unknown as ParsedRelease;
|
|
return effectiveLadder(spec, pseudo, {}, ruleChainOf(spec, pseudo, {})).pri;
|
|
}
|
|
|
|
/** 版本目标引用的规则组链求值;没有规则组时为 null */
|
|
export function ruleChainOf(spec: SlotSpec, r: ParsedRelease, ctx: EvaluationContext = {}): RuleChainResult | null {
|
|
if (!spec.rules?.groups.length) return null;
|
|
return evaluateRuleChain(spec.rules.groups, r, ctx, catalogOf(spec.rules.atoms));
|
|
}
|
|
|
|
/**
|
|
* 实际生效的阶梯:版本目标自身 soft.tiers 视为排在最前的匿名组,规则组链里有带层的组时 pri 取最后一个带层的组。
|
|
*/
|
|
export function effectiveLadder(
|
|
spec: SlotSpec,
|
|
r: ParsedRelease,
|
|
ctx: EvaluationContext,
|
|
chain: RuleChainResult | null,
|
|
): LadderResult {
|
|
const own = priorityLadder(spec, r, ctx);
|
|
if (!chain) return own;
|
|
if (chain.pri === null || chain.decidingGroup === null) return { ...own, trace: [...own.trace, ...chain.trace] };
|
|
const g = chain.groups[chain.decidingGroup]!;
|
|
return {
|
|
pri: chain.pri,
|
|
level: g.level,
|
|
levelName: g.level === null ? null : `${g.name}·第${g.level + 1}层`,
|
|
trace: [...own.trace, ...chain.trace],
|
|
};
|
|
}
|
|
|
|
function chainViolations(chain: RuleChainResult | null, spec: SlotSpec): Violation[] {
|
|
if (!chain) return [];
|
|
const out: Violation[] = [];
|
|
chain.groups.forEach((g, i) => {
|
|
if (g.excludedBy.length) out.push({ field: "rule", code: "hard_exclude", expected: g.name, actual: g.excludedBy });
|
|
const required = spec.rules!.groups[i]!.ladderRequired !== false;
|
|
if (g.pri !== null && g.level === null && required)
|
|
out.push({ field: "rule", code: "no_layer_matched", expected: g.name });
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function prefIndexScore<T>(pref: readonly T[] | undefined, have: readonly T[]): number {
|
|
if (!pref?.length) return 0;
|
|
for (let i = 0; i < pref.length; i++) if (have.includes(pref[i]!)) return pref.length - i;
|
|
return 0;
|
|
}
|
|
|
|
/** 软偏好排序键(越大越好,按字典序比较):HDR、发布组、来源偏好、编码、体积区间 */
|
|
export function softRank(spec: SlotSpec, r: ParsedRelease): number[] {
|
|
const s = spec.soft ?? {};
|
|
const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : [];
|
|
const size = s.preferredSizeBytes;
|
|
let sizeScore = 0;
|
|
if (size && r.sizeBytes !== undefined) {
|
|
const okMin = size.min === undefined || r.sizeBytes >= size.min;
|
|
const okMax = size.max === undefined || r.sizeBytes <= size.max;
|
|
sizeScore = okMin && okMax ? 1 : -1;
|
|
}
|
|
return [
|
|
prefIndexScore(s.hdr, r.hdr),
|
|
prefIndexScore(s.groups?.map(lc), groups),
|
|
prefIndexScore(s.sources, r.source ? [r.source] : []),
|
|
prefIndexScore(s.codecs, r.videoCodec ? [r.videoCodec] : []),
|
|
sizeScore,
|
|
];
|
|
}
|
|
|
|
export function compareRank(a: readonly number[], b: readonly number[]): number {
|
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
const d = (a[i] ?? 0) - (b[i] ?? 0);
|
|
if (d !== 0) return d;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* 候选对某个 Slot 的确定性评估。
|
|
* - 已知不满足硬条件 → reject(列出全部违规)
|
|
* - 硬条件字段缺失且策略为 hold → unknown_field(区分缺字段与不满足)
|
|
* - 配置了阶梯且 tiersRequired、一层都不命中 → reject(tier.no_tier_matched)
|
|
* - 无当前资产 → accept(附软偏好排序键、阶梯 pri)
|
|
* - 有当前资产 → 仅在达到最小改进阈值时 upgrade,否则 reject(同等级不反复更换);
|
|
* priority 口径下要求 pri 严格提升(对照 MP 洗版 pri_order > current_priority)
|
|
* 所有结果带 trace(可读说明)。
|
|
*/
|
|
export function evaluateCandidate(
|
|
spec: SlotSpec,
|
|
r: ParsedRelease,
|
|
current?: QualityFacts | null,
|
|
ctx: EvaluationContext = {},
|
|
): SlotEvaluation {
|
|
const hard = checkHardConditions(spec, r, ctx);
|
|
const { missing, accepted } = hard;
|
|
const chain = ruleChainOf(spec, r, ctx);
|
|
const withRules = chain ? { rules: chain } : {};
|
|
const violations = [...hard.violations, ...chainViolations(chain, spec)];
|
|
const ladder = effectiveLadder(spec, r, ctx, chain);
|
|
if (violations.length > 0)
|
|
return {
|
|
decision: "reject",
|
|
violations,
|
|
trace: [...violations.map(describeViolation), ...ladder.trace],
|
|
ladder,
|
|
...withRules,
|
|
};
|
|
if (missing.length > 0)
|
|
return { decision: "unknown_field", missing, trace: missing.map((f) => `字段未知,需要补充:${f}`) };
|
|
if (spec.soft?.tiers?.length && spec.soft.tiersRequired && ladder.level === null) {
|
|
const v: Violation = { field: "tier", code: "no_tier_matched" };
|
|
return {
|
|
decision: "reject",
|
|
violations: [v],
|
|
trace: [describeViolation(v), ...ladder.trace],
|
|
ladder,
|
|
...withRules,
|
|
};
|
|
}
|
|
const tier = qualityTier(r, spec);
|
|
const rank = softRank(spec, r);
|
|
const reasons = accepted.map((f) => `unknown_${f}_accepted_by_policy`);
|
|
const tiered = !!spec.soft?.tiers?.length || (chain?.pri ?? null) !== null;
|
|
const ruleReasons = (chain?.groups ?? []).flatMap((g) =>
|
|
g.groupId ? [`rule_group:${g.groupId}${g.revision !== undefined ? `@${g.revision}` : ""}`] : [],
|
|
);
|
|
const priReasons = [
|
|
...(tiered ? [`pri:${ladder.pri}`, ...(ladder.levelName ? [`tier:${ladder.levelName}`] : [])] : []),
|
|
...ruleReasons,
|
|
];
|
|
if (!current)
|
|
return {
|
|
decision: "accept",
|
|
tier,
|
|
softRank: rank,
|
|
reasons: [...reasons, "hard_conditions_met", ...priReasons],
|
|
pri: ladder.pri,
|
|
ladder,
|
|
trace: ["硬条件全部满足", ...ladder.trace],
|
|
...withRules,
|
|
};
|
|
|
|
const reject = (v: Violation): SlotEvaluation => ({
|
|
decision: "reject",
|
|
violations: [v],
|
|
trace: [describeViolation(v), ...ladder.trace],
|
|
ladder,
|
|
...withRules,
|
|
});
|
|
const up = spec.upgrade;
|
|
if (!up?.enabled) return reject({ field: "upgrade", code: "upgrade_disabled" });
|
|
if (up.mode === "full_pack" && (r.episodes || r.airDate)) return reject({ field: "upgrade", code: "not_full_pack" });
|
|
const basis = up.basis ?? (tiered ? "priority" : "tier");
|
|
if (basis === "priority") {
|
|
const fromPri = currentPri(spec, current);
|
|
const target = up.targetPri ?? 100;
|
|
if (fromPri >= target) return reject({ field: "upgrade", code: "at_ceiling", expected: target, actual: fromPri });
|
|
if (ladder.pri <= fromPri)
|
|
return reject({ field: "upgrade", code: "not_higher_priority", expected: fromPri, actual: ladder.pri });
|
|
const from = qualityTier(current, spec);
|
|
return {
|
|
decision: "upgrade",
|
|
tier,
|
|
fromTier: from,
|
|
softRank: rank,
|
|
reasons: [...reasons, "priority_improved", ...priReasons],
|
|
pri: ladder.pri,
|
|
fromPri,
|
|
ladder,
|
|
trace: [`阶梯 pri ${fromPri} → ${ladder.pri}`, ...ladder.trace],
|
|
...withRules,
|
|
};
|
|
}
|
|
if (!current.source)
|
|
return { decision: "unknown_field", missing: ["current_asset"], trace: ["当前资产交付特征未知"] };
|
|
const from = qualityTier(current, spec);
|
|
if (up.ceilingTier !== undefined && from.source >= up.ceilingTier)
|
|
return reject({ field: "upgrade", code: "at_ceiling", expected: up.ceilingTier, actual: from.source });
|
|
const minSteps = up.minTierSteps ?? 1;
|
|
const dSource = tier.source - from.source;
|
|
const dRes = tier.resolution - from.resolution;
|
|
const bySource = dSource >= minSteps && dRes >= 0;
|
|
const byRes = !!up.allowResolutionUpgrade && dRes > 0 && dSource >= 0;
|
|
if (!bySource && !byRes) {
|
|
const code =
|
|
dSource === 0 && dRes === 0 ? "same_tier" : dSource < 0 || dRes < 0 ? "lower_tier" : "below_min_improvement";
|
|
return reject({ field: "upgrade", code, expected: { minTierSteps: minSteps, from }, actual: tier });
|
|
}
|
|
if (r.isEncode && up.encodeGroupAllowlist?.length) {
|
|
const groups = r.releaseGroup ? r.releaseGroup.name.split("@").map(lc) : [];
|
|
if (!up.encodeGroupAllowlist.some((g) => groups.includes(lc(g))))
|
|
return reject({
|
|
field: "upgrade",
|
|
code: "encode_not_qualified",
|
|
expected: up.encodeGroupAllowlist,
|
|
actual: r.releaseGroup?.name,
|
|
});
|
|
}
|
|
return {
|
|
decision: "upgrade",
|
|
tier,
|
|
fromTier: from,
|
|
softRank: rank,
|
|
reasons: [...reasons, bySource ? "source_tier_improved" : "resolution_improved", ...priReasons],
|
|
pri: ladder.pri,
|
|
ladder,
|
|
trace: [bySource ? "来源等级提升" : "分辨率提升", ...ladder.trace],
|
|
...withRules,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 分集洗版:候选所含单元里哪些值得替换(对照 MP get_best_version_interested_episodes)。
|
|
* unitCurrent 缺某单元 = 该单元还没有版本(视为可填补)。返回可提升单元与逐单元结论。
|
|
*/
|
|
export function improvableUnits(
|
|
spec: SlotSpec,
|
|
r: ParsedRelease,
|
|
units: readonly string[],
|
|
unitCurrent: Readonly<Record<string, QualityFacts | null | undefined>>,
|
|
ctx: EvaluationContext = {},
|
|
): { units: string[]; perUnit: Record<string, SlotEvaluation["decision"]> } {
|
|
const perUnit: Record<string, SlotEvaluation["decision"]> = {};
|
|
const out: string[] = [];
|
|
for (const u of units) {
|
|
const ev = evaluateCandidate(spec, r, unitCurrent[u] ?? null, ctx);
|
|
perUnit[u] = ev.decision;
|
|
if (ev.decision === "accept" || ev.decision === "upgrade") out.push(u);
|
|
}
|
|
return { units: out, perUnit };
|
|
}
|
|
|
|
export interface RankedEvaluation<T extends ParsedRelease = ParsedRelease> {
|
|
candidate: T;
|
|
evaluation: SlotEvaluation;
|
|
}
|
|
|
|
/** 对一批候选评估并排序:可用(accept/upgrade)在前,按阶梯 pri、质量等级再按软偏好;其余保持原顺序 */
|
|
export function rankForSlot<T extends ParsedRelease>(
|
|
spec: SlotSpec,
|
|
candidates: readonly T[],
|
|
current?: QualityFacts | null,
|
|
ctxOf?: (c: T) => EvaluationContext,
|
|
): RankedEvaluation<T>[] {
|
|
const evals = candidates.map((c, i) => ({
|
|
candidate: c,
|
|
evaluation: evaluateCandidate(spec, c, current, ctxOf?.(c) ?? {}),
|
|
i,
|
|
}));
|
|
const usable = (e: SlotEvaluation) => e.decision === "accept" || e.decision === "upgrade";
|
|
return evals
|
|
.sort((a, b) => {
|
|
const ua = usable(a.evaluation);
|
|
const ub = usable(b.evaluation);
|
|
if (ua !== ub) return ua ? -1 : 1;
|
|
if (ua && ub) {
|
|
const ea = a.evaluation as Extract<SlotEvaluation, { tier: QualityTier }>;
|
|
const eb = b.evaluation as Extract<SlotEvaluation, { tier: QualityTier }>;
|
|
return (
|
|
(eb.pri ?? 100) - (ea.pri ?? 100) ||
|
|
eb.tier.source - ea.tier.source ||
|
|
eb.tier.resolution - ea.tier.resolution ||
|
|
compareRank(eb.softRank, ea.softRank) ||
|
|
a.i - b.i
|
|
);
|
|
}
|
|
return a.i - b.i;
|
|
})
|
|
.map(({ candidate, evaluation }) => ({ candidate, evaluation }));
|
|
}
|