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 = {}; 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][] = [ [/(? { 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(/(? { 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(/(? = { 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 = { HDR: "HDR10", HDR10: "HDR10", HDR10PLUS: "HDR10+", DV: "DV", DOVI: "DV", HLG: "HLG", HDRVIVID: "HDR Vivid", VIVID: "HDR Vivid", }; const CODEC_TOKENS: Record = { 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(); 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*★?|(? e.provider === x.provider)) return; d.explicitIds.push(x); d.evidence.push(`explicit:${x.provider}`); } const BLOCK_PROVIDERS: Record = { 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 } | undefined { const ids: ExplicitId[] = []; const meta: NonNullable = {}; 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(/(? { 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(/(? { 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.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(/(? /^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"; }