anipar
Version:
Anime title Parser built for AnimeGarden.
1 lines • 133 kB
Source Map (JSON)
{"version":3,"file":"index.cjs","sources":["../src/context.ts","../src/tokenizer/tokenizer.ts","../src/file.ts","../src/episodes.ts","../src/keyword.ts","../src/title.ts","../src/fansub.ts","../src/parser.ts"],"sourcesContent":["import type { FileInfo, ParseOptions, ParseResult } from './types.js';\n\nimport { Token } from './tokenizer/index.js';\n\n// MARK: 上下文\nexport class Context {\n // Left cursor for consumed tokens (inclusive)\n public left = 0;\n\n // Right cursor for consumed tokens (inclusive)\n public right;\n\n public tokens: Token[];\n\n public options: ParseOptions;\n\n public result: Partial<ParseResult>;\n\n public tags: string[];\n\n public constructor(tokens: Token[], options: ParseOptions) {\n this.tokens = tokens;\n this.options = options;\n this.right = tokens.length - 1;\n this.tags = [];\n this.result = {};\n if (options.fansub) {\n this.result.fansub = { name: options.fansub };\n }\n }\n\n public update<K1 extends keyof ParseResult>(key: K1, value: ParseResult[K1]) {\n this.result[key] = value;\n }\n\n public update2<\n K1 extends keyof Required<ParseResult>,\n K2 extends keyof Required<ParseResult>[K1]\n >(key1: K1, key2: K2, value: Required<ParseResult>[K1][K2]) {\n if (!this.result[key1]) {\n // @ts-expect-error\n this.result[key1] = {};\n }\n // @ts-expect-error\n this.result[key1][key2] = value;\n }\n\n public update3<\n K1 extends keyof Required<ParseResult>,\n K2 extends keyof Required<ParseResult>[K1],\n K3 extends keyof Required<Required<ParseResult>[K1]>[K2]\n >(key1: K1, key2: K2, key3: K3, value: Required<Required<ParseResult>[K1]>[K2][K3]) {\n if (!this.result[key1]) {\n // @ts-expect-error\n this.result[key1] = {};\n }\n // @ts-expect-error\n if (!this.result[key1][key2]) {\n // @ts-expect-error\n this.result[key1][key2] = {};\n }\n // @ts-expect-error\n this.result[key1][key2][key3] = value;\n }\n\n public get hasEpisode() {\n return this.result.episode || this.result.episodes || this.result.episodesRange;\n }\n\n public normalize(): ParseResult | undefined {\n if (this.tags.length > 0) {\n this.result.tags = [...new Set([...(this.result.tags ?? []), ...this.tags])];\n }\n\n if (this.result.subtitle?.languages) {\n const languages = this.result.subtitle?.languages;\n this.result.subtitle.languages = normalizeLanguages(languages);\n }\n\n if (this.result.subtitle?.format) {\n this.result.subtitle.format = normalizeSubtitleFormat(this.result.subtitle.format);\n }\n\n if (this.result.subtitle?.encoding) {\n this.result.subtitle.encoding = normalizeSubtitleEncoding(this.result.subtitle.encoding);\n }\n\n if (this.result.subtitle?.encodings) {\n this.result.subtitle.encodings = normalizeSubtitleEncodings(this.result.subtitle.encodings);\n }\n\n if (this.result.file) {\n normalizeFileInfo(this.result.file);\n }\n\n if (this.result.variants) {\n this.result.variants = [...new Set(this.result.variants)];\n }\n\n if (this.result.title && this.result.title.length > 0) {\n return this.result as ParseResult;\n }\n\n return undefined;\n }\n}\n\n// MARK: 字幕类型归一化\n\nfunction normalizeSubtitleFormat(format: string): string {\n const trimmed = format.trim();\n const upper = normalizeUpperTag(trimmed);\n\n // 字幕类型使用简体中文的常用发布术语;ASS/SRT 是字幕格式名,保留标准大写。\n //\n // @example \"HARDSUB\" -> \"内嵌字幕\"\n // @example \"SOFTSUB\" -> \"软字幕\"\n // @example \"內封字幕\" -> \"内封字幕\"\n // @example \"SRTx2\" -> \"SRT字幕×2\"\n {\n const res = /^(ASS|SRT)(?:[Xx×](\\d+))?$/i.exec(trimmed);\n if (res) {\n return res[2] ? `${res[1].toUpperCase()}字幕×${res[2]}` : `${res[1].toUpperCase()}字幕`;\n }\n }\n\n if (/^HARDSUBS?$/.test(upper) || /^(内嵌|內嵌)(字幕)?$/.test(trimmed)) {\n return '内嵌字幕';\n }\n\n if (/^SOFTSUBS?$/.test(upper)) {\n return '软字幕';\n }\n\n if (/^(内封|內封)(字幕)?$/.test(trimmed)) {\n return '内封字幕';\n }\n\n if (/^(外挂|外掛)(字幕)?$/.test(trimmed)) {\n return '外挂字幕';\n }\n\n if (/^(内挂|內掛)(字幕)?$/.test(trimmed)) {\n return '内挂字幕';\n }\n\n if (/^(SUB|SUBBED|SUBTITLED)$/.test(upper) || trimmed === '字幕') {\n return '字幕';\n }\n\n return trimmed.replaceAll('內', '内').replaceAll('掛', '挂');\n}\n\nfunction normalizeSubtitleEncoding(encoding: string): string {\n const upper = normalizeUpperTag(encoding);\n\n // Keep release-scene shorthand instead of expanding to a specific code page,\n // because \"GB\" may mean GB2312, GBK, or a broader simplified Chinese package.\n //\n // @example \"gb\" -> \"GB\"\n // @example \"big5\" -> \"BIG5\"\n if (upper === 'GB' || upper === 'BIG5') {\n return upper;\n }\n\n return upper;\n}\n\nfunction normalizeSubtitleEncodings(encodings: string[]): string[] {\n const normalized = new Set(encodings.map(normalizeSubtitleEncoding));\n const order = ['GB', 'BIG5'];\n\n // Multi-encoding subtitle packages use a stable canonical order in snapshots\n // and API output, regardless of source order such as \"BIG5&GB\".\n //\n // @example [\"BIG5\", \"GB\"] -> [\"GB\", \"BIG5\"]\n // @example [\"GB\", \"BIG5\", \"GB\"] -> [\"GB\", \"BIG5\"]\n return [\n ...order.filter((encoding) => normalized.delete(encoding)),\n ...encodings.map(normalizeSubtitleEncoding).filter((encoding) => normalized.delete(encoding))\n ];\n}\n\n// MARK: 媒体信息归一化\n\nfunction normalizeFileInfo(file: FileInfo) {\n if (file.audio) {\n normalizeAudioInfo(file.audio);\n }\n\n if (file.video) {\n normalizeVideoInfo(file.video);\n }\n}\n\nfunction normalizeAudioInfo(audio: NonNullable<FileInfo['audio']>) {\n if (audio.channels) {\n audio.channels = normalizeAudioChannels(audio.channels);\n }\n\n if (audio.codec) {\n audio.codec = normalizeAudioCodec(audio.codec);\n }\n\n if (audio.language) {\n audio.language = normalizeAudioLanguage(audio.language);\n }\n}\n\nfunction normalizeVideoInfo(video: NonNullable<FileInfo['video']>) {\n if (video.bitDepth) {\n video.bitDepth = normalizeBitDepth(video.bitDepth);\n }\n\n if (video.codec) {\n video.codec = normalizeVideoCodec(video.codec);\n }\n\n if (video.enhancement) {\n video.enhancement = normalizeUpperTag(video.enhancement);\n }\n\n if (video.format) {\n video.format = normalizeUpperTag(video.format);\n }\n\n if (video.fps) {\n video.fps = normalizeFrameRate(video.fps);\n }\n\n if (video.quality) {\n video.quality = normalizeUpperTag(video.quality);\n }\n\n if (video.resolution) {\n video.resolution = normalizeVideoResolution(video.resolution);\n }\n}\n\nfunction normalizeUpperTag(value: string): string {\n return value.trim().toUpperCase();\n}\n\nfunction normalizeLowerTag(value: string): string {\n return value.trim().toLowerCase();\n}\n\nfunction normalizeAudioCodec(codec: string): string {\n const upper = normalizeUpperTag(codec);\n\n // Audio codecs follow their common published names. Acronyms stay uppercase,\n // product-style names keep their usual casing, and descriptive tags use lowercase.\n //\n // @example \"EAC3\" -> \"E-AC-3\"\n // @example \"OPUS\" -> \"Opus\"\n // @example \"QAAC\" -> \"qaac\"\n const codecs = new Map([\n ['AAC', 'AAC'],\n ['AC3', 'AC-3'],\n ['AC-3', 'AC-3'],\n ['DTS', 'DTS'],\n ['DTS-ES', 'DTS-ES'],\n ['EAC3', 'E-AC-3'],\n ['E-AC-3', 'E-AC-3'],\n ['EAC3&AAC', 'E-AC-3+AAC'],\n ['FLAC', 'FLAC'],\n ['FLAC/AC3', 'FLAC+AC-3'],\n ['LOSSLESS', 'lossless'],\n ['MP3', 'MP3'],\n ['OGG', 'Ogg'],\n ['OPUS', 'Opus'],\n ['QAAC', 'qaac'],\n ['TRUEHD', 'TrueHD'],\n ['VORBIS', 'Vorbis'],\n ['WAV', 'WAV']\n ]);\n\n return codecs.get(upper) ?? normalizeLowerTag(codec);\n}\n\nfunction normalizeVideoCodec(codec: string): string {\n const upper = normalizeUpperTag(codec);\n\n // Video codecs are normalized to widely used codec-family names from release\n // metadata and media specs.\n //\n // @example \"H.264\" -> \"AVC\"\n // @example \"x265\" -> \"HEVC\"\n // @example \"DIVX5\" -> \"DivX\"\n if (/^(H\\.?264|X\\.?264|AVC)$/.test(upper)) {\n return 'AVC';\n }\n if (/^(H\\.?265|X\\.?265|HEVC2?|HVC1)$/.test(upper)) {\n return 'HEVC';\n }\n if (/^DIVX\\d*$/.test(upper)) {\n return 'DivX';\n }\n if (upper === 'XVID') {\n return 'Xvid';\n }\n if (/^HI10P?$/.test(upper)) {\n return 'Hi10P';\n }\n if (/^HI444P*$/.test(upper)) {\n return upper.replace('HI', 'Hi');\n }\n\n return normalizeLowerTag(codec);\n}\n\nfunction normalizeAudioChannels(channels: string): string {\n const upper = normalizeUpperTag(channels);\n\n // Channel layouts are represented without the optional \"ch\" suffix because\n // \"2.0\" and \"5.1\" are the common layout names in media tooling.\n //\n // @example \"2CH\" -> \"2.0\"\n // @example \"5.1CH\" -> \"5.1\"\n if (upper === '2CH') {\n return '2.0';\n }\n if (upper === '5.1CH') {\n return '5.1';\n }\n\n return upper.replace(/CH$/, '');\n}\n\nfunction normalizeAudioLanguage(language: string): string {\n const upper = normalizeUpperTag(language);\n\n // No formal casing exists for this marker, so use lowercase words.\n //\n // @example \"DUALAUDIO\" -> \"dual audio\"\n // @example \"DUAL AUDIO\" -> \"dual audio\"\n if (upper === 'DUALAUDIO' || upper === 'DUAL AUDIO') {\n return 'dual audio';\n }\n\n return normalizeLowerTag(language);\n}\n\nfunction normalizeBitDepth(bitDepth: string): string {\n const upper = normalizeUpperTag(bitDepth);\n const res = /^(\\d+)[-_ ]?BITS?$/.exec(upper);\n\n // Bit depth is conventionally hyphenated in technical copy.\n //\n // @example \"10BIT\" -> \"10-bit\"\n // @example \"8 bits\" -> \"8-bit\"\n if (res) {\n return `${res[1]}-bit`;\n }\n\n return normalizeLowerTag(bitDepth);\n}\n\nfunction normalizeFrameRate(fps: string): string {\n const upper = normalizeUpperTag(fps);\n const res = /^(\\d+(?:\\.\\d+)?)\\s*FPS$/.exec(upper);\n\n // \"fps\" is a unit-like abbreviation in this schema, so use lowercase.\n //\n // @example \"23.976FPS\" -> \"23.976fps\"\n // @example \"60 fps\" -> \"60fps\"\n return res ? `${res[1]}fps` : normalizeLowerTag(fps);\n}\n\nfunction normalizeVideoResolution(resolution: string): string {\n const trimmed = resolution.trim();\n const upper = trimmed.toUpperCase();\n\n // P-height resolutions keep the conventional lowercase \"p\".\n //\n // @example \"1080P\" -> \"1080p\"\n // @example \"2160p\" -> \"2160p\"\n {\n const res = /^(\\d{3,4})P$/i.exec(trimmed);\n if (res) {\n return `${res[1]}p`;\n }\n }\n\n // Pixel dimensions use a lowercase \"x\" regardless of source separator.\n //\n // @example \"1920X1080\" -> \"1920x1080\"\n // @example \"3840×2160\" -> \"3840x2160\"\n {\n const res = /^(\\d{3,5})[Xx×](\\d{3,5})$/.exec(trimmed);\n if (res) {\n return `${res[1]}x${res[2]}`;\n }\n }\n\n if (/^\\d+K$/.test(upper)) {\n return upper;\n }\n\n return trimmed;\n}\n\n// MARK: 字幕语言归一化\n\nconst NormalizedLanguages = ['简', '繁', '粤', '日', '英', '泰'] as const;\n\nfunction normalizeLanguage(language: string): string[] | undefined {\n const trimmed = language.trim();\n const upper = trimmed.toUpperCase();\n\n if (/^(CN|CHINESE|ZH|中|中文|中字|国语中字|國語中字)$/.test(upper)) {\n return undefined;\n }\n\n const languages: string[] = [];\n const matches = {\n 简: /(^|[^A-Z])CHS($|[^A-Z])|ZH[-_]?HANS|简|簡|简体|簡體|简中|簡中/i.test(trimmed),\n 繁: /(^|[^A-Z])CHT($|[^A-Z])|ZH[-_]?HANT|繁|繁体|繁體|繁中|BIG5/i.test(trimmed),\n 粤: /(^|[^A-Z])YUE($|[^A-Z])|粤|粵|广东话|廣東話|CANTONESE/i.test(trimmed),\n 日: /(^|[^A-Z])(JP|JPN|JA)($|[^A-Z])|日|日本|JAPANESE/i.test(trimmed),\n 英: /(^|[^A-Z])(EN|ENG)($|[^A-Z])|英|ENGLISH/i.test(trimmed),\n 泰: /(^|[^A-Z])(TH|THA)($|[^A-Z])|泰|THAI/i.test(trimmed)\n };\n\n if (/[中华華]/.test(trimmed) && !matches.简 && !matches.繁 && !matches.粤) {\n return undefined;\n }\n\n for (const language of NormalizedLanguages) {\n if (matches[language]) {\n languages.push(language);\n }\n }\n\n return languages.length > 0 ? languages : undefined;\n}\n\nfunction normalizeLanguages(languages: string[]): string[] {\n const normalized = new Set<string>();\n const unknown: string[] = [];\n\n for (const language of languages) {\n const parts = normalizeLanguage(language);\n if (parts) {\n parts.forEach((part) => normalized.add(part));\n } else if (!unknown.includes(language)) {\n unknown.push(language);\n }\n }\n\n return [...NormalizedLanguages.filter((language) => normalized.has(language)), ...unknown];\n}\n","export class Token {\n public readonly text: string;\n\n public readonly left: string | undefined;\n\n public readonly right: string | undefined;\n\n public constructor(text: string, left?: string, right?: string) {\n this.text = text;\n this.left = left;\n this.right = right;\n }\n\n public get isWrapped() {\n return this.left && this.right;\n }\n\n public slice(start: number, end?: number) {\n const text = this.text.slice(start, end);\n return new Token(text, this.left, this.right);\n }\n\n public trim() {\n const text = this.text.trim();\n return new Token(text, this.left, this.right);\n }\n\n public toString() {\n return `${this.left ?? ''}${this.text}${this.right ?? ''}`;\n }\n}\n\nexport const Wrappers = new Map([\n ['[', ']'],\n ['【', '】'],\n ['(', ')'],\n ['(', ')'],\n ['{', '}']\n]);\n\nexport const RevWrappers = new Map([...Wrappers.entries()].map(([k, v]) => [v, k]));\n\nexport function tokenize(text: string) {\n const tokens: Token[] = [];\n\n let cursor = 0;\n let cur = '';\n let left: string | undefined = undefined;\n let right: string | undefined = undefined;\n while (cursor < text.length) {\n const char = text[cursor];\n if (Wrappers.has(char)) {\n if (!left) {\n if (cur) {\n tokens.push(new Token(cur));\n cur = '';\n }\n left = char;\n right = Wrappers.get(char)!;\n } else {\n // nest wrapper\n cur += char;\n }\n } else if (left && right && char === right) {\n tokens.push(new Token(cur, left, right));\n cur = '';\n left = undefined;\n right = undefined;\n } else {\n cur += char;\n }\n cursor += 1;\n }\n if (cur) {\n tokens.push(new Token(cur));\n }\n\n return tokens.filter((t) => t.text);\n}\n","export const FileExtensions = new Set([\n '3GP',\n 'AVI',\n 'DIVX',\n 'FLV',\n 'M2TS',\n 'MKV',\n 'MOV',\n 'MP4',\n 'MPG',\n 'OGM',\n 'RM',\n 'RMVB',\n 'TS',\n 'WEBM',\n 'WMV'\n]);\n\nexport function parseFileExtension(title: string) {\n const match = /\\.([^.\\/\\s]+)\\s*$/.exec(title);\n if (!match) return { title };\n\n const extension = match[1].toUpperCase();\n if (!FileExtensions.has(extension)) return { title };\n\n return {\n title: title.slice(0, match.index).trimEnd(),\n extension\n };\n}\n","import type { Context } from './context.js';\n\nimport { Token } from './tokenizer/index.js';\n\nconst WrappedEpisodeRE1 =\n /^(?<type>TV|OVA|OAD|SP)?(?<ep1>\\d+)(?:\\.(?<sub>\\d))?(?:[vV](?<version>\\d+))?(?:(?:\\s+|_|-)?(?<ep_type>[^\\d集]+))?$|^第(?<ep2>\\d+)[集话話]$|^S(?<season>\\d+)E(?<ep3>\\d+)((?:[_|(])?(?<ep_type>[^\\d][^)]*)(?:))?)?$/;\nconst WrappedEpisodeRE2 = /^(?<ep1>\\d+)(?:\\+(?<type>[^\\d]+)|,|&|、)(?<ep2>\\d+)$/;\nconst WrappedSeasonRE = /^(?:S|Season)(\\d+)\\s*(Fin|End)?$/;\nconst WrappedMovieRE = /^Movie [vV](\\d+)$/;\n\nconst WrappedEpisodesRange1 =\n /^(?<type>TV|OVA|OAD|SP)?(?<ep1>\\d+)(?:\\.(?<sub1>\\d))?[-~](?<ep2>\\d+)(?:\\.(?<sub2>\\d))?\\s*[_]?(?<range_type>.*)$/;\nconst WrappedEpisodesRange2 = /^全(\\d+)集$/;\n\nconst WrappedSeasonsRE = /^S(?<season1>\\d)\\+S(?<season2>\\d)$/;\nconst WrappedSeasonsRangeRE = /^S(?<season1>\\d)-S(?<season2>\\d)$/;\n\nconst WrappedVolumeRE = /^(?:Vol|vol|Volume|volume)\\.?\\s*(?<vol>\\d+)$/;\nconst WrappedVolumesRangeRE =\n /^(?:Vol|vol|Volume|volume)\\.?\\s*(?<vol1>\\d+)-(?<vol2>\\d+)\\s+(?<type>.*)$/;\n\nexport function matchEpiodes(ctx: Context, text: string) {\n text = text.trimEnd();\n\n // 1. Single episode\n {\n const res = WrappedEpisodeRE1.exec(text);\n if (res) {\n const epText = res.groups?.ep1! || res.groups?.ep2! || res.groups?.ep3!;\n const ep = +epText;\n if (!Number.isNaN(ep)) {\n // Handle year: [2024]\n if (1949 <= ep && ep <= 2099 && text === epText && ctx.hasEpisode) {\n ctx.update('year', ep);\n return true;\n }\n\n ctx.update2('episode', 'number', ep);\n\n // 1.5\n if (res.groups?.sub && !Number.isNaN(+res.groups.sub)) {\n ctx.update2('episode', 'numberSub', +res.groups.sub);\n }\n\n // 3v2\n if (res.groups?.version && !Number.isNaN(+res.groups.version)) {\n ctx.update('version', +res.groups.version);\n }\n\n // SP01 OVA01\n if (res.groups?.type) {\n const type = res.groups.type.trim();\n ctx.update('type', type);\n }\n\n // 01 END\n if (res.groups?.ep_type) {\n const type = res.groups.ep_type.trim();\n ctx.update2('episode', 'type', type);\n }\n\n // S01E01\n if (res.groups?.season && !Number.isNaN(+res.groups.season)) {\n const season = +res.groups.season;\n ctx.update2('season', 'number', season);\n }\n\n return true;\n }\n }\n }\n {\n // 06,07 07+ES07\n const res = WrappedEpisodeRE2.exec(text);\n if (res) {\n const ep1 = res.groups?.ep1 ? +res.groups.ep1 : NaN;\n const ep2 = res.groups?.ep2 ? +res.groups.ep2 : NaN;\n const type = res.groups?.type;\n if (!Number.isNaN(ep1) && !Number.isNaN(ep2)) {\n if (type) {\n ctx.update2('episode', 'number', ep1);\n ctx.update('episodes', [\n {\n number: ep2,\n type\n }\n ]);\n } else {\n ctx.update('episodes', [\n {\n number: ep1\n },\n {\n number: ep2\n }\n ]);\n }\n return true;\n }\n }\n }\n {\n // [Movie] / [Movie v2]\n const res = WrappedMovieRE.exec(text);\n if (res) {\n if (res[1] && !Number.isNaN(res[1])) {\n ctx.update('version', +res[1]);\n }\n ctx.update('type', 'Movie');\n return true;\n }\n }\n\n // 2. Episodes range\n {\n // 01-26\n const res = WrappedEpisodesRange1.exec(text);\n if (res) {\n const from = res.groups?.ep1 ? +res.groups.ep1 : NaN;\n const to = res.groups?.ep2 ? +res.groups.ep2 : NaN;\n if (!Number.isNaN(from) && !Number.isNaN(to)) {\n ctx.update2('episodesRange', 'from', from);\n ctx.update2('episodesRange', 'to', to);\n\n const type = res.groups?.type ? res.groups.type.trim() : undefined;\n if (type) {\n ctx.update('type', type);\n }\n\n const episodesRangeType = res.groups?.range_type ? res.groups.range_type.trim() : undefined;\n if (episodesRangeType) {\n const exec2 = /[vV](\\d+)$/.exec(episodesRangeType);\n\n if (exec2) {\n const version = +exec2[1];\n if (!Number.isNaN(version)) {\n ctx.update('version', version);\n ctx.update2(\n 'episodesRange',\n 'type',\n episodesRangeType.slice(0, episodesRangeType.length - exec2[0].length)\n );\n } else {\n ctx.update2('episodesRange', 'type', episodesRangeType);\n }\n } else {\n ctx.update2('episodesRange', 'type', episodesRangeType);\n }\n }\n\n // 12.5-23\n const sub1 = res.groups?.sub1 ? +res.groups?.sub1 : NaN;\n const sub2 = res.groups?.sub2 ? +res.groups?.sub2 : NaN;\n if (!Number.isNaN(sub1)) {\n ctx.update2('episodesRange', 'fromSub', sub1);\n }\n if (!Number.isNaN(sub2)) {\n ctx.update2('episodesRange', 'toSub', sub2);\n }\n\n return true;\n }\n }\n }\n {\n // 全26集\n const res = WrappedEpisodesRange2.exec(text);\n if (res) {\n const to = +res[1];\n if (!Number.isNaN(to)) {\n ctx.update2('episodesRange', 'from', 1);\n ctx.update2('episodesRange', 'to', to);\n return true;\n }\n }\n }\n\n // 3. Season\n {\n const res = WrappedSeasonRE.exec(text);\n if (res) {\n const season = +res[1];\n if (!Number.isNaN(season)) {\n ctx.update2('season', 'number', season);\n\n const tag = res[2];\n if (tag) {\n ctx.tags.push(tag.trim());\n }\n return true;\n }\n }\n }\n\n // 4. Seasons Range\n {\n const res = WrappedSeasonsRE.exec(text);\n if (res) {\n const season1 = res.groups?.season1 ? +res.groups.season1 : NaN;\n const season2 = res.groups?.season2 ? +res.groups.season2 : NaN;\n if (!Number.isNaN(season1) && !Number.isNaN(season2)) {\n ctx.update('seasons', [{ number: season1 }, { number: season2 }]);\n }\n return true;\n }\n }\n {\n // Vol.1-4 Fin\n const res = WrappedSeasonsRangeRE.exec(text);\n if (res) {\n const season1 = res.groups?.season1 ? +res.groups.season1 : NaN;\n const season2 = res.groups?.season2 ? +res.groups.season2 : NaN;\n if (!Number.isNaN(season1) && !Number.isNaN(season2)) {\n ctx.update2('seasonsRange', 'from', season1);\n ctx.update2('seasonsRange', 'to', season2);\n }\n return true;\n }\n }\n\n // 5. Volume\n {\n const res = WrappedVolumeRE.exec(text);\n if (res) {\n const vol = res.groups?.vol ? +res.groups.vol : NaN;\n if (!Number.isNaN(vol)) {\n ctx.update2('volume', 'number', vol);\n return true;\n }\n }\n }\n {\n const res = WrappedVolumesRangeRE.exec(text);\n if (res) {\n const vol1 = res.groups?.vol1 ? +res.groups.vol1 : NaN;\n const vol2 = res.groups?.vol2 ? +res.groups.vol2 : NaN;\n const type = res.groups?.type;\n if (!Number.isNaN(vol1) && !Number.isNaN(vol2)) {\n ctx.update2('volumesRange', 'from', vol1);\n ctx.update2('volumesRange', 'to', vol1);\n if (type) {\n ctx.update2('volumesRange', 'type', type);\n }\n return true;\n }\n }\n }\n\n return false;\n}\n\nexport function parseWrappedEpisodes(ctx: Context) {\n if (ctx.hasEpisode) {\n return true;\n }\n\n const token = ctx.tokens[ctx.right];\n if (token.isWrapped && matchEpiodes(ctx, token.text)) {\n ctx.right -= 1;\n return true;\n }\n\n return false;\n}\n\nconst SuffixEpisodeRE = [\n /\\s*- (?<type>SP|OVA)?(?<ep1>\\d+)(?:\\.(?<sub>\\d))?(?:[vV](?<version>\\d+))?(?<ep_type>\\s+[^\\-]*)?(?:\\s*-)?$/,\n /\\s+S(?<season>\\d+)E(?<ep1>\\d+)$/,\n /\\s*第(?<ep1>\\d+)(?:\\.(?<sub>\\d))?[集话話]$/,\n /\\s+S(?<season1>\\d+)-S(?<season2>\\d+)$/\n];\nconst SuffixepisodesRangeRE = /- (?<from>\\d+)-(?<to>\\d+)(?:\\s+(?<type>.+))?$/;\n\nexport const Types = new Set([\n 'GEKIJOUBAN',\n 'MOVIE',\n 'OAD',\n 'OAV',\n 'ONA',\n 'OVA',\n 'SPECIAL',\n 'SPECIALS',\n 'TV',\n '开播纪念特别篇',\n '開播紀念特別篇',\n '开篇纪念特别篇',\n '開篇紀念特別篇',\n '特别篇',\n '特別篇',\n '特別編',\n '特别话',\n '特別话',\n '特別話',\n '番外篇',\n '番外編',\n '剧场版',\n '劇場版',\n '总集篇',\n '總集篇',\n //\n '广播剧',\n '朗读剧',\n //\n 'SP',\n //\n 'ED',\n 'ENDING',\n 'NCED',\n 'NCOP',\n 'OP',\n 'OPENING',\n 'PREVIEW',\n 'PV',\n '特别篇PV',\n //\n '合集',\n '修正合集'\n]);\n\nexport function parseSuffixTextInlineEpisodes(ctx: Context, text: string) {\n if (ctx.hasEpisode) {\n return text;\n }\n\n text = text.trimEnd();\n\n // - 01-24 修正合集\n {\n const res = SuffixepisodesRangeRE.exec(text);\n if (res) {\n const from = res.groups?.from ? +res.groups.from : NaN;\n const to = res.groups?.to ? +res.groups.to : NaN;\n if (!Number.isNaN(from) && !Number.isNaN(to)) {\n ctx.update2('episodesRange', 'from', from);\n ctx.update2('episodesRange', 'to', to);\n\n const type = res.groups?.type?.trim();\n if (type) {\n const version = /[vV](\\d+)$/.exec(type);\n if (version) {\n const versionNumber = +version[1];\n if (!Number.isNaN(versionNumber)) {\n ctx.update('version', versionNumber);\n const typeWithoutVersion = type.slice(0, type.length - version[0].length).trim();\n if (typeWithoutVersion) {\n ctx.update2('episodesRange', 'type', typeWithoutVersion);\n }\n } else {\n ctx.update2('episodesRange', 'type', type);\n }\n } else {\n ctx.update2('episodesRange', 'type', type);\n }\n }\n\n return text.slice(0, text.length - res[0].length).trimEnd();\n }\n }\n }\n\n // episodes\n for (const RE of SuffixEpisodeRE) {\n const res = RE.exec(text);\n if (res) {\n const ep = res.groups?.ep1 ? +res.groups?.ep1 : NaN;\n if (!Number.isNaN(ep)) {\n if (res.groups?.type) {\n ctx.update('type', res.groups.type);\n }\n\n ctx.update2('episode', 'number', ep);\n\n // 1.5\n const numberSub = res.groups?.sub ? +res.groups?.sub : NaN;\n if (numberSub && !Number.isNaN(numberSub)) {\n ctx.update2('episode', 'numberSub', numberSub);\n }\n\n // v2\n const version = res.groups?.version ? +res.groups.version : NaN;\n if (version && !Number.isNaN(version)) {\n ctx.update('version', version);\n }\n\n // S02Exx\n const season = res.groups?.season ? +res.groups.season : NaN;\n if (!Number.isNaN(season)) {\n ctx.update2('season', 'number', season);\n }\n\n // Remove token suffix\n return text.slice(0, text.length - res[0].length).trimEnd();\n }\n\n // S1-S2\n {\n const season1 = res.groups?.season1 ? +res.groups.season1 : NaN;\n const season2 = res.groups?.season2 ? +res.groups.season2 : NaN;\n if (!Number.isNaN(season1) && !Number.isNaN(season2) && season1 < season2) {\n ctx.update2('seasonsRange', 'from', season1);\n ctx.update2('seasonsRange', 'to', season2);\n\n // Remove token suffix\n return text.slice(0, text.length - res[0].length).trimEnd();\n }\n }\n }\n }\n\n // - 特别篇\n for (const type of Types) {\n const toMatch = ` - ${type}`;\n if (text.endsWith(toMatch)) {\n ctx.update('type', type);\n\n // Remove token suffix\n return text.slice(0, text.length - toMatch.length).trimEnd();\n }\n }\n\n return text;\n}\n\nexport function parseSuffixEpisodes(ctx: Context) {\n if (ctx.hasEpisode) {\n return true;\n }\n\n const token = ctx.tokens[ctx.right];\n const text = token.text.trimEnd();\n\n if (token.isWrapped) {\n return parseWrappedEpisodes(ctx);\n } else {\n const trimmed = parseSuffixTextInlineEpisodes(ctx, text);\n if (trimmed !== text) {\n ctx.tokens[ctx.right] = new Token(trimmed, token.left, token.right);\n return true;\n }\n }\n\n return false;\n}\n\n// Season regexp\nconst SuffixSeasonOrEpisodesRes: Array<[RegExp, (res: RegExpExecArray, ctx: Context) => boolean]> =\n [\n [\n /Parts? (\\d+)$/,\n (res, ctx) => {\n const part = +res[1];\n if (!Number.isNaN(part)) {\n ctx.update2('part', 'number', part);\n return true;\n }\n return false;\n }\n ],\n [\n /第\\s*(\\d+)\\s*部分$/,\n (res, ctx) => {\n const part = +res[1];\n if (!Number.isNaN(part)) {\n ctx.update2('part', 'number', part);\n return true;\n }\n return false;\n }\n ],\n [\n /(?:S|Season\\s?)(\\d+)$/,\n (res, ctx) => {\n const season = +res[1];\n if (!Number.isNaN(season)) {\n if (!ctx.result.season?.number || ctx.result.season.number === season) {\n ctx.update2('season', 'number', season);\n return true;\n }\n }\n return false;\n }\n ],\n [\n /(1st|2nd|3rd|[456789]th) Season$/,\n (res, ctx) => {\n const season = Number.parseInt(res[1]);\n if (!Number.isNaN(season)) {\n if (!ctx.result.season?.number || ctx.result.season.number === season) {\n ctx.update2('season', 'number', season);\n return true;\n }\n }\n return false;\n }\n ],\n [\n /(?:-\\s+)(Third) Season$/,\n (res, ctx) => {\n const season = { Third: 3 }[res[1]];\n if (season && !Number.isNaN(season)) {\n if (!ctx.result.season?.number || ctx.result.season.number === season) {\n ctx.update2('season', 'number', season);\n return true;\n }\n }\n return false;\n }\n ],\n [\n /第?(\\d+)[季期]$/,\n (res, ctx) => {\n const season = +res[1];\n if (!Number.isNaN(season)) {\n if (!ctx.result.season?.number || ctx.result.season.number === season) {\n ctx.update2('season', 'number', season);\n return true;\n }\n }\n return false;\n }\n ],\n [\n /第?((?:[零一二三四五六七八九]十)?[零一二三四五六七八九])[季期]$/,\n (res, ctx) => {\n const text = res[1];\n const map = {\n 零: 0,\n 一: 1,\n 二: 2,\n 三: 3,\n 四: 4,\n 五: 5,\n 六: 6,\n 七: 7,\n 八: 8,\n 九: 9\n };\n const base =\n text.length === 2 && text[0] === '十'\n ? 1\n : text.length === 3\n ? map[text[0] as keyof typeof map]!\n : 0;\n const offset = map[text[text.length - 1] as keyof typeof map];\n const season = base * 10 + offset;\n if (!ctx.result.season?.number || ctx.result.season.number === season) {\n ctx.update2('season', 'number', season);\n return true;\n }\n return false;\n }\n ],\n [\n /(?:Vol|vol|Volume|volume)\\.?\\s*(?<vol>\\d+)$/,\n (res, ctx) => {\n const vol = res.groups?.vol ? +res.groups.vol : NaN;\n if (!Number.isNaN(vol)) {\n ctx.update2('volume', 'number', vol);\n return true;\n }\n return false;\n }\n ],\n [\n /\\s+[vV](\\d+)$/,\n (res, ctx) => {\n const version = +res[1];\n if (!Number.isNaN(version)) {\n ctx.update('version', version);\n return true;\n }\n return false;\n }\n ],\n [\n /\\s+(?<ep1>\\d+)$|\\((?<ep2>\\d+)\\)$/,\n (res, ctx) => {\n const text = res.groups?.ep1 ?? res.groups?.ep2;\n const season = text !== undefined ? +text : NaN;\n if (!Number.isNaN(season) && ctx.hasEpisode) {\n if (!ctx.result.season?.number || ctx.result.season.number === season) {\n ctx.update2('season', 'number', season);\n return true;\n } else if (\n (!ctx.result.year || ctx.result.year === season) &&\n 1949 <= season &&\n season <= 2099\n ) {\n ctx.update('year', season);\n return true;\n }\n }\n return false;\n }\n ]\n ];\n\nexport function parseSuffixTextInlineSeason(ctx: Context, text: string) {\n let changed = false;\n do {\n changed = false;\n text = text.trimEnd();\n for (const [re, fn] of SuffixSeasonOrEpisodesRes) {\n const res = re.exec(text);\n if (res && fn(res, ctx)) {\n changed = true;\n text = text.slice(0, text.length - res[0].length).trimEnd();\n }\n }\n } while (changed);\n return text;\n}\n","import type { Context } from './context.js';\nimport type { FileInfo } from './types.js';\n\nimport { FileExtensions } from './file.js';\nimport { Token, tokenize } from './tokenizer/index.js';\nimport { Types, matchEpiodes } from './episodes.js';\n\n// MARK: audio / video\n\ntype AudioInfo = NonNullable<FileInfo['audio']>;\n\ntype VideoInfo = NonNullable<FileInfo['video']>;\n\nconst AudioChannels = new Set(['2.0CH', '2CH', '5.1', '5.1CH']);\n\nconst AudioCompoundTerms = new Map<string, AudioInfo>([\n ['DTS5.1', { codec: 'DTS', channels: '5.1' }],\n ['TRUEHD5.1', { codec: 'TRUEHD', channels: '5.1' }],\n ['AACX2', { codec: 'AAC', trackCount: 2 }],\n ['AAC×2', { codec: 'AAC', trackCount: 2 }],\n ['AACX3', { codec: 'AAC', trackCount: 3 }],\n ['AAC×3', { codec: 'AAC', trackCount: 3 }],\n ['AACX4', { codec: 'AAC', trackCount: 4 }],\n ['AAC×4', { codec: 'AAC', trackCount: 4 }],\n ['FLACX2', { codec: 'FLAC', trackCount: 2 }],\n ['FLAC×2', { codec: 'FLAC', trackCount: 2 }],\n ['FLACX3', { codec: 'FLAC', trackCount: 3 }],\n ['FLAC×3', { codec: 'FLAC', trackCount: 3 }],\n ['FLACX4', { codec: 'FLAC', trackCount: 4 }],\n ['FLAC×4', { codec: 'FLAC', trackCount: 4 }]\n]);\n\nconst AudioCodecs = new Set([\n 'DTS',\n 'DTS-ES',\n 'EAC3&AAC',\n 'AAC',\n 'QAAC',\n 'AC3',\n 'EAC3',\n 'E-AC-3',\n 'FLAC',\n 'FLAC/AC3', // TODO: split\n 'LOSSLESS',\n 'MP3',\n 'WAV',\n 'OGG',\n 'VORBIS',\n 'OPUS'\n]);\n\nconst AudioLanguages = new Set(['DUALAUDIO', 'DUAL AUDIO']);\n\nconst VideoBitDepths = new Set(['8BIT', '8-BIT', '10BIT', '10BITS', '10-BIT', '10-BITS']);\n\nconst VideoCodecs = new Set([\n 'HI10',\n 'HI10P',\n 'HI444',\n 'HI444P',\n 'HI444PP',\n 'H264',\n 'H265',\n 'H.264',\n 'H.265',\n 'X264',\n 'X265',\n 'X.264',\n 'AVC',\n 'HEVC',\n 'HEVC2',\n 'DIVX',\n 'DIVX5',\n 'DIVX6',\n 'XVID'\n]);\n\nconst VideoFormats = new Set(['AVI', 'RMVB', 'WMV', 'WMV3', 'WMV9']);\n\nconst VideoQualities = new Set(['HDR', 'HQ', 'LQ']);\n\nconst VideoResolutionTerms = new Set(['HD', 'SD']);\n\nconst VideoCompoundTerms = new Map<string, VideoInfo & { audioCodec?: string }>([\n ['AVC-8BIT', { codec: 'AVC', bitDepth: '8BIT' }],\n ['HEVC_OPUS', { codec: 'HEVC', audioCodec: 'OPUS' }],\n ['HEVC-10BIT', { codec: 'HEVC', bitDepth: '10BIT' }],\n ['HEVC-10BIT-1440P', { codec: 'HEVC', bitDepth: '10BIT', resolution: '1440P' }],\n ['HEVC-10BIT-2160P', { codec: 'HEVC', bitDepth: '10BIT', resolution: '2160P' }],\n ['HEVC_10BIT', { codec: 'HEVC', bitDepth: '10BIT' }],\n ['HEVC-8BIT', { codec: 'HEVC', bitDepth: '8BIT' }],\n ['HEVC_8BIT', { codec: 'HEVC', bitDepth: '8BIT' }]\n]);\n\nconst VideoFrameRates = new Set(['23.976FPS', '24FPS', '29.97FPS', '30FPS', '60FPS', '120FPS']);\n\nconst VideoResolutions = new Set(['2K', '4K']);\n\nconst Source = new Set([\n 'BD',\n 'BDRIP',\n 'BLURAY',\n 'BLU-RAY',\n 'BDREMUX',\n 'UHDBDRIP',\n 'DVD',\n 'DVD5',\n 'DVD9',\n 'DVD-R2J',\n 'DVDRIP',\n 'DVD-RIP',\n 'R2DVD',\n 'R2J',\n 'R2JDVD',\n 'R2JDVDRIP',\n 'HDTV',\n 'HDTVRIP',\n 'TVRIP',\n 'TV-RIP',\n 'WEB',\n 'WEBCAST',\n 'WEBDL',\n 'WEB-DL',\n 'WEBRIP',\n 'WEB-RIP',\n 'WEB-MKV',\n 'MASTERRIP',\n 'DISC1', // TODO: volume [雪飄工作室][アイカツプラネット!/Aikatsu_Planet!/偶像活動星球][BDrip][Disc1](檢索:偶活/愛活)\n 'DISC2',\n 'DISC3',\n 'DISC4',\n 'DISC5',\n 'DISC6',\n 'DISC7',\n 'DISC8',\n 'DISC9'\n]);\n\n// MARK: 语言,字幕等\n\nconst Platfroms = new Set([\n 'Baha',\n 'Bili',\n 'Bilibili',\n 'BiliBili',\n 'B-Global',\n 'ABEMA',\n 'CR',\n 'AT-X',\n 'AT-X版',\n 'ViuTV',\n 'AMZN',\n 'ADN',\n 'Sentai',\n 'Netflix',\n 'NF'\n]);\n\nconst Variants = new Set([\n '日配版',\n '中配版',\n '日文配音',\n '中文配音',\n 'Chinese Audio',\n 'Japanese Audio',\n 'JPN Audio',\n 'Japanese Dub',\n 'JP Dub',\n 'English Audio',\n 'English Dub'\n]);\n\nconst SubtitleFormatTerms = new Map([\n ['ASS', 'ASS'],\n ['ASSX2', 'ASSx2'],\n ['ASSX3', 'ASSx3'],\n ['ASSX4', 'ASSx4'],\n ['HARDSUB', 'HARDSUB'],\n ['HARDSUBS', 'HARDSUB'],\n ['SOFTSUB', 'SOFTSUB'],\n ['SOFTSUBS', 'SOFTSUB'],\n ['SUB', 'SUB'],\n ['SUBBED', 'SUB'],\n ['SUBTITLED', 'SUB'],\n ['SRT', 'SRT'],\n ['SRTX2', 'SRTx2'],\n ['SRTX3', 'SRTx3'],\n ['SRTX4', 'SRTx4']\n]);\n\nconst SubtitleEncodingTerms = new Map<\n string,\n { format?: string; encoding?: string; encodings?: string[] }\n>([\n ['GB&BIG5', { encodings: ['GB', 'BIG5'] }],\n ['BIG5&GB', { encodings: ['BIG5', 'GB'] }],\n ['外挂GB/BIG5', { format: '外挂', encodings: ['GB', 'BIG5'] }],\n ['GB/BIG5', { encodings: ['GB', 'BIG5'] }],\n ['GB', { encoding: 'GB' }],\n ['BIG5', { encoding: 'BIG5' }]\n]);\n\nconst PlatformLanguageTerms = new Map([\n ['ViuTV粵語', ['ViuTV', '粵語']],\n ['TVB粵語', ['TVB', '粵語']]\n]);\n\nconst LanguageSubtitleFormatTerms = new Map<string, { language: string; format?: string }>([\n ['代理商粵語', { language: '粵語' }],\n ['粵日雙語+內封繁體中文字幕', { language: '繁體中文', format: '內封字幕' }],\n ['粵語+無對白字幕', { language: '粵語+無對白' }]\n]);\n\nconst SubtitleLanguageTerms = new Map([\n ['CN', 'CN'],\n ['CHS', 'CHS'],\n ['CHT', 'CHT'],\n ['YUE', 'YUE'],\n ['JPN', 'JPN'],\n ['JP', 'JP'],\n ['简体', '简体'],\n ['简/繁·日', '简/繁·日'],\n ['繁/體', '繁/體'],\n ['简繁', '简繁'],\n ['国语中字', '国语中字'],\n ['繁體', '繁體'],\n ['中日双语', '中日双语'],\n ['繁日双语', '繁日双语'],\n ['简日双语', '简日双语'],\n ['繁日雙語', '繁日雙語'],\n ['HOY粵語', 'HOY粵語'],\n ['外挂CHS/CHT', 'CHS/CHT'],\n ['外挂繁简日字幕', '繁简日']\n]);\n\nconst SubtitleLanguagePrefixes = [\n '简繁日双语',\n '简繁日语',\n '简繁英日',\n '简繁日英',\n '简繁日',\n '简日双语',\n '简/繁',\n '简繁英',\n '简繁泰',\n '繁简日',\n '中日英',\n '简日',\n '简繁',\n '簡繁',\n '简英',\n '繁體',\n '繁体',\n '繁日',\n '繁英',\n '英文',\n '简体',\n '简',\n '繁',\n '英'\n];\n\nconst SubtitleFormatSuffixTerms = new Map([\n ['内嵌字幕', '内嵌字幕'],\n ['内嵌', '内嵌'],\n ['內嵌', '內嵌'],\n ['内封字幕', '内封字幕'],\n ['内封', '内封'],\n ['內封', '內封'],\n ['外挂字幕', '外挂字幕'],\n ['外挂', '外挂'],\n ['外掛', '外掛'],\n ['内挂', '内挂'],\n ['字幕', undefined]\n]);\n\n// MARK; 其他标签\n\nconst OtherTags = new Set([\n //\n 'RAW',\n 'DUB',\n 'DUBBED',\n 'retake',\n 'SNS',\n //\n '全歌曲特效',\n '无水印',\n '含副音轨',\n '特典',\n 'LIVE纯享',\n '无损重制',\n '广播剧_Dream☆Arch', // TODO: no hardcode\n //\n '国漫',\n 'Donghua',\n //\n '特別版',\n '先行版',\n '先行版本',\n '正片先行版',\n '正式版',\n '正式版本',\n '放送版',\n '修订版',\n '修訂版',\n 'On-air version',\n //\n '年齡限制版',\n //\n 'Ani-One',\n //\n '僅限港澳台',\n '僅限港澳台地區',\n '僅限港澳臺地區',\n '仅限港澳台',\n '仅限港澳台地区',\n //\n '重播',\n //\n 'End',\n 'END',\n 'TV + Movie Fin',\n 'FIN',\n 'Fin'\n]);\n\n// Prefix\nconst SearchPrefix = [\n '检索:',\n '检索:',\n '檢索:',\n '檢索:',\n '检索用:',\n '检索用:',\n '檢索用:',\n '檢索用:'\n];\n\nconst HiringPrefix = ['招募', '急募', '字幕社招人', '字幕社招人'];\n\nconst OtherPrefix = ['▶'];\n\nconst Ignores = new Set(['务必查看bt站简介', '请看bt站简介', '添加日语', '添加日語']);\n\n// MARK: 解析逻辑\n\nexport function matchSingleTag(ctx: Context, text: string) {\n text = text.trim();\n\n const upper = text.toUpperCase();\n\n // Match keywords\n {\n const info = AudioCompoundTerms.get(upper);\n if (info) {\n if (info.codec) {\n ctx.update3('file', 'audio', 'codec', info.codec);\n }\n if (info.channels) {\n ctx.update3('file', 'audio', 'channels', info.channels);\n }\n if (info.trackCount) {\n ctx.update3('file', 'audio', 'trackCount', info.trackCount);\n }\n return true;\n }\n }\n if (AudioChannels.has(upper)) {\n ctx.update3('file', 'audio', 'channels', text);\n return true;\n }\n if (AudioCodecs.has(upper)) {\n ctx.update3('file', 'audio', 'codec', text);\n return true;\n }\n if (AudioLanguages.has(upper)) {\n ctx.update3('file', 'audio', 'language', text);\n return true;\n }\n {\n const info = VideoCompoundTerms.get(upper);\n if (info) {\n if (info.codec) {\n ctx.update3('file', 'video', 'codec', info.codec);\n }\n if (info.bitDepth) {\n ctx.update3('file', 'video', 'bitDepth', info.bitDepth);\n }\n if (info.resolution) {\n ctx.update3('file', 'video', 'resolution', info.resolution);\n }\n if (info.audioCodec) {\n ctx.update3('file', 'audio', 'codec', info.audioCodec);\n }\n return true;\n }\n }\n if (VideoCodecs.has(upper)) {\n ctx.update3('file', 'video', 'codec', text);\n return true;\n }\n if (VideoBitDepths.has(upper)) {\n ctx.update3('file', 'video', 'bitDepth', text);\n return true;\n }\n if (VideoFormats.has(upper)) {\n ctx.update3('file', 'video', 'format', text);\n return true;\n }\n if (VideoQualities.has(upper)) {\n ctx.update3('file', 'video', 'quality', text);\n return true;\n }\n if (VideoResolutionTerms.has(upper)) {\n ctx.update3('file', 'video', 'resolution', text);\n return true;\n }\n if (VideoFrameRates.has(upper)) {\n ctx.update3('file', 'video', 'fps', text);\n return true;\n }\n {\n const res = /^(AI)(\\d{3,4}[Pp])$/i.exec(text);\n if (res) {\n ctx.update3('file', 'video', 'enhancement', res[1]);\n ctx.update3('file', 'video', 'resolution', res[2]);\n return true;\n }\n }\n {\n const res = /^(\\d{3,5}(?:[Pp]|[Xx×]\\d{3,5}))@(\\d+(?:\\.\\d+)?FPS)$/i.exec(text);\n if (res) {\n ctx.update3('file', 'video', 'resolution', res[1]);\n ctx.update3('file', 'video', 'fps', res[2]);\n return true;\n }\n }\n {\n const res = /^(\\d{3,4}P)(高帧率)$/i.exec(text);\n if (res) {\n ctx.update3('file', 'video', 'resolution', res[1]);\n ctx.update3('file', 'video', 'frameRateMode', res[2]);\n return true;\n }\n }\n if (/^\\d{3,4}P$/i.test(text) || /^\\d{3,5}[Xx×]\\d{3,5}$/.test(text)) {\n ctx.update3('file', 'video', 'resolution', text);\n return true;\n }\n if (VideoResolutions.has(upper)) {\n ctx.update3('file', 'video', 'resolution', text);\n return true;\n }\n if (Source.has(upper)) {\n ctx.update('source', text);\n return true;\n }\n if (Platfroms.has(text)) {\n ctx.update('platform', text);\n return true;\n }\n if (Types.has(upper)) {\n ctx.update('type', text);\n return true;\n }\n if (Variants.has(text)) {\n const variants = [...(ctx.result.variants ?? []), text];\n ctx.update('variants', variants);\n return true;\n }\n if (FileExtensions.has(upper)) {\n ctx.update2('file', 'extension', text);\n return true;\n }\n if (OtherTags.has(text)) {\n ctx.tags.push(text.trim());\n return true;\n }\n if (text.endsWith('.ver')) {\n ctx.tags.push(text.trim());\n return true;\n }\n if (text.startsWith('Bloomy_Cafe')) {\n ctx.tags.push(text.trim());\n return true;\n }\n if (Ignores.has(text)) {\n return true;\n }\n\n // tmdbid=1406607\n {\n const res = /^tmdbid=(.+)$/.exec(text);\n if (res) {\n ctx.update('tmdbId', res[1]);\n return true;\n }\n }\n\n // 抚物语 ...\n {\n const res = /^.+(物语|物語)$/.exec(text);\n if (res) {\n ctx.tags.push(text);\n return true;\n }\n }\n\n // Match language and subtitles\n {\n const appendSubtitleLanguage = (language: string) => {\n const languages = [...(ctx.result.subtitle?.languages ?? []), language];\n ctx.update2('subtitle', 'languages', languages);\n };\n\n const updateSubtitleFormat = (format: string | undefined, overwrite = false) => {\n if (format && (overwrite || !ctx.result.subtitle?.format)) {\n ctx.update2('subtitle', 'format', format);\n }\n };\n\n const updateSubtitleEncoding = (encoding: string | undefined) => {\n if (encoding && !ctx.result.subtitle?.encoding && !ctx.result.subtitle?.encodings) {\n ctx.update2('subtitle', 'encoding', encoding);\n }\n };\n\n const updateSubtitleEncodings = (encodings: string[] | undefined) => {\n if (!encodings) {\n return;\n }\n const values = [...(ctx.result.subtitle?.encodings ?? []), ...encodings];\n ctx.update2('subtitle', 'encodings', values);\n if (ctx.result.subtitle?.encoding) {\n delete ctx.result.subtitle.encoding;\n }\n };\n\n const language = SubtitleLanguageTerms.get(upper) ?? SubtitleLanguageTerms.get(text);\n if (language) {\n appendSubtitleLanguage(language);\n return true;\n }\n\n const format = SubtitleFormatTerms.get(upper);\n if (format) {\n updateSubtitleFormat(format);\n return true;\n }\n\n const encodingInfo = SubtitleEncodingTerms.get(upper) ?? SubtitleEncodingTerms.get(text);\n if (encodingInfo) {\n updateSubtitleFormat(encodingInfo.format, true);\n updateSubtitleEncoding(encodingInfo.encoding);\n updateSubtitleEncodings(encodingInfo.encodings);\n return true;\n }\n\n const languageWithFormat = LanguageSubtitleFormatTerms.get(text);\n if (languageWithFormat) {\n appendSubtitleLanguage(languageWithFormat.language);\n updateSubtitleFormat(languageWithFormat.format);\n return true;\n }\n\n const platformLanguage = PlatformLanguageTerms.get(text);\n if (platformLanguage) {\n const [platform, language] = platformLanguage;\n ctx.update('platform', platform);\n appendSubtitleLanguage(language);\n return true;\n }\n\n for (const prefix of SubtitleLanguagePrefixes) {\n if (text.startsWith(prefix)) {\n const suffix = text.slice(prefix.length);\n if (suffix === '' || SubtitleFormatSuffixTerms.has(suffix)) {\n appendSubtitleLanguage(prefix);\n updateSubtitleFormat(SubtitleFormatSuffixTerms.get(suffix));\n return true;\n }\n }\n }\n }\n\n // Match regex\n {\n {\n // 2024年10月番\n const match = /^(\\d\\d\\d\\d)年(\\d\\d?)月新?番$/.exec(text);\n if (match) {\n const year = +match[1];\n const month = +match[2];\n if (1949 <= year && year <= 2099) {\n ctx.update('year', year);\n }\n if (1 <= month && month <= 12) {\n ctx.update('month', month);\n }\n return true;\n }\n }\n {\n // ★10月新番 ★04月新番★\n const match = /^★?(\\d{1,2})月新?番★?$/.exec(text);\n if (match) {\n const month = +match[1];\n if (1 <= month && month <= 12) {\n ctx.update('month', month);\n }\n return true;\n }\n }\n {\n // [2024.12.15]\n const match = /^(\\d\\d\\d\\d)\\.(\\d?\\d)\\.(\\d?\\d)$/.exec(text);\n if (match) {\n const year = +match[1];\n const month = +match[2];\n if (1949 <= year && year <= 2099) {\n ctx.update('year', year);\n }\n if (1 <= month && month <= 12) {\n ctx.update('month', month);\n }\n return true;\n }\n }\n {\n // [2024SP]\n const match = /^(\\d\\d\\d\\d)(SP)$/.exec(text);\n if (match) {\n const year = +match[1];\n const type = match[2];\n if (1949 <= year && year <= 2099) {\n ctx.update('year', year);\n }\n ctx.update('type', type);\n return true;\n }\n }\n {\n // v2\n const match = /^[vV](\\d+)$/.exec(text);\n if (match) {\n const version = +match[1];\n ctx.update('version', version);\n return true;\n }\n }\n }\n\n // Match prefix\n {\n for (const prefix of SearchPrefix) {\n if (text.startsWith(prefix)) {\n const title = text.slice(prefix.length).trim();\n ctx.update(\n 'search',\n title\n .split('/')\n .map((t) => t.trim())\n .filter(Boolean)\n );\n return true;\n }\n }\n for (const prefix of HiringPrefix) {\n if (text.startsWith(prefix)) {\n return true;\n }\n }\n for (const prefix of OtherPrefix) {\n if (text.startsWith(prefix)) {\n ctx.tags.push(text.trim());\n return true;\n }\n }\n }\n\n // Match vol.1\n {\n const res = /^(?:Vol|vol|Volume|volume)\\.?\\s*(?<vol>\\d+)$/.exec(text);\n if (res) {\n const vol = res.groups?.vol ? +res.groups.vol : NaN;\n if (!Number.isNaN(vol)) {\n ctx.update2('volume', 'number', vol);\n return true;\n }\n }\n }\n\n return false;\n}\n\nexport function matchMultipleTags(\n ctx: Context,\n text: string,\n TagSeperators = [' ', '_', '&', '+']\n) {\n for (const sep of TagSeperators) {\n const parts = text.split(sep);\n if (parts.length <= 1) continue;\n let matched = true;\n for (let i = 0; i < parts.length; i += 1) {\n const part = parts[i];\n if (!matchSingleTag(ctx, part)) {\n matched = false;\n }\n }\n if (matched) {\n return true;\n }\n }\n return false;\n}\n\nfunction parseWrappedTag(ctx: Context, token: Token) {\n if (token.isWrapped) {\n const text = token.text;\n if (matchSingleTag(ctx, text)) {\n return true;\n }\n if (matchEpiodes(ctx, text)) {\n return true;\n }\n if (matchMultipleTags(ctx, text)) {\n return true;\n }\n }\n return false;\n}\n\nexport function parsePrefixWrappedTags(ctx: Context) {\n while (ctx.left < ctx.right) {\n if (parseWrappedTag(ctx, ctx.tokens[ctx.left])) {\n ctx.left += 1;\n } else if (ctx.tokens[ctx.left].trim().toString() === '') {\n ctx.left += 1;\n } else {\n break;\n }\n }\n}\n\nexport function parseSuffixWrappedTags(ctx: Context) {\n while (ctx.left < ctx.right) {\n if (parseWrappedTag(ctx, ctx.tokens[ctx.right])) {\n ctx.right -= 1;\n } else if (ctx.tokens[ctx.right].trim().toString() === '') {\n ctx.right -= 1;\n } else {\n // Unknown tags\n const isIgnoreLast = () => {\n const token = ctx.tokens[ctx.tokens.length - 1];\n if (SearchPrefix.some((prefix) => token.text.startsWith(prefix))) {\n return true;\n }\n if (HiringPrefix.some((prefix) => token.text.startsWith(prefix))) {\n return true;\n }\n if (Ignores.has(token.text)) {\n return true;\n }\n return false;\n };\n\n if (\n // xxx [未知标签]\n