Files
UHD_Media_Tool/packages/domain/src/rules/atoms.ts
T
rootandClaude Opus 5.5 e994a7bcfa 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]>
2026-09-28 14:50:43 +08:00

471 lines
16 KiB
TypeScript

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