feat(parser,sites): W5-D port title/zhconv/site parsing gaps found by MP differential

Title recognition (parseRelease / parseReleasePath):
- anime prefix tags (★N月新番, [国漫], 【22年日剧】), category lead bracket, `A / B` cn aliases
- `Name [01]`, `[TV 04]`, `#13`, `名称 12.mkv`, `S01 13-14`, `E13-14` episodes
- lead bracket as title vs group, trailing [Group], YTS.MX/Hall_of_C style groups, 字幕组 suffix
- `[dot.release.name][…]` unbracketing, `Season 2017` as year, leading date not airDate,
  date-only parent dir does not merge as TV
- original disc: Blu-ray + AVC/HEVC/VC-1/MPEG-2 without x26x/H.26x -> BluRay-Disc
- `{[tmdbid=;type=;s=;e=]}` explicit block, bangumi/anilist ids
- bare [1080]/[5.1], DTS-HD MA5.1, £ multi-group, 合集, pinyin title -> cn from subtitle,
  streaming platform table, CHS/CHT/GB/BIG5 in subtitle
zh-map: add 迴滾魷瑯臥駭; document one-to-many chars kept out.
Sites: tbody branch + header-row tightening via movieclaw-import rowSelector (18 definitions),
row-level case/hr/labels include the row itself, subtitle branch (searchType: "subtitles"),
parseSizeMp banker's rounding.
Differential snapshots regenerated; MP test sample sources registered in ledger.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:09:14 +08:00
1 parent 8352e5e881
commit 36dc048e89
41 files changed
+3111 -2372

No files matched your search

+11 -2
View File
@@ -1,9 +1,18 @@
/**
* 繁 → 简 简单映射表(只覆盖影视标题常见字,不引第三方库)。
* 格式:每两个字符为一对「繁简」。一对多/语境相关的字(乾/著/瞭 等)刻意不收。
* 格式:每两个字符为一对「繁简」。
*
* 一对多/语境相关的字刻意不收(单字表没有词组上下文,收了会把正确用法转错):
* - 乾:乾杯→干杯,但 乾隆/乾坤 不转;
* - 瞭:瞭解→了解,但 瞭望 读 liào 不转;
* - 髮:頭髮/理髮→发,与 發→发 合并后反查不唯一,召回比较时也不需要(TMDB 中文标题极少出现);
* - 著:著作 不转,顯著/著名 的 著 与 着 的分工依语境。
* MP 的 zhconv_fast 用 MediaWiki 词组表处理这些字,差分里登记为 missing(见 test/differential/zhconv-deviations.json)。
*/
const PAIRS =
"萬万與与專专業业叢丛東东絲丝兩两嚴严喪丧個个豐丰臨临為为麗丽舉举麼么義义烏乌樂乐喬乔習习鄉乡書书買买亂乱爭争於于虧亏雲云亞亚產产親亲億亿僅仅從从倉仓儀仪們们價价眾众優优會会傘伞偉伟傳传傷伤倫伦偽伪體体餘余傭佣俠侠侶侣偵侦側侧僑侨儂侬倆俩儉俭債债傾倾償偿儲储兒儿兇凶黨党蘭兰關关興兴養养獸兽內内岡冈冊册寫写軍军農农馮冯衝冲決决況况凍冻淨净準准涼凉減减湊凑幾几鳳凤憑凭凱凯擊击劃划劉刘則则剛刚創创刪删別别劑剂劍剑剝剥劇剧勸劝辦办務务動动勵励勁劲勞劳勢势勳勋勝胜區区醫医華华協协單单賣卖盧卢衛卫卻却廠厂廳厅曆历厲厉壓压厭厌廁厕廂厢廈厦廚厨縣县參参雙双發发變变敘叙疊叠葉叶號号嘆叹嚇吓呂吕嗎吗啟启吳吴員员響响啞哑嘩哗喚唤噴喷團团園园圍围國国圖图圓圆聖圣場场壞坏塊块堅坚壇坛壩坝墳坟墜坠壘垒墊垫牆墙壯壮聲声殼壳壺壶處处備备復复夠够頭头誇夸夾夹奪夺奮奋獎奖奧奥妝妆婦妇媽妈嬌娇娛娱嬰婴嬸婶孫孙學学寧宁寶宝實实寵宠審审憲宪宮宫寬宽賓宾寢寝對对尋寻導导壽寿將将爾尔塵尘嘗尝堯尧屍尸盡尽層层屆届屬属嶼屿歲岁豈岂崗岗島岛嶺岭峽峡嶄崭巔巅幣币帥帅師师帳帐簾帘幟帜帶带幫帮並并廣广莊庄慶庆廬庐庫库應应廟庙龐庞廢废開开異异棄弃張张彌弥彎弯彈弹強强歸归當当錄录彥彦徹彻徑径憶忆憂忧懷怀態态憐怜總总戀恋懇恳惡恶惱恼悅悦懸悬驚惊懼惧慘惨懲惩慚惭慣惯憤愤願愿懶懒戲戏戰战戶户紮扎撲扑執执擴扩掃扫揚扬擾扰撫抚拋抛搶抢護护報报擔担擬拟擁拥攔拦擇择掛挂揮挥撈捞損损撿捡換换據据擲掷攬揽攜携攝摄擺摆搖摇攤摊撐撑敵敌斂敛數数齋斋鬥斗斬斩斷断無无舊旧時时曠旷晝昼顯显晉晋曬晒曉晓暈晕暉晖暫暂術术樸朴機机殺杀雜杂權权條条來来楊杨傑杰極极構构槍枪楓枫櫃柜標标棧栈棟栋欄栏樹树棲栖樣样檔档橋桥夢梦檢检樓楼欖榄櫻樱櫥橱歡欢歐欧殲歼殘残毀毁畢毕斃毙氣气匯汇漢汉湯汤溝沟沒没淚泪瀉泻潑泼澤泽潔洁灑洒淺浅漿浆濁浊測测濟济渾浑濃浓濤涛漲涨淵渊漸渐漁渔滲渗溫温遊游灣湾濕湿潰溃濺溅滿满濾滤濫滥濱滨灘滩瀟潇潛潜瀾澜瀕濒滅灭燈灯靈灵災灾燦灿爐炉點点煉炼熾炽爍烁爛烂燭烛煙烟煩烦燒烧熱热煥焕愛爱爺爷牽牵犧牺狀状猶犹狹狭獅狮獄狱獨独獵猎豬猪貓猫獻献瑪玛環环現现瓊琼電电畫画暢畅療疗瘋疯癢痒發发皺皱盞盏鹽盐監监蓋盖盜盗盤盘睜睁瞞瞒矯矫礦矿碼码磚砖確确礙碍禮礼禍祸禪禅離离禿秃種种積积稱称穩稳穫获窮穷竊窃窯窑竄窜窩窝豎竖競竞筆笔築筑篩筛籌筹簽签簡简籃篮籬篱類类糧粮糾纠紀纪約约紅红紋纹納纳紐纽純纯紗纱紡纺紛纷級级紙纸細细紳绅終终組组結结繞绕給给絢绚絡络絕绝統统綁绑絹绢繡绣經经綜综綠绿綴缀網网綱纲緒绪續续緊紧維维綿绵緣缘編编緩缓緯纬練练縫缝縮缩織织繩绳繪绘繳缴纖纤羅罗罰罚罷罢羈羁翹翘聳耸恥耻聶聂聾聋職职聯联聰聪肅肃腸肠膚肤腫肿脅胁膽胆朧胧膠胶脈脉腦脑腳脚脫脱臉脸臘腊騰腾輿舆艦舰艙舱艱艰豔艳藝艺節节蕪芜蘆芦葦苇蒼苍蘇苏蘋苹莖茎薦荐莢荚蕩荡榮荣藥药萊莱蓮莲獲获瑩莹鶯莺蘿萝螢萤營营蕭萧薩萨蔥葱蔣蒋藍蓝薔蔷藹蔼蘊蕴虜虏慮虑虛虚蟲虫雖虽蝦虾蝕蚀蟻蚁螞蚂蠶蚕蠻蛮蝸蜗蠟蜡蠅蝇蟬蝉補补襯衬襖袄襪袜襲袭裝装褲裤見见觀观規规覓觅視视覽览覺觉觸触計计訂订認认討讨讓让訓训議议訊讯記记講讲許许論论設设訪访訣诀證证評评識识詐诈訴诉診诊詞词譯译試试詩诗誠诚話话誕诞詭诡詢询該该詳详誤误語语誘诱說说誦诵請请諸诸諾诺讀读課课誰谁調调諒谅談谈誼谊謀谋謊谎諧谐謂谓謎谜謝谢謠谣謙谦謹谨謬谬譜谱貝贝貞贞負负貢贡財财責责賢贤敗败賬账貨货質质販贩貪贪貧贫購购貫贯貼贴貴贵貸贷貿贸費费賀贺賊贼資资賦赋賭赌贖赎賞赏賜赐賠赔賴赖賺赚賽赛贈赠贊赞贏赢趙赵趕赶趨趋躍跃踐践蹤踪躲躲車车軌轨軍军軒轩轉转輪轮軟软轟轰軸轴輕轻載载較较輔辅輛辆輩辈輝辉輸输轄辖辭辞辯辩邊边遼辽達达遷迁過过邁迈運运還还這这進进遠远違违連连遲迟適适選选遜逊遞递邏逻遺遗遙遥鄧邓郵邮鄰邻鬱郁鄭郑醞酝醬酱釀酿釋释裏里鑒鉴針针釘钉鐘钟鋼钢鑰钥鈴铃鐵铁鉛铅銅铜鋁铝銀银鏈链銷销鎖锁鋒锋鋤锄鍋锅錯错錨锚錘锤錦锦鍵键鋸锯鍛锻鏡镜鐮镰鑲镶長长門门閃闪閉闭問问闖闯閑闲間间悶闷閘闸鬧闹閨闺聞闻閣Line truncated
"萬万與与專专業业叢丛東东絲丝兩两嚴严喪丧個个豐丰臨临為为麗丽舉举麼么義义烏乌樂乐喬乔習习鄉乡書书買买亂乱爭争於于虧亏雲云亞亚產产親亲億亿僅仅從从倉仓儀仪們们價价眾众優优會会傘伞偉伟傳传傷伤倫伦偽伪體体餘余傭佣俠侠侶侣偵侦側侧僑侨儂侬倆俩儉俭債债傾倾償偿儲储兒儿兇凶黨党蘭兰關关興兴養养獸兽內内岡冈冊册寫写軍军農农馮冯衝冲決决況况凍冻淨净準准涼凉減减湊凑幾几鳳凤憑凭凱凯擊击劃划劉刘則则剛刚創创刪删別别劑剂劍剑剝剥劇剧勸劝辦办務务動动勵励勁劲勞劳勢势勳勋勝胜區区醫医華华協协單单賣卖盧卢衛卫卻却廠厂廳厅曆历厲厉壓压厭厌廁厕廂厢廈厦廚厨縣县參参雙双發发變变敘叙疊叠葉叶號号嘆叹嚇吓呂吕嗎吗啟启吳吴員员響响啞哑嘩哗喚唤噴喷團团園园圍围國国圖图圓圆聖圣場场壞坏塊块堅坚壇坛壩坝墳坟墜坠壘垒墊垫牆墙壯壮聲声殼壳壺壶處处備备復复夠够頭头誇夸夾夹奪夺奮奋獎奖奧奥妝妆婦妇媽妈嬌娇娛娱嬰婴嬸婶孫孙學学寧宁寶宝實实寵宠審审憲宪宮宫寬宽賓宾寢寝對对尋寻導导壽寿將将爾尔塵尘嘗尝堯尧屍尸盡尽層层屆届屬属嶼屿歲岁豈岂崗岗島岛嶺岭峽峡嶄崭巔巅幣币帥帅師师帳帐簾帘幟帜帶带幫帮並并廣广莊庄慶庆廬庐庫库應应廟庙龐庞廢废開开異异棄弃張张彌弥彎弯彈弹強强歸归當当錄录彥彦徹彻徑径憶忆憂忧懷怀態态憐怜總总戀恋懇恳惡恶惱恼悅悦懸悬驚惊懼惧慘惨懲惩慚惭慣惯憤愤願愿懶懒戲戏戰战戶户紮扎撲扑執执擴扩掃扫揚扬擾扰撫抚拋抛搶抢護护報报擔担擬拟擁拥攔拦擇择掛挂揮挥撈捞損损撿捡換换據据擲掷攬揽攜携攝摄擺摆搖摇攤摊撐撑敵敌斂敛數数齋斋鬥斗斬斩斷断無无舊旧時时曠旷晝昼顯显晉晋曬晒曉晓暈晕暉晖暫暂術术樸朴機机殺杀雜杂權权條条來来楊杨傑杰極极構构槍枪楓枫櫃柜標标棧栈棟栋欄栏樹树棲栖樣样檔档橋桥夢梦檢检樓楼欖榄櫻樱櫥橱歡欢歐欧殲歼殘残毀毁畢毕斃毙氣气匯汇漢汉湯汤溝沟沒没淚泪瀉泻潑泼澤泽潔洁灑洒淺浅漿浆濁浊測测濟济渾浑濃浓濤涛漲涨淵渊漸渐漁渔滲渗溫温遊游灣湾濕湿潰溃濺溅滿满濾滤濫滥濱滨灘滩瀟潇潛潜瀾澜瀕濒滅灭燈灯靈灵災灾燦灿爐炉點点煉炼熾炽爍烁爛烂燭烛煙烟煩烦燒烧熱热煥焕愛爱爺爷牽牵犧牺狀状猶犹狹狭獅狮獄狱獨独獵猎豬猪貓猫獻献瑪玛環环現现瓊琼電电畫画暢畅療疗瘋疯癢痒發发皺皱盞盏鹽盐監监蓋盖盜盗盤盘睜睁瞞瞒矯矫礦矿碼码磚砖確确礙碍禮礼禍祸禪禅離离禿秃種种積积稱称穩稳穫获窮穷竊窃窯窑竄窜窩窝豎竖競竞筆笔築筑篩筛籌筹簽签簡简籃篮籬篱類类糧粮糾纠紀纪約约紅红紋纹納纳紐纽純纯紗纱紡纺紛纷級级紙纸細细紳绅終终組组結结繞绕給给絢绚絡络絕绝統统綁绑絹绢繡绣經经綜综綠绿綴缀網网綱纲緒绪續续緊紧維维綿绵緣缘編编緩缓緯纬練练縫缝縮缩織织繩绳繪绘繳缴纖纤羅罗罰罚罷罢羈羁翹翘聳耸恥耻聶聂聾聋職职聯联聰聪肅肃腸肠膚肤腫肿脅胁膽胆朧胧膠胶脈脉腦脑腳脚脫脱臉脸臘腊騰腾輿舆艦舰艙舱艱艰豔艳藝艺節节蕪芜蘆芦葦苇蒼苍蘇苏蘋苹莖茎薦荐莢荚蕩荡榮荣藥药萊莱蓮莲獲获瑩莹鶯莺蘿萝螢萤營营蕭萧薩萨蔥葱蔣蒋藍蓝薔蔷藹蔼蘊蕴虜虏慮虑虛虚蟲虫雖虽蝦虾蝕蚀蟻蚁螞蚂蠶蚕蠻蛮蝸蜗蠟蜡蠅蝇蟬蝉補补襯衬襖袄襪袜襲袭裝装褲裤見见觀观規规覓觅視视覽览覺觉觸触計计訂订認认討讨讓让訓训議议訊讯記记講讲許许論论設设訪访訣诀證证評评識识詐诈訴诉診诊詞词譯译試试詩诗誠诚話话誕诞詭诡詢询該该詳详誤误語语誘诱說说誦诵請请諸诸諾诺讀读課课誰谁調调諒谅談谈誼谊謀谋謊谎諧谐謂谓謎谜謝谢謠谣謙谦謹谨謬谬譜谱貝贝貞贞負负貢贡財财責责賢贤敗败賬账貨货質质販贩貪贪貧贫購购貫贯貼贴貴贵貸贷貿贸費费賀贺賊贼資资賦赋賭赌贖赎賞赏賜赐賠赔賴赖賺赚賽赛贈赠贊赞贏赢趙赵趕赶趨趋躍跃踐践蹤踪躲躲車车軌轨軍军軒轩轉转輪轮軟软轟轰軸轴輕轻載载較较輔辅輛辆輩辈輝辉輸输轄辖辭辞辯辩邊边遼辽達达遷迁過过邁迈運运還还這这進进遠远違违連连遲迟適适選选遜逊遞递邏逻遺遗遙遥鄧邓郵邮鄰邻鬱郁鄭郑醞酝醬酱釀酿釋释裏里鑒鉴針针釘钉鐘钟鋼钢鑰钥鈴铃鐵铁鉛铅銅铜鋁铝銀银鏈链銷销鎖锁鋒锋鋤锄鍋锅錯错錨锚錘锤錦锦鍵键鋸锯鍛锻鏡镜鐮镰鑲镶長长門门閃闪閉闭問问闖闯閑闲間间悶闷閘闸鬧闹閨闺聞闻閣Line truncated
// W5-D 按 MP zhconv 差分补字
"迴回滾滚魷鱿瑯琅臥卧駭骇";
export const TRAD_TO_SIMP: ReadonlyMap<string, string> = (() => {
const chars = [...PAIRS];
+260 -81
View File
@@ -1,5 +1,6 @@
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 {
type Confidence,
@@ -38,7 +39,14 @@ export function fileKindOf(name: string): FileKind | undefined {
// ---------------------------------------------------------------- 可变草稿
type Draft = ParsedRelease & { _complete?: boolean; _fin?: boolean; _disc?: boolean; _remux?: boolean };
type Draft = ParsedRelease & {
_complete?: boolean;
_fin?: boolean;
_disc?: boolean;
_remux?: boolean;
/** `{[k=v;…]}` 显式身份块里的季/集/类型,finalize 时覆盖推断值 */
_explicit?: { season?: number; seasonEnd?: number; episodes?: EpisodeRange; tv?: boolean };
};
function emptyDraft(raw: string): Draft {
return {
@@ -67,6 +75,9 @@ 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();
@@ -86,7 +97,23 @@ function extractGroup(s: string, extra: readonly string[]): { s: string; group?:
if (lead) {
const content = lead[1]!.trim();
const rest = s.slice(lead[0].length);
if (!TECH_IN_BRACKET.test(content) && (/[[【]/u.test(rest) || /\s-\s\d{1,3}\b/.test(rest))) {
// `[片名][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" };
}
}
@@ -137,6 +164,8 @@ function extractAirDate(s: string): { s: string; airDate?: string; ev?: string }
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)}`,
@@ -267,61 +296,41 @@ export function scanCnMarkers(input: string): { s: string } & CnMarkers {
const AUDIO_CODEC_ALT = "DDP|DD|AAC|TrueHD|DTSHDMA|DTSHDHR|DTSHD|DTSX|DTS|FLAC|LPCM|PCM|AC3|EAC3|Opus|Atmos";
function normalizeCompounds(s: string): string {
return s
.replace(/'/g, "")
.replace(/\bWEB[-. ]?DL\b/gi, " WEBDL ")
.replace(/\bWEB[-. ]?Rip\b/gi, " WEBRip ")
.replace(/\bBlu[-. ]?Ray\b/gi, " BluRay ")
.replace(/\bDTS[-. ]?HD[-. ]?MA\b/gi, " DTSHDMA ")
.replace(/\bDTS[-. ]?HD[-. ]?(?:HRA|HR)\b/gi, " DTSHDHR ")
.replace(/\bDTS[-. ]?HD\b/gi, " DTSHD ")
.replace(/\bDTS[-. :]?X\b/gi, " DTSX ")
.replace(/\b([HhXx])[. ]?26([45])\b/g, " $126$2 ")
.replace(/\bHDR10(?:\+|Plus|P)(?![A-Za-z0-9])/gi, " HDR10PLUS ")
.replace(/\bDD\+/gi, " DDP")
.replace(/\bE-?AC-?3\b/gi, " EAC3 ")
.replace(/\bAC-?3\b/gi, " AC3 ")
.replace(/\bDolby[ .]?Vision\b/gi, " DV ")
.replace(/\bVC-?1\b/gi, " VC1 ")
.replace(/\bMPEG-?2\b/gi, " MPEG2 ")
.replace(/\b(8|10|12)[ .\-_]?bits?\b/gi, " $1BIT ")
.replace(
new RegExp(`\\b(${AUDIO_CODEC_ALT})[ ._]*([1-9])[ .]([0-2])(?![0-9])`, "gi"),
(_all, codec: string, a: string, b: string) => ` ${codec} CH${a}${b} `,
)
.replace(/(\p{Script=Han})([A-Za-z])/gu, "$1 $2")
.replace(/([A-Za-z])(\p{Script=Han})/gu, "$1 $2");
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 PLATFORMS: Record<string, string> = {
NF: "Netflix",
AMZN: "Amazon",
MAX: "Max",
HMAX: "HBO Max",
DSNP: "Disney+",
ATVP: "Apple TV+",
IT: "iTunes",
HULU: "Hulu",
PCOK: "Peacock",
PMTP: "Paramount+",
CR: "Crunchyroll",
IQ: "iQIYI",
IQIYI: "iQIYI",
TX: "Tencent Video",
WETV: "WeTV",
YK: "Youku",
MGTV: "MGTV",
BILI: "Bilibili",
BG: "B-Global",
VIU: "Viu",
KKTV: "KKTV",
FRIDAY: "friDay",
LINETV: "LINE TV",
HBO: "HBO",
STAN: "Stan",
};
const TOKEN_SPLIT = /[\s._[\]()【】/~~;&|#「」,,()::"!!??、+\-《》£]+/u;
const AUDIO_TOKENS: Record<string, string> = {
TRUEHD: "TrueHD",
@@ -389,7 +398,14 @@ 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;
@@ -398,9 +414,15 @@ function walkTokens(tokens: string[], d: Draft): void {
stopped = true;
if (kind !== "year") nonYearStops++;
};
const hasTitle = () => en.length > 0 || cn.length > 0;
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) : en;
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;
};
@@ -472,7 +494,8 @@ function walkTokens(tokens: string[], d: Draft): void {
stop("season");
continue;
}
if ((u === "SEASON" || u === "EPISODE") && next && /^\d{1,4}$/.test(next)) {
// `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);
@@ -556,8 +579,11 @@ function walkTokens(tokens: string[], d: Draft): void {
// 来源
if (gate) {
let src: ReleaseSource | undefined;
if (u === "BLURAY" || u === "BDRIP" || u === "BRRIP" || u === "BD") src = "BluRay";
else if (u === "WEBDL") src = "WEB-DL";
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";
@@ -609,6 +635,8 @@ function walkTokens(tokens: string[], d: Draft): void {
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;
@@ -626,9 +654,12 @@ function walkTokens(tokens: string[], d: Draft): void {
continue;
}
m = /^CH(\d)(\d)$/.exec(u);
if (m && d.audio.length > 0) {
if (m) {
const last = d.audio.length - 1;
if (!/\d\.\d$/.test(d.audio[last]!)) d.audio[last] = `${d.audio[last]} ${m[1]}.${m[2]}`;
// 裸声道 `[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);
@@ -661,12 +692,14 @@ function walkTokens(tokens: string[], d: Draft): void {
stop("platform");
continue;
}
if (stopped && (u === "CHS" || u === "GB")) {
if (u === "CHS" || u === "GB") {
d.subtitles = [...new Set([...(d.subtitles ?? []), "zh-Hans"])];
stop("subtitle");
continue;
}
if (stopped && (u === "CHT" || u === "BIG5")) {
if (u === "CHT" || u === "BIG5") {
d.subtitles = [...new Set([...(d.subtitles ?? []), "zh-Hant"])];
stop("subtitle");
continue;
}
if (stopped && (u === "ENG" || u === "EN")) {
@@ -680,20 +713,26 @@ function walkTokens(tokens: string[], d: Draft): void {
if (stopped) continue;
if (/\p{Script=Han}/u.test(t)) {
if (!hanClosed) {
cn.push(t);
// 「三部曲合集」「合集」是打包说明,不是片名的一部分
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 (d._remux) d.source = "REMUX";
else if (d._disc) d.source = "BluRay-Disc";
else {
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;
@@ -703,6 +742,82 @@ function walkTokens(tokens: string[], d: Draft): void {
}
}
// ---------------------------------------------------------------- 预处理
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;
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;
}
/** 常见拼音音节(不含声调);用于判断英文名是否其实是拼音 */
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;
// ---------------------------------------------------------------- 主入口
/**
@@ -724,19 +839,31 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
s = s.slice(0, -(ext.length + 1));
} else if (opts.isFile) d.isFile = true;
// 2. 显式身份标签
// 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)\s*[=-]\s*([A-Za-z0-9]+)\s*[\]}]/gi,
/[[{]\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) => {
const k = key.toLowerCase().replace(/id$/, "") as ExplicitId["provider"];
if (id !== "0" && !d.explicitIds.some((x) => x.provider === k)) {
d.explicitIds.push({ provider: k, id });
ev.push(`explicit:${k}`);
}
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, "");
// 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());
@@ -765,9 +892,11 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
for (const [re, cut] of CUT_RULES) {
s = s.replace(re, () => {
d.cut ??= cut;
return " ";
return "\uE001";
});
}
// `Caligula.The.Ultimate.Cut` 去掉剪辑版本后,紧挨着的冠词 The 一并去掉
s = s.replace(/\bThe[ .\-_]*\uE001/gi, " ").replace(/\uE001/g, " ");
if (d.cut) ev.push("cut");
// 7. 综艺播出日期(在年份识别之前,避免被当成年份或被丢弃)
@@ -786,7 +915,26 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
// 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]"
// 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) => {
@@ -814,11 +962,18 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
}
return MARK_HARD;
});
if (d.releaseGroup?.source === "bracket") {
s = s.replace(/[[【](\d{1,3})(?:v\d)?[\]】]/u, (all, n: string) => {
if (d.episodes) return all;
// `[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:anime_bracket");
ev.push("episode:cn_name_number");
return MARK_HARD;
});
}
@@ -837,7 +992,22 @@ export function parseRelease(input: string, opts: ParseOptions = {}): ParsedRele
// 13. 副标题补缺
if (opts.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(opts.subtitle)?.[1];
if (first && words.length === [...first].length && words.every((w) => PINYIN_SYLLABLE.test(w))) {
d.titleCn = first;
ev.push("subtitle:title_cn_pinyin");
}
}
const sub = scanCnMarkers(opts.subtitle);
// 副标题里的拉丁字幕标记 CHS/CHT/GB/BIG5(`60 GB` 这类体积不算)
for (const m of opts.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(opts.subtitle);
@@ -900,13 +1070,22 @@ function finalize(d: Draft, opts: ParseOptions): ParsedRelease {
} 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.totalEpisodes !== undefined ||
d._explicit?.tv === true;
d.mediaTypeHint = tvSignals ? "tv" : "unknown";
// 季号推定:有集号/总集数但无季号,且不是综艺日期/期号
@@ -929,7 +1108,7 @@ function finalize(d: Draft, opts: ParseOptions): ParsedRelease {
d.title = d.titleCn ?? d.titleEn;
d.confidence = confidenceOf(d);
const { _complete, _fin, _disc, _remux, ...out } = d;
const { _complete, _fin, _disc, _remux, _explicit, ...out } = d;
return out;
}
+8 -1
View File
@@ -69,7 +69,14 @@ export function mergeParsed(
evidence: [...primary.evidence],
};
const used: string[] = [];
const fbTv = fallback.mediaTypeHint === "tv";
// 目录只有日期(`…Collection.20240501.FRDS` 这类收藏/打包日期)不算剧集信号,不参与类型合并
const dateOnly =
fallback.airDate !== undefined &&
fallback.season === undefined &&
fallback.episodes === undefined &&
fallback.issue === undefined &&
fallback.totalEpisodes === undefined;
const fbTv = fallback.mediaTypeHint === "tv" && !dateOnly;
const deviation = primary.mediaTypeHint !== "tv" && fbTv;
if (!primary.title) {
@@ -0,0 +1,169 @@
/**
* 流媒体平台简称 → 展示名(scene 命名惯例,事实数据)。
* 条目对照 MoviePilot `streamingplatform.py` 的简称表补齐;只收单词元简称(含 `-` 的写法在 normalizeCompounds 里先合并),
* 与英文常用词/其他技术词冲突的简称(FREE、NOW、PLAY、LIFE、MA、AS、SE、TEN…)刻意不收,避免误判。
* 词元只在 WEB 来源词前后 2 个位置内才当作平台(见 walkTokens 的 nearWeb)。
* 与 MP 的表示差异:HMAX 记 "HBO Max"(MP 记 "Max"),差分登记为 representation。
*/
export const PLATFORMS: Readonly<Record<string, string>> = {
NF: "Netflix",
AMZN: "Amazon",
MAX: "Max",
HMAX: "HBO Max",
DSNP: "Disney+",
DSNY: "Disney Networks",
ATVP: "Apple TV+",
IT: "iTunes",
HULU: "Hulu",
HULUJP: "Hulu",
PCOK: "Peacock",
PMTP: "Paramount+",
PMNT: "Paramount Network",
CR: "Crunchyroll",
FUNI: "Funimation",
HIDI: "HIDIVE",
IQ: "iQIYI",
IQIYI: "iQIYI",
TX: "Tencent Video",
WETV: "WeTV",
YK: "Youku",
MGTV: "MGTV",
BILI: "Bilibili",
BG: "B-Global",
BGLOBAL: "B-Global",
BAHA: "Baha",
VIU: "Viu",
KKTV: "KKTV",
FRIDAY: "friDay",
LITV: "LiTV",
MYVIDEO: "MyVideo",
OFIII: "ofiii",
HAMI: "Hami Video",
HAMIVIDEO: "Hami Video",
LINETV: "LINE TV",
CATCHPLAY: "CATCHPLAY+",
CPP: "CATCHPLAY+",
CTHP: "CATCHPLAY+",
MYTVS: "MyTVSuper",
NOWE: "Now E",
NOWPLAYER: "NowPlayer",
GAGA: "GagaOOLala",
HBO: "HBO",
HBOGO: "HBO GO",
STAN: "Stan",
HS: "Hotstar",
APPS: "Disney+ MENA",
BCORE: "Bravia Core",
SHO: "Showtime",
SKST: "SkyShowtime",
FXTL: "Foxtel Now",
BNGE: "Binge",
CRKL: "Crackle",
RKTN: "Rakuten TV",
VIKI: "Rakuten Viki",
ALL4: "Channel 4",
MY5: "Channel 5",
ITVX: "ITV",
IP: "BBC iPlayer",
BBC: "BBC",
BRITBOX: "BritBox",
UKTV: "UKTV",
CNLP: "Canal+",
CRIT: "Criterion Channel",
DSCP: "Discovery+",
DISC: "Discovery Channel",
MUBI: "Mubi",
YT: "YouTube",
MW: "meWATCH",
ABMA: "Abema",
ABEMATV: "Abema",
UNXT: "U-NEXT",
DMM: "DMM",
LEMINO: "Lemino",
TVER: "TVer",
WAKA: "Wakanim",
WAKANIM: "Wakanim",
AOD: "Anime on Demand",
BCH: "Bandai Channel",
VMJ: "VideoMarket",
LFTL: "Laftel",
LFTLNET: "Laftel",
AO: "AnimeOnegai",
"9NOW": "9Now",
"7PLUS": "7plus",
SBS: "SBS",
AUBC: "ABC iview",
SHAHID: "Shahid",
OSN: "OSN+",
TOU: "Ici TOU.TV",
ROKU: "Roku",
KNPY: "Kanopy",
SNXT: "Sun NXT",
CUR: "Curiosity Stream",
AHA: "aha",
WOWP: "WOW Presents Plus",
JC: "JioCinema",
ZEE5: "ZEE5",
HOICHOI: "Hoichoi",
PLEX: "Plex",
SHDR: "Shudder",
CRAV: "Crave",
CPE: "Cineplex Entertainment",
VIAP: "Viaplay",
TUBI: "TubiTV",
PLUTOTV: "Pluto TV",
PBSK: "PBS KIDS",
PBS: "PBS",
LGP: "Lionsgate Play",
STZ: "STARZ",
FUBO: "fuboTV",
CBC: "CBC Gem",
CW: "The CW",
CWS: "CW Seed",
CMAX: "Cinemax",
HLMK: "Hallmark",
AMC: "AMC",
USAN: "USA Network",
BRAV: "BravoTV",
TNT: "TNT",
TCM: "TCM",
TLC: "TLC",
HGTV: "HGTV",
ANPL: "Animal Planet",
TRVL: "Travel Channel",
NATG: "National Geographic",
STRP: "Star+",
CNNP: "CNN+",
EPIX: "EPIX MGM+",
HIST: "History Channel",
NICK: "Nickelodeon",
SYFY: "SyFy",
DCU: "DC Universe",
QIBI: "Quibi",
VRV: "VRV",
VMEO: "Vimeo",
WTCH: "Watcha",
WAVVE: "Wavve",
TVING: "TVING",
ODK: "OnDemandKorea",
VIDIO: "Vidio",
FPT: "FPT Play",
FPTP: "FPT Play",
JOYN: "Joyn",
RTL: "RTL+",
ARTE: "Arte",
ZDF: "ZDF",
ARD: "ARD",
SVT: "Sveriges Television",
VIAPLAY: "Viaplay",
CMOR: "C More",
FTV: "France.tv",
GLBO: "Globoplay",
A3P: "Atresplayer",
FILMIN: "Filmin",
OKKO: "Okko",
IVI: "Ivi",
VOYO: "Voyo",
DANT: "DANET",
SMNS: "SAMANSA",
};
@@ -11,7 +11,8 @@ export const RELEASE_GROUP_SEED: Readonly<Record<string, readonly string[]>> = {
hdsky: ["HDSky", "HDS", "HDSWEB", "HDSTV", "HDSPad"],
pthome: ["PTHome", "PTH", "PTHweb"],
cmct: ["CMCT", "CMCTV"],
frds: ["FRDS"],
frds: ["FRDS", "cXcY"],
league: ["LeagueWEB", "LeagueTV", "LeagueHD", "LeagueNF", "LeagueCD"],
ttg: ["TTG", "WiKi", "NGB", "DoA"],
hdchina: ["HDChina", "HDC"],
beitai: ["BeiTai"],
@@ -54,12 +55,12 @@ export const RELEASE_GROUP_SEED: Readonly<Record<string, readonly string[]>> = {
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
/** 构造发布组匹配正则:前界定符 - @ [ 【 & ,后界定符 结尾 @ . 空白 ] [ 】 & */
/** 构造发布组匹配正则:前界定符 - @ [ 【 & £,后界定符 结尾 @ . 空白 ] [ 】 & £(`AC3£cXcY@FRDS` 这类多组写法) */
export function buildGroupRegex(extra: readonly string[] = []): RegExp {
const all = [...Object.values(RELEASE_GROUP_SEED).flat(), ...extra];
// 长名优先,避免 CHD 抢先匹配 CHDWEB
const uniq = [...new Set(all)].sort((a, b) => b.length - a.length).map(escapeRe);
return new RegExp(`(?<=[-@\\[【&])(?:${uniq.join("|")})(?=$|[@.\\s\\]\\[】&])`, "giu");
return new RegExp(`(?<=[-@\\[【&£])(?:${uniq.join("|")})(?=$|[@.\\s\\]\\[】&£])`, "giu");
}
let cached: RegExp | undefined;
+1 -1
View File
@@ -63,7 +63,7 @@ export interface ReleaseGroup {
}
export interface ExplicitId {
provider: "tmdb" | "imdb" | "douban" | "tvdb";
provider: "tmdb" | "imdb" | "douban" | "tvdb" | "bangumi" | "anilist";
id: string;
}