feat(domain): pure business logic for parsing, identity, slots, acquisition, paths, organize, download state (W1-2)
- release-parser: title/file/dir parsing with MP parity + deviations (airDate/issue, cut, release group suffix fallback, dir+file merge, safe episode offset) - identity: title normalization (NFKC, trad->simp map, season suffix) and evidence-level candidate ranking (year tolerance ±1, no probabilities) - slot: hard/soft/upgrade evaluation (missing vs unmet fields), state machine, movie/unit completion (season pack must be covered by actual file list) - acquisition: new_title/catch_up/upgrade modes, priority order, budget, cache volume hard threshold with resume watermark (no double counting) - path: immutable path bindings, restricted naming templates, sanitize, conflict detection, downloader<->host path mapping - organize: planOrganize with sidecar pairing, sample exclusion, existing target policy, crossDevice marking, H&R -> conflict for move - download-state: qB state mapping, completion, no-progress evidence 230 tests, ~97% statement coverage. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
a6d049db81
commit
7ba712ea55
40 files changed
+5885
-2
No files matched your search
@@ -0,0 +1,38 @@
|
||||
const DIGITS: Record<string, number> = {
|
||||
零: 0,
|
||||
〇: 0,
|
||||
一: 1,
|
||||
二: 2,
|
||||
两: 2,
|
||||
兩: 2,
|
||||
三: 3,
|
||||
四: 4,
|
||||
五: 5,
|
||||
六: 6,
|
||||
七: 7,
|
||||
八: 8,
|
||||
九: 9,
|
||||
};
|
||||
const UNITS: Record<string, number> = { 十: 10, 拾: 10, 百: 100, 千: 1000 };
|
||||
|
||||
/** 中文数字/阿拉伯数字 → 整数;无法解析返回 undefined */
|
||||
export function cnToInt(s: string): number | undefined {
|
||||
const t = s.trim();
|
||||
if (/^\d+$/.test(t)) return Number.parseInt(t, 10);
|
||||
if (t === "" || ![...t].every((c) => c in DIGITS || c in UNITS)) return undefined;
|
||||
let total = 0;
|
||||
let current = 0;
|
||||
for (const c of t) {
|
||||
const d = DIGITS[c];
|
||||
if (d !== undefined) {
|
||||
current = d;
|
||||
continue;
|
||||
}
|
||||
const u = UNITS[c]!;
|
||||
total += (current || 1) * u;
|
||||
current = 0;
|
||||
}
|
||||
return total + current;
|
||||
}
|
||||
|
||||
export const CN_NUM = "[0-9零〇一二两兩三四五六七八九十拾百千]+";
|
||||
@@ -0,0 +1,15 @@
|
||||
export { cnToInt } from "./cn-number";
|
||||
export { evalEpisodeOffset, normalizeOffsetExpression, OffsetExpressionError } from "./offset";
|
||||
export { type CnMarkers, confidenceOf, fileKindOf, parseRelease, scanCnMarkers } from "./parse";
|
||||
export { mergeParsed, type PathSegments, parseReleasePath, splitReleasePath } from "./path";
|
||||
export { buildGroupRegex, RELEASE_GROUP_SEED } from "./release-groups";
|
||||
export * from "./types";
|
||||
|
||||
import type { EpisodeRange } from "./types";
|
||||
/** 展开集号区间 */
|
||||
export function expandEpisodes(r: EpisodeRange | undefined): number[] {
|
||||
if (!r) return [];
|
||||
const out: number[] = [];
|
||||
for (let i = r.start; i <= r.end; i++) out.push(i);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 集号偏移表达式的白名单求值器(禁止 eval/Function)。
|
||||
* 语法:数字、EP 占位符、+ - * / // % ** 和括号。
|
||||
* 省略 EP 时按 MP 约定补全:`-12` → `EP-12`,`+1` → `EP+1`,`*2` → `EP*2`。
|
||||
*/
|
||||
export class OffsetExpressionError extends Error {
|
||||
override name = "OffsetExpressionError";
|
||||
}
|
||||
|
||||
type Tok = { t: "num"; v: number } | { t: "ep" } | { t: "op"; v: string } | { t: "lp" } | { t: "rp" };
|
||||
|
||||
function lex(src: string): Tok[] {
|
||||
const out: Tok[] = [];
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const c = src[i]!;
|
||||
if (c === " ") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (/\d/.test(c)) {
|
||||
let j = i;
|
||||
while (j < src.length && /\d/.test(src[j]!)) j++;
|
||||
out.push({ t: "num", v: Number(src.slice(i, j)) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (src.slice(i, i + 2).toUpperCase() === "EP") {
|
||||
out.push({ t: "ep" });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const two = src.slice(i, i + 2);
|
||||
if (two === "//" || two === "**") {
|
||||
out.push({ t: "op", v: two });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ("+-*/%".includes(c)) {
|
||||
out.push({ t: "op", v: c });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === "(") out.push({ t: "lp" });
|
||||
else if (c === ")") out.push({ t: "rp" });
|
||||
else throw new OffsetExpressionError(`非法字符: ${c}`);
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeOffsetExpression(expr: string): string {
|
||||
const e = expr.trim();
|
||||
if (e === "") throw new OffsetExpressionError("空表达式");
|
||||
if (/ep/i.test(e)) return e;
|
||||
return /^[+\-*/%]/.test(e) ? `EP${e}` : `EP+${e}`;
|
||||
}
|
||||
|
||||
export function evalEpisodeOffset(expr: string, ep: number): number {
|
||||
if (expr.length > 64) throw new OffsetExpressionError("表达式过长");
|
||||
const toks = lex(normalizeOffsetExpression(expr));
|
||||
let pos = 0;
|
||||
const peek = () => toks[pos];
|
||||
// expr := term (('+'|'-') term)*
|
||||
const parseExpr = (): number => {
|
||||
let v = parseTerm();
|
||||
for (let p = peek(); p?.t === "op" && (p.v === "+" || p.v === "-"); p = peek()) {
|
||||
pos++;
|
||||
const r = parseTerm();
|
||||
v = p.v === "+" ? v + r : v - r;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
const parseTerm = (): number => {
|
||||
let v = parsePow();
|
||||
for (let p = peek(); p?.t === "op" && ["*", "/", "//", "%"].includes(p.v); p = peek()) {
|
||||
pos++;
|
||||
const r = parsePow();
|
||||
if ((p.v === "/" || p.v === "//" || p.v === "%") && r === 0) throw new OffsetExpressionError("除零");
|
||||
if (p.v === "*") v *= r;
|
||||
else if (p.v === "/") v /= r;
|
||||
else if (p.v === "//") v = Math.floor(v / r);
|
||||
else v = ((v % r) + r) % r;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
const parsePow = (): number => {
|
||||
const base = parseUnary();
|
||||
const p = peek();
|
||||
if (p?.t === "op" && p.v === "**") {
|
||||
pos++;
|
||||
return base ** parsePow();
|
||||
}
|
||||
return base;
|
||||
};
|
||||
const parseUnary = (): number => {
|
||||
const p = peek();
|
||||
if (p?.t === "op" && (p.v === "-" || p.v === "+")) {
|
||||
pos++;
|
||||
const v = parseUnary();
|
||||
return p.v === "-" ? -v : v;
|
||||
}
|
||||
return parseAtom();
|
||||
};
|
||||
const parseAtom = (): number => {
|
||||
const p = toks[pos++];
|
||||
if (!p) throw new OffsetExpressionError("表达式不完整");
|
||||
if (p.t === "num") return p.v;
|
||||
if (p.t === "ep") return ep;
|
||||
if (p.t === "lp") {
|
||||
const v = parseExpr();
|
||||
if (toks[pos++]?.t !== "rp") throw new OffsetExpressionError("括号不匹配");
|
||||
return v;
|
||||
}
|
||||
throw new OffsetExpressionError("语法错误");
|
||||
};
|
||||
const v = parseExpr();
|
||||
if (pos !== toks.length) throw new OffsetExpressionError("多余的内容");
|
||||
if (!Number.isFinite(v)) throw new OffsetExpressionError("结果不是有限数");
|
||||
return Math.trunc(v);
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
import { CN_NUM, cnToInt } from "./cn-number";
|
||||
import { evalEpisodeOffset } from "./offset";
|
||||
import { buildGroupRegex, defaultGroupRegex } from "./release-groups";
|
||||
import {
|
||||
type Confidence,
|
||||
type ContentType,
|
||||
type Cut,
|
||||
type EpisodeRange,
|
||||
type ExplicitId,
|
||||
type FileKind,
|
||||
type HdrFormat,
|
||||
PARSER_VERSION,
|
||||
type PackHint,
|
||||
type ParsedRelease,
|
||||
type ParseOptions,
|
||||
type ReleaseGroup,
|
||||
type ReleaseSource,
|
||||
type Resolution,
|
||||
type Segment,
|
||||
type VideoCodec,
|
||||
} from "./types";
|
||||
|
||||
// ---------------------------------------------------------------- 扩展名
|
||||
|
||||
const EXT_KIND: Record<string, FileKind> = {};
|
||||
for (const e of "mp4 mkv ts iso rmvb avi mov mpeg mpg wmv 3gp asf m4v flv m2ts strm tp f4v webm vob".split(" "))
|
||||
EXT_KIND[e] = "video";
|
||||
for (const e of "srt ass ssa sup sub idx vtt".split(" ")) EXT_KIND[e] = "subtitle";
|
||||
for (const e of "aac ac3 eac3 m4a mka dts flac mp3 thd wav".split(" ")) EXT_KIND[e] = "audio";
|
||||
EXT_KIND.nfo = "nfo";
|
||||
for (const e of "jpg jpeg png webp bmp".split(" ")) EXT_KIND[e] = "image";
|
||||
for (const e of "txt url torrent md5 sfv".split(" ")) EXT_KIND[e] = "other";
|
||||
|
||||
export function fileKindOf(name: string): FileKind | undefined {
|
||||
const m = /\.([A-Za-z0-9]{2,5})$/.exec(name);
|
||||
return m ? EXT_KIND[m[1]!.toLowerCase()] : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 可变草稿
|
||||
|
||||
type Draft = ParsedRelease & { _complete?: boolean; _fin?: boolean; _disc?: boolean; _remux?: boolean };
|
||||
|
||||
function emptyDraft(raw: string): Draft {
|
||||
return {
|
||||
parserVersion: PARSER_VERSION,
|
||||
raw,
|
||||
isEncode: false,
|
||||
audio: [],
|
||||
hdr: [],
|
||||
contentType: "unknown",
|
||||
variantTags: [],
|
||||
mediaTypeHint: "unknown",
|
||||
isSeasonPack: false,
|
||||
packHint: "unknown",
|
||||
isFile: false,
|
||||
explicitIds: [],
|
||||
confidence: "needs_confirm",
|
||||
evidence: [],
|
||||
};
|
||||
}
|
||||
|
||||
const MARK = " 〓 "; // 中文季集标记被剥离后的占位:之后的中文不再并入标题
|
||||
const MARK_HARD = " 〆 "; // 日期/动漫集号被剥离后的占位:之后不再取任何标题词
|
||||
|
||||
// ---------------------------------------------------------------- 发布组
|
||||
|
||||
const GROUP_SUFFIX_EXCLUDE =
|
||||
/^(?:DL|HD|MA|HR|RIP|RAY|X|ES|\d+|\d+BIT|\d{3,4}[PI]|[XH]\.?26[45]|HEVC|AVC|AAC|FLAC|DTS|DDP?[\d.]*|ATMOS|WEB|BLURAY|REMUX|HDR\d*|DV|HLG|SDR|FIN|END|S\d+|E\d+|EP\d+|CD\d*|PART\d*|\d+CH|MKV|MP4)$/i;
|
||||
const TECH_IN_BRACKET = /\b(?:\d{3,4}[pi]|x26[45]|hevc|avc|web-?dl|webrip|bluray|bdrip|remux)\b|^\d+$/i;
|
||||
|
||||
function extractGroup(s: string, extra: readonly string[]): { s: string; group?: ReleaseGroup; ev?: string } {
|
||||
const re = extra.length > 0 ? buildGroupRegex(extra) : defaultGroupRegex();
|
||||
const found: string[] = [];
|
||||
for (const m of s.matchAll(re)) {
|
||||
if (!found.some((f) => f.toLowerCase() === m[0].toLowerCase())) found.push(m[0]);
|
||||
}
|
||||
const lead = /^\s*[[【]([^\]】]+)[\]】]\s*/u.exec(s);
|
||||
if (found.length > 0) {
|
||||
const lowered = found.map((f) => f.toLowerCase());
|
||||
const hasKnown = (txt: string) => lowered.some((f) => txt.toLowerCase().includes(f));
|
||||
let out = s;
|
||||
if (lead && hasKnown(lead[1]!)) out = out.slice(lead[0].length);
|
||||
out = out.replace(/[-@]([^\s.[\]【】-]+(?:-[^\s.[\]【】-]+)?)$/u, (all, g: string) => (hasKnown(g) ? " " : all));
|
||||
return { s: out, group: { name: found.join("@"), source: "known" }, ev: "group:known" };
|
||||
}
|
||||
if (lead) {
|
||||
const content = lead[1]!.trim();
|
||||
const rest = s.slice(lead[0].length);
|
||||
if (!TECH_IN_BRACKET.test(content) && (/[[【]/u.test(rest) || /\s-\s\d{1,3}\b/.test(rest))) {
|
||||
return { s: rest, group: { name: content.replace(/&/g, "@"), source: "bracket" }, ev: "group:bracket" };
|
||||
}
|
||||
}
|
||||
const suf = /-\s?([A-Za-z0-9][A-Za-z0-9_@&]{0,23})$/.exec(s.trimEnd());
|
||||
if (suf && !GROUP_SUFFIX_EXCLUDE.test(suf[1]!) && /[A-Za-z]/.test(suf[1]!)) {
|
||||
return {
|
||||
s: s.trimEnd().slice(0, -suf[0].length),
|
||||
group: { name: suf[1]!, source: "suffix" },
|
||||
ev: "group:suffix",
|
||||
};
|
||||
}
|
||||
return { s };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 剪辑版本
|
||||
|
||||
const CUT_RULES: [RegExp, Cut][] = [
|
||||
[/\b(?:Director'?s|Directors)[ .\-_]?Cut\b|导演剪辑版?|導演剪輯版?|导演剪切版/giu, "Director's Cut"],
|
||||
[/\bExtended(?:[ .\-_](?:Edition|Cut|Version))?\b|加长版|加長版/giu, "Extended"],
|
||||
[/\bTheatrical(?:[ .\-_](?:Edition|Cut|Version))?\b|院线版|院線版/giu, "Theatrical"],
|
||||
[/\bUnrated(?:[ .\-_](?:Edition|Cut|Version))?\b/giu, "Unrated"],
|
||||
[/\bUncut\b|未删减版?|未刪減版?/giu, "Uncut"],
|
||||
[/\bIMAX(?:[ .\-_](?:Edition|Enhanced))?\b|IMAX版/giu, "IMAX"],
|
||||
[/\bRemastered\b|重制版|數位修復版/giu, "Remastered"],
|
||||
[/\bCriterion(?:[ .\-_]Collection)?\b|CC标准收藏版/giu, "Criterion"],
|
||||
[/\bFinal[ .\-_]Cut\b/giu, "Final Cut"],
|
||||
[/\bUltimate[ .\-_](?:Edition|Cut)\b/giu, "Ultimate"],
|
||||
[/\bSpecial[ .\-_]Edition\b/giu, "Special Edition"],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------- 日期
|
||||
|
||||
function validDate(y: number, m: number, d: number): boolean {
|
||||
if (y < 1990 || y > 2100 || m < 1 || m > 12 || d < 1) return false;
|
||||
const days = [31, y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
return d <= days[m - 1]!;
|
||||
}
|
||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
function extractAirDate(s: string): { s: string; airDate?: string; ev?: string } {
|
||||
const rules: [RegExp, string][] = [
|
||||
[/(?<!\d)((?:19|20)\d{2})年(\d{1,2})月(\d{1,2})日?/u, "airDate:cn"],
|
||||
[/(?<!\d)((?:19|20)\d{2})[.\-_ /](\d{1,2})[.\-_ /](\d{1,2})(?!\d)/, "airDate:ymd_sep"],
|
||||
[/(?<!\d)((?:19|20)\d{2})(\d{2})(\d{2})(?!\d)/, "airDate:ymd8"],
|
||||
];
|
||||
for (const [re, ev] of rules) {
|
||||
const m = re.exec(s);
|
||||
if (!m) continue;
|
||||
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
if (!validDate(y, mo, d)) continue;
|
||||
return {
|
||||
s: s.slice(0, m.index) + MARK_HARD + s.slice(m.index + m[0].length),
|
||||
airDate: `${y}${pad2(mo)}${pad2(d)}`,
|
||||
ev,
|
||||
};
|
||||
}
|
||||
return { s };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 中文标记
|
||||
|
||||
export interface CnMarkers {
|
||||
season?: number;
|
||||
seasonEnd?: number;
|
||||
episodes?: EpisodeRange;
|
||||
issue?: number;
|
||||
totalEpisodes?: number;
|
||||
segment?: Segment;
|
||||
contentType?: ContentType;
|
||||
variantTags: string[];
|
||||
subtitles?: string[];
|
||||
evidence: string[];
|
||||
}
|
||||
|
||||
const CONTENT_RULES: [RegExp, ContentType, string][] = [
|
||||
[/(?:会员|會員)?加更版?/u, "extra", "content:extra"],
|
||||
[/花絮|幕后|幕後|彩蛋/u, "behind_the_scenes", "content:behind_the_scenes"],
|
||||
[/纯享版?|純享版?|直拍/u, "performance_only", "content:performance_only"],
|
||||
[/预告片?|預告片?|先导片|先導片/u, "preview", "content:preview"],
|
||||
[/陪看|(?<![A-Za-z])reaction(?![A-Za-z])/iu, "reaction", "content:reaction"],
|
||||
[/正片/u, "main", "content:main"],
|
||||
];
|
||||
const VARIANT_RE = /会员版|會員版|超前点播|超前點播|精编版|精編版|未播版?|独家版|獨家版|加长版?(?=\s|$)/gu;
|
||||
const SUB_KEYWORDS =
|
||||
/字幕|中字|双字|雙字|内封|內封|内嵌|內嵌|外挂|外掛|简繁|簡繁|简中|繁中|简体|简日|繁日|繁体|繁體|簡體|简英|繁英|中英|双语|雙語/u;
|
||||
const AUDIO_LANG_RE = /国粤双语|國粵雙語|国英双语|國英雙語|国语配音|國語配音|国配|國配|国语|國語|粤语|粵語/gu;
|
||||
|
||||
function scanSubtitleLangs(run: string): string[] {
|
||||
const langs: string[] = [];
|
||||
const add = (l: string) => {
|
||||
if (!langs.includes(l)) langs.push(l);
|
||||
};
|
||||
if (/[简簡]/u.test(run)) add("zh-Hans");
|
||||
if (/繁/u.test(run)) add("zh-Hant");
|
||||
if (/中/u.test(run) && !langs.some((l) => l.startsWith("zh"))) add("zh");
|
||||
if (/双语|雙語/u.test(run) && !langs.some((l) => l.startsWith("zh"))) add("zh");
|
||||
if (/英|双语|雙語/u.test(run)) add("en");
|
||||
if (/日/u.test(run)) add("ja");
|
||||
if (/[韩韓]/u.test(run)) add("ko");
|
||||
return langs;
|
||||
}
|
||||
|
||||
export function scanCnMarkers(input: string): { s: string } & CnMarkers {
|
||||
let s = input;
|
||||
const out: CnMarkers = { variantTags: [], evidence: [] };
|
||||
const take = (re: RegExp, fn: (m: RegExpExecArray) => unknown) => {
|
||||
const m = re.exec(s);
|
||||
if (m && fn(m) !== false) s = s.slice(0, m.index) + MARK + s.slice(m.index + m[0].length);
|
||||
};
|
||||
const N = CN_NUM;
|
||||
take(new RegExp(`第\\s*(${N})\\s*[-~~至到]\\s*(${N})\\s*季`, "u"), (m) => {
|
||||
out.season = cnToInt(m[1]!);
|
||||
out.seasonEnd = cnToInt(m[2]!);
|
||||
out.evidence.push("season:cn_range");
|
||||
});
|
||||
if (out.season === undefined)
|
||||
take(new RegExp(`第\\s*(${N})\\s*季`, "u"), (m) => {
|
||||
out.season = cnToInt(m[1]!);
|
||||
out.evidence.push("season:cn");
|
||||
});
|
||||
take(new RegExp(`[全共]\\s*(${N})\\s*季`, "u"), (m) => {
|
||||
if (out.season !== undefined) return true;
|
||||
out.season = 1;
|
||||
out.seasonEnd = cnToInt(m[1]!);
|
||||
out.evidence.push("season:cn_all");
|
||||
});
|
||||
take(new RegExp(`第\\s*(${N})\\s*[-~~至到]\\s*(${N})\\s*[集话話回]`, "u"), (m) => {
|
||||
out.episodes = { start: cnToInt(m[1]!)!, end: cnToInt(m[2]!)! };
|
||||
out.evidence.push("episode:cn_range");
|
||||
});
|
||||
if (!out.episodes)
|
||||
take(new RegExp(`第\\s*(${N})\\s*[集话話回]`, "u"), (m) => {
|
||||
const n = cnToInt(m[1]!)!;
|
||||
out.episodes = { start: n, end: n };
|
||||
out.evidence.push("episode:cn");
|
||||
});
|
||||
take(new RegExp(`第\\s*(${N})\\s*期`, "u"), (m) => {
|
||||
out.issue = cnToInt(m[1]!);
|
||||
out.evidence.push("issue:cn");
|
||||
});
|
||||
take(new RegExp(`[全共]\\s*(${N})\\s*[集话話期]|(${N})\\s*[集话話]\\s*全`, "u"), (m) => {
|
||||
out.totalEpisodes = cnToInt((m[1] ?? m[2])!);
|
||||
out.evidence.push("total:cn");
|
||||
});
|
||||
s = s.replace(AUDIO_LANG_RE, " ");
|
||||
for (const [re, ct, ev] of CONTENT_RULES) {
|
||||
take(re, () => {
|
||||
out.contentType ??= ct;
|
||||
out.evidence.push(ev);
|
||||
});
|
||||
}
|
||||
s = s.replace(VARIANT_RE, (v) => {
|
||||
out.variantTags.push(v);
|
||||
return " ";
|
||||
});
|
||||
if (out.variantTags.length > 0) out.evidence.push("variant:cn");
|
||||
take(/完整版/u, () => {
|
||||
out.segment = "full";
|
||||
out.evidence.push("segment:full");
|
||||
});
|
||||
take(/(?<!\p{Script=Han})[((【[]?\s*([上中下])(?:集|期|篇|部)?\s*[))】\]]?(?!\p{Script=Han})/u, (m) => {
|
||||
out.segment = m[1] === "上" ? "upper" : m[1] === "中" ? "middle" : "lower";
|
||||
out.evidence.push("segment:cn");
|
||||
});
|
||||
// 字幕语言:含关键词的连续汉字串
|
||||
s = s.replace(/[\p{Script=Han}]+/gu, (run) => {
|
||||
if (!SUB_KEYWORDS.test(run)) return run;
|
||||
const langs = scanSubtitleLangs(run);
|
||||
out.subtitles = [...new Set([...(out.subtitles ?? []), ...langs])];
|
||||
out.evidence.push("subtitle:cn");
|
||||
return " ";
|
||||
});
|
||||
return { s, ...out };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 词元归一化
|
||||
|
||||
const AUDIO_CODEC_ALT = "DDP|DD|AAC|TrueHD|DTSHDMA|DTSHDHR|DTSHD|DTSX|DTS|FLAC|LPCM|PCM|AC3|EAC3|Opus|Atmos";
|
||||
|
||||
function normalizeCompounds(s: string): string {
|
||||
return s
|
||||
.replace(/'/g, "")
|
||||
.replace(/\bWEB[-. ]?DL\b/gi, " WEBDL ")
|
||||
.replace(/\bWEB[-. ]?Rip\b/gi, " WEBRip ")
|
||||
.replace(/\bBlu[-. ]?Ray\b/gi, " BluRay ")
|
||||
.replace(/\bDTS[-. ]?HD[-. ]?MA\b/gi, " DTSHDMA ")
|
||||
.replace(/\bDTS[-. ]?HD[-. ]?(?:HRA|HR)\b/gi, " DTSHDHR ")
|
||||
.replace(/\bDTS[-. ]?HD\b/gi, " DTSHD ")
|
||||
.replace(/\bDTS[-. :]?X\b/gi, " DTSX ")
|
||||
.replace(/\b([HhXx])[. ]?26([45])\b/g, " $126$2 ")
|
||||
.replace(/\bHDR10(?:\+|Plus|P)(?![A-Za-z0-9])/gi, " HDR10PLUS ")
|
||||
.replace(/\bDD\+/gi, " DDP")
|
||||
.replace(/\bE-?AC-?3\b/gi, " EAC3 ")
|
||||
.replace(/\bAC-?3\b/gi, " AC3 ")
|
||||
.replace(/\bDolby[ .]?Vision\b/gi, " DV ")
|
||||
.replace(/\bVC-?1\b/gi, " VC1 ")
|
||||
.replace(/\bMPEG-?2\b/gi, " MPEG2 ")
|
||||
.replace(/\b(8|10|12)[ .\-_]?bits?\b/gi, " $1BIT ")
|
||||
.replace(
|
||||
new RegExp(`\\b(${AUDIO_CODEC_ALT})[ ._]*([1-9])[ .]([0-2])(?![0-9])`, "gi"),
|
||||
(_all, codec: string, a: string, b: string) => ` ${codec} CH${a}${b} `,
|
||||
)
|
||||
.replace(/(\p{Script=Han})([A-Za-z])/gu, "$1 $2")
|
||||
.replace(/([A-Za-z])(\p{Script=Han})/gu, "$1 $2");
|
||||
}
|
||||
|
||||
const TOKEN_SPLIT = /[\s._[\]()【】/~~;&|#「」,,()::"!!??、+\-《》]+/u;
|
||||
|
||||
const PLATFORMS: Record<string, string> = {
|
||||
NF: "Netflix",
|
||||
AMZN: "Amazon",
|
||||
MAX: "Max",
|
||||
HMAX: "HBO Max",
|
||||
DSNP: "Disney+",
|
||||
ATVP: "Apple TV+",
|
||||
IT: "iTunes",
|
||||
HULU: "Hulu",
|
||||
PCOK: "Peacock",
|
||||
PMTP: "Paramount+",
|
||||
CR: "Crunchyroll",
|
||||
IQ: "iQIYI",
|
||||
IQIYI: "iQIYI",
|
||||
TX: "Tencent Video",
|
||||
WETV: "WeTV",
|
||||
YK: "Youku",
|
||||
MGTV: "MGTV",
|
||||
BILI: "Bilibili",
|
||||
BG: "B-Global",
|
||||
VIU: "Viu",
|
||||
KKTV: "KKTV",
|
||||
FRIDAY: "friDay",
|
||||
LINETV: "LINE TV",
|
||||
HBO: "HBO",
|
||||
STAN: "Stan",
|
||||
};
|
||||
|
||||
const AUDIO_TOKENS: Record<string, string> = {
|
||||
TRUEHD: "TrueHD",
|
||||
ATMOS: "Atmos",
|
||||
DTSHDMA: "DTS-HD MA",
|
||||
DTSHDHR: "DTS-HD HR",
|
||||
DTSHD: "DTS-HD",
|
||||
DTSX: "DTS:X",
|
||||
DTS: "DTS",
|
||||
DDP: "DDP",
|
||||
DD: "DD",
|
||||
EAC3: "E-AC3",
|
||||
AC3: "AC3",
|
||||
AAC: "AAC",
|
||||
FLAC: "FLAC",
|
||||
LPCM: "LPCM",
|
||||
PCM: "PCM",
|
||||
OPUS: "Opus",
|
||||
MP3: "MP3",
|
||||
};
|
||||
|
||||
const HDR_TOKENS: Record<string, HdrFormat> = {
|
||||
HDR: "HDR10",
|
||||
HDR10: "HDR10",
|
||||
HDR10PLUS: "HDR10+",
|
||||
DV: "DV",
|
||||
DOVI: "DV",
|
||||
HLG: "HLG",
|
||||
HDRVIVID: "HDR Vivid",
|
||||
VIVID: "HDR Vivid",
|
||||
};
|
||||
|
||||
const CODEC_TOKENS: Record<string, VideoCodec> = {
|
||||
X264: "H.264",
|
||||
H264: "H.264",
|
||||
AVC: "H.264",
|
||||
X265: "H.265",
|
||||
H265: "H.265",
|
||||
HEVC: "H.265",
|
||||
AV1: "AV1",
|
||||
VC1: "VC-1",
|
||||
MPEG2: "MPEG-2",
|
||||
XVID: "XviD",
|
||||
DIVX: "DivX",
|
||||
};
|
||||
|
||||
const RES_ALLOWED = new Set(["4320p", "2160p", "1440p", "1080p", "1080i", "720p", "576p", "480p"]);
|
||||
|
||||
function heightToRes(h: number): Resolution | undefined {
|
||||
if (h >= 4000) return "4320p";
|
||||
if (h >= 2000) return "2160p";
|
||||
if (h >= 1400) return "1440p";
|
||||
if (h >= 1000) return "1080p";
|
||||
if (h >= 700) return "720p";
|
||||
if (h >= 560) return "576p";
|
||||
if (h >= 470) return "480p";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const WEB_TOKENS = new Set(["WEBDL", "WEBRIP", "WEB"]);
|
||||
|
||||
// ---------------------------------------------------------------- 词元扫描
|
||||
|
||||
function walkTokens(tokens: string[], d: Draft): void {
|
||||
const ev = d.evidence;
|
||||
const en: string[] = [];
|
||||
const cn: string[] = [];
|
||||
let lastTitleList: string[] | undefined;
|
||||
let stopped = false;
|
||||
let hanClosed = false;
|
||||
let nonYearStops = 0;
|
||||
const sources = new Set<ReleaseSource>();
|
||||
const stop = (kind: string) => {
|
||||
stopped = true;
|
||||
if (kind !== "year") nonYearStops++;
|
||||
};
|
||||
const hasTitle = () => en.length > 0 || cn.length > 0;
|
||||
const pushTitle = (t: string) => {
|
||||
const list = /\p{Script=Han}/u.test(t) ? cn : /^\d+$/.test(t) ? (lastTitleList ?? en) : en;
|
||||
list.push(t);
|
||||
lastTitleList = list;
|
||||
};
|
||||
const setEpisode = (n: number, rule: string) => {
|
||||
if (!d.episodes) {
|
||||
d.episodes = { start: n, end: n };
|
||||
ev.push(rule);
|
||||
} else if (n > d.episodes.end) d.episodes.end = n;
|
||||
};
|
||||
const nearWeb = (i: number) => {
|
||||
for (let k = Math.max(0, i - 2); k <= Math.min(tokens.length - 1, i + 2); k++)
|
||||
if (k !== i && WEB_TOKENS.has(tokens[k]!.toUpperCase())) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const t = tokens[i]!;
|
||||
const u = t.toUpperCase();
|
||||
const next = tokens[i + 1];
|
||||
if (t === "〓") {
|
||||
hanClosed = true;
|
||||
continue;
|
||||
}
|
||||
if (t === "〆") {
|
||||
if (hasTitle()) stop("marker");
|
||||
continue;
|
||||
}
|
||||
let m: RegExpExecArray | null;
|
||||
// 季集
|
||||
m = /^S(\d{1,3})((?:E[P]?\d{1,4})+)$/i.exec(t);
|
||||
if (m) {
|
||||
d.season ??= Number(m[1]);
|
||||
for (const e of m[2]!.match(/\d+/g)!) setEpisode(Number(e), "episode:SxxEyy");
|
||||
ev.push("season:SxxEyy");
|
||||
stop("season");
|
||||
continue;
|
||||
}
|
||||
m = /^S(\d{1,3})D(\d{1,2})$/i.exec(t);
|
||||
if (m) {
|
||||
d.season ??= Number(m[1]);
|
||||
d.part = `Disc${Number(m[2])}`;
|
||||
ev.push("season:SxxDyy");
|
||||
stop("season");
|
||||
continue;
|
||||
}
|
||||
m = /^S(\d{1,3})$/i.exec(t);
|
||||
if (m && (hasTitle() || stopped || i === 0)) {
|
||||
const n = Number(m[1]);
|
||||
if (d.season === undefined) {
|
||||
d.season = n;
|
||||
ev.push("season:Sxx");
|
||||
} else if (n > d.season) {
|
||||
d.seasonEnd = n;
|
||||
ev.push("season:Sxx_range");
|
||||
}
|
||||
stop("season");
|
||||
continue;
|
||||
}
|
||||
m = /^EP?(\d{1,4})$/i.exec(t);
|
||||
if (m && (stopped || hasTitle() || i === 0)) {
|
||||
setEpisode(Number(m[1]), "episode:Exx");
|
||||
stop("episode");
|
||||
continue;
|
||||
}
|
||||
m = /^(\d{1,2})x(\d{2,3})$/i.exec(t);
|
||||
if (m) {
|
||||
d.season ??= Number(m[1]);
|
||||
setEpisode(Number(m[2]), "episode:NxNN");
|
||||
stop("season");
|
||||
continue;
|
||||
}
|
||||
if ((u === "SEASON" || u === "EPISODE") && next && /^\d{1,4}$/.test(next)) {
|
||||
if (u === "SEASON") {
|
||||
if (d.season === undefined) d.season = Number(next);
|
||||
else if (Number(next) > d.season) d.seasonEnd = Number(next);
|
||||
ev.push("season:word");
|
||||
} else setEpisode(Number(next), "episode:word");
|
||||
i++;
|
||||
stop("season");
|
||||
continue;
|
||||
}
|
||||
// 分辨率
|
||||
m = /^(\d{3,4})([PI])$/i.exec(t);
|
||||
if (m && RES_ALLOWED.has(`${m[1]}${m[2]!.toLowerCase()}`)) {
|
||||
d.resolution ??= `${m[1]}${m[2]!.toLowerCase()}` as Resolution;
|
||||
ev.push("resolution");
|
||||
stop("res");
|
||||
continue;
|
||||
}
|
||||
m = /^([248])K$/i.exec(t);
|
||||
if (m && (hasTitle() || stopped)) {
|
||||
d.resolution ??= m[1] === "8" ? "4320p" : m[1] === "4" ? "2160p" : "1440p";
|
||||
ev.push("resolution:nK");
|
||||
stop("res");
|
||||
continue;
|
||||
}
|
||||
m = /^(\d{3,4})X(\d{3,4})$/i.exec(t);
|
||||
if (m) {
|
||||
const r = heightToRes(Number(m[2]));
|
||||
if (r) {
|
||||
d.resolution ??= r;
|
||||
ev.push("resolution:WxH");
|
||||
stop("res");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 年份
|
||||
if (/^(?:19|20)\d{2}$/.test(t)) {
|
||||
const y = Number(t);
|
||||
if (!hasTitle() && !stopped) {
|
||||
pushTitle(t);
|
||||
continue;
|
||||
}
|
||||
if (d.year === undefined) {
|
||||
d.year = y;
|
||||
stop("year");
|
||||
continue;
|
||||
}
|
||||
if (nonYearStops === 0) {
|
||||
pushTitle(String(d.year));
|
||||
d.year = y;
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 分盘/Part
|
||||
m = /^(CD|DISC|DISK)(\d{1,2})$/i.exec(t);
|
||||
if (m) {
|
||||
d.part = `${m[1]!.toUpperCase() === "CD" ? "CD" : "Disc"}${Number(m[2])}`;
|
||||
ev.push("part");
|
||||
stop("part");
|
||||
continue;
|
||||
}
|
||||
m = /^PART(\d{1,2}|[A-D]|I{1,3})?$/i.exec(t);
|
||||
if (m) {
|
||||
if (stopped) {
|
||||
let p = m[1];
|
||||
if (!p && next && /^(\d{1,2}|[A-D]|I{1,3})$/i.test(next)) {
|
||||
p = next;
|
||||
i++;
|
||||
}
|
||||
if (p) {
|
||||
d.part = `Part${p.toUpperCase()}`;
|
||||
ev.push("part");
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pushTitle(t);
|
||||
continue;
|
||||
}
|
||||
const gate = hasTitle() || stopped;
|
||||
// 来源
|
||||
if (gate) {
|
||||
let src: ReleaseSource | undefined;
|
||||
if (u === "BLURAY" || u === "BDRIP" || u === "BRRIP" || u === "BD") src = "BluRay";
|
||||
else if (u === "WEBDL") src = "WEB-DL";
|
||||
else if (u === "WEBRIP") src = "WEBRip";
|
||||
else if (u === "WEB" && (stopped || (next !== undefined && /^\d{3,4}[pi]$/i.test(next)))) src = "WEB-DL";
|
||||
else if (u === "HDTV" || u === "UHDTV") src = "HDTV";
|
||||
else if (u === "HDRIP") src = "HDRip";
|
||||
else if (u === "DVDRIP") src = "DVDRip";
|
||||
else if (u === "DVD" || u === "DVD5" || u === "DVD9") src = "DVD";
|
||||
if (src) {
|
||||
sources.add(src);
|
||||
ev.push(`source:${src}`);
|
||||
stop("source");
|
||||
continue;
|
||||
}
|
||||
if (u === "REMUX") {
|
||||
d._remux = true;
|
||||
ev.push("source:REMUX");
|
||||
stop("source");
|
||||
continue;
|
||||
}
|
||||
if (/^(BD25|BD50|BD66|BD100|BDMV|ISO)$/.test(u)) {
|
||||
d._disc = true;
|
||||
ev.push("source:disc");
|
||||
stop("source");
|
||||
continue;
|
||||
}
|
||||
if (u === "UHD") {
|
||||
d.uhd = true;
|
||||
stop("source");
|
||||
continue;
|
||||
}
|
||||
if (u === "COMPLETE") {
|
||||
const n2 = next?.toUpperCase();
|
||||
if (n2 === "UHD" || n2 === "BLURAY") {
|
||||
d._disc = true;
|
||||
ev.push("source:disc");
|
||||
} else {
|
||||
d._complete = true;
|
||||
ev.push("pack:complete");
|
||||
}
|
||||
stop("complete");
|
||||
continue;
|
||||
}
|
||||
if ((u === "FIN" || u === "END") && d.episodes) {
|
||||
d._fin = true;
|
||||
ev.push("pack:fin");
|
||||
stop("complete");
|
||||
continue;
|
||||
}
|
||||
const codec = CODEC_TOKENS[u];
|
||||
if (codec) {
|
||||
d.videoCodec ??= codec;
|
||||
if (u === "X264" || u === "X265") d.encoder ??= u === "X264" ? "x264" : "x265";
|
||||
ev.push("codec");
|
||||
stop("codec");
|
||||
continue;
|
||||
}
|
||||
m = /^(8|10|12)BIT$/.exec(u);
|
||||
if (m) {
|
||||
d.bitDepth ??= Number(m[1]) as 8 | 10 | 12;
|
||||
stop("bit");
|
||||
continue;
|
||||
}
|
||||
const audio = AUDIO_TOKENS[u];
|
||||
if (audio && (stopped || u !== "DD")) {
|
||||
if (!d.audio.includes(audio)) d.audio.push(audio);
|
||||
stop("audio");
|
||||
continue;
|
||||
}
|
||||
m = /^CH(\d)(\d)$/.exec(u);
|
||||
if (m && d.audio.length > 0) {
|
||||
const last = d.audio.length - 1;
|
||||
if (!/\d\.\d$/.test(d.audio[last]!)) d.audio[last] = `${d.audio[last]} ${m[1]}.${m[2]}`;
|
||||
continue;
|
||||
}
|
||||
m = /^(\d)AUDIOS?$/.exec(u);
|
||||
if (m) {
|
||||
d.audio.push(`${m[1]}Audios`);
|
||||
stop("audio");
|
||||
continue;
|
||||
}
|
||||
const hdr = HDR_TOKENS[u];
|
||||
if (hdr && (stopped || u !== "DV")) {
|
||||
if (!d.hdr.includes(hdr)) d.hdr.push(hdr);
|
||||
ev.push(`hdr:${hdr}`);
|
||||
stop("hdr");
|
||||
continue;
|
||||
}
|
||||
if (u === "SDR" || u === "REPACK" || u === "PROPER" || u === "3D") {
|
||||
stop("flag");
|
||||
continue;
|
||||
}
|
||||
m = /^(\d{2,3})FPS$/.exec(u);
|
||||
if (m) {
|
||||
d.fps = Number(m[1]);
|
||||
stop("fps");
|
||||
continue;
|
||||
}
|
||||
const platform = PLATFORMS[u];
|
||||
if (platform && nearWeb(i)) {
|
||||
d.webSource ??= platform;
|
||||
ev.push("webSource");
|
||||
stop("platform");
|
||||
continue;
|
||||
}
|
||||
if (stopped && (u === "CHS" || u === "GB")) {
|
||||
d.subtitles = [...new Set([...(d.subtitles ?? []), "zh-Hans"])];
|
||||
continue;
|
||||
}
|
||||
if (stopped && (u === "CHT" || u === "BIG5")) {
|
||||
d.subtitles = [...new Set([...(d.subtitles ?? []), "zh-Hant"])];
|
||||
continue;
|
||||
}
|
||||
if (stopped && (u === "ENG" || u === "EN")) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (u === "AKA" && hasTitle()) {
|
||||
stop("aka");
|
||||
continue;
|
||||
}
|
||||
if (stopped) continue;
|
||||
if (/\p{Script=Han}/u.test(t)) {
|
||||
if (!hanClosed) {
|
||||
cn.push(t);
|
||||
lastTitleList = cn;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pushTitle(t);
|
||||
}
|
||||
const enTitle = en.join(" ").trim();
|
||||
const cnTitle = cn.join(" ").trim();
|
||||
if (enTitle) d.titleEn = enTitle;
|
||||
if (cnTitle) d.titleCn = cnTitle;
|
||||
if (d._remux) d.source = "REMUX";
|
||||
else if (d._disc) d.source = "BluRay-Disc";
|
||||
else {
|
||||
for (const s of ["BluRay", "WEB-DL", "WEBRip", "HDTV", "HDRip", "DVDRip", "DVD"] as const) {
|
||||
if (sources.has(s)) {
|
||||
d.source = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 主入口
|
||||
|
||||
/**
|
||||
* 解析发布标题 / 文件名 / 目录名(纯函数)。
|
||||
* 与 MoviePilot MetaInfo 的行为对照及偏离点见 docs/porting/moviepilot-metadata-recognition.md。
|
||||
*/
|
||||
export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRelease {
|
||||
const d = emptyDraft(input);
|
||||
const ev = d.evidence;
|
||||
let s = input.trim();
|
||||
|
||||
// 1. 扩展名
|
||||
const kind = fileKindOf(s);
|
||||
if (kind) {
|
||||
const ext = /\.([A-Za-z0-9]{2,5})$/.exec(s)![1]!.toLowerCase();
|
||||
d.isFile = true;
|
||||
d.fileKind = kind;
|
||||
d.extension = ext;
|
||||
s = s.slice(0, -(ext.length + 1));
|
||||
} else if (opts.isFile) d.isFile = true;
|
||||
|
||||
// 2. 显式身份标签
|
||||
s = s.replace(
|
||||
/[[{]\s*(tmdbid|tmdb|imdbid|imdb|doubanid|douban|tvdbid|tvdb)\s*[=-]\s*([A-Za-z0-9]+)\s*[\]}]/gi,
|
||||
(_all, key: string, id: string) => {
|
||||
const k = key.toLowerCase().replace(/id$/, "") as ExplicitId["provider"];
|
||||
if (id !== "0" && !d.explicitIds.some((x) => x.provider === k)) {
|
||||
d.explicitIds.push({ provider: k, id });
|
||||
ev.push(`explicit:${k}`);
|
||||
}
|
||||
return " ";
|
||||
},
|
||||
);
|
||||
|
||||
// 3. 纯数字文件名 → 集号
|
||||
if (d.isFile && /^\s*\d{1,4}\s*$/.test(s)) {
|
||||
const n = Number(s.trim());
|
||||
d.episodes = { start: n, end: n };
|
||||
ev.push("episode:numeric_filename");
|
||||
return finalize(d, opts);
|
||||
}
|
||||
|
||||
// 4. 体积
|
||||
s = s.replace(/(?<![\w.])(\d+(?:\.\d+)?)\s?(TB|TiB|GB|GiB|MB|MiB)(?![A-Za-z])/gi, (_all, n: string, unit: string) => {
|
||||
const mult = /^T/i.test(unit) ? 1024 ** 4 : /^G/i.test(unit) ? 1024 ** 3 : 1024 ** 2;
|
||||
d.sizeBytes = Math.round(Number(n) * mult);
|
||||
ev.push("size");
|
||||
return " ";
|
||||
});
|
||||
|
||||
// 5. 发布组
|
||||
const g = extractGroup(s, opts.releaseGroups ?? []);
|
||||
s = g.s;
|
||||
if (g.group) {
|
||||
d.releaseGroup = g.group;
|
||||
ev.push(g.ev!);
|
||||
}
|
||||
|
||||
// 6. 剪辑版本
|
||||
for (const [re, cut] of CUT_RULES) {
|
||||
s = s.replace(re, () => {
|
||||
d.cut ??= cut;
|
||||
return " ";
|
||||
});
|
||||
}
|
||||
if (d.cut) ev.push("cut");
|
||||
|
||||
// 7. 综艺播出日期(在年份识别之前,避免被当成年份或被丢弃)
|
||||
const ad = extractAirDate(s);
|
||||
s = ad.s;
|
||||
if (ad.airDate) {
|
||||
d.airDate = ad.airDate;
|
||||
ev.push(ad.ev!);
|
||||
}
|
||||
|
||||
// 8. 中文季/集/期/分段/字幕标记
|
||||
const cm = scanCnMarkers(s);
|
||||
s = cm.s;
|
||||
applyCnMarkers(d, cm, "");
|
||||
|
||||
// 9. 年份区间 2019-2020 只保留前者
|
||||
s = s.replace(/(?<!\d)((?:19|20)\d{2})\s*[-~]\s*(?:19|20)\d{2}(?!\d)/, "$1");
|
||||
|
||||
// 10. 动漫式集号:" - 12 "、"[01-11 Fin]"、"[12]"
|
||||
s = s.replace(
|
||||
/[[【]\s*(\d{1,4})\s*[-~]\s*(\d{1,4})(\s*(?:Fin|END|完))?\s*[\]】]/iu,
|
||||
(all, a: string, b: string, fin) => {
|
||||
const [x, y] = [Number(a), Number(b)];
|
||||
if (x >= 1900 && y >= 1900) return all;
|
||||
d.episodes = { start: x, end: y };
|
||||
ev.push("episode:bracket_range");
|
||||
if (fin) {
|
||||
d._fin = true;
|
||||
ev.push("pack:fin");
|
||||
}
|
||||
return MARK_HARD;
|
||||
},
|
||||
);
|
||||
s = s.replace(/(?<!\d)(\d{1,3})\s*-\s*(\d{1,3})\s+(?:Fin|END)\b/i, (_all, a: string, b: string) => {
|
||||
d.episodes ??= { start: Number(a), end: Number(b) };
|
||||
d._fin = true;
|
||||
ev.push("pack:fin");
|
||||
return MARK_HARD;
|
||||
});
|
||||
s = s.replace(/\s-\s(\d{1,3})(?:v\d)?(?=\s|\[|【|$)/u, (_all, n: string) => {
|
||||
if (!d.episodes) {
|
||||
d.episodes = { start: Number(n), end: Number(n) };
|
||||
ev.push("episode:anime_dash");
|
||||
}
|
||||
return MARK_HARD;
|
||||
});
|
||||
if (d.releaseGroup?.source === "bracket") {
|
||||
s = s.replace(/[[【](\d{1,3})(?:v\d)?[\]】]/u, (all, n: string) => {
|
||||
if (d.episodes) return all;
|
||||
d.episodes = { start: Number(n), end: Number(n) };
|
||||
ev.push("episode:anime_bracket");
|
||||
return MARK_HARD;
|
||||
});
|
||||
}
|
||||
|
||||
// 11. 词元扫描
|
||||
const tokens = normalizeCompounds(s)
|
||||
.split(TOKEN_SPLIT)
|
||||
.filter((t) => t.length > 0);
|
||||
walkTokens(tokens, d);
|
||||
|
||||
// 12. 文件名只剩辅助词
|
||||
if (d.isFile && !d.titleCn && !d.titleEn && cm.subtitles && !d.season && !d.episodes) {
|
||||
d.auxiliaryOnly = true;
|
||||
ev.push("auxiliary_only");
|
||||
}
|
||||
|
||||
// 13. 副标题补缺
|
||||
if (opts.subtitle) {
|
||||
const sub = scanCnMarkers(opts.subtitle);
|
||||
applyCnMarkers(d, sub, "subtitle:");
|
||||
if (!d.airDate) {
|
||||
const sad = extractAirDate(opts.subtitle);
|
||||
if (sad.airDate) {
|
||||
d.airDate = sad.airDate;
|
||||
ev.push(`subtitle:${sad.ev}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalize(d, opts);
|
||||
}
|
||||
|
||||
function applyCnMarkers(d: Draft, cm: CnMarkers, prefix: string): void {
|
||||
const ev = d.evidence;
|
||||
const note = (k: string) => {
|
||||
for (const e of cm.evidence) if (e.startsWith(k)) ev.push(prefix + e);
|
||||
};
|
||||
if (cm.season !== undefined && d.season === undefined) {
|
||||
d.season = cm.season;
|
||||
if (cm.seasonEnd !== undefined && cm.seasonEnd > cm.season) d.seasonEnd = cm.seasonEnd;
|
||||
note("season");
|
||||
}
|
||||
if (cm.episodes && !d.episodes) {
|
||||
d.episodes = cm.episodes;
|
||||
note("episode");
|
||||
}
|
||||
if (cm.issue !== undefined && d.issue === undefined) {
|
||||
d.issue = cm.issue;
|
||||
note("issue");
|
||||
}
|
||||
if (cm.totalEpisodes !== undefined && d.totalEpisodes === undefined) {
|
||||
d.totalEpisodes = cm.totalEpisodes;
|
||||
note("total");
|
||||
}
|
||||
if (cm.segment && !d.segment) {
|
||||
d.segment = cm.segment;
|
||||
note("segment");
|
||||
}
|
||||
if (cm.contentType && d.contentType === "unknown") {
|
||||
d.contentType = cm.contentType;
|
||||
note("content");
|
||||
}
|
||||
for (const v of cm.variantTags) if (!d.variantTags.includes(v)) d.variantTags.push(v);
|
||||
if (cm.subtitles) {
|
||||
d.subtitles = [...new Set([...(d.subtitles ?? []), ...cm.subtitles])];
|
||||
note("subtitle");
|
||||
}
|
||||
}
|
||||
|
||||
function finalize(d: Draft, opts: ParseOptions): ParsedRelease {
|
||||
const ev = d.evidence;
|
||||
// 集号偏移
|
||||
if (opts.episodeOffset && d.episodes) {
|
||||
const start = evalEpisodeOffset(opts.episodeOffset, d.episodes.start);
|
||||
const end = evalEpisodeOffset(opts.episodeOffset, d.episodes.end);
|
||||
if (start >= 0 && end >= start) {
|
||||
d.episodes = { start, end };
|
||||
ev.push("episode:offset");
|
||||
} else ev.push("episode:offset_rejected");
|
||||
}
|
||||
if (d.totalEpisodes !== undefined && d.episodes === undefined && d.isFile) d.totalEpisodes = undefined;
|
||||
|
||||
const tvSignals =
|
||||
d.season !== undefined ||
|
||||
d.episodes !== undefined ||
|
||||
d.airDate !== undefined ||
|
||||
d.issue !== undefined ||
|
||||
d.totalEpisodes !== undefined;
|
||||
d.mediaTypeHint = tvSignals ? "tv" : "unknown";
|
||||
|
||||
// 季号推定:有集号/总集数但无季号,且不是综艺日期/期号
|
||||
if (
|
||||
d.season === undefined &&
|
||||
(d.episodes !== undefined || d.totalEpisodes !== undefined) &&
|
||||
d.airDate === undefined &&
|
||||
d.issue === undefined
|
||||
) {
|
||||
d.season = 1;
|
||||
d.seasonInferred = true;
|
||||
ev.push("season:inferred");
|
||||
}
|
||||
|
||||
d.packHint = packHint(d);
|
||||
d.isSeasonPack = d.packHint === "season_pack" || d.packHint === "multi_season_pack";
|
||||
|
||||
d.isEncode = d.source === "BluRay";
|
||||
if (d.isEncode) ev.push("encode:bluray");
|
||||
d.title = d.titleCn ?? d.titleEn;
|
||||
d.confidence = confidenceOf(d);
|
||||
|
||||
const { _complete, _fin, _disc, _remux, ...out } = d;
|
||||
return out;
|
||||
}
|
||||
|
||||
function packHint(d: Draft): PackHint {
|
||||
if (d.seasonEnd !== undefined && d.season !== undefined && d.seasonEnd > d.season) return "multi_season_pack";
|
||||
if (d.airDate !== undefined || d.issue !== undefined) return "single";
|
||||
if (d.episodes) {
|
||||
if (d._fin || d._complete) return "season_pack";
|
||||
return d.episodes.end > d.episodes.start ? "multi_episode" : "single";
|
||||
}
|
||||
if (d.isFile) return d.mediaTypeHint === "tv" ? "unknown" : "single";
|
||||
if (d.season !== undefined || d.totalEpisodes !== undefined || d._complete) {
|
||||
return d.season !== undefined || d.totalEpisodes !== undefined ? "season_pack" : "unknown";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function confidenceOf(d: ParsedRelease): Confidence {
|
||||
if (!d.title || d.auxiliaryOnly) return "needs_confirm";
|
||||
if (d.variantTags.length > 0) return "needs_confirm";
|
||||
const derived = d.evidence.some((e) => e.startsWith("subtitle:") || e === "episode:offset");
|
||||
if (d.mediaTypeHint === "tv") {
|
||||
if (d.season === undefined) return "needs_confirm"; // 综艺只有日期/期号,季未知
|
||||
if (d.seasonInferred || derived) return "multi_evidence";
|
||||
return "rule_confirmed";
|
||||
}
|
||||
if (d.year === undefined) return "needs_confirm";
|
||||
return derived ? "multi_evidence" : "rule_confirmed";
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { confidenceOf, parseRelease } from "./parse";
|
||||
import type { Confidence, PackHint, ParsedRelease, ParseOptions } from "./types";
|
||||
|
||||
export interface PathSegments {
|
||||
file: string;
|
||||
parent?: string;
|
||||
grandParent?: string;
|
||||
}
|
||||
|
||||
const EXTRAS_DIR =
|
||||
/^(?:featurettes?|extras?|bonus(?:es)?|behind[ ._-]the[ ._-]scenes|deleted[ ._-]scenes|interviews?|trailers?|shorts|scenes|花絮|特典|特辑|特輯|幕后|幕後)$/iu;
|
||||
const SAMPLE_DIR = /^samples?$/i;
|
||||
|
||||
/** 把路径拆成 文件 / 父目录 / 祖父目录;附加内容目录会被跳过并记为 extra */
|
||||
export function splitReleasePath(path: string): PathSegments & { extrasDir?: string; sampleDir?: boolean } {
|
||||
const parts = path.split(/[\\/]+/).filter((p) => p.length > 0);
|
||||
const file = parts.pop() ?? "";
|
||||
let parent = parts.pop();
|
||||
let extrasDir: string | undefined;
|
||||
let sampleDir = false;
|
||||
if (parent && (EXTRAS_DIR.test(parent) || SAMPLE_DIR.test(parent))) {
|
||||
if (SAMPLE_DIR.test(parent)) sampleDir = true;
|
||||
else extrasDir = parent;
|
||||
parent = parts.pop();
|
||||
}
|
||||
const grandParent = parts.pop();
|
||||
return {
|
||||
file,
|
||||
...(parent !== undefined ? { parent } : {}),
|
||||
...(grandParent !== undefined ? { grandParent } : {}),
|
||||
...(extrasDir ? { extrasDir } : {}),
|
||||
...(sampleDir ? { sampleDir } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 合并后的置信度:由证据推导,多次合并不会把降级"洗掉" */
|
||||
function mergedConfidence(r: ParsedRelease): Confidence {
|
||||
let conf = confidenceOf(r);
|
||||
if (r.evidence.some((e) => /_dir:/.test(e))) conf = minConfidence(conf, "multi_evidence");
|
||||
if (r.evidence.some((e) => e.endsWith(":type_inferred"))) conf = "needs_confirm";
|
||||
if (r.mediaTypeHint !== "tv" && r.evidence.some((e) => e.endsWith(":conflict_year"))) conf = "needs_confirm";
|
||||
return conf;
|
||||
}
|
||||
|
||||
const RANK: Record<Confidence, number> = { rule_confirmed: 2, multi_evidence: 1, needs_confirm: 0 };
|
||||
const minConfidence = (a: Confidence, b: Confidence): Confidence => (RANK[a] <= RANK[b] ? a : b);
|
||||
|
||||
function filePackHint(r: ParsedRelease): PackHint {
|
||||
if (r.airDate !== undefined || r.issue !== undefined) return "single";
|
||||
if (r.episodes) return r.episodes.end > r.episodes.start ? "multi_episode" : "single";
|
||||
return r.mediaTypeHint === "tv" ? "unknown" : "single";
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件优先、上级目录只补空字段(对照 MP MetaInfoPath.merge)。
|
||||
* 偏离:文件本身无剧集信号而目录是剧集时,MP 不合并;本项目合并名称与季号,
|
||||
* 打 `<level>:type_inferred` 证据并把置信度降为 needs_confirm。
|
||||
*/
|
||||
export function mergeParsed(
|
||||
primary: ParsedRelease,
|
||||
fallback: ParsedRelease,
|
||||
level: "parent_dir" | "grandparent_dir",
|
||||
): ParsedRelease {
|
||||
const r: ParsedRelease = {
|
||||
...primary,
|
||||
audio: [...primary.audio],
|
||||
hdr: [...primary.hdr],
|
||||
variantTags: [...primary.variantTags],
|
||||
evidence: [...primary.evidence],
|
||||
};
|
||||
const used: string[] = [];
|
||||
const fbTv = fallback.mediaTypeHint === "tv";
|
||||
const deviation = primary.mediaTypeHint !== "tv" && fbTv;
|
||||
|
||||
if (!primary.title) {
|
||||
if (fallback.title) {
|
||||
r.title = fallback.title;
|
||||
r.titleCn = fallback.titleCn;
|
||||
r.titleEn = fallback.titleEn;
|
||||
used.push("title");
|
||||
}
|
||||
}
|
||||
if (r.year === undefined && fallback.year !== undefined) {
|
||||
r.year = fallback.year;
|
||||
used.push("year");
|
||||
}
|
||||
if ((r.season === undefined || r.seasonInferred) && fallback.season !== undefined && !fallback.seasonInferred) {
|
||||
r.season = fallback.season;
|
||||
r.seasonInferred = undefined;
|
||||
if (!r.isFile && fallback.seasonEnd !== undefined) r.seasonEnd = fallback.seasonEnd;
|
||||
used.push("season");
|
||||
}
|
||||
const fill = <K extends keyof ParsedRelease>(k: K) => {
|
||||
if (r[k] === undefined && fallback[k] !== undefined) {
|
||||
r[k] = fallback[k];
|
||||
used.push(String(k));
|
||||
}
|
||||
};
|
||||
for (const k of [
|
||||
"resolution",
|
||||
"source",
|
||||
"videoCodec",
|
||||
"encoder",
|
||||
"bitDepth",
|
||||
"releaseGroup",
|
||||
"cut",
|
||||
"webSource",
|
||||
"uhd",
|
||||
"fps",
|
||||
"subtitles",
|
||||
] as const)
|
||||
fill(k);
|
||||
if (used.includes("source")) r.isEncode = fallback.isEncode;
|
||||
if (r.audio.length === 0 && fallback.audio.length > 0) {
|
||||
r.audio = [...fallback.audio];
|
||||
used.push("audio");
|
||||
}
|
||||
if (r.hdr.length === 0 && fallback.hdr.length > 0) {
|
||||
r.hdr = [...fallback.hdr];
|
||||
used.push("hdr");
|
||||
}
|
||||
if (r.explicitIds.length === 0 && fallback.explicitIds.length > 0) {
|
||||
r.explicitIds = [...fallback.explicitIds];
|
||||
used.push("explicitIds");
|
||||
}
|
||||
if (r.contentType === "unknown" && fallback.contentType !== "unknown") {
|
||||
r.contentType = fallback.contentType;
|
||||
used.push("contentType");
|
||||
}
|
||||
if (fbTv) r.mediaTypeHint = "tv";
|
||||
for (const u of used) r.evidence.push(`${level}:${u}`);
|
||||
|
||||
if (r.isFile) {
|
||||
r.packHint = filePackHint(r);
|
||||
r.isSeasonPack = false;
|
||||
}
|
||||
if (deviation && used.length > 0) r.evidence.push(`${level}:type_inferred`);
|
||||
if (primary.year !== undefined && fallback.year !== undefined && primary.year !== fallback.year) {
|
||||
// 电影合集年份冲突:以文件为准,但需要确认
|
||||
r.evidence.push(`${level}:conflict_year`);
|
||||
}
|
||||
r.confidence = mergedConfidence(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录名 + 文件名合并识别。纯函数,不访问文件系统;最多看两级上级目录。
|
||||
* 附加内容目录(Featurettes/Extras/花絮…)不参与命名,结果标记 contentType=extra。
|
||||
*/
|
||||
export function parseReleasePath(path: string | PathSegments, opts: ParseOptions = {}): ParsedRelease {
|
||||
const seg = typeof path === "string" ? splitReleasePath(path) : path;
|
||||
const { subtitle: _subtitle, ...dirOpts } = opts;
|
||||
let r = parseRelease(seg.file, { ...opts, isFile: true });
|
||||
if (seg.parent) r = mergeParsed(r, parseRelease(seg.parent, { ...dirOpts, isFile: false }), "parent_dir");
|
||||
if (seg.grandParent)
|
||||
r = mergeParsed(r, parseRelease(seg.grandParent, { ...dirOpts, isFile: false }), "grandparent_dir");
|
||||
const extras = typeof path === "string" ? splitReleasePath(path) : undefined;
|
||||
if (extras?.extrasDir && seg.parent) {
|
||||
// 附加内容归属父作品:名称取父目录,文件自身名称不参与身份识别
|
||||
const owner = parseRelease(seg.parent, { ...dirOpts, isFile: false });
|
||||
r.title = owner.title;
|
||||
r.titleCn = owner.titleCn;
|
||||
r.titleEn = owner.titleEn;
|
||||
r.contentType = "extra";
|
||||
r.evidence.push("dir:extras");
|
||||
r.confidence = "needs_confirm";
|
||||
}
|
||||
if (extras?.sampleDir) r.evidence.push("dir:sample");
|
||||
return r;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 发布组种子数据(本项目自维护,按站点分组,只是组名事实数据)。
|
||||
* 设置表可通过 ParseOptions.releaseGroups 扩展。
|
||||
*/
|
||||
export const RELEASE_GROUP_SEED: Readonly<Record<string, readonly string[]>> = {
|
||||
mteam: ["MTeam", "MTeamTV", "MPAD", "MWeb", "TnP"],
|
||||
audiences: ["Audies", "ADWeb", "ADE", "ADAudio", "ADMusic", "ADEbook"],
|
||||
hhanclub: ["HHWEB", "HHCLUB"],
|
||||
chdbits: ["CHD", "CHDBits", "CHDWEB", "CHDPAD", "CHDTV", "CHDHKTV"],
|
||||
ourbits: ["OurBits", "OurTV", "iLoveTV", "iLoveHD", "FLTTH"],
|
||||
hdsky: ["HDSky", "HDS", "HDSWEB", "HDSTV", "HDSPad"],
|
||||
pthome: ["PTHome", "PTH", "PTHweb"],
|
||||
cmct: ["CMCT", "CMCTV"],
|
||||
frds: ["FRDS"],
|
||||
ttg: ["TTG", "WiKi", "NGB", "DoA"],
|
||||
hdchina: ["HDChina", "HDC"],
|
||||
beitai: ["BeiTai"],
|
||||
international: [
|
||||
"FraMeSToR",
|
||||
"SPARKS",
|
||||
"FLUX",
|
||||
"NTb",
|
||||
"CtrlHD",
|
||||
"EbP",
|
||||
"HiFi",
|
||||
"BHDStudio",
|
||||
"VARYG",
|
||||
"ROVERS",
|
||||
"EPSiLON",
|
||||
"TEPES",
|
||||
"playWEB",
|
||||
"HONE",
|
||||
"BLUTONiUM",
|
||||
"DON",
|
||||
"decibeL",
|
||||
"ZQ",
|
||||
"KiNGS",
|
||||
"SMURF",
|
||||
"CiNEPHiLES",
|
||||
],
|
||||
anime: [
|
||||
"LoliHouse",
|
||||
"喵萌奶茶屋",
|
||||
"Nekomoe kissaten",
|
||||
"ANi",
|
||||
"Lilith-Raws",
|
||||
"VCB-Studio",
|
||||
"桜都字幕组",
|
||||
"北宇治字幕组",
|
||||
"SweetSub",
|
||||
"Sakurato",
|
||||
],
|
||||
};
|
||||
|
||||
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
/** 构造发布组匹配正则:前界定符 - @ [ 【 & ,后界定符 结尾 @ . 空白 ] [ 】 & */
|
||||
export function buildGroupRegex(extra: readonly string[] = []): RegExp {
|
||||
const all = [...Object.values(RELEASE_GROUP_SEED).flat(), ...extra];
|
||||
// 长名优先,避免 CHD 抢先匹配 CHDWEB
|
||||
const uniq = [...new Set(all)].sort((a, b) => b.length - a.length).map(escapeRe);
|
||||
return new RegExp(`(?<=[-@\\[【&])(?:${uniq.join("|")})(?=$|[@.\\s\\]\\[】&])`, "giu");
|
||||
}
|
||||
|
||||
let cached: RegExp | undefined;
|
||||
export function defaultGroupRegex(): RegExp {
|
||||
cached ??= buildGroupRegex();
|
||||
return cached;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/** 解析器版本:规则变化时递增,缓存/识别结果按此失效 */
|
||||
export const PARSER_VERSION = 1;
|
||||
|
||||
export type Resolution = "4320p" | "2160p" | "1440p" | "1080p" | "1080i" | "720p" | "576p" | "480p";
|
||||
|
||||
/** 交付来源。BluRay = 蓝光压制(Encode/BDRip),BluRay-Disc = 原盘 */
|
||||
export type ReleaseSource =
|
||||
| "REMUX"
|
||||
| "BluRay"
|
||||
| "BluRay-Disc"
|
||||
| "WEB-DL"
|
||||
| "WEBRip"
|
||||
| "HDTV"
|
||||
| "HDRip"
|
||||
| "DVD"
|
||||
| "DVDRip";
|
||||
|
||||
export type VideoCodec = "H.264" | "H.265" | "AV1" | "VC-1" | "MPEG-2" | "XviD" | "DivX";
|
||||
|
||||
export type HdrFormat = "DV" | "HDR10" | "HDR10+" | "HLG" | "HDR Vivid";
|
||||
|
||||
/** 内容版本(剪辑版),与交付版本分开(REQ §6.1) */
|
||||
export type Cut =
|
||||
| "Extended"
|
||||
| "Director's Cut"
|
||||
| "Theatrical"
|
||||
| "Unrated"
|
||||
| "Uncut"
|
||||
| "IMAX"
|
||||
| "Remastered"
|
||||
| "Criterion"
|
||||
| "Final Cut"
|
||||
| "Ultimate"
|
||||
| "Special Edition";
|
||||
|
||||
/** 综艺内容类型(REQ §5.1);v0.1 只识别标记,不做分段建模 */
|
||||
export type ContentType =
|
||||
| "unknown"
|
||||
| "main"
|
||||
| "extra"
|
||||
| "behind_the_scenes"
|
||||
| "performance_only"
|
||||
| "preview"
|
||||
| "reaction";
|
||||
|
||||
export type Segment = "full" | "upper" | "middle" | "lower";
|
||||
|
||||
export type Confidence = "rule_confirmed" | "multi_evidence" | "needs_confirm";
|
||||
|
||||
export type PackHint = "single" | "multi_episode" | "season_pack" | "multi_season_pack" | "unknown";
|
||||
|
||||
export type FileKind = "video" | "subtitle" | "audio" | "nfo" | "image" | "other";
|
||||
|
||||
export interface EpisodeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface ReleaseGroup {
|
||||
name: string;
|
||||
/** known = 内置/自定义表命中;suffix = 末段 -GROUP 兜底;bracket = 开头方括号(动漫字幕组) */
|
||||
source: "known" | "suffix" | "bracket";
|
||||
}
|
||||
|
||||
export interface ExplicitId {
|
||||
provider: "tmdb" | "imdb" | "douban" | "tvdb";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ParsedRelease {
|
||||
parserVersion: number;
|
||||
/** 原始输入(文件名含扩展名) */
|
||||
raw: string;
|
||||
/** 主展示名:有中文名取中文,否则英文 */
|
||||
title?: string;
|
||||
titleCn?: string;
|
||||
titleEn?: string;
|
||||
year?: number;
|
||||
season?: number;
|
||||
seasonEnd?: number;
|
||||
/** 季号不是从文本读出,而是按"有集号无季号 → 第 1 季"推定 */
|
||||
seasonInferred?: boolean;
|
||||
episodes?: EpisodeRange;
|
||||
totalEpisodes?: number;
|
||||
/** 综艺播出日期 YYYYMMDD */
|
||||
airDate?: string;
|
||||
/** 综艺来源期号(第N期),与 episode 分开 */
|
||||
issue?: number;
|
||||
resolution?: Resolution;
|
||||
source?: ReleaseSource;
|
||||
/** 蓝光二次压制(非原盘、非 Remux) */
|
||||
isEncode: boolean;
|
||||
/** 标题里出现 UHD 字样 */
|
||||
uhd?: boolean;
|
||||
videoCodec?: VideoCodec;
|
||||
/** 可识别的编码器(x264/x265 小写即压制) */
|
||||
encoder?: "x264" | "x265";
|
||||
bitDepth?: 8 | 10 | 12;
|
||||
audio: string[];
|
||||
hdr: HdrFormat[];
|
||||
fps?: number;
|
||||
webSource?: string;
|
||||
releaseGroup?: ReleaseGroup;
|
||||
cut?: Cut;
|
||||
part?: string;
|
||||
/** 字幕语言(zh / zh-Hans / zh-Hant / en / ja / ko);undefined = 标题无信息(未知),不是"无字幕" */
|
||||
subtitles?: string[];
|
||||
sizeBytes?: number;
|
||||
contentType: ContentType;
|
||||
segment?: Segment;
|
||||
/** 会员版/超前点播等需判断的版本词,原样保留作为证据 */
|
||||
variantTags: string[];
|
||||
mediaTypeHint: "tv" | "unknown";
|
||||
isSeasonPack: boolean;
|
||||
packHint: PackHint;
|
||||
isFile: boolean;
|
||||
fileKind?: FileKind;
|
||||
extension?: string;
|
||||
/** 文件名只含"简体/双语"之类辅助词,不能用于识别身份 */
|
||||
auxiliaryOnly?: boolean;
|
||||
explicitIds: ExplicitId[];
|
||||
confidence: Confidence;
|
||||
/** 命中的规则名 */
|
||||
evidence: string[];
|
||||
}
|
||||
|
||||
export interface ParseOptions {
|
||||
/** 站点副标题,只补主标题缺失的季集信息 */
|
||||
subtitle?: string;
|
||||
/** 附加发布组(设置表扩展) */
|
||||
releaseGroups?: string[];
|
||||
/** 集号偏移表达式,如 `EP-12`、`EP*2`、`+1`(白名单解析,不用 eval) */
|
||||
episodeOffset?: string;
|
||||
/** 强制视为文件(无扩展名时) */
|
||||
isFile?: boolean;
|
||||
}
|
||||
Reference in new issue
Block a user