feat(domain): rule groups — MP rule-string parser/serializer, builtin keywords, chain evaluation wired into slot
- 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]>
This commit is contained in:
1 parent
22856d0447
commit
e994a7bcfa
13 files changed
+23919
-10
No files matched your search
@@ -7,4 +7,5 @@ export * from "./identity/index.ts";
|
||||
export * from "./organize/index.ts";
|
||||
export * from "./path/index.ts";
|
||||
export * from "./release-parser/index.ts";
|
||||
export * from "./rules/index.ts";
|
||||
export * from "./slot/index.ts";
|
||||
@@ -0,0 +1,470 @@
|
||||
import type { ParsedRelease } from "../release-parser/types.ts";
|
||||
import type { CustomAtom, RuleContext } from "./types.ts";
|
||||
|
||||
// 内置关键字表:名称与 MP BUILTIN_RULE_SET(app/application/rules.py)一一对应。
|
||||
// mp 字段原样记录上游的判定口径(正则/条件),供关键字说明与差分核对;
|
||||
// test 是我们的判定:能用解析字段的用字段(分辨率/来源/HDR/编码/字幕),其余沿用 MP 正则。
|
||||
// 音乐 13 条不支持(解析时报 unsupported_atom)。
|
||||
|
||||
export interface AtomInput {
|
||||
r: ParsedRelease;
|
||||
ctx: RuleContext;
|
||||
/** 标题 + 副标题 + 标签(对照 MP `title description labels`) */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface AtomHit {
|
||||
hit: boolean;
|
||||
/** 可读依据,如 "分辨率 1080p"、"原语言 zh 放行" */
|
||||
why: string;
|
||||
}
|
||||
|
||||
export interface BuiltinAtom {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 一句话语义 */
|
||||
description: string;
|
||||
/** 我们的判定依据:field = 解析字段;regex = 沿用 MP 正则;site = 站点事实 */
|
||||
basis: "field" | "regex" | "site";
|
||||
supported: boolean;
|
||||
/** MP 上游定义(只读,说明用) */
|
||||
mp: {
|
||||
include?: string[];
|
||||
exclude?: string[];
|
||||
match?: string[];
|
||||
tmdb?: Record<string, string>;
|
||||
downloadvolumefactor?: number;
|
||||
};
|
||||
test?: (i: AtomInput) => AtomHit;
|
||||
}
|
||||
|
||||
/** MP 正则是 Python 语法:去掉内联 (?i),统一按不区分大小写编译;非法返回 null */
|
||||
export function compileMpPattern(p: string): RegExp | null {
|
||||
try {
|
||||
return new RegExp(p.replace(/\(\?i\)/g, ""), "i");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const reCache = new Map<string, RegExp | null>();
|
||||
function re(p: string): RegExp | null {
|
||||
if (!reCache.has(p)) {
|
||||
if (reCache.size > 500) reCache.clear();
|
||||
reCache.set(p, compileMpPattern(p));
|
||||
}
|
||||
return reCache.get(p)!;
|
||||
}
|
||||
|
||||
const anyRe = (ps: readonly string[], text: string) => ps.some((p) => re(p)?.test(text) ?? false);
|
||||
|
||||
function regexAtom(ps: string[], label: string) {
|
||||
return ({ text }: AtomInput): AtomHit =>
|
||||
anyRe(ps, text) ? { hit: true, why: `含「${label}」` } : { hit: false, why: `不含「${label}」` };
|
||||
}
|
||||
|
||||
const zhLang = (lang: string | undefined, accept: readonly string[]) =>
|
||||
!!lang && accept.includes(lang.toLowerCase().split("-")[0]!);
|
||||
|
||||
const MP_CNSUB =
|
||||
"[中国國繁简](/|\\s|\\\\|\\|)?[繁简英粤]|[英简繁](/|\\s|\\\\|\\|)?[中繁简]" +
|
||||
"|繁體|简体|[中国國][字配]|国语|國語|中文|中字|简日|繁日|简繁|繁体" +
|
||||
"|([\\s,.-\\[])(chs|cht)(|[\\s,.-\\]])" +
|
||||
"|(?<![a-z0-9])(?<!\\d\\s)(gb|big5)(?![a-z0-9])";
|
||||
const MP_CNVOI = "[国國][语語]配音|[国國]配|[国國][语語]";
|
||||
|
||||
const res =
|
||||
(...rs: string[]) =>
|
||||
(i: AtomInput): AtomHit =>
|
||||
i.r.resolution && rs.includes(i.r.resolution)
|
||||
? { hit: true, why: `分辨率 ${i.r.resolution}` }
|
||||
: { hit: false, why: `分辨率 ${i.r.resolution ?? "未知"}` };
|
||||
const src =
|
||||
(...ss: string[]) =>
|
||||
(i: AtomInput): AtomHit =>
|
||||
i.r.source && ss.includes(i.r.source)
|
||||
? { hit: true, why: `来源 ${i.r.source}` }
|
||||
: { hit: false, why: `来源 ${i.r.source ?? "未知"}` };
|
||||
const codec =
|
||||
(c: string) =>
|
||||
(i: AtomInput): AtomHit =>
|
||||
i.r.videoCodec === c ? { hit: true, why: `编码 ${c}` } : { hit: false, why: `编码 ${i.r.videoCodec ?? "未知"}` };
|
||||
const hdrList = (i: AtomInput) => (i.r.hdr.length ? i.r.hdr.join("/") : "无");
|
||||
|
||||
const MUSIC: [string, string, string][] = [
|
||||
[
|
||||
"HIRES",
|
||||
"Hi-Res 无损",
|
||||
"(?i)\\b(?:Hi[ ._-]?Res(?:olution)?|DSD(?:64|128|256|512)?)\\b|高解析|(?:24|32)\\s*(?:-?bit|位)",
|
||||
],
|
||||
["LOSSLESS", "无损音频", "(?i)\\b(?:Lossless|FLAC|ALAC|APE|WAV|WAVE|AIFF?|PCM|DSD|DSF|DFF)\\b|无损"],
|
||||
["FLAC", "FLAC", "(?i)(?<![A-Z0-9])FLAC(?![A-Z0-9])"],
|
||||
["ALAC", "ALAC", "(?i)(?<![A-Z0-9])ALAC(?![A-Z0-9])"],
|
||||
["APE", "APE", "(?i)(?<![A-Z0-9])APE(?![A-Z0-9])"],
|
||||
["WAV", "WAV", "(?i)(?<![A-Z0-9])WAV(?:E)?(?![A-Z0-9])"],
|
||||
["DSD", "DSD", "(?i)(?<![A-Z0-9])(?:DSD(?:64|128|256|512)?|DSF|DFF)(?![A-Z0-9])"],
|
||||
["MP3", "MP3", "(?i)(?<![A-Z0-9])MP3(?![A-Z0-9])"],
|
||||
["AAC", "AAC", "(?i)(?<![A-Z0-9])(?:AAC|M4A)(?![A-Z0-9])"],
|
||||
["OPUS", "OPUS", "(?i)(?<![A-Z0-9])OPUS(?![A-Z0-9])"],
|
||||
["BITRATE320", "320k", "(?i)(?<!\\d)320\\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"],
|
||||
["BITRATE256", "256k", "(?i)(?<!\\d)256\\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"],
|
||||
["BITRATE192", "192k", "(?i)(?<!\\d)192\\s*k(?:bps?|b(?:it)?/?s?)?(?![a-z])"],
|
||||
];
|
||||
|
||||
export const BUILTIN_ATOMS: readonly BuiltinAtom[] = [
|
||||
{
|
||||
id: "BLU",
|
||||
name: "蓝光原盘",
|
||||
description: "来源为蓝光原盘(BDMV/ISO,非 Remux、非压制)",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: {
|
||||
include: [
|
||||
"(?i)(\\bBlu-?Ray\\b.*\\b(?:VC-?1|AVC|MPEG-?2)\\b|\\b(?:UHD|4K|2160p)\\b(?:.*Blu-?Ray)?.*\\b(?:HEVC|H\\.?265)\\b|\\bBlu-?Ray\\b.*\\b(?:UHD|4K|2160p)\\b.*\\b(?:HEVC|H\\.?265)\\b|\\b(?:COMPLETE|FULL)\\b.*\\b(?:(?:UHD|4K|2160p)\\b.*)?Blu-?Ray\\b|\\b(BD25|BD50|BD66|BD100|BDMV|MiniBD)\\b)",
|
||||
],
|
||||
exclude: [
|
||||
"(?i)(\\b[XH]\\.?264\\b|\\b[XH]\\.?265\\b|\\bWEB-?DL\\b|\\bWEB-?RIP\\b|\\bHDTV(?:RIP)?\\b|\\bREMUX\\b|\\bBDRip\\b|\\bBRRip\\b|\\bHDRip\\b|\\bENCODE\\b|\\b(?<!WEB-|HDTV)RIP\\b)",
|
||||
],
|
||||
},
|
||||
test: src("BluRay-Disc"),
|
||||
},
|
||||
{
|
||||
id: "4K",
|
||||
name: "4K",
|
||||
description: "分辨率 2160p",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: { include: ["4k|2160p|x2160"] },
|
||||
test: res("2160p"),
|
||||
},
|
||||
{
|
||||
id: "1080P",
|
||||
name: "1080P",
|
||||
description: "分辨率 1080p / 1080i",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: { include: ["1080[pi]|x1080"] },
|
||||
test: res("1080p", "1080i"),
|
||||
},
|
||||
{
|
||||
id: "720P",
|
||||
name: "720P",
|
||||
description: "分辨率 720p",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: { include: ["720[pi]|x720"] },
|
||||
test: res("720p"),
|
||||
},
|
||||
{
|
||||
id: "CNSUB",
|
||||
name: "中字",
|
||||
description: "含中文字幕(含 CHS/CHT/GB/BIG5);作品原语言为中文时直接满足",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: { include: [MP_CNSUB], tmdb: { original_language: "zh,cn" } },
|
||||
test: (i) => {
|
||||
if (zhLang(i.ctx.originalLanguage, ["zh", "cn"]))
|
||||
return { hit: true, why: `原语言 ${i.ctx.originalLanguage} 放行` };
|
||||
const zh = (i.r.subtitles ?? []).filter((s) => s.startsWith("zh"));
|
||||
if (zh.length) return { hit: true, why: `字幕 ${zh.join("/")}` };
|
||||
return { hit: false, why: i.r.subtitles === undefined ? "字幕未知" : `字幕 ${i.r.subtitles.join("/") || "无"}` };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "GZ",
|
||||
name: "官种",
|
||||
description: "站点标签含 官方/官种/官组(只看标签)",
|
||||
basis: "site",
|
||||
supported: true,
|
||||
mp: { include: ["官方", "官种", "官组"], match: ["labels"] },
|
||||
test: (i) => {
|
||||
const labels = (i.ctx.labels ?? []).join(" ");
|
||||
return anyRe(["官方", "官种", "官组"], labels)
|
||||
? { hit: true, why: "官方标签" }
|
||||
: { hit: false, why: labels ? `标签 ${labels}` : "无站点标签" };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "SPECSUB",
|
||||
name: "特效字幕",
|
||||
description: "标题/副标题/标签含「特效」",
|
||||
basis: "regex",
|
||||
supported: true,
|
||||
mp: { include: ["特效"] },
|
||||
test: regexAtom(["特效"], "特效"),
|
||||
},
|
||||
{
|
||||
id: "BLURAY",
|
||||
name: "蓝光",
|
||||
description: "蓝光来源(原盘、蓝光 Remux、蓝光压制)",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: { include: ["Blu-?Ray"] },
|
||||
test: (i) => {
|
||||
const s = i.r.source;
|
||||
if (s === "BluRay" || s === "BluRay-Disc") return { hit: true, why: `来源 ${s}` };
|
||||
if (s === "REMUX" && /blu-?ray/i.test(i.r.raw)) return { hit: true, why: "蓝光 REMUX" };
|
||||
return { hit: false, why: `来源 ${s ?? "未知"}` };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "UHD",
|
||||
name: "UHD",
|
||||
description: "标题含 UHD / UltraHD",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: { include: ["UHD|UltraHD"] },
|
||||
test: (i) => (i.r.uhd || /UltraHD/i.test(i.text) ? { hit: true, why: "UHD" } : { hit: false, why: "无 UHD 标记" }),
|
||||
},
|
||||
{
|
||||
id: "H265",
|
||||
name: "H265",
|
||||
description: "视频编码 H.265/HEVC",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: { include: ["[Hx].?265|HEVC"] },
|
||||
test: codec("H.265"),
|
||||
},
|
||||
{
|
||||
id: "H264",
|
||||
name: "H264",
|
||||
description: "视频编码 H.264/AVC",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: { include: ["[Hx].?264|AVC"] },
|
||||
test: codec("H.264"),
|
||||
},
|
||||
{
|
||||
id: "DOLBY",
|
||||
name: "杜比视界",
|
||||
description: "HDR 含杜比视界(DV/DoVi)",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: { include: ["Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+|杜比视界"] },
|
||||
test: (i) =>
|
||||
i.r.hdr.includes("DV") || /杜比视界/.test(i.text)
|
||||
? { hit: true, why: "杜比视界" }
|
||||
: { hit: false, why: `HDR ${hdrList(i)}` },
|
||||
},
|
||||
{
|
||||
id: "ATMOS",
|
||||
name: "杜比全景声",
|
||||
description: "音轨含 Atmos(杜比全景声)",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: { include: ["Dolby[\\s.+]+Atmos|Atmos|杜比全景[声聲]"] },
|
||||
test: (i) =>
|
||||
i.r.audio.some((a) => /atmos/i.test(a)) || anyRe(["Atmos|杜比全景[声聲]"], i.text)
|
||||
? { hit: true, why: "Atmos" }
|
||||
: { hit: false, why: "无 Atmos" },
|
||||
},
|
||||
{
|
||||
id: "HDR",
|
||||
name: "HDR",
|
||||
description: "HDR10 / HDR10+ / HDR Vivid(不含只有杜比视界的)",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: { include: ["[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+|HDRVivid"] },
|
||||
test: (i) =>
|
||||
i.r.hdr.some((h) => h === "HDR10" || h === "HDR10+" || h === "HDR Vivid")
|
||||
? { hit: true, why: `HDR ${hdrList(i)}` }
|
||||
: { hit: false, why: `HDR ${hdrList(i)}` },
|
||||
},
|
||||
{
|
||||
id: "SDR",
|
||||
name: "SDR",
|
||||
description: "标题明确标 SDR",
|
||||
basis: "regex",
|
||||
supported: true,
|
||||
mp: { include: ["[\\s.]+SDR[\\s.]+"] },
|
||||
test: regexAtom(["[\\s.]+SDR[\\s.]+"], "SDR"),
|
||||
},
|
||||
{
|
||||
id: "REMUX",
|
||||
name: "Remux",
|
||||
description: "来源为 Remux",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: { include: ["REMUX"] },
|
||||
test: src("REMUX"),
|
||||
},
|
||||
{
|
||||
id: "WEBDL",
|
||||
name: "WEB-DL",
|
||||
description: "来源为 WEB-DL 或 WEBRip",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: { include: ["WEB-?DL|WEB-?RIP"] },
|
||||
test: src("WEB-DL", "WEBRip"),
|
||||
},
|
||||
{
|
||||
id: "FREE",
|
||||
name: "免费",
|
||||
description: "站点促销为免费(下载量不计)",
|
||||
basis: "site",
|
||||
supported: true,
|
||||
mp: { downloadvolumefactor: 0 },
|
||||
test: (i) =>
|
||||
i.ctx.downloadVolumeFactor === 0
|
||||
? { hit: true, why: "免费" }
|
||||
: {
|
||||
hit: false,
|
||||
why: i.ctx.downloadVolumeFactor === undefined ? "促销未知" : `下载系数 ${i.ctx.downloadVolumeFactor}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "CNVOI",
|
||||
name: "国语配音",
|
||||
description: "含国语配音;作品原语言为中文时直接满足",
|
||||
basis: "regex",
|
||||
supported: true,
|
||||
mp: { include: [MP_CNVOI], tmdb: { original_language: "zh" } },
|
||||
test: (i) =>
|
||||
zhLang(i.ctx.originalLanguage, ["zh"])
|
||||
? { hit: true, why: `原语言 ${i.ctx.originalLanguage} 放行` }
|
||||
: regexAtom([MP_CNVOI], "国语")(i),
|
||||
},
|
||||
{
|
||||
id: "HKVOI",
|
||||
name: "粤语配音",
|
||||
description: "含粤语配音",
|
||||
basis: "regex",
|
||||
supported: true,
|
||||
mp: { include: ["粤语配音|粤语"] },
|
||||
test: regexAtom(["粤语配音|粤语"], "粤语"),
|
||||
},
|
||||
{
|
||||
id: "60FPS",
|
||||
name: "60帧",
|
||||
description: "帧率 ≥ 60",
|
||||
basis: "field",
|
||||
supported: true,
|
||||
mp: { include: ["60fps|60帧"] },
|
||||
test: (i) =>
|
||||
(i.r.fps ?? 0) >= 60 || anyRe(["60fps|60帧"], i.text)
|
||||
? { hit: true, why: `帧率 ${i.r.fps ?? 60}` }
|
||||
: { hit: false, why: `帧率 ${i.r.fps ?? "未知"}` },
|
||||
},
|
||||
{
|
||||
id: "3D",
|
||||
name: "3D",
|
||||
description: "标题含 3D",
|
||||
basis: "regex",
|
||||
supported: true,
|
||||
mp: { include: ["3D"] },
|
||||
test: regexAtom(["3D"], "3D"),
|
||||
},
|
||||
...MUSIC.map(
|
||||
([id, name, p]): BuiltinAtom => ({
|
||||
id,
|
||||
name,
|
||||
description: "音乐资源条件,暂不支持",
|
||||
basis: "regex",
|
||||
supported: false,
|
||||
mp: { include: [p] },
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
export const BUILTIN_ATOM_MAP: ReadonlyMap<string, BuiltinAtom> = new Map(BUILTIN_ATOMS.map((a) => [a.id, a]));
|
||||
|
||||
/** 自定义条件 id:大写字母开头或数字+字母(对照 MP pyparsing 原子),规范化为大写 */
|
||||
export const ATOM_ID_RE = /^(?:[A-Z][A-Z0-9]*|[0-9]+[A-Z][A-Z0-9]*)$/;
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
function unitCount(i: AtomInput): number {
|
||||
const { r, ctx } = i;
|
||||
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 1;
|
||||
}
|
||||
|
||||
/** 自定义条件判定(对照 MP __match_rule:包含 → 排除 → 体积(每集)→ 做种 → 促销 → 发布时间) */
|
||||
export function testCustomAtom(a: CustomAtom, i: AtomInput): AtomHit {
|
||||
let text = i.text;
|
||||
if (a.matchFields?.length) {
|
||||
const parts: string[] = [];
|
||||
if (a.matchFields.includes("title")) parts.push(i.r.raw);
|
||||
if (a.matchFields.includes("subtitle") && i.ctx.subtitle) parts.push(i.ctx.subtitle);
|
||||
if (a.matchFields.includes("labels")) parts.push(...(i.ctx.labels ?? []));
|
||||
text = parts.join(" ");
|
||||
}
|
||||
if (a.include?.length && !anyRe(a.include, text)) return { hit: false, why: `不含 ${a.include.join(" / ")}` };
|
||||
const ex = (a.exclude ?? []).find((p) => re(p)?.test(text));
|
||||
if (ex) return { hit: false, why: `含排除词 ${ex}` };
|
||||
if (a.unitSizeMB && (a.unitSizeMB.min !== undefined || a.unitSizeMB.max !== undefined)) {
|
||||
if (i.r.sizeBytes === undefined) return { hit: false, why: "体积未知" };
|
||||
const n = unitCount(i);
|
||||
const per = i.r.sizeBytes / n;
|
||||
const mb = Math.round(per / MB);
|
||||
const tag = n > 1 ? `每集 ${mb}MB` : `${mb}MB`;
|
||||
if (a.unitSizeMB.min !== undefined && per < a.unitSizeMB.min * MB)
|
||||
return { hit: false, why: `${tag} 小于 ${a.unitSizeMB.min}MB` };
|
||||
if (a.unitSizeMB.max !== undefined && per > a.unitSizeMB.max * MB)
|
||||
return { hit: false, why: `${tag} 大于 ${a.unitSizeMB.max}MB` };
|
||||
}
|
||||
if (a.minSeeders !== undefined && a.minSeeders > 0) {
|
||||
if (i.ctx.seeders === undefined) return { hit: false, why: "做种数未知" };
|
||||
if (i.ctx.seeders < a.minSeeders) return { hit: false, why: `做种 ${i.ctx.seeders} 小于 ${a.minSeeders}` };
|
||||
}
|
||||
if (a.free && i.ctx.downloadVolumeFactor !== 0)
|
||||
return { hit: false, why: i.ctx.downloadVolumeFactor === undefined ? "促销未知" : "非免费" };
|
||||
const pd = a.publishDelayMinutes;
|
||||
if (pd && (pd.min !== undefined || pd.max !== undefined)) {
|
||||
const m = i.ctx.publishedMinutesAgo;
|
||||
if (m === undefined) return { hit: false, why: "发布时间未知" };
|
||||
if (pd.min !== undefined && m < pd.min) return { hit: false, why: `发布 ${Math.round(m)} 分钟,不足 ${pd.min}` };
|
||||
if (pd.max !== undefined && m > pd.max) return { hit: false, why: `发布 ${Math.round(m)} 分钟,超过 ${pd.max}` };
|
||||
}
|
||||
return { hit: true, why: "满足" };
|
||||
}
|
||||
|
||||
/** MP 自定义规则(导入格式)→ CustomAtom;size_range 形如 "a-b" / ">a" / "<b",publish_time 形如 "n" / "a-b" */
|
||||
export function customAtomFromMp(
|
||||
id: string,
|
||||
mp: {
|
||||
name?: string;
|
||||
include?: string | string[];
|
||||
exclude?: string | string[];
|
||||
size_range?: string;
|
||||
seeders?: string | number;
|
||||
publish_time?: string;
|
||||
downloadvolumefactor?: number;
|
||||
match?: string[];
|
||||
},
|
||||
): CustomAtom {
|
||||
const list = (v: string | string[] | undefined) => (v === undefined || v === "" ? [] : Array.isArray(v) ? v : [v]);
|
||||
const out: CustomAtom = { id: id.toUpperCase() };
|
||||
if (mp.name) out.name = mp.name;
|
||||
const inc = list(mp.include);
|
||||
const exc = list(mp.exclude);
|
||||
if (inc.length) out.include = inc;
|
||||
if (exc.length) out.exclude = exc;
|
||||
const sr = mp.size_range?.trim();
|
||||
if (sr) {
|
||||
if (sr.includes("-")) {
|
||||
const [a, b] = sr.split("-").map((x) => Number(x.trim()));
|
||||
out.unitSizeMB = { min: a!, max: b! };
|
||||
} else if (sr.startsWith(">")) out.unitSizeMB = { min: Number(sr.slice(1).trim()) };
|
||||
else if (sr.startsWith("<")) out.unitSizeMB = { max: Number(sr.slice(1).trim()) };
|
||||
}
|
||||
if (mp.seeders !== undefined && mp.seeders !== "") out.minSeeders = Number(mp.seeders);
|
||||
if (mp.publish_time) {
|
||||
const ts = mp.publish_time.split("-").map((x) => Number(x.trim()));
|
||||
out.publishDelayMinutes = ts.length === 1 ? { min: ts[0]! } : { min: ts[0]!, max: ts[1]! };
|
||||
}
|
||||
if (mp.downloadvolumefactor === 0) out.free = true;
|
||||
const fields = (mp.match ?? []).flatMap((m) =>
|
||||
m === "title"
|
||||
? ["title" as const]
|
||||
: m === "description"
|
||||
? ["subtitle" as const]
|
||||
: m === "labels"
|
||||
? ["labels" as const]
|
||||
: [],
|
||||
);
|
||||
if (fields.length) out.matchFields = fields;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ParsedRelease } from "../release-parser/types.ts";
|
||||
import { type AtomHit, type AtomInput, BUILTIN_ATOM_MAP, testCustomAtom } from "./atoms.ts";
|
||||
import { serializeExpr } from "./expr.ts";
|
||||
import type { CustomAtom, RuleChainResult, RuleContext, RuleExpr, RuleGroupResult, RuleGroupSpec } from "./types.ts";
|
||||
|
||||
// 规则组求值(对照 MP FilterModule.__get_order / __match_group / __match_rule):
|
||||
// - 组内:硬排除任一命中 → 拒绝;层按序匹配,第 k 层(0 起)命中 pri = 100 - k;一层不中且 ladderRequired → 拒绝。
|
||||
// - 多组串联取交集(任一组拒绝即拒绝);pri 取最后一个带层的组。
|
||||
// - 未知原子视为不匹配(MP 语义),trace 里写明。
|
||||
|
||||
export type AtomCatalog = ReadonlyMap<string, CustomAtom>;
|
||||
|
||||
export function catalogOf(atoms: readonly CustomAtom[] = []): AtomCatalog {
|
||||
return new Map(atoms.map((a) => [a.id.toUpperCase(), a]));
|
||||
}
|
||||
|
||||
function inputOf(r: ParsedRelease, ctx: RuleContext): AtomInput {
|
||||
return { r, ctx, text: [r.raw ?? "", ctx.subtitle ?? "", ...(ctx.labels ?? [])].join(" ") };
|
||||
}
|
||||
|
||||
/** 单个原子判定 */
|
||||
export function testAtom(id: string, input: AtomInput, catalog: AtomCatalog = new Map()): AtomHit {
|
||||
const key = id.toUpperCase();
|
||||
const b = BUILTIN_ATOM_MAP.get(key);
|
||||
if (b) {
|
||||
if (!b.supported || !b.test) return { hit: false, why: "不支持" };
|
||||
return b.test(input);
|
||||
}
|
||||
const c = catalog.get(key);
|
||||
if (c) return testCustomAtom(c, input);
|
||||
return { hit: false, why: "条件不存在" };
|
||||
}
|
||||
|
||||
/** 表达式求值(短路,同 MP);notes 收集已求值原子的依据(按字面:`!X✗` 表示取反后不满足),供 trace */
|
||||
function evalExpr(e: RuleExpr, input: AtomInput, catalog: AtomCatalog, notes: string[], neg = false): boolean {
|
||||
switch (e.op) {
|
||||
case "atom": {
|
||||
const h = testAtom(e.id, input, catalog);
|
||||
const lit = neg ? !h.hit : h.hit;
|
||||
notes.push(`${neg ? "!" : ""}${e.id.toUpperCase()}${lit ? "✓" : "✗"} ${h.why}`);
|
||||
return h.hit;
|
||||
}
|
||||
case "not":
|
||||
return !evalExpr(e.arg, input, catalog, notes, !neg);
|
||||
case "and":
|
||||
return e.args.every((a) => evalExpr(a, input, catalog, notes, neg));
|
||||
case "or":
|
||||
return e.args.some((a) => evalExpr(a, input, catalog, notes, neg));
|
||||
}
|
||||
}
|
||||
|
||||
/** 表达式是否命中(不要 trace 时用) */
|
||||
export function matchesExpr(e: RuleExpr, r: ParsedRelease, ctx: RuleContext = {}, catalog?: AtomCatalog): boolean {
|
||||
return evalExpr(e, inputOf(r, ctx), catalog ?? new Map(), []);
|
||||
}
|
||||
|
||||
export function evaluateRuleGroup(
|
||||
g: RuleGroupSpec,
|
||||
r: ParsedRelease,
|
||||
ctx: RuleContext = {},
|
||||
catalog: AtomCatalog = new Map(),
|
||||
): RuleGroupResult {
|
||||
const input = inputOf(r, ctx);
|
||||
const label = `规则组「${g.name}」${g.revision !== undefined ? `@r${g.revision}` : ""}`;
|
||||
const trace: string[] = [];
|
||||
const excludedBy: string[] = [];
|
||||
for (const x of g.hardExcludes ?? []) {
|
||||
const notes: string[] = [];
|
||||
if (evalExpr(x, input, catalog, notes)) {
|
||||
const s = serializeExpr(x);
|
||||
excludedBy.push(s);
|
||||
trace.push(`${label} 硬排除 ${s} 命中(${notes.join(";")})`);
|
||||
}
|
||||
}
|
||||
const base = {
|
||||
...(g.id !== undefined ? { groupId: g.id } : {}),
|
||||
name: g.name,
|
||||
...(g.revision !== undefined ? { revision: g.revision } : {}),
|
||||
};
|
||||
if (g.layers.length === 0) {
|
||||
return { ...base, excludedBy, level: null, pri: null, passed: excludedBy.length === 0, trace };
|
||||
}
|
||||
let level: number | null = null;
|
||||
for (let k = 0; k < g.layers.length; k++) {
|
||||
const layer = g.layers[k]!;
|
||||
const notes: string[] = [];
|
||||
const hit = evalExpr(layer.expr, input, catalog, notes);
|
||||
const name = layer.name ? `第${k + 1}层「${layer.name}」` : `第${k + 1}层`;
|
||||
const expr = serializeExpr(layer.expr);
|
||||
if (hit) {
|
||||
trace.push(`${label} ${name} ${expr}:命中,pri=${100 - k}(${notes.join(";")})`);
|
||||
level = k;
|
||||
break;
|
||||
}
|
||||
trace.push(`${label} ${name} ${expr}:未命中(${notes.join(";")})`);
|
||||
}
|
||||
const required = g.ladderRequired !== false;
|
||||
if (level === null && required) trace.push(`${label} 不命中任何一层,过滤`);
|
||||
return {
|
||||
...base,
|
||||
excludedBy,
|
||||
level,
|
||||
pri: level === null ? 0 : 100 - level,
|
||||
passed: excludedBy.length === 0 && (level !== null || !required),
|
||||
trace,
|
||||
};
|
||||
}
|
||||
|
||||
/** 多组串联:任一组不通过即不通过;pri 取最后一个带层的组 */
|
||||
export function evaluateRuleChain(
|
||||
groups: readonly RuleGroupSpec[],
|
||||
r: ParsedRelease,
|
||||
ctx: RuleContext = {},
|
||||
catalog: AtomCatalog = new Map(),
|
||||
): RuleChainResult {
|
||||
const results = groups.map((g) => evaluateRuleGroup(g, r, ctx, catalog));
|
||||
let deciding: number | null = null;
|
||||
results.forEach((x, i) => {
|
||||
if (x.pri !== null) deciding = i;
|
||||
});
|
||||
const passed = results.every((x) => x.passed);
|
||||
const pri = deciding === null ? null : results[deciding]!.pri;
|
||||
const trace = results.flatMap((x) => x.trace);
|
||||
if (deciding !== null && results.filter((x) => x.pri !== null).length > 1)
|
||||
trace.push(`pri 取最后一个带层的规则组「${results[deciding]!.name}」:${pri}`);
|
||||
return { passed, pri, decidingGroup: deciding, groups: results, trace };
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { BUILTIN_ATOM_MAP } from "./atoms.ts";
|
||||
import type { RuleExpr, RuleLayer, RuleParseError } from "./types.ts";
|
||||
|
||||
// MP 规则串 ↔ 结构化层。语法对齐 MP RuleParser(pyparsing infix_notation):
|
||||
// 规则串 = 层 ('>' 层)*;层内优先级 ! > & > |,可加括号;原子 = 字母开头的字母数字串,或数字+字母(4K、60FPS)。
|
||||
// 偏离:MP 对未知原子视为不匹配、对多余 token 静默截断;我们在解析时报错并给位置(allowUnknown 时保留未知原子)。
|
||||
|
||||
export interface RuleParseOptions {
|
||||
/** 自定义条件 id(大写);不在内置表也不在这里的原子报 unknown_atom */
|
||||
customAtoms?: Iterable<string>;
|
||||
/** 保留未知原子(求值时视为不匹配,对齐 MP);缺省 false 报错 */
|
||||
allowUnknown?: boolean;
|
||||
}
|
||||
|
||||
export interface RuleParseResult {
|
||||
layers: RuleLayer[];
|
||||
errors: RuleParseError[];
|
||||
}
|
||||
|
||||
type Tok = { t: "atom" | "(" | ")" | "!" | "&" | "|" | "bad"; v: string; pos: number };
|
||||
|
||||
function lex(s: string, base: number): Tok[] {
|
||||
const out: Tok[] = [];
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const ch = s[i]!;
|
||||
if (/\s/.test(ch)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if ("()!&|".includes(ch)) {
|
||||
out.push({ t: ch as Tok["t"], v: ch, pos: base + i });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const m = /^[A-Za-z0-9]+/.exec(s.slice(i));
|
||||
if (m) {
|
||||
out.push({ t: "atom", v: m[0], pos: base + i });
|
||||
i += m[0].length;
|
||||
continue;
|
||||
}
|
||||
out.push({ t: "bad", v: ch, pos: base + i });
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function levenshtein(a: string, b: string): number {
|
||||
const dp = Array.from({ length: b.length + 1 }, (_, j) => j);
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
let prev = dp[0]!;
|
||||
dp[0] = i;
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const tmp = dp[j]!;
|
||||
dp[j] = Math.min(dp[j]! + 1, dp[j - 1]! + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
||||
prev = tmp;
|
||||
}
|
||||
}
|
||||
return dp[b.length]!;
|
||||
}
|
||||
|
||||
/** 相近原子名(给 unknown_atom 提示) */
|
||||
export function suggestAtoms(id: string, custom: Iterable<string> = []): string[] {
|
||||
const all = [...[...BUILTIN_ATOM_MAP.values()].filter((a) => a.supported).map((a) => a.id), ...custom];
|
||||
return all
|
||||
.map((x) => ({ x, d: levenshtein(id, x) }))
|
||||
.filter((e) => e.d <= Math.max(1, Math.floor(id.length / 2)) || e.x.startsWith(id) || id.startsWith(e.x))
|
||||
.sort((a, b) => a.d - b.d)
|
||||
.slice(0, 3)
|
||||
.map((e) => e.x);
|
||||
}
|
||||
|
||||
const ATOM_SHAPE = /^(?:[A-Za-z][A-Za-z0-9]*|[0-9]+[A-Za-z][A-Za-z0-9]*)$/;
|
||||
|
||||
class LayerParser {
|
||||
i = 0;
|
||||
constructor(
|
||||
readonly toks: Tok[],
|
||||
readonly layer: number,
|
||||
readonly errors: RuleParseError[],
|
||||
readonly custom: Set<string>,
|
||||
readonly opts: RuleParseOptions,
|
||||
) {}
|
||||
peek() {
|
||||
return this.toks[this.i];
|
||||
}
|
||||
err(code: RuleParseError["code"], message: string, pos: number, extra: Partial<RuleParseError> = {}) {
|
||||
this.errors.push({ code, message, position: pos, layer: this.layer, ...extra });
|
||||
}
|
||||
or(): RuleExpr | null {
|
||||
const first = this.and();
|
||||
if (!first) return null;
|
||||
const args = [first];
|
||||
while (this.peek()?.t === "|") {
|
||||
this.i++;
|
||||
const n = this.and();
|
||||
if (!n) return null;
|
||||
args.push(n);
|
||||
}
|
||||
return args.length === 1 ? first : flatten({ op: "or", args });
|
||||
}
|
||||
and(): RuleExpr | null {
|
||||
const first = this.unary();
|
||||
if (!first) return null;
|
||||
const args = [first];
|
||||
while (this.peek()?.t === "&") {
|
||||
this.i++;
|
||||
const n = this.unary();
|
||||
if (!n) return null;
|
||||
args.push(n);
|
||||
}
|
||||
return args.length === 1 ? first : flatten({ op: "and", args });
|
||||
}
|
||||
unary(): RuleExpr | null {
|
||||
const t = this.peek();
|
||||
if (t?.t === "!") {
|
||||
this.i++;
|
||||
const a = this.unary();
|
||||
return a ? { op: "not", arg: a } : null;
|
||||
}
|
||||
return this.primary();
|
||||
}
|
||||
primary(): RuleExpr | null {
|
||||
const t = this.peek();
|
||||
if (!t) {
|
||||
const last = this.toks[this.toks.length - 1];
|
||||
this.err("unexpected_token", "表达式不完整,缺少条件", last ? last.pos + last.v.length : 0);
|
||||
return null;
|
||||
}
|
||||
if (t.t === "(") {
|
||||
this.i++;
|
||||
const e = this.or();
|
||||
if (!e) return null;
|
||||
if (this.peek()?.t !== ")") {
|
||||
this.err("unbalanced_paren", "括号不配对:缺少 )", t.pos);
|
||||
return null;
|
||||
}
|
||||
this.i++;
|
||||
return e;
|
||||
}
|
||||
if (t.t === "atom") {
|
||||
this.i++;
|
||||
if (!ATOM_SHAPE.test(t.v)) {
|
||||
this.err("unexpected_token", `「${t.v}」不是合法条件名(字母开头,或数字加字母如 4K)`, t.pos);
|
||||
return null;
|
||||
}
|
||||
const id = t.v.toUpperCase();
|
||||
const b = BUILTIN_ATOM_MAP.get(id);
|
||||
if (b && !b.supported)
|
||||
this.err("unsupported_atom", `${id}(${b.name})不支持:音乐资源条件没有迁移`, t.pos, { atom: id });
|
||||
else if (!b && !this.custom.has(id) && !this.opts.allowUnknown) {
|
||||
const suggestions = suggestAtoms(id, this.custom);
|
||||
this.err(
|
||||
"unknown_atom",
|
||||
`未知条件 ${id}${suggestions.length ? `,是不是 ${suggestions.join(" / ")}` : ""}`,
|
||||
t.pos,
|
||||
{ atom: id, suggestions },
|
||||
);
|
||||
}
|
||||
return { op: "atom", id };
|
||||
}
|
||||
if (t.t === ")") this.err("unbalanced_paren", "括号不配对:多余的 )", t.pos);
|
||||
else if (t.t === "bad") this.err("invalid_char", `不认识的字符「${t.v}」`, t.pos);
|
||||
else this.err("unexpected_token", `「${t.v}」前缺少条件`, t.pos);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function flatten(e: RuleExpr): RuleExpr {
|
||||
if (e.op !== "and" && e.op !== "or") return e;
|
||||
const args: RuleExpr[] = [];
|
||||
for (const a of e.args) {
|
||||
const f = flatten(a);
|
||||
if (f.op === e.op) args.push(...f.args);
|
||||
else args.push(f);
|
||||
}
|
||||
return { op: e.op, args };
|
||||
}
|
||||
|
||||
/** 规范化:同类 AND/OR 展平、原子大写(结构等价比较与指纹用) */
|
||||
export function normalizeExpr(e: RuleExpr): RuleExpr {
|
||||
switch (e.op) {
|
||||
case "atom":
|
||||
return { op: "atom", id: e.id.toUpperCase() };
|
||||
case "not":
|
||||
return { op: "not", arg: normalizeExpr(e.arg) };
|
||||
default:
|
||||
return flatten({ op: e.op, args: e.args.map(normalizeExpr) });
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析一层表达式(不含 `>`);offset 用于报错位置 */
|
||||
export function parseExpr(
|
||||
s: string,
|
||||
opts: RuleParseOptions = {},
|
||||
layer = 0,
|
||||
offset = 0,
|
||||
): { expr: RuleExpr | null; errors: RuleParseError[] } {
|
||||
const errors: RuleParseError[] = [];
|
||||
const toks = lex(s, offset);
|
||||
if (toks.length === 0) {
|
||||
errors.push({ code: "empty_layer", message: `第 ${layer + 1} 层是空的`, position: offset, layer });
|
||||
return { expr: null, errors };
|
||||
}
|
||||
const p = new LayerParser(
|
||||
toks,
|
||||
layer,
|
||||
errors,
|
||||
new Set([...(opts.customAtoms ?? [])].map((x) => x.toUpperCase())),
|
||||
opts,
|
||||
);
|
||||
const expr = p.or();
|
||||
if (expr && p.i < toks.length && errors.length === 0) {
|
||||
const t = toks[p.i]!;
|
||||
if (t.t === ")") p.err("unbalanced_paren", "括号不配对:多余的 )", t.pos);
|
||||
else if (t.t === "bad") p.err("invalid_char", `不认识的字符「${t.v}」`, t.pos);
|
||||
else p.err("unexpected_token", `「${t.v}」前缺少 & 或 |`, t.pos);
|
||||
}
|
||||
return { expr: errors.length ? null : expr, errors };
|
||||
}
|
||||
|
||||
/** 规则串 → 结构化层(`>` 分层,第 1 层 pri=100) */
|
||||
export function parseRuleString(s: string, opts: RuleParseOptions = {}): RuleParseResult {
|
||||
const errors: RuleParseError[] = [];
|
||||
const layers: RuleLayer[] = [];
|
||||
if (!s.trim()) return { layers, errors: [{ code: "empty", message: "规则串是空的", position: 0, layer: 0 }] };
|
||||
let offset = 0;
|
||||
s.split(">").forEach((part, k) => {
|
||||
const r = parseExpr(part, opts, k, offset);
|
||||
errors.push(...r.errors);
|
||||
if (r.expr) layers.push({ expr: r.expr });
|
||||
offset += part.length + 1;
|
||||
});
|
||||
return { layers: errors.length ? [] : layers, errors };
|
||||
}
|
||||
|
||||
const PREC = { or: 1, and: 2, not: 3, atom: 4 } as const;
|
||||
|
||||
/** 表达式 → 规则串(原子大写,只加必要的括号) */
|
||||
export function serializeExpr(e: RuleExpr, parent = 0): string {
|
||||
switch (e.op) {
|
||||
case "atom":
|
||||
return e.id.toUpperCase();
|
||||
case "not":
|
||||
return `!${serializeExpr(e.arg, PREC.not)}`;
|
||||
case "and": {
|
||||
const s = e.args.map((a) => serializeExpr(a, PREC.and)).join(" & ");
|
||||
return parent > PREC.and ? `(${s})` : s;
|
||||
}
|
||||
case "or": {
|
||||
const s = e.args.map((a) => serializeExpr(a, PREC.or)).join(" | ");
|
||||
return parent > PREC.or ? `(${s})` : s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 结构化层 → 规则串 */
|
||||
export function serializeLayers(layers: readonly RuleLayer[]): string {
|
||||
return layers.map((l) => serializeExpr(l.expr)).join(" > ");
|
||||
}
|
||||
|
||||
/** 表达式引用到的原子 id(去重) */
|
||||
export function atomsOf(e: RuleExpr, out = new Set<string>()): Set<string> {
|
||||
if (e.op === "atom") out.add(e.id.toUpperCase());
|
||||
else if (e.op === "not") atomsOf(e.arg, out);
|
||||
else for (const a of e.args) atomsOf(a, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** MP pyparsing as_list() 形状 → RuleExpr(差分测试用):"A" | ["not", X] | [X, "and", Y, "and", Z] */
|
||||
export function fromMpParseList(v: unknown): RuleExpr {
|
||||
if (typeof v === "string") return { op: "atom", id: v.toUpperCase() };
|
||||
const a = v as unknown[];
|
||||
if (a.length === 1) return fromMpParseList(a[0]);
|
||||
if (a[0] === "not") return { op: "not", arg: fromMpParseList(a.length === 2 ? a[1] : a.slice(1)) };
|
||||
const op = a[1] as "and" | "or";
|
||||
return flatten({ op, args: a.filter((_, i) => i % 2 === 0).map(fromMpParseList) });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export {
|
||||
ATOM_ID_RE,
|
||||
type AtomHit,
|
||||
type AtomInput,
|
||||
BUILTIN_ATOM_MAP,
|
||||
BUILTIN_ATOMS,
|
||||
type BuiltinAtom,
|
||||
compileMpPattern,
|
||||
customAtomFromMp,
|
||||
testCustomAtom,
|
||||
} from "./atoms.ts";
|
||||
export {
|
||||
type AtomCatalog,
|
||||
catalogOf,
|
||||
evaluateRuleChain,
|
||||
evaluateRuleGroup,
|
||||
matchesExpr,
|
||||
testAtom,
|
||||
} from "./evaluate.ts";
|
||||
export {
|
||||
atomsOf,
|
||||
fromMpParseList,
|
||||
normalizeExpr,
|
||||
parseExpr,
|
||||
parseRuleString,
|
||||
type RuleParseOptions,
|
||||
type RuleParseResult,
|
||||
serializeExpr,
|
||||
serializeLayers,
|
||||
suggestAtoms,
|
||||
} from "./expr.ts";
|
||||
export * from "./types.ts";
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { EvaluationContext } from "../slot/types.ts";
|
||||
|
||||
// 规则组(对照 MP 规则组 `name + rule_string`):结构化层 + 硬排除。规则串只是它的一种文本表示。
|
||||
|
||||
/** 条件表达式:内置关键字 / 自定义条件的 AND / OR / NOT 树 */
|
||||
export type RuleExpr =
|
||||
| { op: "atom"; id: string }
|
||||
| { op: "not"; arg: RuleExpr }
|
||||
| { op: "and"; args: RuleExpr[] }
|
||||
| { op: "or"; args: RuleExpr[] };
|
||||
|
||||
/** 优先级阶梯的一层;第 k 层(0 起)命中 → pri = 100 - k */
|
||||
export interface RuleLayer {
|
||||
name?: string;
|
||||
expr: RuleExpr;
|
||||
}
|
||||
|
||||
export type RuleGroupKind = "acquisition" | "upgrade" | "search";
|
||||
|
||||
/** 参与求值的规则组(server 从 rule_group 行编译) */
|
||||
export interface RuleGroupSpec {
|
||||
id?: string;
|
||||
name: string;
|
||||
revision?: number;
|
||||
layers: RuleLayer[];
|
||||
/** 任一表达式命中即拒绝 */
|
||||
hardExcludes?: RuleExpr[];
|
||||
/** 一层都不命中即拒绝(MP 语义);缺省 true。软偏好组为 false:不中只是 pri 低 */
|
||||
ladderRequired?: boolean;
|
||||
}
|
||||
|
||||
/** 自定义条件(对照 MP 自定义规则 7 字段;同 id 不允许覆盖内置) */
|
||||
export interface CustomAtom {
|
||||
id: string;
|
||||
name?: string;
|
||||
/** 正则(不区分大小写),至少命中一条 */
|
||||
include?: string[];
|
||||
/** 正则,一条都不能命中 */
|
||||
exclude?: string[];
|
||||
/** 匹配范围,缺省 标题+副标题+标签 */
|
||||
matchFields?: ("title" | "subtitle" | "labels")[];
|
||||
/** 体积区间(MB,1 MB = 1024²),剧集按每集折算 */
|
||||
unitSizeMB?: { min?: number; max?: number };
|
||||
minSeeders?: number;
|
||||
/** 发布后经过的分钟数区间(只给 min = 至少发布这么久) */
|
||||
publishDelayMinutes?: { min?: number; max?: number };
|
||||
/** 只要免费 */
|
||||
free?: boolean;
|
||||
}
|
||||
|
||||
/** 规则求值上下文:站点事实 + 作品事实(tmdb 放行条件) */
|
||||
export interface RuleContext extends EvaluationContext {
|
||||
/** 作品原语言(TMDB original_language),CNSUB/CNVOI 据此放行 */
|
||||
originalLanguage?: string;
|
||||
/** 发布后经过的分钟数 */
|
||||
publishedMinutesAgo?: number;
|
||||
}
|
||||
|
||||
export interface RuleParseError {
|
||||
code:
|
||||
| "empty"
|
||||
| "empty_layer"
|
||||
| "unknown_atom"
|
||||
| "unsupported_atom"
|
||||
| "unbalanced_paren"
|
||||
| "unexpected_token"
|
||||
| "invalid_char";
|
||||
message: string;
|
||||
/** 在整个规则串里的字符偏移(0 起) */
|
||||
position: number;
|
||||
/** 第几层(0 起) */
|
||||
layer: number;
|
||||
atom?: string;
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
export interface RuleGroupResult {
|
||||
groupId?: string;
|
||||
name: string;
|
||||
revision?: number;
|
||||
/** 命中的硬排除表达式(规则串形式) */
|
||||
excludedBy: string[];
|
||||
/** 命中的层(0 起);null = 未命中或没有层 */
|
||||
level: number | null;
|
||||
/** 本组 pri;没有层时为 null(不参与"pri 取最后一组") */
|
||||
pri: number | null;
|
||||
/** 本组放行(未被硬排除,且有层时命中某层或 ladderRequired=false) */
|
||||
passed: boolean;
|
||||
trace: string[];
|
||||
}
|
||||
|
||||
export interface RuleChainResult {
|
||||
passed: boolean;
|
||||
/** 最后一个带层的组的 pri;整条链都没有层时为 null */
|
||||
pri: number | null;
|
||||
/** 决定 pri 的组(下标) */
|
||||
decidingGroup: number | null;
|
||||
groups: RuleGroupResult[];
|
||||
trace: string[];
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
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,
|
||||
@@ -288,6 +290,10 @@ export function describeViolation(v: Violation): string {
|
||||
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":
|
||||
@@ -366,8 +372,47 @@ export function priorityLadder(spec: SlotSpec, r: ParsedRelease, ctx: Evaluation
|
||||
/** 当前资产的阶梯 pri:入库时记下的优先;否则按交付特征重算(关键词条件对已入库资产视为不命中) */
|
||||
export function currentPri(spec: SlotSpec, current: QualityFacts): number {
|
||||
if (typeof current.pri === "number") return current.pri;
|
||||
const pseudo = { raw: "", hdr: [], isEncode: false, ...current } as unknown as ParsedRelease;
|
||||
return priorityLadder(spec, pseudo).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 {
|
||||
@@ -420,21 +465,43 @@ export function evaluateCandidate(
|
||||
current?: QualityFacts | null,
|
||||
ctx: EvaluationContext = {},
|
||||
): SlotEvaluation {
|
||||
const { violations, missing, accepted } = checkHardConditions(spec, r, ctx);
|
||||
const ladder = priorityLadder(spec, r, ctx);
|
||||
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 };
|
||||
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 };
|
||||
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;
|
||||
const priReasons = tiered ? [`pri:${ladder.pri}`, ...(ladder.levelName ? [`tier:${ladder.levelName}`] : [])] : [];
|
||||
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",
|
||||
@@ -444,6 +511,7 @@ export function evaluateCandidate(
|
||||
pri: ladder.pri,
|
||||
ladder,
|
||||
trace: ["硬条件全部满足", ...ladder.trace],
|
||||
...withRules,
|
||||
};
|
||||
|
||||
const reject = (v: Violation): SlotEvaluation => ({
|
||||
@@ -451,6 +519,7 @@ export function evaluateCandidate(
|
||||
violations: [v],
|
||||
trace: [describeViolation(v), ...ladder.trace],
|
||||
ladder,
|
||||
...withRules,
|
||||
});
|
||||
const up = spec.upgrade;
|
||||
if (!up?.enabled) return reject({ field: "upgrade", code: "upgrade_disabled" });
|
||||
@@ -473,6 +542,7 @@ export function evaluateCandidate(
|
||||
fromPri,
|
||||
ladder,
|
||||
trace: [`阶梯 pri ${fromPri} → ${ladder.pri}`, ...ladder.trace],
|
||||
...withRules,
|
||||
};
|
||||
}
|
||||
if (!current.source)
|
||||
@@ -509,6 +579,7 @@ export function evaluateCandidate(
|
||||
pri: ladder.pri,
|
||||
ladder,
|
||||
trace: [bySource ? "来源等级提升" : "分辨率提升", ...ladder.trace],
|
||||
...withRules,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
currentPri,
|
||||
DEFAULT_SOURCE_TIERS,
|
||||
describeViolation,
|
||||
effectiveLadder,
|
||||
evaluateCandidate,
|
||||
improvableUnits,
|
||||
priorityLadder,
|
||||
@@ -25,6 +26,7 @@ export {
|
||||
type RankedEvaluation,
|
||||
RESOLUTION_RANK,
|
||||
rankForSlot,
|
||||
ruleChainOf,
|
||||
softRank,
|
||||
unitCountOf,
|
||||
validateKeywordPattern,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Resolution,
|
||||
VideoCodec,
|
||||
} from "../release-parser/types.ts";
|
||||
import type { CustomAtom, RuleChainResult, RuleGroupSpec } from "../rules/types.ts";
|
||||
|
||||
/** 可判定的硬条件字段 */
|
||||
export type HardField =
|
||||
@@ -115,10 +116,18 @@ export interface SlotUpgradePolicy {
|
||||
|
||||
export type SlotCompletionSpec = { kind: "movie"; cuts?: (Cut | "default")[] } | { kind: "units" };
|
||||
|
||||
/** 版本目标引用的规则组链(已按 scope 过滤、按引用顺序排列)与自定义条件 */
|
||||
export interface SlotRuleChain {
|
||||
groups: RuleGroupSpec[];
|
||||
atoms?: CustomAtom[];
|
||||
}
|
||||
|
||||
export interface SlotSpec {
|
||||
id?: string;
|
||||
name: string;
|
||||
hard: SlotHardConditions;
|
||||
/** 规则组:硬排除 → 拒绝;各组须命中某层(ladderRequired);pri 取最后一个带层的组(覆盖 soft.tiers 的 pri) */
|
||||
rules?: SlotRuleChain;
|
||||
unknownPolicy?: UnknownPolicy | Partial<Record<HardField, UnknownPolicy>>;
|
||||
soft?: SlotSoftPreferences;
|
||||
upgrade?: SlotUpgradePolicy;
|
||||
@@ -151,6 +160,10 @@ export interface EvaluationContext {
|
||||
downloadVolumeFactor?: number;
|
||||
/** 候选覆盖的内容单元数(每集体积折算);未给时按解析出的集范围/总集数推断 */
|
||||
unitCount?: number;
|
||||
/** 作品原语言(TMDB original_language),规则组 CNSUB/CNVOI 据此放行 */
|
||||
originalLanguage?: string;
|
||||
/** 发布后经过的分钟数(自定义条件 publishDelayMinutes) */
|
||||
publishedMinutesAgo?: number;
|
||||
}
|
||||
|
||||
/** 阶梯判定结果:pri 0–100 与逐层说明 */
|
||||
@@ -169,7 +182,7 @@ export interface QualityTier {
|
||||
}
|
||||
|
||||
export interface Violation {
|
||||
field: HardField | "upgrade" | "tier";
|
||||
field: HardField | "upgrade" | "tier" | "rule";
|
||||
code: string;
|
||||
expected?: unknown;
|
||||
actual?: unknown;
|
||||
@@ -184,6 +197,7 @@ export type SlotEvaluation =
|
||||
pri?: number;
|
||||
ladder?: LadderResult;
|
||||
trace?: string[];
|
||||
rules?: RuleChainResult;
|
||||
}
|
||||
| {
|
||||
decision: "upgrade";
|
||||
@@ -195,6 +209,7 @@ export type SlotEvaluation =
|
||||
fromPri?: number;
|
||||
ladder?: LadderResult;
|
||||
trace?: string[];
|
||||
rules?: RuleChainResult;
|
||||
}
|
||||
| { decision: "reject"; violations: Violation[]; trace?: string[]; ladder?: LadderResult }
|
||||
| { decision: "reject"; violations: Violation[]; trace?: string[]; ladder?: LadderResult; rules?: RuleChainResult }
|
||||
| { decision: "unknown_field"; missing: HardField[] | ["current_asset"]; trace?: string[] };
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* MoviePilot 规则串 / 规则组链 差分测试(W9-D)。
|
||||
*
|
||||
* rules-cases.json 由 scripts/mp-sync/gen-rules-differential.py 用 MP 的 Python 实现生成:
|
||||
* - parse:每个规则串按 `>` 切层后 pyparsing RuleParser 的 as_list();
|
||||
* - cases:FilterModule.filter_torrents(多组串联,pri 取最后一组)的 pri_order 与 debug trace。
|
||||
* 我们用 parseRuleString → evaluateRuleChain 求同样的 pri。
|
||||
* 断言:解析结构全部一致;pri 不一致集合 == deviations 登记集合(补迁消除差异后测试会失败,提醒删登记)。
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BUILTIN_ATOMS,
|
||||
catalogOf,
|
||||
customAtomFromMp,
|
||||
evaluateRuleChain,
|
||||
fromMpParseList,
|
||||
normalizeExpr,
|
||||
parseRelease,
|
||||
parseRuleString,
|
||||
type RuleChainResult,
|
||||
type RuleGroupSpec,
|
||||
serializeLayers,
|
||||
} from "../../src/index.ts";
|
||||
|
||||
interface Torrent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
size: number;
|
||||
seeders: number;
|
||||
labels?: string[];
|
||||
downloadvolumefactor?: number;
|
||||
}
|
||||
interface Rule {
|
||||
id: string;
|
||||
groups: string[];
|
||||
mediainfo?: { original_language?: string };
|
||||
}
|
||||
interface Case {
|
||||
caseId: string;
|
||||
ruleId: string;
|
||||
torrentId: string;
|
||||
mp: { pri: number | null; trace: string[]; rustTrace?: string[] };
|
||||
}
|
||||
type DeviationKind = "intentional" | "missing" | "our_bug" | "mp_bug" | "not_expressible";
|
||||
interface Deviation {
|
||||
kind: DeviationKind;
|
||||
note: string;
|
||||
mp?: number | null;
|
||||
ours?: number | null;
|
||||
}
|
||||
interface Fixture {
|
||||
generatedFrom: { commit: string };
|
||||
customRules: Record<string, Record<string, string>>;
|
||||
torrents: Torrent[];
|
||||
rules: Rule[];
|
||||
parse: Record<string, unknown[]>;
|
||||
cases: Case[];
|
||||
deviations: Record<string, Deviation>;
|
||||
}
|
||||
|
||||
const fx = JSON.parse(readFileSync(new URL("./rules-cases.json", import.meta.url), "utf8")) as Fixture;
|
||||
const custom = Object.entries(fx.customRules).map(([id, r]) => customAtomFromMp(id, r));
|
||||
const catalog = catalogOf(custom);
|
||||
const customIds = custom.map((a) => a.id);
|
||||
const torrents = new Map(fx.torrents.map((t) => [t.id, t]));
|
||||
const rules = new Map(fx.rules.map((r) => [r.id, r]));
|
||||
|
||||
function groupsOf(rule: Rule): RuleGroupSpec[] {
|
||||
return rule.groups.map((s, i) => {
|
||||
const p = parseRuleString(s, { customAtoms: customIds, allowUnknown: true });
|
||||
if (p.errors.length) throw new Error(`${rule.id}: ${JSON.stringify(p.errors)}`);
|
||||
return { name: `${rule.id}#${i}`, layers: p.layers };
|
||||
});
|
||||
}
|
||||
|
||||
function ours(c: Case): RuleChainResult {
|
||||
const rule = rules.get(c.ruleId)!;
|
||||
const t = torrents.get(c.torrentId)!;
|
||||
const parsed = { ...parseRelease(t.title, t.description ? { subtitle: t.description } : {}), sizeBytes: t.size };
|
||||
return evaluateRuleChain(
|
||||
groupsOf(rule),
|
||||
parsed,
|
||||
{
|
||||
...(t.description ? { subtitle: t.description } : {}),
|
||||
labels: t.labels ?? [],
|
||||
seeders: t.seeders,
|
||||
downloadVolumeFactor: t.downloadvolumefactor ?? 1,
|
||||
...(rule.mediainfo?.original_language ? { originalLanguage: rule.mediainfo.original_language } : {}),
|
||||
},
|
||||
catalog,
|
||||
);
|
||||
}
|
||||
const priOf = (r: RuleChainResult) => (r.passed ? (r.pri ?? 100) : null);
|
||||
|
||||
describe("MP 规则串解析差分(rules-cases.json#parse)", () => {
|
||||
it("≥40 条规则链,覆盖全部支持的内置关键字", () => {
|
||||
expect(fx.rules.length).toBeGreaterThanOrEqual(40);
|
||||
const used = new Set(Object.keys(fx.parse).flatMap((s) => s.match(/[A-Za-z0-9]+/g) ?? []));
|
||||
const supported = BUILTIN_ATOMS.filter((a) => a.supported).map((a) => a.id);
|
||||
expect(supported.filter((id) => !used.has(id))).toEqual([]);
|
||||
});
|
||||
|
||||
it("每个规则串的分层与层内结构 == MP RuleParser", () => {
|
||||
for (const [s, mpLayers] of Object.entries(fx.parse)) {
|
||||
const p = parseRuleString(s, { customAtoms: customIds, allowUnknown: true });
|
||||
expect(p.errors, s).toEqual([]);
|
||||
expect(
|
||||
p.layers.map((l) => l.expr),
|
||||
s,
|
||||
).toEqual(mpLayers.map((l) => normalizeExpr(fromMpParseList(l))));
|
||||
}
|
||||
});
|
||||
|
||||
it("序列化后再解析结构不变(规则串双向映射)", () => {
|
||||
for (const s of Object.keys(fx.parse)) {
|
||||
const a = parseRuleString(s, { customAtoms: customIds, allowUnknown: true }).layers;
|
||||
const text = serializeLayers(a);
|
||||
const b = parseRuleString(text, { customAtoms: customIds, allowUnknown: true }).layers;
|
||||
expect(b, `${s} → ${text}`).toEqual(a);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("MP 规则组过滤差分(rules-cases.json#cases)", () => {
|
||||
it("pri 不一致集合 == deviations 登记集合", () => {
|
||||
const actual: Record<string, { mp: number | null; ours: number | null }> = {};
|
||||
for (const c of fx.cases) {
|
||||
const o = priOf(ours(c));
|
||||
if (o !== c.mp.pri) actual[c.caseId] = { mp: c.mp.pri, ours: o };
|
||||
}
|
||||
if (process.env.UMT_RULES_DUMP) console.log(`UMT_RULES_DUMP ${JSON.stringify(actual)}`);
|
||||
expect(Object.keys(actual).sort()).toEqual(Object.keys(fx.deviations).sort());
|
||||
for (const [k, v] of Object.entries(fx.deviations)) {
|
||||
expect(["intentional", "missing", "our_bug", "mp_bug", "not_expressible"]).toContain(v.kind);
|
||||
expect(actual[k], k).toEqual({ mp: v.mp ?? null, ours: v.ours ?? null });
|
||||
}
|
||||
// 一致率(报告用):登记外全部一致
|
||||
expect(fx.cases.length - Object.keys(actual).length).toBeGreaterThan(fx.cases.length * 0.9);
|
||||
});
|
||||
|
||||
it("trace 可读:过滤给出未命中层与原子依据,放行给出命中层与 pri,未知条件写明不存在", () => {
|
||||
for (const c of fx.cases) {
|
||||
const r = ours(c);
|
||||
const text = r.trace.join("\n");
|
||||
if (!r.passed) expect(text, c.caseId).toMatch(/未命中|硬排除|过滤/);
|
||||
const withLayers = r.groups.some((g) => g.pri !== null);
|
||||
if (r.passed && withLayers) expect(text, c.caseId).toMatch(/命中,pri=\d+/);
|
||||
// MP trace 里提到的"不包含任何项"对应我们 ✗ 的原子依据
|
||||
if (c.mp.trace.some((x) => x.startsWith("不包含任何项")) && !r.passed) expect(text).toMatch(/✗/);
|
||||
if (c.mp.trace.some((x) => /规则 \w+ 不存在/.test(x))) expect(text).toMatch(/条件不存在/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("规则串错误", () => {
|
||||
it("未知条件给位置与相近名;音乐条件不支持;括号/空层", () => {
|
||||
const a = parseRuleString("4K & CNSUBB > 1080P");
|
||||
expect(a.errors[0]).toMatchObject({ code: "unknown_atom", position: 5, layer: 0, atom: "CNSUBB" });
|
||||
expect(a.errors[0]!.suggestions).toContain("CNSUB");
|
||||
expect(parseRuleString("FLAC").errors[0]).toMatchObject({ code: "unsupported_atom", atom: "FLAC" });
|
||||
expect(parseRuleString("(4K | 1080P").errors[0]).toMatchObject({ code: "unbalanced_paren", position: 0 });
|
||||
expect(parseRuleString("4K)").errors[0]).toMatchObject({ code: "unbalanced_paren", position: 2 });
|
||||
expect(parseRuleString("4K > > 720P").errors[0]).toMatchObject({ code: "empty_layer", layer: 1, position: 4 });
|
||||
expect(parseRuleString("4K 1080P").errors[0]).toMatchObject({ code: "unexpected_token", position: 3 });
|
||||
expect(parseRuleString("4K & $").errors[0]).toMatchObject({ code: "invalid_char", position: 5 });
|
||||
expect(parseRuleString("").errors[0]!.code).toBe("empty");
|
||||
expect(parseRuleString("4k & !remux").layers.map((l) => l.expr)).toEqual([
|
||||
{
|
||||
op: "and",
|
||||
args: [
|
||||
{ op: "atom", id: "4K" },
|
||||
{ op: "not", arg: { op: "atom", id: "REMUX" } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("序列化只加必要括号", () => {
|
||||
const s = "SPECSUB & CNSUB > (4K | 1080P) & !(BLU | REMUX) > !!3D | 720P & H265";
|
||||
expect(serializeLayers(parseRuleString(s).layers)).toBe(s);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
currentPri,
|
||||
evaluateCandidate,
|
||||
parseRelease,
|
||||
parseRuleString,
|
||||
type RuleGroupSpec,
|
||||
rankForSlot,
|
||||
type SlotSpec,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const group = (name: string, s: string, extra: Partial<RuleGroupSpec> = {}): RuleGroupSpec => ({
|
||||
name,
|
||||
layers: s ? parseRuleString(s).layers : [],
|
||||
...extra,
|
||||
});
|
||||
const base = (groups: RuleGroupSpec[], extra: Partial<SlotSpec> = {}): SlotSpec => ({
|
||||
name: "main",
|
||||
hard: {},
|
||||
rules: { groups },
|
||||
...extra,
|
||||
});
|
||||
const rel = (t: string, subtitle?: string) => parseRelease(t, subtitle ? { subtitle } : {});
|
||||
|
||||
describe("版本目标接入规则组", () => {
|
||||
it("硬排除命中 → reject(rule.hard_exclude),trace 带组名与修订号", () => {
|
||||
const g = group("不要原盘", "", {
|
||||
id: "g1",
|
||||
revision: 3,
|
||||
hardExcludes: parseRuleString("BLU | REMUX").layers.map((l) => l.expr),
|
||||
});
|
||||
const ev = evaluateCandidate(base([g]), rel("Movie.2023.1080p.BluRay.REMUX.AVC-EPSiLON"));
|
||||
expect(ev.decision).toBe("reject");
|
||||
if (ev.decision !== "reject") return;
|
||||
expect(ev.violations).toEqual([
|
||||
{ field: "rule", code: "hard_exclude", expected: "不要原盘", actual: ["BLU | REMUX"] },
|
||||
]);
|
||||
expect(ev.trace?.join("\n")).toContain("规则组「不要原盘」@r3 硬排除 BLU | REMUX 命中");
|
||||
});
|
||||
|
||||
it("多组串联取交集,pri 取最后一个带层的组", () => {
|
||||
const spec = base([group("分辨率", "4K > 1080P"), group("字幕", "CNSUB > !CNSUB"), group("空组", "")]);
|
||||
const a = evaluateCandidate(spec, rel("Dune.2024.1080p.WEB-DL.H264-HHWEB", "中字"));
|
||||
expect(a).toMatchObject({ decision: "accept", pri: 100 });
|
||||
const b = evaluateCandidate(spec, rel("Dune.2024.2160p.WEB-DL.H265-FLUX"));
|
||||
expect(b).toMatchObject({ decision: "accept", pri: 99 });
|
||||
const c = evaluateCandidate(spec, rel("Dune.2024.720p.WEBRip.x264-GRP"));
|
||||
expect(c.decision).toBe("reject");
|
||||
if (c.decision === "reject")
|
||||
expect(c.violations[0]).toMatchObject({ code: "no_layer_matched", expected: "分辨率" });
|
||||
});
|
||||
|
||||
it("ladderRequired=false 的软偏好组不中只降 pri,不拒绝", () => {
|
||||
const spec = base([group("偏好", "SPECSUB", { ladderRequired: false })]);
|
||||
const r = rel("X.2024.1080p.WEB-DL.H264-G", "特效中字");
|
||||
expect(evaluateCandidate(spec, r, null, { subtitle: "特效中字" })).toMatchObject({ pri: 100 });
|
||||
expect(evaluateCandidate(spec, rel("X.2024.1080p.WEB-DL.H264-G"))).toMatchObject({ decision: "accept", pri: 0 });
|
||||
});
|
||||
|
||||
it("有规则组阶梯时升级按 pri 严格提升(洗版)", () => {
|
||||
const spec = base([group("洗版", "REMUX > BLURAY > WEBDL")], { upgrade: { enabled: true } });
|
||||
const up = evaluateCandidate(spec, rel("M.2023.1080p.BluRay.REMUX.AVC-E"), {
|
||||
source: "WEB-DL",
|
||||
resolution: "1080p",
|
||||
pri: 98,
|
||||
});
|
||||
expect(up).toMatchObject({ decision: "upgrade", pri: 100, fromPri: 98 });
|
||||
const same = evaluateCandidate(spec, rel("M.2023.1080p.WEB-DL.H264-G"), { source: "WEB-DL", pri: 98 });
|
||||
expect(same.decision).toBe("reject");
|
||||
expect(currentPri(spec, { source: "REMUX", resolution: "1080p" })).toBe(100);
|
||||
});
|
||||
|
||||
it("作品原语言中文时 CNSUB 放行(MP tmdb 条件)", () => {
|
||||
const spec = base([group("中字", "CNSUB")]);
|
||||
const r = rel("Movie.2023.1080p.WEB-DL.H264-G");
|
||||
expect(evaluateCandidate(spec, r).decision).toBe("reject");
|
||||
expect(evaluateCandidate(spec, r, null, { originalLanguage: "zh" })).toMatchObject({ decision: "accept" });
|
||||
});
|
||||
|
||||
it("rankForSlot 按规则组 pri 排序", () => {
|
||||
const spec = base([group("阶梯", "4K > 1080P > 720P")]);
|
||||
const ranked = rankForSlot(spec, [
|
||||
rel("A.720p.WEB-DL.x264-G"),
|
||||
rel("A.2160p.WEB-DL.x265-G"),
|
||||
rel("A.1080p.WEB-DL.x264-G"),
|
||||
]);
|
||||
expect(ranked.map((x) => x.evaluation.decision === "accept" && x.evaluation.pri)).toEqual([100, 99, 98]);
|
||||
});
|
||||
|
||||
it("没有规则组时行为不变", () => {
|
||||
const r = rel("Movie.2023.1080p.WEB-DL.H264-G");
|
||||
expect(evaluateCandidate(base([]), r)).toEqual(evaluateCandidate({ name: "main", hard: {} }, r));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""规则组(W9-D)差分期望:MP 规则串解析 + 多规则组串联过滤的 pri 与 trace。
|
||||
|
||||
用法(必须用 MP 自己的 venv):
|
||||
|
||||
MP_ROOT=/opt/moviepilot-plus/upstream/MoviePilot \
|
||||
$MP_ROOT/.venv/bin/python scripts/mp-sync/gen-rules-differential.py
|
||||
|
||||
输出 packages/domain/test/differential/rules-cases.json:
|
||||
- parse:每个规则串按 `>` 切层后 RuleParser.parse(layer).as_list()(pyparsing,Python 参考实现)
|
||||
- cases:FilterModule.filter_torrents(Python 回退路径)对每个 (规则组链 × 种子) 的 pri_order;
|
||||
trace 为 Python 路径的 debug 日志(逐条规则失败原因),rustTrace 为 filter_torrents_with_trace_fast 的调试输出
|
||||
- deviations 不由本脚本生成,重跑时保留已有登记。
|
||||
只读调用上游,不复制上游源码。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
spec = importlib.util.spec_from_file_location("gen_differential", HERE / "gen-differential.py")
|
||||
gd = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(gd) # type: ignore[union-attr]
|
||||
|
||||
OUT = gd.DOMAIN_DIFF / "rules-cases.json"
|
||||
GiB = 1024 ** 3
|
||||
|
||||
EXTRA_TORRENTS: list[dict[str, Any]] = [
|
||||
{"id": "t29", "title": "Infernal.Affairs.2002.1080p.BluRay.x264.DTS-HK-GRP", "description": "无间道 粤语 中字", "size": 12 * GiB, "seeders": 6},
|
||||
{"id": "t30", "title": "Movie.2019.1080p.WEB-DL.SDR.H264-GRP", "description": "", "size": 5 * GiB, "seeders": 5},
|
||||
{"id": "t31", "title": "Show.S01E05.2160p.WEB-DL.HDR10+.H265-GRP", "description": "官种 特效中字", "labels": ["官种"], "size": 4 * GiB, "seeders": 30, "downloadvolumefactor": 0.5},
|
||||
{"id": "t32", "title": "Movie.2018.1080p.BluRay.Remux.AVC.Atmos-GRP", "description": "国配 杜比全景声", "size": 28 * GiB, "seeders": 2},
|
||||
]
|
||||
TORRENTS = gd.TORRENTS + EXTRA_TORRENTS
|
||||
|
||||
# groups:规则组链(按顺序串联取交集,pri 取最后一组);单组即单条规则串
|
||||
RULES: list[dict[str, Any]] = [
|
||||
*({"id": r["id"], "groups": [r["rule"]], **({"mediainfo": r["mediainfo"]} if r.get("mediainfo") else {})} for r in gd.RULES),
|
||||
{"id": "X01", "groups": ["SPECSUB & CNSUB > CNSUB & 4K > 1080P & !BLU"]},
|
||||
{"id": "X02", "groups": ["HKVOI"]},
|
||||
{"id": "X03", "groups": ["SDR"]},
|
||||
{"id": "X04", "groups": ["HDR & !DOLBY"]},
|
||||
{"id": "X05", "groups": ["DOLBY | HDR"]},
|
||||
{"id": "X06", "groups": ["H264 > H265"]},
|
||||
{"id": "X07", "groups": ["!(BLU | REMUX) & 4K"]},
|
||||
{"id": "X08", "groups": ["BLURAY"]},
|
||||
{"id": "X09", "groups": ["FREE | SEED5"]},
|
||||
{"id": "X10", "groups": ["UHD & REMUX > 4K"]},
|
||||
{"id": "X11", "groups": ["CNVOI | HKVOI > !CNVOI"]},
|
||||
{"id": "X12", "groups": ["!!4K"]},
|
||||
{"id": "X13", "groups": ["4K & (CNSUB | SPECSUB) & !3D"]},
|
||||
{"id": "X14", "groups": ["ATMOS & (REMUX | BLU)"]},
|
||||
{"id": "X15", "groups": ["GZ > FREE > 4K"]},
|
||||
{"id": "X16", "groups": ["CNVOI"], "mediainfo": {"original_language": "zh"}},
|
||||
{"id": "X17", "groups": ["SIZESMALL > SIZEBIG"]},
|
||||
{"id": "X18", "groups": ["1080P & CNSUB | 4K & !CNSUB"]},
|
||||
{"id": "X19", "groups": ["60FPS | 3D > ATMOS"]},
|
||||
{"id": "M01", "groups": ["4K | 1080P", "CNSUB > !CNSUB"]},
|
||||
{"id": "M02", "groups": ["!BLU", "4K > 1080P > 720P"]},
|
||||
{"id": "M03", "groups": ["4K > 1080P", "WEBDL"]},
|
||||
{"id": "M04", "groups": ["CNSUB", "SIZEMOVIE", "H265 > H264"]},
|
||||
{"id": "M05", "groups": ["REMUX > BLU > WEBDL", "!DOLBY"]},
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = gd._filter_mp_root(None)
|
||||
if str(root) not in sys.path:
|
||||
sys.path.insert(0, str(root))
|
||||
old = os.getcwd()
|
||||
os.chdir(root)
|
||||
try:
|
||||
from app.adapters.system import rust as rust_accel
|
||||
from app.application.rules import BUILTIN_RULE_SET, RuleParser
|
||||
from app.domain.context import TorrentInfo
|
||||
from app.modules import filter as filter_mod
|
||||
from app.modules.filter import FilterModule
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
rule_set = {**BUILTIN_RULE_SET, **gd.CUSTOM_RULES}
|
||||
|
||||
class _RuleHelper:
|
||||
def __init__(self, groups):
|
||||
self._groups = groups
|
||||
|
||||
def get_rule_group_by_media(self, media=None, group_names=None): # noqa: ARG002
|
||||
return self._groups
|
||||
|
||||
def mk(t: dict) -> Any:
|
||||
return TorrentInfo(
|
||||
site_name="S", title=t["title"], description=t.get("description") or "",
|
||||
size=t["size"], seeders=t.get("seeders") or 0, labels=t.get("labels") or [],
|
||||
downloadvolumefactor=t.get("downloadvolumefactor", 1.0),
|
||||
)
|
||||
|
||||
parser = RuleParser()
|
||||
parse_out: dict[str, Any] = {}
|
||||
for r in RULES:
|
||||
for g in r["groups"]:
|
||||
if g in parse_out:
|
||||
continue
|
||||
with patch("app.application.rules._filter_rule_parser", None):
|
||||
parse_out[g] = [parser.expr.parse_string(layer.strip()).as_list()[0] for layer in g.split(">")]
|
||||
|
||||
def run(r: dict, t: dict) -> tuple[Optional[int], list[str], list[str]]:
|
||||
module = FilterModule()
|
||||
groups = [SimpleNamespace(name=f"{r['id']}#{i}", rule_string=g) for i, g in enumerate(r["groups"])]
|
||||
module.rulehelper = _RuleHelper(groups)
|
||||
module.rule_set = rule_set
|
||||
mi = SimpleNamespace(**r["mediainfo"]) if r.get("mediainfo") else None
|
||||
logs: list[str] = []
|
||||
with patch("app.modules.filter.rust_accel.is_enabled", return_value=False), \
|
||||
patch.object(filter_mod.logger, "debug", side_effect=lambda m, *a, **k: logs.append(str(m))):
|
||||
res = module.filter_torrents(rule_groups=[g.name for g in groups], torrent_list=[mk(t)], mediainfo=mi)
|
||||
pri = res[0].pri_order if res else None
|
||||
rust_trace: list[str] = []
|
||||
native = getattr(rust_accel, "_moviepilot_rust", None)
|
||||
if rust_accel.is_enabled() and native is not None and hasattr(native, "filter_torrents_with_trace_fast"):
|
||||
try:
|
||||
_m, rust_trace = native.filter_torrents_with_trace_fast(
|
||||
[vars(g) for g in groups], [mk(t)], rule_set, mi, filter_mod._rust_parse_options(),
|
||||
)
|
||||
except BaseException: # noqa: BLE001
|
||||
rust_trace = []
|
||||
return pri, logs, [str(x) for x in rust_trace]
|
||||
|
||||
cases = []
|
||||
for r in RULES:
|
||||
for t in TORRENTS:
|
||||
pri, logs, rtrace = run(r, t)
|
||||
# 去掉每行重复的 "种子 S - <标题> " 前缀;Rust trace 与 Python 相同时不重复存
|
||||
prefix = f"种子 S - {t['title']} "
|
||||
strip = lambda xs: [x[len(prefix):].strip() if x.startswith(prefix) else x for x in xs] # noqa: E731
|
||||
mp: dict[str, Any] = {"pri": pri, "trace": strip(logs)}
|
||||
if rtrace and rtrace != logs:
|
||||
mp["rustTrace"] = strip(rtrace)
|
||||
cases.append({"caseId": f"{r['id']}:{t['id']}", "ruleId": r["id"], "torrentId": t["id"], "mp": mp})
|
||||
|
||||
deviations: dict = {}
|
||||
if OUT.exists():
|
||||
try:
|
||||
deviations = json.loads(OUT.read_text("utf-8")).get("deviations", {})
|
||||
except Exception: # noqa: BLE001
|
||||
deviations = {}
|
||||
doc = {
|
||||
"generatedFrom": {
|
||||
"repo": "MoviePilot", "commit": gd._filter_mp_commit(root),
|
||||
"engine": "python-fallback (FilterModule, rust_accel disabled); parse = pyparsing RuleParser",
|
||||
"generator": "scripts/mp-sync/gen-rules-differential.py",
|
||||
"semantics": "groups 串联取交集,pri = 最后一组命中层的 pri_order(100 起逐层 -1);null = 被过滤",
|
||||
},
|
||||
"customRules": gd.CUSTOM_RULES,
|
||||
"torrents": TORRENTS,
|
||||
"rules": RULES,
|
||||
"parse": parse_out,
|
||||
"cases": cases,
|
||||
"deviations": deviations,
|
||||
}
|
||||
OUT.write_text(json.dumps(doc, ensure_ascii=False, indent=1) + "\n", "utf-8")
|
||||
print(f"rules: rules={len(RULES)} cases={len(cases)} parse={len(parse_out)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user