- 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]>
471 lines
16 KiB
TypeScript
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;
|
|
}
|