标题写成 `Bluray H.264` 这类压制字样、但副标题写「自购自抓自扫」「BDMV」「原盘」「DIY」 「Blu-ray Disc」的,或带 LPCM / PCM 音轨又没有 x264 / x265 / BDRip / 压制说法的,判为原盘。 只在来源是 BluRay(或没写来源但有分辨率)时补判;「原盘压制」「自压」、WEB-DL、Remux、单个视频文件不受影响。 用「药屋少女的呢喃」真实搜索结果补正例与反例。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
1238 lines
44 KiB
TypeScript
1238 lines
44 KiB
TypeScript
import { CN_NUM, cnToInt } from "./cn-number.ts";
|
||
import { evalEpisodeOffset } from "./offset.ts";
|
||
import { PLATFORMS } from "./platforms.ts";
|
||
import { buildGroupRegex, defaultGroupRegex } from "./release-groups.ts";
|
||
import { splitEmbeddedDescription, subtitleTitles } from "./subtitle-titles.ts";
|
||
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.ts";
|
||
|
||
// ---------------------------------------------------------------- 扩展名
|
||
|
||
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;
|
||
/** 标题里有 BDRip / BRRip(压制) */
|
||
_rip?: boolean;
|
||
/** `{[k=v;…]}` 显式身份块里的季/集/类型,finalize 时覆盖推断值 */
|
||
_explicit?: { season?: number; seasonEnd?: number; episodes?: EpisodeRange; tv?: 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;
|
||
/** 开头方括号里明显是组名的写法:字幕组/汉化组/Raws 等后缀,或 `YTS.MX`、`Hall_of_C` 这种单个点/下划线连接的词元 */
|
||
const GROUPISH_BRACKET =
|
||
/(?:字幕[组組社]|[汉漢]化[组組]|[发發][布佈][组組]|[压壓]制[组組]|工作室|搬运[组組]?|Raws?|Subs?|Studio|Fansubs?|Team)$|^(?=.{3,16}$)[A-Za-z0-9]+(?:[._][A-Za-z0-9]+){1,2}$/iu;
|
||
|
||
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);
|
||
// `[片名][01]…`:开头方括号紧跟集号,它就是片名
|
||
if (/^\s*[[【]\d{1,3}(?:v\d)?[\]】]/u.test(rest)) return { s };
|
||
// `[中文片名][English Title]…[Group]`:开头是纯中文片名,组名取末尾方括号
|
||
if (/^\p{Script=Han}+$/u.test(content) && !GROUPISH_BRACKET.test(content)) {
|
||
const tail = /[[【]([A-Za-z][A-Za-z0-9-]{1,23})[\]】]\s*$/u.exec(rest);
|
||
if (tail && !TECH_IN_BRACKET.test(tail[1]!) && !GROUP_SUFFIX_EXCLUDE.test(tail[1]!)) {
|
||
return {
|
||
s: s.slice(0, s.length - tail[0].length),
|
||
group: { name: tail[1]!, source: "bracket" },
|
||
ev: "group:bracket_tail",
|
||
};
|
||
}
|
||
}
|
||
if (
|
||
!TECH_IN_BRACKET.test(content) &&
|
||
(/[[【]/u.test(rest) || /\s-\s\d{1,3}\b/.test(rest) || GROUPISH_BRACKET.test(content))
|
||
) {
|
||
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;
|
||
// 标题开头的日期(`2022.8.2.Twelve.Monkeys.1995`)是发布/收藏日期,不是播出日期
|
||
if (/^[\s[【(]*$/u.test(s.slice(0, m.index))) return { s: s.slice(m.index + m[0].length) };
|
||
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[];
|
||
audioLangs?: 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;
|
||
|
||
/** 配音语言标记(国语 / 粤语 / 国英双语…)→ cmn / yue / en */
|
||
export function scanAudioLangs(text: string): string[] {
|
||
const out: string[] = [];
|
||
for (const m of text.matchAll(AUDIO_LANG_RE)) {
|
||
const t = m[0];
|
||
if (/[国國]/u.test(t) && !out.includes("cmn")) out.push("cmn");
|
||
if (/[粤粵]/u.test(t) && !out.includes("yue")) out.push("yue");
|
||
if (/英/u.test(t) && !out.includes("en")) out.push("en");
|
||
}
|
||
return out;
|
||
}
|
||
|
||
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");
|
||
});
|
||
const dubs = scanAudioLangs(s);
|
||
if (dubs.length) {
|
||
out.audioLangs = dubs;
|
||
out.evidence.push("audio_lang: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(?![A-Za-z])/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(/\b(EP?)(\d{1,4})-(?:EP?)?(\d{1,4})\b/gi, " $1$2 $1$3 ")
|
||
.replace(/\bS(\d{1,2})\s+(\d{1,3})-(\d{1,3})(?![\d.])/gi, " S$1 E$2 E$3 ")
|
||
.replace(/\bB-Global\b/gi, " BGLOBAL ")
|
||
.replace(/[[【]\s*(4320|2160|1440|1080|720|576|480)\s*[\]】]/gu, " $1p ")
|
||
.replace(/[[【(]\s*([2-9])\.([0-2])\s*[\]】)]/gu, " CH$1$2 ")
|
||
// 汉字之间夹单个拉丁字母(哆啦A梦)不拆开
|
||
.replace(/(\p{Script=Han})([A-Za-z])(?!\p{Script=Han})/gu, "$1 $2")
|
||
.replace(/(?<!\p{Script=Han})([A-Za-z])(\p{Script=Han})/gu, "$1 $2")
|
||
);
|
||
}
|
||
|
||
const TOKEN_SPLIT = /[\s._[\]()【】/~~;&|#「」,,()::"!!??、+\-《》£]+/u;
|
||
|
||
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[] = [];
|
||
/** 纯假名词元(日文原名):不混进英文名,英文名为空时才用 */
|
||
const ja: string[] = [];
|
||
let lastTitleList: string[] | undefined;
|
||
// 原盘判定:Blu-ray 字样 + AVC/HEVC/VC-1/MPEG-2 且没有 x26x/H.26x(对照 MP 内置规则 BLU)
|
||
let blurayWord = false;
|
||
let discCodec = false;
|
||
let encodeCodec = false;
|
||
let ripWord = false;
|
||
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 || ja.length > 0;
|
||
const pushTitle = (t: string) => {
|
||
const list = /\p{Script=Han}/u.test(t)
|
||
? cn
|
||
: /^\d+$/.test(t)
|
||
? (lastTitleList ?? en)
|
||
: /^[\p{Script=Hiragana}\p{Script=Katakana}ー・]+$/u.test(t)
|
||
? ja
|
||
: 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;
|
||
}
|
||
// `Season 2017` 是片名 + 年份(The Long Season 2017),季号最多 3 位
|
||
if ((u === "SEASON" || u === "EPISODE") && next && /^\d{1,3}$/.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";
|
||
if (u === "BLURAY") blurayWord = true;
|
||
else if (u !== "BD") ripWord = true;
|
||
} 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";
|
||
if (u === "AVC" || u === "HEVC" || u === "VC1" || u === "MPEG2") discCodec = true;
|
||
else if (/^[XH]26[45]$/.test(u)) encodeCodec = true;
|
||
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) {
|
||
const last = d.audio.length - 1;
|
||
// 裸声道 `[5.1]`:没有音频编码时单独记一条
|
||
if (last < 0) d.audio.push(`${m[1]}.${m[2]}`);
|
||
else if (!/\d\.\d$/.test(d.audio[last]!)) d.audio[last] = `${d.audio[last]} ${m[1]}.${m[2]}`;
|
||
stop("audio");
|
||
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 (u === "CHS" || u === "GB") {
|
||
d.subtitles = [...new Set([...(d.subtitles ?? []), "zh-Hans"])];
|
||
stop("subtitle");
|
||
continue;
|
||
}
|
||
if (u === "CHT" || u === "BIG5") {
|
||
d.subtitles = [...new Set([...(d.subtitles ?? []), "zh-Hant"])];
|
||
stop("subtitle");
|
||
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) {
|
||
// 「三部曲合集」「合集」是打包说明,不是片名的一部分
|
||
const name = t.length > 2 ? t.replace(/合集$/u, "") : t;
|
||
cn.push(name);
|
||
lastTitleList = cn;
|
||
}
|
||
continue;
|
||
}
|
||
pushTitle(t);
|
||
}
|
||
if (en.length === 0 && ja.length > 0) en.push(...ja);
|
||
const enTitle = en.join(" ").trim();
|
||
const cnTitle = cn.join(" ").trim();
|
||
if (enTitle) d.titleEn = enTitle;
|
||
if (cnTitle) d.titleCn = cnTitle;
|
||
if (ripWord) d._rip = true;
|
||
if (d._remux) d.source = "REMUX";
|
||
else if (d._disc) d.source = "BluRay-Disc";
|
||
else if (blurayWord && discCodec && !encodeCodec && !ripWord && !d.encoder && sources.size === 1) {
|
||
d.source = "BluRay-Disc";
|
||
ev.push("source:disc_codec");
|
||
} else {
|
||
for (const s of ["BluRay", "WEB-DL", "WEBRip", "HDTV", "HDRip", "DVDRip", "DVD"] as const) {
|
||
if (sources.has(s)) {
|
||
d.source = s;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 预处理
|
||
|
||
const BRACKET_DOT_TITLE =
|
||
/^\s*\[([A-Za-z0-9][^\s[\]]*?\.(?:19|20)\d{2}\.[^\s[\]]*?\b(?:\d{3,4}p|Blu-?Ray|WEB-?DL|WEBRip|HDTV|REMUX|UHD)\b[^\s[\]]*)\]/i;
|
||
const ANIME_TAG_CORE =
|
||
"(?:(?:\\d{2,4}\\s*年\\s*)?(?:\\d{1,2}\\s*月\\s*)?(?:新番|番剧|番)|(?:\\d{2,4}\\s*年\\s*)?[日美国國韩韓港台][漫剧劇])";
|
||
const ANIME_TAG_BRACKET = new RegExp(`[[【]\\s*★?\\s*${ANIME_TAG_CORE}\\s*★?\\s*[\\]】]`, "gu");
|
||
const ANIME_TAG_BARE = new RegExp(`★\\s*${ANIME_TAG_CORE}\\s*★?|(?<![\\p{Script=Han}\\d])\\d{1,2}月新番★?`, "gu");
|
||
/** 开头方括号是分类词(动画/电影/纪录片/Animation/Movie…),MP 同样整块去掉 */
|
||
const CATEGORY_LEAD =
|
||
/^\s*[[【](?:[^\]】]*?(?:Animations?|Movies?|Documentar(?:y|ies)|Anime)\b[^\]】]*|[动動漫画畫纪紀录錄片电電影视視连連续續剧劇集日美韩韓中港台海外亚亞洲华華语語大陆陸综綜艺藝原盘盤高清]{2,})[\]】]\s*(?=[[【])/iu;
|
||
/** 开头方括号只有一个分类词(RSS 标题 `[动漫]Name S01 …`、`[电视剧]…`),后面直接接片名 */
|
||
const CATEGORY_WORD_LEAD =
|
||
/^\s*[[【](动漫|動漫|动画|動畫|电影|電影|电视剧|電視劇|剧集|劇集|综艺|綜藝|纪录片|紀錄片|短剧|短劇|体育|體育|音乐|音樂|MV|游戏|遊戲|软件|軟體|学习|學習|其他|其它)[\]】]\s*/u;
|
||
const CN_ALIAS_SLASH =
|
||
/(?<=^|[\s\]】])[^\s/[\]【】]*\p{Script=Han}[^\s/[\]【】]*\s*\/\s*(?=[^\s/[\]【】]*\p{Script=Han})/u;
|
||
|
||
function addExplicit(d: Draft, x: ExplicitId): void {
|
||
if (x.id === "0" || d.explicitIds.some((e) => e.provider === x.provider)) return;
|
||
d.explicitIds.push(x);
|
||
d.evidence.push(`explicit:${x.provider}`);
|
||
}
|
||
|
||
const BLOCK_PROVIDERS: Record<string, ExplicitId["provider"]> = {
|
||
tmdbid: "tmdb",
|
||
tmdb: "tmdb",
|
||
doubanid: "douban",
|
||
douban: "douban",
|
||
imdbid: "imdb",
|
||
imdb: "imdb",
|
||
tvdbid: "tvdb",
|
||
tvdb: "tvdb",
|
||
bangumiid: "bangumi",
|
||
bangumi: "bangumi",
|
||
anilistid: "anilist",
|
||
anilist: "anilist",
|
||
};
|
||
|
||
/** `{[tmdbid=94997;type=tv;s=1;e=3-4;g=…]}`:返回 ID 与季/集/类型;没有任何可识别键时返回 undefined(原样保留) */
|
||
function parseExplicitBlock(body: string): { ids: ExplicitId[]; meta: NonNullable<Draft["_explicit"]> } | undefined {
|
||
const ids: ExplicitId[] = [];
|
||
const meta: NonNullable<Draft["_explicit"]> = {};
|
||
let hit = false;
|
||
const range = (v: string) => {
|
||
const m = /^(\d{1,4})(?:\s*-\s*(\d{1,4}))?$/.exec(v.trim());
|
||
return m ? { start: Number(m[1]), end: Number(m[2] ?? m[1]) } : undefined;
|
||
};
|
||
for (const part of body.split(";")) {
|
||
const kv = /^\s*([A-Za-z]+)\s*=\s*(.*?)\s*$/.exec(part);
|
||
if (!kv) continue;
|
||
const key = kv[1]!.toLowerCase();
|
||
const val = kv[2]!;
|
||
const provider = BLOCK_PROVIDERS[key];
|
||
if (provider) {
|
||
if (/^[A-Za-z0-9]+$/.test(val)) ids.push({ provider, id: val });
|
||
hit = true;
|
||
} else if (key === "type") {
|
||
if (/^(tv|电视剧|電視劇)$/i.test(val)) meta.tv = true;
|
||
hit = true;
|
||
} else if (key === "s") {
|
||
const r = range(val);
|
||
if (r) {
|
||
meta.season = r.start;
|
||
if (r.end > r.start) meta.seasonEnd = r.end;
|
||
}
|
||
hit = true;
|
||
} else if (key === "e") {
|
||
const r = range(val);
|
||
if (r) meta.episodes = r;
|
||
hit = true;
|
||
} else if (key === "g") hit = true;
|
||
}
|
||
return hit ? { ids, meta } : undefined;
|
||
}
|
||
|
||
/** 副标题里的中文名能否当主中文名:去掉季号后不含空白分隔的多段汉字、不是句子、长度合理;返回去掉季号的名字 */
|
||
function cnTitleFromSubtitle(t: string): string | undefined {
|
||
const core = t.replace(new RegExp(`\\s*第\\s*${CN_NUM}\\s*[季部]\\s*$`, "u"), "").trim();
|
||
if (!core || [...core].length > 24) return undefined;
|
||
if (/\p{Script=Han}\s+\p{Script=Han}/u.test(core)) return undefined;
|
||
return /[。??]/u.test(core) ? undefined : core;
|
||
}
|
||
|
||
/** 常见拼音音节(不含声调);用于判断英文名是否其实是拼音 */
|
||
const PINYIN_SYLLABLE =
|
||
/^(?:[bpmfdtnlgkhjqxrzcsyw]|zh|ch|sh)?(?:iang|iong|uang|ueng|ang|eng|ing|ong|ian|iao|uai|uan|ai|ei|ao|ou|an|en|in|un|ia|ie|iu|ua|uo|ui|ue|er|a|o|e|i|u|v)$/i;
|
||
|
||
// ---------------------------------------------------------------- 主入口
|
||
|
||
/**
|
||
* 解析发布标题 / 文件名 / 目录名(纯函数)。
|
||
* 与 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;
|
||
|
||
// 1b. RSS 标题末尾内嵌的站点描述 `…-GRP[中文名 / … | 第N集 | …][体积]`:拆出来当副标题用
|
||
let subtitle = opts.subtitle;
|
||
if (!kind) {
|
||
const emb = splitEmbeddedDescription(s);
|
||
if (emb.description) {
|
||
s = emb.title;
|
||
subtitle = subtitle ? `${emb.description} | ${subtitle}` : emb.description;
|
||
ev.push("subtitle:embedded");
|
||
}
|
||
}
|
||
|
||
// 2. 显式身份标签:先取 `{[k=v;…]}` 块(季/集/类型一并给出),再取单个 `[tmdbid=…]`
|
||
s = s.replace(/\{\[([^\]{}]*)\]\}/g, (all, body: string) => {
|
||
const block = parseExplicitBlock(body);
|
||
if (!block) return all;
|
||
for (const x of block.ids) addExplicit(d, x);
|
||
d._explicit = block.meta;
|
||
ev.push("explicit:block");
|
||
return " ";
|
||
});
|
||
s = s.replace(
|
||
/[[{]\s*(tmdbid|tmdb|imdbid|imdb|doubanid|douban|tvdbid|tvdb|bangumiid|bangumi|anilistid|anilist)\s*[=-]\s*([A-Za-z0-9]+)\s*[\]}]/gi,
|
||
(_all, key: string, id: string) => {
|
||
addExplicit(d, { provider: key.toLowerCase().replace(/id$/, "") as ExplicitId["provider"], id });
|
||
return " ";
|
||
},
|
||
);
|
||
|
||
// 2b. 开头方括号里是完整的点分隔发布名(`[Name.2023.2160p.UHD.Blu-ray…-GRP][DIY中字原盘]…`):去括号保留内容
|
||
s = s.replace(BRACKET_DOT_TITLE, " $1 ");
|
||
// 2c. 动漫前缀标签(★7月新番★、[国漫]、【22年日剧】、[2022年7月番])与开头的分类方括号([Animations(动画片)])
|
||
s = s.replace(ANIME_TAG_BRACKET, " ").replace(ANIME_TAG_BARE, " ");
|
||
s = s.replace(CATEGORY_LEAD, "").replace(CATEGORY_WORD_LEAD, (_all, c: string) => {
|
||
d.category = c;
|
||
ev.push("category:lead");
|
||
return "";
|
||
});
|
||
// 2d. `中文名A / 中文名B`:同一作品的两个中文译名只留后一个(与 MP 取法一致),避免拼成一个名字
|
||
s = s.replace(CN_ALIAS_SLASH, "");
|
||
|
||
// 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 "\uE001";
|
||
});
|
||
}
|
||
// `Caligula.The.Ultimate.Cut` 去掉剪辑版本后,紧挨着的冠词 The 一并去掉
|
||
s = s.replace(/\bThe[ .\-_]*\uE001/gi, " ").replace(/\uE001/g, " ");
|
||
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]"、"[TV 04]"、"#13"
|
||
s = s.replace(
|
||
/[[【]\s*TV\s*(\d{1,4})(?:\s*[-~]\s*(\d{1,4}))?(\s*(?:Fin|END|完))?\s*[\]】]/iu,
|
||
(all, a: string, b, fin) => {
|
||
if (d.episodes) return all;
|
||
d.episodes = { start: Number(a), end: Number(b ?? a) };
|
||
ev.push("episode:bracket_tv");
|
||
if (fin) {
|
||
d._fin = true;
|
||
ev.push("pack:fin");
|
||
}
|
||
return MARK_HARD;
|
||
},
|
||
);
|
||
s = s.replace(/(?<=\S\s+)#(\d{1,4})(?=\s|[[((]|$)/u, (all, n: string) => {
|
||
if (d.episodes) return all;
|
||
d.episodes = { start: Number(n), end: Number(n) };
|
||
ev.push("episode:hash");
|
||
return MARK_HARD;
|
||
});
|
||
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;
|
||
});
|
||
// `Travis - 12 Memories`:数字后紧跟一个普通单词时是片名的一部分,不是集号
|
||
s = s.replace(
|
||
/\s-\s(\d{1,3})(?:v\d)?(?=\s|\[|【|$)(?!\s+(?!(?:END|Fin|Final|RAW)\b)[A-Za-z][a-z]+\b)/u,
|
||
(_all, n: string) => {
|
||
if (!d.episodes) {
|
||
d.episodes = { start: Number(n), end: Number(n) };
|
||
ev.push("episode:anime_dash");
|
||
}
|
||
return MARK_HARD;
|
||
},
|
||
);
|
||
// `[Group][Name][05]`、`Name [01][1080p]`:名称后的方括号纯数字是集号
|
||
s = s.replace(/(?<=\S\s*)[[【](\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;
|
||
});
|
||
// 文件名 `西部世界 12`:中文名后空格加 2~3 位数字
|
||
if (d.isFile && !d.episodes) {
|
||
s = s.replace(/(?<=\p{Script=Han})\s+(\d{2,3})\s*$/u, (_all, n: string) => {
|
||
d.episodes = { start: Number(n), end: Number(n) };
|
||
ev.push("episode:cn_name_number");
|
||
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 (subtitle) {
|
||
// 英文名其实是拼音(Mai Xiang ↔ 麦香):中文名取副标题第一个词
|
||
if (!d.titleCn && d.titleEn) {
|
||
const words = d.titleEn.split(/\s+/);
|
||
const first = /^\s*(\p{Script=Han}{2,12})(?=[\s||/,,]|$)/u.exec(subtitle)?.[1];
|
||
if (first && words.length === [...first].length && words.every((w) => PINYIN_SYLLABLE.test(w))) {
|
||
d.titleCn = first;
|
||
ev.push("subtitle:title_cn_pinyin");
|
||
}
|
||
}
|
||
// 副标题第一段的片名(ID-2):主标题没有中文名时取第一个中文名;其余作为别名参与匹配
|
||
const names = subtitleTitles(subtitle);
|
||
const first = names.find((n) => /\p{Script=Han}/u.test(n));
|
||
const cn = first ? cnTitleFromSubtitle(first) : undefined;
|
||
if (!d.titleCn && cn) {
|
||
d.titleCn = cn;
|
||
ev.push("subtitle:title_cn");
|
||
}
|
||
const alts = names.filter((n) => n !== d.titleCn && n !== d.titleEn).slice(0, 6);
|
||
if (alts.length) d.altTitles = alts;
|
||
const sub = scanCnMarkers(subtitle);
|
||
// 副标题里的拉丁字幕标记 CHS/CHT/GB/BIG5(`60 GB` 这类体积不算)
|
||
for (const m of subtitle.matchAll(/(?<![A-Za-z0-9])(?<!\d\s?)(CHS|CHT|GB|BIG5)(?![A-Za-z0-9])/gi)) {
|
||
const lang = /^(CHS|GB)$/i.test(m[1]!) ? "zh-Hans" : "zh-Hant";
|
||
sub.subtitles = [...new Set([...(sub.subtitles ?? []), lang])];
|
||
if (!sub.evidence.includes("subtitle:latin")) sub.evidence.push("subtitle:latin");
|
||
}
|
||
applyCnMarkers(d, sub, "subtitle:");
|
||
if (!d.airDate) {
|
||
const sad = extractAirDate(subtitle);
|
||
if (sad.airDate) {
|
||
d.airDate = sad.airDate;
|
||
ev.push(`subtitle:${sad.ev}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 14. 原盘线索:标题的编码字样不足以判定时,参考副标题 / 简介里的明确说法与无损 PCM 音轨
|
||
refineDisc(d, s, subtitle);
|
||
|
||
return finalize(d, opts);
|
||
}
|
||
|
||
/** 明确说是原盘:原盘(不含「原盘压制」)、BDMV、自购自抓 / 自抓自扫、Blu-ray Disc、DIY(原盘 DIY) */
|
||
const DISC_CUE =
|
||
/原[盘盤](?![\s::]*(?:压|壓|rip|转|轉))|BDMV|自[购購]自抓|自抓自[扫掃]|Blu-?ray[\s.]+Disc|(?<![A-Za-z])DIY(?![A-Za-z])/i;
|
||
/** 明确说是压制或其他来源 */
|
||
const ENCODE_CUE =
|
||
/自[压壓]|[压壓]制|重[编編]码|(?<![A-Za-z])(?:[xX]\.?26[45]|BDRip|BRRip|WEB-?DL|WEBRip|HDTV|Remux)(?![A-Za-z])/i;
|
||
|
||
/**
|
||
* 原盘补判(对照 MP 内置规则 BLU 之外的站点惯例):标题写成 `Bluray H.264` 这类压制字样、但副标题写「自购自抓自扫」,
|
||
* 或带 LPCM / PCM 音轨又没有 x264 / x265 / BDRip / 压制说法的,按原盘。只在来源是 BluRay(或标题没写来源但有分辨率)时补判,
|
||
* WEB / HDTV / Remux 不动。
|
||
*/
|
||
function refineDisc(d: Draft, title: string, subtitle: string | undefined): void {
|
||
// 单个视频文件(mkv / mp4 …)不会是原盘
|
||
if (d.isFile || d._rip || d.encoder || d._remux || d.source === "BluRay-Disc") return;
|
||
if (d.source !== "BluRay" && !(d.source === undefined && d.resolution)) return;
|
||
const text = subtitle ? `${title} | ${subtitle}` : title;
|
||
if (ENCODE_CUE.test(text)) return;
|
||
const pcm = d.source === "BluRay" && d.audio.some((a) => /^L?PCM\b/i.test(a));
|
||
if (DISC_CUE.test(text)) {
|
||
d.source = "BluRay-Disc";
|
||
d.evidence.push("source:disc_hint");
|
||
} else if (pcm) {
|
||
d.source = "BluRay-Disc";
|
||
d.evidence.push("source:disc_pcm");
|
||
}
|
||
}
|
||
|
||
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");
|
||
}
|
||
if (cm.audioLangs) {
|
||
d.audioLangs = [...new Set([...(d.audioLangs ?? []), ...cm.audioLangs])];
|
||
note("audio_lang");
|
||
}
|
||
}
|
||
|
||
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;
|
||
// 显式身份块的季/集最后覆盖(对照 MP _apply_explicit_metainfo)
|
||
const x = d._explicit;
|
||
if (x?.season !== undefined) {
|
||
d.season = x.season;
|
||
d.seasonEnd = x.seasonEnd;
|
||
d.seasonInferred = undefined;
|
||
}
|
||
if (x?.episodes) d.episodes = x.episodes;
|
||
|
||
const tvSignals =
|
||
d.season !== undefined ||
|
||
d.episodes !== undefined ||
|
||
d.airDate !== undefined ||
|
||
d.issue !== undefined ||
|
||
d.totalEpisodes !== undefined ||
|
||
d._explicit?.tv === true;
|
||
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, _rip, _explicit, ...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";
|
||
}
|