refactor backend module architecture

This commit is contained in:
jxxghp
2026-08-14 15:45:38 +08:00
parent 557cc0e2e3
commit 7b3444c366
716 changed files with 10378 additions and 7709 deletions
+1
View File
@@ -0,0 +1 @@
"""媒体标题解析实现;调用方应从具体子模块导入所需类型。"""
+81
View File
@@ -0,0 +1,81 @@
import regex as re
from typing import Callable
from app.foundation.singleton import Singleton
_customization_provider: Callable[[], object] = lambda: ()
def configure_customization_provider(provider: Callable[[], object]) -> None:
"""注入当前自定义占位符来源,避免领域匹配器读取持久化配置。"""
global _customization_provider
_customization_provider = provider
def get_customization() -> object:
"""返回当前自定义占位符原始配置。"""
return _customization_provider()
class CustomizationMatcher(metaclass=Singleton):
"""
识别自定义占位符
"""
def __init__(self):
"""初始化自定义占位符正则缓存。"""
self.customization = None
self.custom_separator = None
self._customization_re_cache = {}
@staticmethod
def normalize_customization(customization):
"""
规范化自定义占位符配置,兼容历史字符串与列表两种保存格式。
"""
if isinstance(customization, str):
customization = customization.replace("\n", ";").replace("|", ";").strip(";").split(";")
if not customization:
return []
return list(filter(None, customization))
@staticmethod
def _normalize_customization(customization):
"""
兼容旧调用,统一转到公开的自定义占位符规范化入口。
"""
return CustomizationMatcher.normalize_customization(customization)
def match(self, title=None):
"""
:param title: 资源标题或文件名
:return: 匹配结果
"""
if not title:
return ""
# 自定义占位符需要跟随系统配置实时生效,避免单例缓存导致保存后仍沿用旧规则。
customization = self.normalize_customization(
get_customization()
)
if not customization:
self.customization = None
return ""
self.customization = "|".join([f"({item})" for item in customization])
customization_re = self._customization_re_cache.get(self.customization)
if not customization_re:
# 配置每次读取、编译结果按规则缓存,兼顾实时生效和高频识别性能。
customization_re = re.compile(r"%s" % self.customization)
self._customization_re_cache[self.customization] = customization_re
# 处理重复多次的情况,保留先后顺序(按添加自定义占位符的顺序)
unique_customization = {}
for item in customization_re.findall(title):
if not isinstance(item, tuple):
item = (item,)
for i in range(len(item)):
if item[i] and unique_customization.get(item[i]) is None:
unique_customization[item[i]] = i
unique_customization = list(dict(sorted(unique_customization.items(), key=lambda x: x[1])).keys())
separator = self.custom_separator or "@"
return separator.join(unique_customization)
+45
View File
@@ -0,0 +1,45 @@
import regex as re
from app.domain.meta.metabase import MetaBase
from app.domain.string import StringUtils
AUXILIARY_CN_STEM_FULLMATCH_RE = re.compile(
r"^(双语|字幕|特效|内封|外挂|官译|简体|繁体|繁中|简中|中英|简英|多语|"
r"国英|台粤|音轨|评论|国配|台配|粤语|韩语|日语|杜比|全景声|无损|中字|"
r"国语|原声)+$"
)
PARENT_LATIN_TITLE_RE = re.compile(r"[A-Za-z]{2,}")
SEASON_EPISODE_CN_RE = re.compile(r"[第共]\s*[0-9一二三四五六七八九十百零]+\s*[季集话話]")
def should_use_parent_title_for_file_stem(
stem: str, parent_dir_name: str, file_meta: MetaBase
) -> bool:
"""
文件名(无后缀)是否仅为简繁体/字幕/特效等辅助说明,应改用父目录标题识别。
要求:
- stem 纯中文且能被辅助关键词完全覆盖(无残留有意义汉字)
- 父目录含拉丁字母,避免纯中文资源目录误把正片中文名当标签清空
"""
if not file_meta.isfile or not stem or not parent_dir_name:
return False
if file_meta.media_source and file_meta.media_id:
return False
if not PARENT_LATIN_TITLE_RE.search(parent_dir_name):
return False
if not StringUtils.is_all_chinese(stem):
return False
if len(stem) > 16:
return False
if not AUXILIARY_CN_STEM_FULLMATCH_RE.match(stem):
return False
if SEASON_EPISODE_CN_RE.search(stem):
return False
return True
def clear_parsed_title_for_parent_merge(meta: MetaBase) -> None:
"""在父目录合并前清理会干扰二次识别的标题字段。"""
meta.cn_name = None
meta.en_name = None
meta.original_name = None
+284
View File
@@ -0,0 +1,284 @@
import logging
import re
import traceback
import anitopy
from app.domain.meta.customization import CustomizationMatcher
from app.domain.meta.metabase import MetaBase
from app.domain.meta.releasegroup import ReleaseGroupsMatcher
from app.domain.string import StringUtils
from app.foundation.zhconv import convert as zhconv_convert
from app.schemas.types import MediaType
logger = logging.getLogger(__name__)
BRACKET_TITLE_RE = re.compile(r'\[(.+?)]')
RESOURCE_PIX_X_RE = re.compile(r'x', re.IGNORECASE)
RESOURCE_PIX_SPLIT_RE = re.compile(r'[Xx]')
ANIME_MARK_RE = re.compile(r"新番|月?番|[日美国][漫剧]")
ANIME_PREFIX_RE = re.compile(r".*番.|.*[日美国][漫剧].")
CATEGORY_TAG_RE = re.compile(
r"[动漫画纪录片电影视连续剧集日美韩中港台海外亚洲华语大陆综艺原盘高清]{2,}|TV|Animation|Movie|Documentar|Anime",
re.IGNORECASE,
)
LEADING_BRACKET_BLOCK_RE = re.compile(r"^[^]]*]")
FILE_SIZE_RE = re.compile(r'[0-9.]+\s*[MGT]i?B(?![A-Z]+)', re.IGNORECASE)
TV_EPISODE_BRACKET_RE = re.compile(r"\[TV\s+(\d{1,4})", re.IGNORECASE)
FOUR_K_BRACKET_RE = re.compile(r'\[4k]', re.IGNORECASE)
NUMERIC_BRACKET_RE = re.compile(r"\[\d+", re.IGNORECASE)
MIXED_CHINESE_TOKEN_RE = re.compile(r'[\d|#:\-()()\u4e00-\u9fff]')
class MetaAnime(MetaBase):
"""
识别动漫
"""
_anime_no_words = ['CHS&CHT', 'MP4', 'GB MP4', 'WEB-DL']
_name_nostring_re = r"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}|\s+GB"
_fps_re = r"(\d{2,3})(?=FPS)"
_name_nostring_pattern = re.compile(_name_nostring_re, re.IGNORECASE)
_fps_pattern = re.compile(r"(%s)" % _fps_re, re.IGNORECASE)
@staticmethod
def _parse_season_number(value):
"""解析第三方动漫季号,仅接受整数或纯数字字符串并保留数值 0。"""
if value is None:
return None
text = str(value).strip()
return int(text) if text.isdigit() else None
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
"""解析动漫标题并补充季集、字幕组和媒体规格。"""
super().__init__(title, subtitle, isfile)
if not title:
return
# 调用第三方模块识别动漫
try:
original_title = title
# 字幕组信息会被预处理掉
anitopy_info_origin = anitopy.parse(title)
title = self.__prepare_title(title)
anitopy_info = anitopy.parse(title)
if anitopy_info:
# 名称
name = anitopy_info.get("anime_title")
if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)):
anitopy_info = anitopy.parse("[ANIME]" + title)
if anitopy_info:
name = anitopy_info.get("anime_title")
if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)):
name_match = BRACKET_TITLE_RE.search(title)
if name_match and name_match.group(1):
name = name_match.group(1).strip()
# 拆份中英文名称
if name:
_split_flag = True
# 按/拆分中英文
if name.find("/") != -1:
names = name.split("/")
if StringUtils.is_chinese(names[0]):
self.cn_name = names[0]
if len(names) > 1:
self.en_name = names[1]
_split_flag = False
elif StringUtils.is_chinese(names[-1]):
self.cn_name = names[-1]
if len(names) > 1:
self.en_name = names[0]
_split_flag = False
else:
name = names[-1]
# 拆分中英文
if _split_flag:
lastword_type = ""
for word in name.split():
if not word:
continue
if word.endswith(']'):
word = word[:-1]
if word.isdigit():
if lastword_type == "cn":
self.cn_name = "%s %s" % (self.cn_name or "", word)
elif lastword_type == "en":
self.en_name = "%s %s" % (self.en_name or "", word)
elif StringUtils.is_chinese(word):
self.cn_name = "%s %s" % (self.cn_name or "", word)
lastword_type = "cn"
else:
self.en_name = "%s %s" % (self.en_name or "", word)
lastword_type = "en"
if self.cn_name:
_, self.cn_name, _, _, _, _ = StringUtils.get_keyword(self.cn_name)
if self.cn_name:
self.cn_name = self._name_nostring_pattern.sub('', self.cn_name).strip()
if self.en_name:
self.en_name = self._name_nostring_pattern.sub('', self.en_name).strip().title()
self._name = StringUtils.str_title(self.en_name)
# 年份
year = anitopy_info.get("anime_year")
if str(year).isdigit():
self.year = str(year)
# 季号
anime_season = anitopy_info.get("anime_season")
if isinstance(anime_season, list):
seasons = [
season for item in anime_season
if (season := self._parse_season_number(item)) is not None
]
begin_season = seasons[0] if seasons else None
end_season = seasons[-1] if len(seasons) > 1 else None
else:
begin_season = self._parse_season_number(anime_season)
end_season = None
if begin_season is not None:
self.begin_season = begin_season
if end_season is not None and end_season != self.begin_season:
self.end_season = end_season
self.total_season = (self.end_season - self.begin_season) + 1
else:
self.total_season = 1
self.type = MediaType.TV
# 集号
episode_number = anitopy_info.get("episode_number")
if isinstance(episode_number, list):
if len(episode_number) == 1:
begin_episode = episode_number[0]
end_episode = None
else:
begin_episode = episode_number[0]
end_episode = episode_number[-1]
elif episode_number:
begin_episode = episode_number
end_episode = None
else:
begin_episode = None
end_episode = None
if begin_episode:
try:
self.begin_episode = int(begin_episode)
if end_episode and int(end_episode) != self.begin_episode:
self.end_episode = int(end_episode)
self.total_episode = (self.end_episode - self.begin_episode) + 1
else:
self.total_episode = 1
except Exception as err:
logger.debug(f"解析集数失败:{str(err)} - {traceback.format_exc()}")
self.begin_episode = None
self.end_episode = None
self.type = MediaType.TV
# 类型
if not self.type:
anime_type = anitopy_info.get('anime_type')
if isinstance(anime_type, list):
anime_type = anime_type[0]
if anime_type and anime_type.upper() == "TV":
self.type = MediaType.TV
else:
self.type = MediaType.MOVIE
# 分辨率
self.resource_pix = anitopy_info.get("video_resolution")
if isinstance(self.resource_pix, list):
self.resource_pix = self.resource_pix[0]
if self.resource_pix:
if RESOURCE_PIX_X_RE.search(self.resource_pix):
self.resource_pix = RESOURCE_PIX_SPLIT_RE.split(self.resource_pix)[-1] + "p"
else:
self.resource_pix = self.resource_pix.lower()
if str(self.resource_pix).isdigit():
self.resource_pix = str(self.resource_pix) + "p"
# 制作组/字幕组
self.resource_team = \
ReleaseGroupsMatcher().match(title=original_title) or \
anitopy_info_origin.get("release_group") or None
# 自定义占位符
self.customization = CustomizationMatcher().match(title=original_title) or None
# 视频编码
self.video_encode = anitopy_info.get("video_term")
if isinstance(self.video_encode, list):
self.video_encode = self.video_encode[0]
# 视频位深
self.video_bit = self.extract_video_bit(original_title) or self.extract_video_bit(self.video_encode)
# 音频编码
self.audio_encode = anitopy_info.get("audio_term")
if isinstance(self.audio_encode, list):
self.audio_encode = self.audio_encode[0]
# 帧率信息
self.__init_anime_fps(anitopy_info, original_title)
# 解析副标题,只要季和集
self.init_subtitle(self.org_string)
if not self._subtitle_flag and self.subtitle:
self.init_subtitle(self.subtitle)
if not self.type:
self.type = MediaType.TV
except Exception as e:
logger.error(f"解析动漫信息失败:{str(e)} - {traceback.format_exc()}")
def __init_anime_fps(self, anitopy_info: dict, original_title: str):
"""
从原始标题中提取帧率信息,与MetaVideo保持完全一致的实现
"""
re_res = self._fps_pattern.search(original_title)
if re_res:
fps_value = None
if re_res.group(1): # FPS格式
fps_value = re_res.group(1)
if fps_value and fps_value.isdigit():
# 只存储纯数值
self.fps = int(fps_value)
@staticmethod
def __prepare_title(title: str):
"""
对命名进行预处理
"""
if not title:
return title
# 所有【】换成[]
title = title.replace("", "[").replace("", "]").strip()
# 截掉xx番剧漫
match = ANIME_MARK_RE.search(title)
if match and match.span()[1] < len(title) - 1:
title = ANIME_PREFIX_RE.sub("", title)
elif match:
title = title[:title.rfind('[')]
# 截掉分类
first_item = title.split(']')[0]
if first_item and CATEGORY_TAG_RE.search(zhconv_convert(first_item, "zh-hans")):
title = LEADING_BRACKET_BLOCK_RE.sub("", title).strip()
# 去掉大小
title = FILE_SIZE_RE.sub("", title)
# 将TVxx改为xx
title = TV_EPISODE_BRACKET_RE.sub(r"[\1", title)
# 将4K转为2160p
title = FOUR_K_BRACKET_RE.sub('2160p', title)
# 处理/分隔的中英文标题
names = title.split("]")
if len(names) > 1 and title.find("- ") == -1:
titles = []
for name in names:
if not name:
continue
left_char = ''
if name.startswith('['):
left_char = '['
name = name[1:]
if name and name.find("/") != -1:
if name.split("/")[-1].strip():
titles.append("%s%s" % (left_char, name.split("/")[-1].strip()))
else:
titles.append("%s%s" % (left_char, name.split("/")[0].strip()))
elif name:
if StringUtils.is_chinese(name) and not StringUtils.is_all_chinese(name):
if not NUMERIC_BRACKET_RE.search(name):
name = MIXED_CHINESE_TOKEN_RE.sub('', name).strip()
if not name or name.strip().isdigit():
continue
if name == '[':
titles.append("")
else:
titles.append("%s%s" % (left_char, name.strip()))
return "]".join(titles)
return title
+705
View File
@@ -0,0 +1,705 @@
import logging
import traceback
from dataclasses import dataclass
from typing import Union, Optional, List, Self
import cn2an
import regex as re
from app.schemas.types import MediaSource, MediaType
from app.domain.media import resolve_media_identity
from app.domain.string import StringUtils
logger = logging.getLogger(__name__)
TITLE_EPISODE_RE = re.compile(r"Episode\s+(\d{1,4})", re.IGNORECASE)
SUBTITLE_HAS_SEASON_EPISODE_RE = re.compile(r"[全第季集话話期幕]", re.IGNORECASE)
SUBTITLE_SEASON_RE = re.compile(r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十S\-]+)\s*季(?!\s*[全共])", re.IGNORECASE)
SUBTITLE_SEASON_ALL_RE = re.compile(r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季", re.IGNORECASE)
SUBTITLE_EPISODE_RE = re.compile(r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP]+)\s*[集话話期幕](?!\s*[全共])", re.IGNORECASE)
SUBTITLE_EPISODE_BETWEEN_RE = re.compile(
r"[第]*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]?\s*-\s*第*\s*"
r"([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]",
re.IGNORECASE,
)
SUBTITLE_EPISODE_ALL_RE = re.compile(
r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]",
re.IGNORECASE,
)
# 结尾分支显式区分有无右方括号,避免可选括号回溯后绕过数字后缀边界
SUBTITLE_EPISODE_RANGE_FIN_RE = re.compile(
r"(?<!\d)\[?\s*(\d{1,4})\s*-\s*(\d{1,4})\s*"
r"(?:(?:Fin|End)(?![a-z0-9])|完结(?![\u4e00-\u9fff]))"
r"(?:\s*\](?!\d)|(?!\s*(?:\]\d|\d))\s*)",
re.IGNORECASE,
)
VIDEO_BIT_RE = re.compile(
r"(?<![A-Za-z0-9])(?P<bit>8|10|12|16)[\s._-]*bits?(?![A-Za-z0-9])",
re.IGNORECASE,
)
@dataclass
class MetaBase(object):
"""
媒体信息基类
"""
# 是否处理的文件
isfile: bool = False
# 原标题字符串(未经过识别词处理)
title: str = ""
# 识别用字符串(经过识别词处理后)
org_string: Optional[str] = None
# 副标题
subtitle: Optional[str] = None
# 类型 电影、电视剧
type: MediaType = MediaType.UNKNOWN
# 识别的中文名
cn_name: Optional[str] = None
# 识别的英文名
en_name: Optional[str] = None
# 未应用识别词时识别出的名称
original_name: Optional[str] = None
# 年份
year: Optional[str] = None
# 总季数
total_season: int = 0
# 识别的开始季 数字
begin_season: Optional[int] = None
# 识别的结束季 数字
end_season: Optional[int] = None
# 总集数
total_episode: int = 0
# 识别的开始集
begin_episode: Optional[int] = None
# 识别的结束集
end_episode: Optional[int] = None
# Partx Cd Dvd Disk Disc
part: Optional[str] = None
# 识别的资源类型
resource_type: Optional[str] = None
# 识别的效果
resource_effect: Optional[str] = None
# 识别的分辨率
resource_pix: Optional[str] = None
# 识别的制作组/字幕组
resource_team: Optional[str] = None
# 识别的自定义占位符
customization: Optional[str] = None
# 识别的流媒体平台
web_source: Optional[str] = None
# 视频编码
video_encode: Optional[str] = None
# 视频位深
video_bit: Optional[str] = None
# 音频编码
audio_encode: Optional[str] = None
# 应用的识别词信息
apply_words: Optional[List[str]] = None
# 媒体主身份;来源与ID必须成对使用
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
episode_group: Optional[str] = None
# 帧率信息(纯数值)
fps: Optional[int] = None
# 副标题解析
_subtitle_flag = False
_title_episodel_re = r"Episode\s+(\d{1,4})"
_subtitle_season_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十S\-]+)\s*季(?!\s*[全共])"
_subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季"
_subtitle_episode_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP]+)\s*[集话話期幕](?!\s*[全共])"
_subtitle_episode_between_re = r"[第]*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]?\s*-\s*第*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
_subtitle_episode_all_re = r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
"""保存原始标题、辅助描述和文件识别上下文。"""
if not title:
return
self.org_string = title.strip() if title else None
self.subtitle = subtitle.strip() if subtitle else None
self.isfile = isfile
@property
def name(self) -> str:
"""
返回名称
"""
if self.cn_name and StringUtils.is_all_chinese(self.cn_name):
return self.cn_name
elif self.en_name:
return self.en_name
elif self.cn_name:
return self.cn_name
return ""
@name.setter
def name(self, name: str):
"""
设置名称
"""
if StringUtils.is_all_chinese(name):
self.cn_name = name
else:
self.en_name = name
self.cn_name = None
def init_subtitle(self, title_text: str):
"""
副标题识别
"""
if not title_text:
return
title_text = f" {title_text} "
episode_str = TITLE_EPISODE_RE.search(title_text)
if episode_str:
if episode_str:
try:
episode = int(episode_str.group(1))
except Exception as err:
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
return
if episode >= 10000:
return
if self.begin_episode is None:
self.begin_episode = episode
self.total_episode = 1
self.type = MediaType.TV
self._subtitle_flag = True
elif SUBTITLE_HAS_SEASON_EPISODE_RE.search(title_text):
# 全x季 x季全
season_all_str = SUBTITLE_SEASON_ALL_RE.search(title_text)
if season_all_str:
season_all = season_all_str.group(1)
if not season_all:
season_all = season_all_str.group(2)
if season_all and self.begin_season is None and self.begin_episode is None:
try:
self.total_season = int(cn2an.cn2an(season_all.strip(), mode='smart'))
except Exception as err:
logger.debug(f'识别季失败:{str(err)} - {traceback.format_exc()}')
return
self.begin_season = 1
self.end_season = self.total_season
self.type = MediaType.TV
self._subtitle_flag = True
return
# 第x季
season_str = SUBTITLE_SEASON_RE.search(title_text)
if season_str:
seasons = season_str.group(1)
if seasons:
seasons = seasons.upper().replace("S", "").strip()
else:
return
try:
end_season = None
if seasons.find('-') != -1:
seasons = seasons.split('-')
begin_season = int(cn2an.cn2an(seasons[0].strip(), mode='smart'))
if len(seasons) > 1:
end_season = int(cn2an.cn2an(seasons[1].strip(), mode='smart'))
else:
begin_season = int(cn2an.cn2an(seasons, mode='smart'))
except Exception as err:
logger.debug(f'识别季失败:{str(err)} - {traceback.format_exc()}')
return
if begin_season and begin_season > 100:
return
if end_season and end_season > 100:
return
if self.begin_season is None and isinstance(begin_season, int):
self.begin_season = begin_season
self.total_season = 1
if self.begin_season is not None \
and self.end_season is None \
and isinstance(end_season, int) \
and end_season != self.begin_season:
self.end_season = end_season
self.total_season = (self.end_season - self.begin_season) + 1
self.type = MediaType.TV
self._subtitle_flag = True
# 第x-x集 第x集-x集
episode_between_str = SUBTITLE_EPISODE_BETWEEN_RE.search(title_text)
if episode_between_str:
episodes = episode_between_str.groups()
if episodes:
begin_episode = episodes[0]
end_episode = episodes[1]
else:
return
try:
begin_episode = int(cn2an.cn2an(begin_episode.strip(), mode='smart'))
end_episode = int(cn2an.cn2an(end_episode.strip(), mode='smart'))
except Exception as err:
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
return
if begin_episode and begin_episode >= 10000:
return
if end_episode and end_episode >= 10000:
return
if self.begin_episode is None and isinstance(begin_episode, int):
self.begin_episode = begin_episode
self.total_episode = 1
if self.begin_episode is not None \
and self.end_episode is None \
and isinstance(end_episode, int) \
and end_episode != self.begin_episode:
self.end_episode = end_episode
self.total_episode = (self.end_episode - self.begin_episode) + 1
self.type = MediaType.TV
self._subtitle_flag = True
return
# 第x集
episode_str = SUBTITLE_EPISODE_RE.search(title_text)
if episode_str:
episodes = episode_str.group(1)
if episodes:
episodes = episodes.upper().replace("E", "").replace("P", "").strip()
else:
return
try:
end_episode = None
if episodes.find('-') != -1:
episodes = episodes.split('-')
begin_episode = int(cn2an.cn2an(episodes[0].strip(), mode='smart'))
if len(episodes) > 1:
end_episode = int(cn2an.cn2an(episodes[1].strip(), mode='smart'))
else:
begin_episode = int(cn2an.cn2an(episodes, mode='smart'))
except Exception as err:
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
return
if begin_episode and begin_episode >= 10000:
return
if end_episode and end_episode >= 10000:
return
if self.begin_episode is None and isinstance(begin_episode, int):
self.begin_episode = begin_episode
self.total_episode = 1
if self.begin_episode is not None \
and self.end_episode is None \
and isinstance(end_episode, int) \
and end_episode != self.begin_episode:
self.end_episode = end_episode
self.total_episode = (self.end_episode - self.begin_episode) + 1
self.type = MediaType.TV
self._subtitle_flag = True
return
# x集全/全x集
episode_all_str = SUBTITLE_EPISODE_ALL_RE.search(title_text)
if episode_all_str:
episode_all = episode_all_str.group(1)
if not episode_all:
episode_all = episode_all_str.group(2)
if episode_all and self.begin_episode is None:
try:
self.total_episode = int(cn2an.cn2an(episode_all.strip(), mode='smart'))
except Exception as err:
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
return
self.type = MediaType.TV
self._subtitle_flag = True
return
# 01-26Fin 等数字范围+完结标记
self.__init_episode_range_fin(title_text)
else:
# 副标题无中文季集标记时,仍识别 01-26Fin 等数字范围+完结标记
self.__init_episode_range_fin(title_text)
def __init_episode_range_fin(self, title_text: str):
"""
识别 01-26Fin / [01-38 END] 等"数字范围+完结标记"格式的集数信息
"""
episode_range_str = SUBTITLE_EPISODE_RANGE_FIN_RE.search(title_text)
if not episode_range_str:
return
try:
begin_episode = int(episode_range_str.group(1))
end_episode = int(episode_range_str.group(2))
except Exception as err:
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
return
if begin_episode < 1 or begin_episode > end_episode or end_episode >= 10000:
return
# 两个数字都落在常见年份区间时视为年份范围而非集数(如 2019-2020完结)
if begin_episode >= 1900 and end_episode <= 2155:
return
if self.begin_episode is None:
self.begin_episode = begin_episode
self.end_episode = end_episode
self.total_episode = end_episode
self.type = MediaType.TV
self._subtitle_flag = True
@property
def season(self) -> str:
"""
返回开始季、结束季字符串,确定是剧集没有季的返回S01
"""
if self.begin_season is not None:
return "S%s" % str(self.begin_season).rjust(2, "0") \
if self.end_season is None \
else "S%s-S%s" % \
(str(self.begin_season).rjust(2, "0"),
str(self.end_season).rjust(2, "0"))
else:
if self.type == MediaType.TV:
return "S01"
else:
return ""
@property
def sea(self) -> str:
"""
返回开始季字符串,确定是剧集没有季的返回空
"""
if self.begin_season is not None:
return self.season
else:
return ""
@property
def season_seq(self) -> str:
"""
返回begin_season 的数字,电视剧没有季的返回1
"""
if self.begin_season is not None:
return str(self.begin_season)
else:
if self.type == MediaType.TV:
return "1"
else:
return ""
@property
def season_list(self) -> List[int]:
"""
返回季的数组
"""
if self.begin_season is None:
if self.type == MediaType.TV:
return [1]
else:
return []
elif self.end_season is not None:
return [season for season in range(self.begin_season, self.end_season + 1)]
else:
return [self.begin_season]
@property
def episode(self) -> str:
"""
返回开始集、结束集字符串
"""
if self.begin_episode is not None:
return "E%s" % str(self.begin_episode).rjust(2, "0") \
if self.end_episode is None \
else "E%s-E%s" % \
(
str(self.begin_episode).rjust(2, "0"),
str(self.end_episode).rjust(2, "0"))
else:
return ""
@property
def episode_list(self) -> List[int]:
"""
返回集的数组
"""
if self.begin_episode is None:
return []
elif self.end_episode is not None:
return [episode for episode in range(self.begin_episode, self.end_episode + 1)]
else:
return [self.begin_episode]
@property
def episodes(self) -> str:
"""
返回集的并列表达方式,用于支持单文件多集
"""
return "E%s" % "E".join(str(episode).rjust(2, '0') for episode in self.episode_list)
@property
def episode_seqs(self) -> str:
"""
返回单文件多集的集数表达方式,用于支持单文件多集
"""
episodes = self.episode_list
if episodes:
# 集 xx
if len(episodes) == 1:
return str(episodes[0])
else:
return "%s-%s" % (episodes[0], episodes[-1])
else:
return ""
@property
def episode_seq(self) -> str:
"""
返回begin_episode 的数字
"""
episodes = self.episode_list
if episodes:
return str(episodes[0])
else:
return ""
@property
def season_episode(self) -> str:
"""
返回季集字符串
"""
if self.type == MediaType.TV:
seaion = self.season
episode = self.episode
if seaion and episode:
return "%s %s" % (seaion, episode)
elif seaion:
return "%s" % seaion
elif episode:
return "%s" % episode
else:
return ""
return ""
@property
def resource_term(self) -> str:
"""
返回资源类型字符串,含分辨率
"""
ret_string = ""
if self.resource_type:
ret_string = f"{ret_string} {self.resource_type}"
if self.resource_effect:
ret_string = f"{ret_string} {self.resource_effect}"
if self.resource_pix:
ret_string = f"{ret_string} {self.resource_pix}"
return ret_string
@property
def edition(self) -> str:
"""
返回资源类型字符串,不含分辨率
"""
ret_string = ""
if self.resource_type:
ret_string = f"{ret_string} {self.resource_type}"
if self.resource_effect:
ret_string = f"{ret_string} {self.resource_effect}"
return ret_string.strip()
@property
def release_group(self) -> str:
"""
返回发布组/字幕组字符串
"""
if self.resource_team:
return self.resource_team
else:
return ""
@property
def video_term(self) -> str:
"""
返回视频编码
"""
return self.video_encode or ""
@property
def audio_term(self) -> str:
"""
返回音频编码
"""
return self.audio_encode or ""
@property
def frame_rate(self) -> int:
"""
返回帧率信息
"""
return self.fps or None
@staticmethod
def extract_video_bit(value: Optional[str]) -> Optional[str]:
"""
从标题或编码文本中提取视频位深标签。
"""
if not value:
return None
bit_match = VIDEO_BIT_RE.search(value)
if not bit_match:
return None
return f"{bit_match.group('bit')}bit"
def is_in_season(self, season: Union[list, int, str]) -> bool:
"""
是否包含季
"""
if isinstance(season, list):
if self.end_season is not None:
meta_season = list(range(self.begin_season, self.end_season + 1))
else:
if self.begin_season is not None:
meta_season = [self.begin_season]
else:
meta_season = [1]
return set(meta_season).issuperset(set(season))
else:
if self.end_season is not None:
return self.begin_season <= int(season) <= self.end_season
else:
if self.begin_season is not None:
return int(season) == self.begin_season
else:
return int(season) == 1
def is_in_episode(self, episode: Union[list, int, str]) -> bool:
"""
是否包含集
"""
if isinstance(episode, list):
if self.end_episode is not None:
meta_episode = list(range(self.begin_episode, self.end_episode + 1))
else:
meta_episode = [self.begin_episode]
return set(meta_episode).issuperset(set(episode))
else:
if self.end_episode is not None:
return self.begin_episode <= int(episode) <= self.end_episode
else:
return int(episode) == self.begin_episode
def set_season(self, sea: Union[list, int, str]):
"""
更新季
"""
if not sea:
return
if isinstance(sea, list):
if len(sea) == 1 and str(sea[0]).isdigit():
self.begin_season = int(sea[0])
self.end_season = None
elif len(sea) > 1 and str(sea[0]).isdigit() and str(sea[-1]).isdigit():
self.begin_season = int(sea[0])
self.end_season = int(sea[-1])
elif str(sea).isdigit():
self.begin_season = int(sea)
self.end_season = None
def set_episode(self, ep: Union[list, int, str]):
"""
更新集
"""
if not ep:
return
if isinstance(ep, list):
if len(ep) == 1 and str(ep[0]).isdigit():
self.begin_episode = int(ep[0])
self.end_episode = None
elif len(ep) > 1 and str(ep[0]).isdigit() and str(ep[-1]).isdigit():
self.begin_episode = int(ep[0])
self.end_episode = int(ep[-1])
self.total_episode = (self.end_episode - self.begin_episode) + 1
elif str(ep).isdigit():
self.begin_episode = int(ep)
self.end_episode = None
def set_episodes(self, begin: int, end: int):
"""
设置开始集结束集
"""
if begin:
self.begin_episode = begin
if end:
self.end_episode = end
if self.begin_episode and self.end_episode:
self.total_episode = (self.end_episode - self.begin_episode) + 1
def merge(self, meta: Self):
"""
合并Meta信息
"""
# 类型
if self.type == MediaType.UNKNOWN \
and meta.type != MediaType.UNKNOWN:
self.type = meta.type
# 名称
if not self.name:
self.cn_name = meta.cn_name
self.en_name = meta.en_name
# 未应用识别词时识别出的名称
if not self.original_name:
self.original_name = meta.original_name
# 年份
if not self.year:
self.year = meta.year
# 季
if (self.type == MediaType.TV
and self.begin_season is None):
self.begin_season = meta.begin_season
self.end_season = meta.end_season
self.total_season = meta.total_season
# 开始集
if (self.type == MediaType.TV
and self.begin_episode is None):
self.begin_episode = meta.begin_episode
self.end_episode = meta.end_episode
self.total_episode = meta.total_episode
# 版本
if not self.resource_type:
self.resource_type = meta.resource_type
# 分辨率
if not self.resource_pix:
self.resource_pix = meta.resource_pix
# 制作组/字幕组
if not self.resource_team:
self.resource_team = meta.resource_team
# 自定义占位符
if not self.customization:
self.customization = meta.customization
# 特效
if not self.resource_effect:
self.resource_effect = meta.resource_effect
# 视频编码
if not self.video_encode:
self.video_encode = meta.video_encode
# 视频位深
if not self.video_bit:
self.video_bit = meta.video_bit
# 音频编码
if not self.audio_encode:
self.audio_encode = meta.audio_encode
# 帧率信息
if not self.fps:
self.fps = meta.fps
# Part
if not self.part:
self.part = meta.part
# 媒体身份必须原子合并,不能将不同目录层级的来源和ID拼成一对
current_source, current_id = resolve_media_identity(media=self)
if current_source and current_id:
self.media_source, self.media_id = current_source, current_id
else:
self.media_source, self.media_id = resolve_media_identity(media=meta)
# 剧集组
if not self.episode_group and meta.episode_group:
self.episode_group = meta.episode_group
def to_dict(self):
"""
转为字典
"""
dicts = vars(self).copy()
dicts["type"] = self.type.value if self.type else None
dicts["season_episode"] = self.season_episode
dicts["edition"] = self.edition
dicts["name"] = self.name
dicts["episode_list"] = self.episode_list
return dicts
File diff suppressed because it is too large Load Diff
+838
View File
@@ -0,0 +1,838 @@
import re
from typing import Optional
from Pinyin2Hanzi import is_pinyin
from app.domain.meta.customization import CustomizationMatcher
from app.domain.meta.metabase import MetaBase
from app.domain.meta.releasegroup import ReleaseGroupsMatcher
from app.schemas.types import MediaType
from app.domain.string import StringUtils
from app.domain.tokens import Tokens
from app.domain.meta.streamingplatform import StreamingPlatforms
from app.domain.meta.runtime import get_media_extensions
SEASON_FULL_RE = re.compile(r"^(?:Season\s+|S)(\d{1,3})$", re.IGNORECASE)
FIRST_BRACKET_RE = re.compile(r'^[\[【](.+?)[\]】]')
BRACKET_DOT_TITLE_RE = re.compile(r'[A-Za-z]+\..+(?:19|20)\d{2}')
BRACKET_RESOURCE_RE = re.compile(
r'(?:2160|1080|720|480)[PIpi]|4K|UHD|Blu[\-.]?ray|REMUX|WEB[\-.]?DL|HDTV',
re.IGNORECASE,
)
YEAR_RANGE_RE = re.compile(r'([\s.]+)(\d{4})-(\d{4})')
FILE_SIZE_RE = re.compile(r'[0-9.]+\s*[MGT]i?B(?![A-Z]+)', re.IGNORECASE)
DATE_RE = re.compile(r'\d{4}[\s._-]\d{1,2}[\s._-]\d{1,2}')
DIY_RE = re.compile(r'DIY', re.IGNORECASE)
DIY_TITLE_RE = re.compile(r'-DIY@', re.IGNORECASE)
DESCRIPTION_SPLIT_RE = re.compile(r'[\s/|]+')
SPACE_RE = re.compile(r'\s+')
SEASON_SUFFIX_RE = re.compile(r"SEASON$", re.IGNORECASE)
SOURCE_RE = (
r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|"
r"^BLU$|^WEB$|^BD$|^HDRip$|^REMUX$|^UHD$"
)
SOURCE_PATTERN = re.compile(r"(%s)" % SOURCE_RE, re.IGNORECASE)
SOURCE_NAMES = {
"BLURAY": "BluRay",
"HDTV": "HDTV",
"UHDTV": "UHDTV",
"HDDVD": "HDDVD",
"WEBRIP": "WEBRip",
"DVDRIP": "DVDRip",
"BDRIP": "BDRIP",
"BLU": "BLU",
"WEB": "WEB",
"BD": "BD",
"HDRIP": "HDRip",
"REMUX": "REMUX",
"UHD": "UHD",
}
class MetaVideo(MetaBase):
"""
识别电影、电视剧
"""
# 控制标位区
_stop_name_flag = False
_stop_cnname_flag = False
_last_token = ""
_last_token_type = ""
_continue_flag = True
_unknown_name_str = ""
_sources = []
_effect = []
# 正则式区
_season_re = r"S(\d{3})|^S(\d{1,3})$|S(\d{1,3})E"
_episode_re = r"EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})"
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
_source_re = SOURCE_RE
_effect_re = r"^SDR$|^HDR\d*$|^HDRVIVID$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$|^HLG$|^HDR10(\+|Plus)$|^HDR10P$|^VIVID$|^EDR$|^HQ$"
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
_name_no_begin_re = r"^[\[【].+?[\]】]"
_name_no_chinese_re = r".*版|.*字幕"
_name_se_words = ['', '', '', '', '', '', '']
_name_movie_words = ['剧场版', '劇場版', '电影版', '電影版']
_name_nostring_re = r"^PTS|^JADE|^AOD|^CHC|^[A-Z]{1,4}TV[\-0-9UVHDK]*" \
r"|\d{1,2}th|\d{1,2}bit|IMAX|^3D|\s+3D|\s+DC$" \
r"|[第\s共]+[0-9一二三四五六七八九十\-\s]+季" \
r"|[第\s共]+[0-9一二三四五六七八九十百零\-\s]+[集话話]" \
r"|连载|日剧|美剧|电视剧|动画片|动漫|欧美|西德|日韩|超高清|高清|无水印|下载|蓝光|翡翠台|梦幻天堂·龙网|★?\d*月?新番" \
r"|最终季|合集|[多中国英葡法俄日韩德意西印泰台港粤双文语简繁体特效内封官译外挂]+字幕|版本|出品|台版|港版|\w+字幕组|\w+字幕社" \
r"|未删减版|UNCUT$|UNRATE$|WITH EXTRAS$|RERIP$|SUBBED$|PROPER$|REPACK$|SEASON$|EPISODE$|Complete$|Extended$|Extended Version$" \
r"|S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}" \
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]" \
r"|[248]K|\d{3,4}[PIX]+" \
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]|\s+GB"
_resources_pix_re = r"^[SBUHD]*(\d{3,4}[PI]+)|\d{3,4}X(\d{3,4})"
_resources_pix_re2 = r"(^[248]+K)"
_video_encode_re = r"^(H26[45])$|^(x26[45])$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^AV1$|^HDR\d*$|^AVS(\+|[23])$"
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^EAC3\d?$|^\dAudios?$|^DDP\d?$|^DD\+\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$|^HR\d?$|^Opus\d?$|^Vorbis\d?$|^AV[3S]A$"
_fps_re = r"(\d{2,3})(?=FPS)"
_season_pattern = re.compile(_season_re, re.IGNORECASE)
_episode_pattern = re.compile(_episode_re, re.IGNORECASE)
_part_pattern = re.compile(_part_re, re.IGNORECASE)
_roman_numerals_pattern = re.compile(_roman_numerals)
_source_pattern = SOURCE_PATTERN
_effect_pattern = re.compile(r"(%s)" % _effect_re, re.IGNORECASE)
_resources_type_pattern = re.compile(r"(%s)" % _resources_type_re, re.IGNORECASE)
_name_no_chinese_pattern = re.compile(_name_no_chinese_re, re.IGNORECASE)
_name_movie_words_pattern = re.compile("|".join(_name_movie_words), re.IGNORECASE)
_name_nostring_pattern = re.compile(_name_nostring_re, re.IGNORECASE)
_resources_pix_pattern = re.compile(_resources_pix_re, re.IGNORECASE)
_resources_pix_pattern2 = re.compile(_resources_pix_re2, re.IGNORECASE)
_video_encode_pattern = re.compile(r"(%s)" % _video_encode_re, re.IGNORECASE)
_audio_encode_pattern = re.compile(r"(%s)" % _audio_encode_re, re.IGNORECASE)
_fps_pattern = re.compile(r"(%s)" % _fps_re, re.IGNORECASE)
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
"""
初始化
:param title: 标题,文件为去掉了后缀
:param subtitle: 副标题
:param isfile: 是否是文件名
"""
super().__init__(title, subtitle, isfile)
if not title:
return
original_title = title
self._sources = []
self._effect = []
self._index = 0
# 判断是否纯数字命名
if isfile \
and title.isdigit() \
and len(title) < 5:
self.begin_episode = int(title)
self.type = MediaType.TV
return
# 全名为Season xx 及 Sxx 直接返回
season_full_res = SEASON_FULL_RE.search(title)
if season_full_res:
self.type = MediaType.TV
season = season_full_res.group(1)
if season:
self.begin_season = int(season)
self.total_season = 1
return
# 去掉名称中第1个[]的内容
_first_bracket = FIRST_BRACKET_RE.match(title)
if _first_bracket:
_bracket_content = _first_bracket.group(1)
# 如果第一个括号内为点分隔的英文发布名格式(含年份+资源类型),保留内容去掉括号
if BRACKET_DOT_TITLE_RE.search(_bracket_content) \
and BRACKET_RESOURCE_RE.search(_bracket_content):
title = _bracket_content + title[_first_bracket.end():]
else:
title = title[_first_bracket.end():]
# 把xxxx-xxxx年份换成前一个年份,常出现在季集上
title = YEAR_RANGE_RE.sub(r'\1\2', title)
# 把大小去掉
title = FILE_SIZE_RE.sub("", title)
# 把年月日去掉
title = DATE_RE.sub("", title)
media_exts = get_media_extensions()
# 拆分tokens
tokens = Tokens(title)
# 实例化StreamingPlatforms对象
streaming_platforms = StreamingPlatforms()
# 解析名称、年份、季、集、资源类型、分辨率等
token = tokens.get_next()
while token:
self._index += 1 # 更新当前处理的token索引
# Part
self.__init_part(token, tokens)
# 标题
if self._continue_flag:
self.__init_name(token, media_exts)
# 年份
if self._continue_flag:
self.__init_year(token)
# 分辨率
if self._continue_flag:
self.__init_resource_pix(token)
# 季
if self._continue_flag:
self.__init_season(token)
# 集
if self._continue_flag:
self.__init_episode(token)
# 资源类型
if self._continue_flag:
self.__init_resource_type(token)
# 流媒体平台
if self._continue_flag:
self.__init_web_source(token, tokens, streaming_platforms)
# 视频编码
if self._continue_flag:
self.__init_video_encode(token)
# 视频位深
if self._continue_flag:
self.__init_video_bit(token)
# 音频编码
if self._continue_flag:
self.__init_audio_encode(token)
# 帧率
if self._continue_flag:
self.__init_fps(token)
# 取下一个,直到没有为卡
token = tokens.get_next()
self._continue_flag = True
# 合成质量
if self._effect:
self._effect.reverse()
self.resource_effect = " ".join(self._effect)
if self._sources:
self.resource_type = " ".join(self._sources)
# 提取原盘DIY
if self.resource_type and "BluRay" in self.resource_type:
if (self.subtitle and DIY_RE.search(self.subtitle)) \
or DIY_TITLE_RE.search(original_title):
self.resource_type = f"{self.resource_type} DIY"
# 解析副标题,只要季和集
self.init_subtitle(self.org_string)
if not self._subtitle_flag and self.subtitle:
self.init_subtitle(self.subtitle)
# 去掉名字中不需要的干扰字符,过短的纯数字不要
self.cn_name = self.__fix_name(self.cn_name)
self.en_name = StringUtils.str_title(self.__fix_name(self.en_name))
# 处理part
if self.part and self.part.upper() == "PART":
self.part = None
# 没有中文标题时,尝试中描述中获取中文名
if not self.cn_name and self.en_name and self.subtitle:
if self.__is_pinyin(self.en_name):
# 英文名是拼音
cn_name = self.__get_title_from_description(self.subtitle)
if cn_name and len(cn_name) == len(self.en_name.split()):
# 中文名和拼音单词数相同,认为是中文名
self.cn_name = cn_name
# 制作组/字幕组
self.resource_team = ReleaseGroupsMatcher().match(title=original_title) or None
# 自定义占位符
self.customization = CustomizationMatcher().match(title=original_title) or None
if not self.video_bit:
self.video_bit = self.extract_video_bit(self.video_encode)
@staticmethod
def __get_title_from_description(description: str) -> Optional[str]:
"""
从描述中提取标题
"""
if not description:
return None
titles = DESCRIPTION_SPLIT_RE.split(description)
if StringUtils.is_chinese(titles[0]):
return titles[0]
return None
@staticmethod
def __is_pinyin(name_str: Optional[str]) -> bool:
"""
判断是否拼音
"""
if not name_str:
return False
for n in name_str.lower().split():
if not is_pinyin(n):
return False
return True
def __fix_name(self, name: Optional[str]):
"""
去掉名字中不需要的干扰字符
"""
if not name:
return name
name = self._name_nostring_pattern.sub('', name).strip()
name = SPACE_RE.sub(' ', name)
if name.isdecimal() \
and int(name) < 1800 \
and not self.year \
and self.begin_season is None \
and not self.resource_pix \
and not self.resource_type \
and not self.audio_encode \
and not self.video_encode:
if self.begin_episode is None:
self.begin_episode = int(name)
name = None
elif self.is_in_episode(int(name)) and self.begin_season is None:
name = None
return name
def __init_name(self, token: Optional[str], media_exts: list):
"""
识别名称
"""
if not token:
return
# 回收标题
if self._unknown_name_str:
if not self.cn_name:
if not self.en_name:
self.en_name = self._unknown_name_str
elif self._unknown_name_str != self.year:
self.en_name = "%s %s" % (self.en_name, self._unknown_name_str)
self._last_token_type = "enname"
self._unknown_name_str = ""
if self._stop_name_flag:
return
if token.upper() == "AKA":
self._continue_flag = False
self._stop_name_flag = True
return
if token in self._name_se_words:
self._last_token_type = 'name_se_words'
return
if StringUtils.is_chinese(token):
# 含有中文,直接做为标题(连着的数字或者英文会保留),且不再取用后面出现的中文
self._last_token_type = "cnname"
if not self.cn_name:
self.cn_name = token
elif not self._stop_cnname_flag:
if self._name_movie_words_pattern.search(token) \
or (not self._name_no_chinese_pattern.search(token)
and not any(w in token for w in self._name_se_words)):
self.cn_name = "%s %s" % (self.cn_name, token)
self._stop_cnname_flag = True
else:
is_roman_digit = self._roman_numerals_pattern.search(token)
# 阿拉伯数字或者罗马数字
if token.isdigit() or is_roman_digit:
# 第季集后面的不要
if self._last_token_type == 'name_se_words':
return
if self.name:
# 名字后面以 0 开头的不要,极有可能是集
if token.startswith('0'):
return
# 检查是否真正的数字
if token.isdigit():
try:
int(token)
except ValueError:
return
# 中文名后面跟的数字不是年份的极有可能是集
if not is_roman_digit \
and self._last_token_type == "cnname" \
and int(token) < 1900:
return
if (token.isdigit() and len(token) < 4) or is_roman_digit:
# 4位以下的数字或者罗马数字,拼装到已有标题中
if self._last_token_type == "cnname":
self.cn_name = "%s %s" % (self.cn_name, token)
elif self._last_token_type == "enname":
self.en_name = "%s %s" % (self.en_name, token)
self._continue_flag = False
elif token.isdigit() and len(token) == 4:
# 4位数字,可能是年份,也可能真的是标题的一部分,也有可能是集
if not self._unknown_name_str:
self._unknown_name_str = token
else:
# 名字未出现前的第一个数字,记下来
if not self._unknown_name_str:
self._unknown_name_str = token
elif self._season_pattern.search(token):
# 季的处理
if self.en_name and SEASON_SUFFIX_RE.search(self.en_name):
# 如果匹配到季,英文名结尾为Season,说明Season属于标题,不应在后续作为干扰词去除
self.en_name += ' '
self._stop_name_flag = True
return
elif self._episode_pattern.search(token) \
or self._resources_type_pattern.search(token) \
or self._resources_pix_pattern.search(token):
# 集、来源、版本等不要
self._stop_name_flag = True
return
else:
# 后缀名不要
if ".%s".lower() % token in media_exts:
return
# 英文或者英文+数字,拼装起来
if self.en_name:
self.en_name = "%s %s" % (self.en_name, token)
else:
self.en_name = token
self._last_token_type = "enname"
def __init_part(self, token: str, tokens: Tokens):
"""
识别Part
"""
if not self.name:
return
if not self.year \
and self.begin_season is None \
and not self.begin_episode \
and not self.resource_pix \
and not self.resource_type:
return
re_res = self._part_pattern.search(token)
if re_res:
if not self.part:
self.part = re_res.group(1)
nextv = tokens.cur()
if nextv \
and ((nextv.isdigit() and (len(nextv) == 1 or len(nextv) == 2 and nextv.startswith('0')))
or nextv.upper() in ['A', 'B', 'C', 'I', 'II', 'III']):
self.part = "%s%s" % (self.part, nextv)
tokens.get_next()
self._last_token_type = "part"
self._continue_flag = False
# self._stop_name_flag = False
def __init_year(self, token: str):
"""
识别年份
"""
if not self.name:
return
if not token.isdigit():
return
if len(token) != 4:
return
if not 1900 < int(token) < 2050:
return
if self.year:
if self.en_name:
self.en_name = "%s %s" % (self.en_name.strip(), self.year)
elif self.cn_name:
self.cn_name = "%s %s" % (self.cn_name, self.year)
elif self.en_name and SEASON_SUFFIX_RE.search(self.en_name):
# 如果匹配到年,且英文名结尾为Season,说明Season属于标题,不应在后续作为干扰词去除
self.en_name += ' '
self.year = token
self._last_token_type = "year"
self._continue_flag = False
self._stop_name_flag = True
def __init_resource_pix(self, token: str):
"""
识别分辨率
"""
if not self.name:
return
re_res = self._resources_pix_pattern.findall(token)
if re_res:
self._last_token_type = "pix"
self._continue_flag = False
self._stop_name_flag = True
resource_pix = None
for pixs in re_res:
if isinstance(pixs, tuple):
pix_t = None
for pix_i in pixs:
if pix_i:
pix_t = pix_i
break
if pix_t:
resource_pix = pix_t
else:
resource_pix = pixs
if resource_pix and not self.resource_pix:
self.resource_pix = resource_pix.lower()
break
if self.resource_pix \
and self.resource_pix.isdigit() \
and self.resource_pix[-1] not in 'kpi':
self.resource_pix = "%sp" % self.resource_pix
else:
re_res = self._resources_pix_pattern2.search(token)
if re_res:
self._last_token_type = "pix"
self._continue_flag = False
self._stop_name_flag = True
if not self.resource_pix:
self.resource_pix = re_res.group(1).lower()
def __init_season(self, token: str):
"""
识别季
"""
re_res = self._season_pattern.findall(token)
if re_res:
self._last_token_type = "season"
self.type = MediaType.TV
self._stop_name_flag = True
self._continue_flag = True
for se in re_res:
if isinstance(se, tuple):
se_t = None
for se_i in se:
if se_i and str(se_i).isdigit():
se_t = se_i
break
if se_t:
se = int(se_t)
else:
break
else:
se = int(se)
if self.begin_season is None:
self.begin_season = se
self.total_season = 1
else:
if se > self.begin_season:
self.end_season = se
self.total_season = (self.end_season - self.begin_season) + 1
if self.isfile and self.total_season > 1:
self.end_season = None
self.total_season = 1
elif token.isdigit():
try:
int(token)
except ValueError:
return
if self._last_token_type == "SEASON" \
and self.begin_season is None \
and len(token) < 3:
self.begin_season = int(token)
self.total_season = 1
self._last_token_type = "season"
self._stop_name_flag = True
self._continue_flag = False
self.type = MediaType.TV
elif token.upper() == "SEASON" and self.begin_season is None:
self._last_token_type = "SEASON"
elif self.type == MediaType.TV and self.begin_season is None:
self.begin_season = 1
def __init_episode(self, token: str):
"""
识别集
"""
re_res = self._episode_pattern.findall(token)
if re_res:
self._last_token_type = "episode"
self._continue_flag = False
self._stop_name_flag = True
self.type = MediaType.TV
for se in re_res:
if isinstance(se, tuple):
se_t = None
for se_i in se:
if se_i and str(se_i).isdigit():
se_t = se_i
break
if se_t:
se = int(se_t)
else:
break
else:
se = int(se)
if self.begin_episode is None:
self.begin_episode = se
self.total_episode = 1
else:
if se > self.begin_episode:
self.end_episode = se
self.total_episode = (self.end_episode - self.begin_episode) + 1
if self.isfile and self.total_episode > 2:
self.end_episode = None
self.total_episode = 1
elif token.isdigit():
try:
int(token)
except ValueError:
return
if self.begin_episode is not None \
and self.end_episode is None \
and len(token) < 5 \
and int(token) > self.begin_episode \
and self._last_token_type == "episode":
self.end_episode = int(token)
self.total_episode = (self.end_episode - self.begin_episode) + 1
if self.isfile and self.total_episode > 2:
self.end_episode = None
self.total_episode = 1
self._continue_flag = False
self.type = MediaType.TV
elif self.begin_episode is None \
and 1 < len(token) < 4 \
and self._last_token_type != "year" \
and self._last_token_type != "videoencode" \
and token != self._unknown_name_str:
self.begin_episode = int(token)
self.total_episode = 1
self._last_token_type = "episode"
self._continue_flag = False
self._stop_name_flag = True
self.type = MediaType.TV
elif self._last_token_type == "EPISODE" \
and self.begin_episode is None \
and len(token) < 5:
self.begin_episode = int(token)
self.total_episode = 1
self._last_token_type = "episode"
self._continue_flag = False
self._stop_name_flag = True
self.type = MediaType.TV
elif token.upper() == "EPISODE":
self._last_token_type = "EPISODE"
def __append_resource_source(self, source: str) -> None:
"""
按出现顺序追加资源类型并忽略重复项。
:param source: 原始资源类型标记
"""
source_name = SOURCE_NAMES.get(source.upper(), source)
if source_name.casefold() not in {
item.casefold() for item in self._sources
}:
self._sources.append(source_name)
def __replace_last_resource_source(self, source: str, replacement: str) -> None:
"""
将拆分的资源类型前缀替换为完整规范名称。
:param source: 待替换的末尾资源类型
:param replacement: 完整资源类型
"""
if self._sources and self._sources[-1].casefold() == source.casefold():
self._sources.pop()
self.__append_resource_source(replacement)
def __init_resource_type(self, token):
"""
识别资源类型
"""
if not self.name:
return
if token.upper() == "DL" \
and self._last_token_type == "source" \
and self._last_token == "WEB":
self.__replace_last_resource_source("WEB", "WEB-DL")
self._continue_flag = False
return
elif token.upper() == "RAY" \
and self._last_token_type == "source" \
and self._last_token == "BLU":
self.__replace_last_resource_source("BLU", "BluRay")
self._continue_flag = False
return
elif token.upper() == "WEBDL":
self.__append_resource_source("WEB-DL")
self._continue_flag = False
return
source_res = self._source_pattern.search(token)
if source_res:
self._last_token_type = "source"
self._continue_flag = False
self._stop_name_flag = True
source = source_res.group(1)
self.__append_resource_source(source)
self._last_token = source.upper()
return
effect_res = self._effect_pattern.search(token)
if effect_res:
self._last_token_type = "effect"
self._continue_flag = False
self._stop_name_flag = True
effect = effect_res.group(1)
if effect not in self._effect:
self._effect.append(effect)
self._last_token = effect.upper()
def __init_web_source(self, token: str, tokens: Tokens, streaming_platforms: StreamingPlatforms):
"""
识别流媒体平台
"""
if not self.name:
return
platform_name = None
query_range = 1
prev_token = None
prev_idx = self._index - 2
if 0 <= prev_idx < len(tokens.tokens):
prev_token = tokens.tokens[prev_idx]
next_token = tokens.peek()
if streaming_platforms.is_streaming_platform(token):
platform_name = streaming_platforms.get_streaming_platform_name(token)
else:
for adjacent_token, is_next in [(prev_token, False), (next_token, True)]:
if not adjacent_token or platform_name:
continue
for separator in [" ", "-"]:
if is_next:
combined_token = f"{token}{separator}{adjacent_token}"
else:
combined_token = f"{adjacent_token}{separator}{token}"
if streaming_platforms.is_streaming_platform(combined_token):
platform_name = streaming_platforms.get_streaming_platform_name(combined_token)
query_range = 2
if is_next:
tokens.get_next()
break
if not platform_name:
return
web_tokens = ["WEB", "DL", "WEBDL", "WEBRIP"]
match_start_idx = self._index - query_range
match_end_idx = self._index - 1
start_index = max(0, match_start_idx - query_range)
end_index = min(len(tokens.tokens), match_end_idx + 1 + query_range)
tokens_to_check = tokens.tokens[start_index:end_index]
if any(tok and tok.upper() in web_tokens for tok in tokens_to_check):
self.web_source = platform_name
self._continue_flag = False
def __init_video_encode(self, token: str):
"""
识别视频编码
"""
if not self.name:
return
if not self.year \
and not self.resource_pix \
and not self.resource_type \
and self.begin_season is None \
and not self.begin_episode:
return
re_res = self._video_encode_pattern.search(token)
if re_res:
self._continue_flag = False
self._stop_name_flag = True
self._last_token_type = "videoencode"
if not self.video_encode:
if re_res.group(2):
self.video_encode = re_res.group(2).upper()
elif re_res.group(3):
self.video_encode = re_res.group(3).lower()
else:
self.video_encode = re_res.group(1).upper()
self._last_token = self.video_encode
elif self.video_encode == "10bit":
self.video_encode = f"{re_res.group(1).upper()} 10bit"
self._last_token = re_res.group(1).upper()
elif token.upper() in ['H', 'X']:
self._continue_flag = False
self._stop_name_flag = True
self._last_token_type = "videoencode"
self._last_token = token.upper() if token.upper() == "H" else token.lower()
elif token in ["264", "265"] \
and self._last_token_type == "videoencode" \
and self._last_token in ['H', 'X']:
self.video_encode = "%s%s" % (self._last_token, token)
elif token.isdigit() \
and self._last_token_type == "videoencode" \
and self._last_token in ['VC', 'MPEG']:
self.video_encode = "%s%s" % (self._last_token, token)
elif token.upper() == "10BIT":
self._last_token_type = "videoencode"
if not self.video_encode:
self.video_encode = "10bit"
else:
self.video_encode = f"{self.video_encode} 10bit"
def __init_video_bit(self, token: str):
"""
识别视频位深。
"""
if not self.name:
return
if not self.year \
and not self.resource_pix \
and not self.resource_type \
and self.begin_season is None \
and not self.begin_episode:
return
video_bit = self.extract_video_bit(token)
if not video_bit:
return
self._continue_flag = False
self._stop_name_flag = True
self._last_token_type = "videobit"
if not self.video_bit:
self.video_bit = video_bit
def __init_audio_encode(self, token: str):
"""
识别音频编码
"""
if not self.name:
return
if not self.year \
and not self.resource_pix \
and not self.resource_type \
and self.begin_season is None \
and not self.begin_episode:
return
re_res = self._audio_encode_pattern.search(token)
if re_res:
self._continue_flag = False
self._stop_name_flag = True
self._last_token_type = "audioencode"
self._last_token = re_res.group(1).upper()
if not self.audio_encode:
self.audio_encode = re_res.group(1)
else:
if self.audio_encode.upper() == "DTS":
self.audio_encode = "%s-%s" % (self.audio_encode, re_res.group(1))
else:
self.audio_encode = "%s %s" % (self.audio_encode, re_res.group(1))
elif token.isdigit() \
and self._last_token_type == "audioencode":
if self.audio_encode:
if self._last_token.isdigit():
self.audio_encode = "%s.%s" % (self.audio_encode, token)
elif self.audio_encode[-1].isdigit() and self.audio_encode.upper() not in {"AC3", "EAC3"}:
self.audio_encode = "%s %s.%s" % (self.audio_encode[:-1], self.audio_encode[-1], token)
else:
self.audio_encode = "%s %s" % (self.audio_encode, token)
self._last_token = token
def __init_fps(self, token: str):
"""
识别帧率
"""
if not self.name:
return
re_res = self._fps_pattern.search(token)
if re_res:
self._continue_flag = False
self._stop_name_flag = True
self._last_token_type = "fps"
# 提取帧率数值
fps_value = None
if re_res.group(1): # FPS格式
fps_value = re_res.group(1)
if fps_value and fps_value.isdigit():
# 只存储纯数值
self.fps = int(fps_value)
self._last_token = f"{self.fps}FPS"
+145
View File
@@ -0,0 +1,145 @@
import regex as re
from typing import Callable
from app.foundation.singleton import Singleton
_release_groups_provider: Callable[[], object] = lambda: ()
def configure_release_groups_provider(provider: Callable[[], object]) -> None:
"""注入用户制作组来源,保持领域匹配器与配置持久化解耦。"""
global _release_groups_provider
_release_groups_provider = provider
def get_custom_release_groups() -> object:
"""返回当前用户制作组原始配置。"""
return _release_groups_provider()
class ReleaseGroupsMatcher(metaclass=Singleton):
"""
识别制作组、字幕组
"""
# 内置组
RELEASE_GROUPS: dict = {
"0ff": ['FF(?:(?:A|WE)B|CD|E(?:DU|B)|TV)'],
"1pt": [],
"52pt": [],
"audiences": ['Audies', 'AD(?:Audio|E(?:book|)|Music|Web)'],
"azusa": [],
"beitai": ['BeiTai'],
"btschool": ['Bts(?:CHOOL|HD|PAD|TV)', 'Zone'],
"carpt": ['CarPT'],
"chdbits": ['CHD(?:Bits|PAD|(?:|HK)TV|WEB|)', 'StBOX', 'OneHD', 'Lee', 'xiaopie'],
"discfan": [],
"dragonhd": [],
"eastgame": ['(?:(?:iNT|(?:HALFC|Mini(?:S|H|FH)D))-|)TLF'],
"filelist": [],
"gainbound": ['(?:DG|GBWE)B'],
"hares": ['Hares(?:(?:M|T)V|Web|)'],
"hd4fans": [],
"hdarea": ['HDA(?:pad|rea|TV)', 'EPiC'],
"hdatmos": [],
"hdbd": [],
"hdchina": ['HDC(?:hina|TV|)', 'k9611', 'tudou', 'iHD'],
"hddolby": ['D(?:ream|BTV)', '(?:HD|QHstudI)o'],
"hdfans": ['beAst(?:TV|)'],
"hdhome": ['HDH(?:ome|Pad|TV|WEB|)'],
"hdpt": ['HDPT(?:Web|)'],
"hdsky": ['HDS(?:ky|TV|Pad|WEB|)', 'AQLJ'],
"hdtime": [],
"HDU": [],
"hdvideo": [],
"hdzone": ['HDZ(?:one|)'],
"hhanclub": ['HHWEB'],
"hitpt": [],
"htpt": ['HTPT'],
"iptorrents": [],
"joyhd": [],
"keepfrds": ['FRDS', 'Yumi', 'cXcY'],
"lemonhd": ['L(?:eague(?:(?:C|H)D|(?:M|T)V|NF|WEB)|HD)', 'i18n', 'CiNT'],
"mteam": ['MTeam(?:TV|)', 'MPAD', 'MWeb'],
"nanyangpt": [],
"nicept": [],
"oshen": [],
"ourbits": ['Our(?:Bits|TV)', 'FLTTH', 'Ao', 'PbK', 'MGs', 'iLove(?:HD|TV)'],
"panda": ['Panda', 'AilMWeb'],
"piggo": ['PiGo(?:NF|(?:H|WE)B)'],
"ptchina": [],
"pterclub": ['PTer(?:DIY|Game|(?:M|T)V|WEB|)'],
"pthome": ['PTH(?:Audio|eBook|music|ome|tv|WEB|)'],
"ptmsg": [],
"ptsbao": ['PTsbao', 'OPS', 'F(?:Fans(?:AIeNcE|BD|D(?:VD|IY)|TV|WEB)|HDMv)', 'SGXT'],
"pttime": [],
"putao": ['PuTao'],
"soulvoice": [],
"springsunday": ['CMCT(?:V|)'],
"sharkpt": ['Shark(?:WEB|DIY|TV|MV|)'],
"tccf": [],
"tjupt": ['TJUPT'],
"totheglory": ['TTG', 'WiKi', 'NGB', 'DoA', '(?:ARi|ExRE)N'],
"U2": [],
"ultrahd": [],
"others": ['B(?:MDru|eyondHD|TN)', 'C(?:fandora|trlhd|MRG)', 'DON', 'EVO', 'FLUX', 'HONE(?:yG|)',
'N(?:oGroup|T(?:b|G))', 'PandaMoon', 'SMURF', 'T(?:EPES|aengoo|rollHD )'],
"anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', 'SweetSub', 'MingY',
'(?:Lilith|NC)-Raws', '织梦字幕组', '枫叶字幕组', '猎户手抄部', '喵萌奶茶屋', '漫猫字幕社',
'霜庭云花Sub', '北宇治字幕组', '氢气烤肉架', '云歌字幕组', '萌樱字幕组', '极影字幕社',
'悠哈璃羽字幕社',
'❀拨雪寻春❀', '沸羊羊(?:制作|字幕组)', '(?:桜|樱)都字幕组'],
"forge": ['FROG(?:E|Web|)'],
"ubits": ['UB(?:its|WEB|TV)'],
}
def __init__(self):
"""构建内置制作组匹配规则及编译缓存。"""
release_groups = []
for site_groups in self.RELEASE_GROUPS.values():
for release_group in site_groups:
release_groups.append(release_group)
self.__release_groups = '|'.join(release_groups)
self.__groups_re_cache = {}
def get_release_groups(self) -> str:
"""
返回内置与用户自定义制作组组成的匹配规则。
"""
custom_release_groups = get_custom_release_groups()
if isinstance(custom_release_groups, list):
custom_release_groups = list(filter(None, custom_release_groups))
if custom_release_groups:
custom_release_groups_str = '|'.join(custom_release_groups)
return f"{self.__release_groups}|{custom_release_groups_str}"
return self.__release_groups
def __get_groups_re(self, groups: str):
"""
发布组规则通常很长,按规则文本缓存编译结果,避免每个标题都重复编译。
"""
groups_re = self.__groups_re_cache.get(groups)
if not groups_re:
groups_re = re.compile(r"(?<=[-@\[£【&])(?:(?:%s))(?=$|[@.\s\]\[】&])" % groups, re.I)
self.__groups_re_cache[groups] = groups_re
return groups_re
def match(self, title: str = None, groups: str = None):
"""
:param title: 资源标题或文件名
:param groups: 制作组/字幕组
:return: 匹配结果
"""
if not title:
return ""
if not groups:
groups = self.get_release_groups()
title = f"{title} "
groups_re = self.__get_groups_re(groups)
unique_groups = []
for item in groups_re.findall(title):
item_str = item[0] if isinstance(item, tuple) else item
if item_str not in unique_groups:
unique_groups.append(item_str)
return "@".join(unique_groups)
+68
View File
@@ -0,0 +1,68 @@
from typing import Callable, Optional, Protocol, Sequence
class MetaInfoAccelerator(Protocol):
"""领域识别可选使用的加速器契约,具体实现由启动层注入。"""
def parse_metainfo(
self,
title: str,
subtitle: Optional[str] = None,
options: Optional[dict] = None,
) -> Optional[dict]:
"""解析单个标题,无法处理时返回空值。"""
def parse_metainfo_path(
self,
path: str,
options: Optional[dict] = None,
) -> Optional[dict]:
"""解析文件路径,无法处理时返回空值。"""
def parse_metamusic(
self,
title: str,
artists: Optional[list[str]] = None,
year: Optional[int] = None,
) -> Optional[dict]:
"""解析音乐标题,无法处理时返回空值。"""
def find_metainfo(self, title: str) -> Optional[dict]:
"""提取标题中的显式媒体标签。"""
def supports_extended_media_ids(self) -> bool:
"""返回加速器是否支持扩展媒体来源标签。"""
_media_extensions_provider: Callable[[], Sequence[str]] = lambda: ()
_audio_extensions_provider: Callable[[], Sequence[str]] = lambda: ()
_metainfo_accelerator: Optional[MetaInfoAccelerator] = None
def configure_recognition_runtime(
*,
media_extensions_provider: Callable[[], Sequence[str]],
audio_extensions_provider: Callable[[], Sequence[str]],
accelerator: Optional[MetaInfoAccelerator],
) -> None:
"""注入文件类型配置和可选加速器,保持领域解析器与平台实现解耦。"""
global _media_extensions_provider, _audio_extensions_provider
global _metainfo_accelerator
_media_extensions_provider = media_extensions_provider
_audio_extensions_provider = audio_extensions_provider
_metainfo_accelerator = accelerator
def get_media_extensions() -> tuple[str, ...]:
"""返回当前影视、字幕和音频文件后缀。"""
return tuple(_media_extensions_provider() or ())
def get_audio_extensions() -> tuple[str, ...]:
"""返回当前音频文件后缀。"""
return tuple(_audio_extensions_provider() or ())
def get_metainfo_accelerator() -> Optional[MetaInfoAccelerator]:
"""返回启动层注入的可选识别加速器。"""
return _metainfo_accelerator
+320
View File
@@ -0,0 +1,320 @@
from typing import Optional, List, Tuple
from app.foundation.singleton import Singleton
class StreamingPlatforms(metaclass=Singleton):
"""
流媒体平台简称与全称。
"""
STREAMING_PLATFORMS: List[Tuple[str, str]] = [
("AMZN", "Amazon"),
("NF", "Netflix"),
("ATVP", "Apple TV+"),
("iT", "iTunes"),
("DSNP", "Disney+"),
("HS", "Hotstar"),
("APPS", "Disney+ MENA"),
("PMTP", "Paramount+"),
("HMAX", "Max"),
("", "Max"),
("HULU", "Hulu Networks"),
("MA", "Movies Anywhere"),
("BCORE", "Bravia Core"),
("MS", "Microsoft Store"),
("SHO", "Showtime"),
("STAN", "Stan"),
("PCOK", "Peacock"),
("SKST", "SkyShowtime"),
("NOW", "Now"),
("FXTL", "Foxtel Now"),
("BNGE", "Binge"),
("CRKL", "Crackle"),
("RKTN", "Rakuten TV"),
("ALL4", "Channel 4"),
("AS", "Adult Swim"),
("BRTB", "Brtb TV"),
("CNLP", "Canal+"),
("CRIT", "Criterion Channel"),
("DSCP", "Discovery+"),
("FOOD", "Food Network"),
("MUBI", "Mubi"),
("PLAY", "Google Play"),
("YT", "YouTube"),
("", "friDay"),
("", "KKTV"),
("", "ofiii"),
("", "LiTV"),
("", "MyVideo"),
("Hami", "Hami Video"),
("HamiVideo", "Hami Video"),
("MW", "meWATCH"),
("CATCHPLAY", "CATCHPLAY+"),
("CPP", "CATCHPLAY+"),
("LINETV", "LINE TV"),
("VIU", "Viu"),
("IQ", ""),
("", "WeTV"),
("ABMA", "Abema"),
("ADN", ""),
("AT-X", ""),
("Baha", ""),
("BG", "B-Global"),
("CR", "Crunchyroll"),
("", "DMM"),
("FOD", ""),
("FUNi", "Funimation"),
("HIDI", "HIDIVE"),
("UNXT", "U-NEXT"),
("FAA", "Filmarchiv Austria"),
("CC", "Comedy Central"),
("iP", "BBC iPlayer"),
("9NOW", "9Now"),
("ABC", ""),
("", "AMC"),
("", "ZEE5"),
("", "WAVO"),
("SHAHID", "Shahid"),
("Flixole", "FlixOlé"),
("TOU", "Ici TOU.TV"),
("ROKU", "Roku"),
("KNPY", "Kanopy"),
("SNXT", "Sun NXT"),
("CUR", "Curiosity Stream"),
("MY5", "Channel 5"),
("AHA", "aha"),
("WOWP", "WOW Presents Plus"),
("JC", "JioCinema"),
("", "Dekkoo"),
("FILMZIE", "Filmzie"),
("HoiChoi", "Hoichoi"),
("VIKI", "Rakuten Viki"),
("SF", "SF Anytime"),
("PLEX", "Plex"),
("SHDR", "Shudder"),
("CRAV", "Crave"),
("CPE", "Cineplex Entertainment"),
("JF HC", ""),
("JF", ""),
("JFFP", ""),
("VIAP", "Viaplay"),
("TUBI", "TubiTV"),
("", "PBS"),
("PBSK", "PBS KIDS"),
("LGP", "Lionsgate Play"),
("", "CTV"),
("", "Cineverse"),
("LN", "Love Nature"),
("MP", "Movistar Plus+"),
("RUNTIME", "Runtime"),
("STZ", "STARZ"),
("FUBO", "fuboTV"),
("TENK", "Tënk"),
("KNOW", "Knowledge Network"),
("TVO", "tvo"),
("", "OVID"),
("CBC", "CBC Gem"),
("FANDOR", "fandor"),
("CW", "The CW"),
("KNPY", "Kanopy"),
("FREE", "Freeform"),
("AE", "A&E"),
("LIFE", "Lifetime"),
("WWEN", "WWE Network"),
("CMAX", "Cinemax"),
("HLMK", "Hallmark"),
("BYU", "BYUtv"),
("", "ViX"),
("VICE", "Viceland"),
("", "TVING"),
("USAN", "USA Network"),
("FOX", ""),
("", "TCM"),
("BRAV", "BravoTV"),
("", "TNT"),
("", "ZDF"),
("", "IndieFlix"),
("", "TLC"),
("", "HGTV"),
("ANPL", "Animal Planet"),
("TRVL", "Travel Channel"),
("", "VH1"),
("SAINA", "Saina Play"),
("SP", "Saina Play"),
("OXGN", "Oxygen"),
("PSN", "PlayStation Network"),
("PMNT", "Paramount Network"),
("FAWESOME", "Fawesome"),
("KLASSIKI", "Klassiki"),
("STRP", "Star+"),
("NATG", "National Geographic"),
("REVEEL", "Reveel"),
("FYI", "FYI Network"),
("WatchiT", "WATCH IT"),
("ITVX", "ITV"),
("GAIA", "Gaia"),
("", "FlixLatino"),
("CNNP", "CNN+"),
("TROMA", "Troma"),
("IVI", "Ivi"),
("9NOW", "9Now"),
("A3P", "Atresplayer"),
("7PLUS", "7plus"),
("", "SBS"),
("TEN", "10Play"),
("AUBC", ""),
("DSNY", "Disney Networks"),
("OSN", "OSN+"),
("SVT", "Sveriges Television"),
("LACINETEK", "LaCinetek"),
("", "Maxdome"),
("RTL", "RTL+"),
("ARTE", "Arte"),
("JOYN", "Joyn"),
("TV2", "TV 2"),
("3SAT", "3sat"),
("FILMINGO", "filmingo"),
("", "WOW"),
("OKKO", "Okko"),
("", "Go3"),
("ARGP", "Argo"),
("VOYO", "Voyo"),
("VMAX", "vivamax"),
("FILMIN", "Filmin"),
("", "Mitele"),
("MY5", "Channel 5"),
("", "ARD"),
("BK", "Bentkey"),
("BOOM", "Boomerang"),
("", "CBS"),
("CLBI", "Club illico"),
("CMOR", "C More"),
("CMT", ""),
("", "CNBC"),
("COOK", "Cooking Channel"),
("CWS", "CW Seed"),
("DCU", "DC Universe"),
("DDY", "Digiturk Dilediğin Yerde"),
("DEST", "Destination America"),
("DISC", "Discovery Channel"),
("DW", "DailyWire+"),
("DLWP", "DailyWire+"),
("DPLY", "dplay"),
("DRPO", "Dropout"),
("EPIX", "EPIX MGM+"),
("ESQ", "Esquire"),
("ETV", "E!"),
("FBWatch", "Facebook Watch"),
("FPT", "FPT Play"),
("FTV", "France.tv"),
("GLOB", "GloboSat Play"),
("GLBO", "Globoplay"),
("GO90", "go90"),
("HIST", "History Channel"),
("HPLAY", "Hungama Play"),
("KS", "Kaleidescape"),
("", "MBC"),
("MMAX", "ManoramaMAX"),
("MNBC", "MSNBC"),
("MTOD", "Motor Trend OnDemand"),
("NBC", ""),
("NBLA", "Nebula"),
("NICK", "Nickelodeon"),
("ODK", "OnDemandKorea"),
("POGO", "PokerGO"),
("PUHU", "puhutv"),
("QIBI", "Quibi"),
("RTE", "RTÉ"),
("SESO", "Seeso"),
("SPIK", "Spike"),
("SS", "Simply South"),
("SYFY", "SyFy"),
("TIMV", "TIMvision"),
("TK", "Tentkotta"),
("", "TV4"),
("TVL", "TV Land"),
("", "TVNZ"),
("", "UKTV"),
("VLCT", "Discovery Velocity"),
("VMEO", "Vimeo"),
("VRV", "VRV Defunct"),
("WTCH", "Watcha"),
("", "NowPlayer"),
("HuluJP", "Hulu Networks"),
("Gaga", "GagaOOLala"),
("MyTVS", "MyTVSuper"),
("", "BBC"),
("CC", "Comedy Central"),
("NowE", "Now E"),
("WAVVE", "Wavve"),
("SE", ""),
("", "BritBox"),
("AOD", "Anime on Demand"),
("AF", ""),
("BCH", "Bandai Channel"),
("VMJ", "VideoMarket"),
("LFTL", "Laftel"),
("WAKA", "Wakanim"),
("WAKANIM", "Wakanim"),
("AO", "AnimeOnegai"),
("", "Lemino"),
("VIDIO", "Vidio"),
("TVER", "TVer"),
("", "MBS"),
("LFTLNET", "Laftel"),
("JONU", "Jonu Play"),
("PlutoTV", "Pluto TV"),
("AbemaTV", "Abema"),
("", "dTV"),
("NYMEY", "Nymey"),
("SMNS", "SAMANSA"),
("CTHP", "CATCHPLAY+"),
("HBOGO", "HBO GO"),
("HBO", "HBO"),
("FPTP", "FPT Play"),
("", "LOCIPO"),
("DANT", "DANET"),
("OV", "OceanVeil"),
]
def __init__(self):
"""初始化流媒体平台匹配器"""
self._lookup_cache = {}
self._build_cache()
def _build_cache(self) -> None:
"""
构建查询缓存。
"""
self._lookup_cache.clear()
for short_name, full_name in self.STREAMING_PLATFORMS:
canonical_name = full_name or short_name
if not canonical_name:
continue
aliases = {short_name, full_name}
for alias in aliases:
if alias:
self._lookup_cache[alias.upper()] = canonical_name
def get_lookup_cache(self) -> dict:
"""
返回流媒体平台查询表副本,供批量解析配置复用。
"""
return dict(self._lookup_cache)
def get_streaming_platform_name(self, platform_code: str) -> Optional[str]:
"""
根据流媒体平台简称或全称获取标准名称。
"""
if platform_code is None:
return None
return self._lookup_cache.get(platform_code.upper())
def is_streaming_platform(self, name: str) -> bool:
"""
判断给定的字符串是否为已知的流媒体平台代码或名称。
"""
if name is None:
return False
return name.upper() in self._lookup_cache
+221
View File
@@ -0,0 +1,221 @@
import ast
import logging
import operator
from functools import lru_cache
from typing import Callable, List, Optional, Tuple
import cn2an
import regex as re
from app.foundation.singleton import Singleton
_custom_words_provider: Callable[[], object] = lambda: ()
logger = logging.getLogger(__name__)
def configure_custom_words_provider(provider: Callable[[], object]) -> None:
"""注入用户识别词来源,领域层只负责解析和应用规则。"""
global _custom_words_provider
_custom_words_provider = provider
def get_custom_words() -> object:
"""返回当前自定义识别词原始配置。"""
return _custom_words_provider()
_COMBINED_WORD_RE = re.compile(r'^\s*(.*?)\s*=>\s*(.*?)\s*&&\s*(.*?)\s*<>\s*(.*?)\s*>>\s*(.*?)\s*$')
_LEADING_ZERO_RE = re.compile(r"^0+")
_EP_TOKEN_RE = re.compile(r"(?<![A-Za-z0-9_])EP(?![A-Za-z0-9_])")
_IMPLICIT_EP_EXPRESSION_RE = re.compile(r"(?:\d|\))\s*EP|EP\s*(?:\d|\()")
_EPISODE_OFFSET_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
}
_EPISODE_OFFSET_UNARY_OPS = {
ast.UAdd: operator.pos,
ast.USub: operator.neg,
}
@lru_cache(maxsize=1024)
def _compile_custom_word_regex(pattern: str):
"""
编译自定义识别词正则,缓存重复识别链路中反复使用的同一规则。
"""
return re.compile(pattern)
def _calculate_episode_offset(offset: str, episode: int) -> int:
"""
按白名单算术语法计算集数偏移,避免执行任意表达式。
"""
if _IMPLICIT_EP_EXPRESSION_RE.search(offset):
raise ValueError("EP 表达式不支持省略运算符")
expression, replace_count = _EP_TOKEN_RE.subn(str(episode), offset)
if "EP" in offset and replace_count == 0:
raise ValueError("EP 占位符格式不正确")
tree = ast.parse(expression, mode="eval")
return int(_evaluate_episode_offset_node(tree.body))
def _evaluate_episode_offset_node(node: ast.AST):
"""
递归计算集数偏移 AST 节点,仅允许数字和基础算术运算。
"""
if isinstance(node, ast.Constant) and isinstance(node.value, int):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _EPISODE_OFFSET_OPS:
left = _evaluate_episode_offset_node(node.left)
right = _evaluate_episode_offset_node(node.right)
return _EPISODE_OFFSET_OPS[type(node.op)](left, right)
if isinstance(node, ast.UnaryOp) and type(node.op) in _EPISODE_OFFSET_UNARY_OPS:
operand = _evaluate_episode_offset_node(node.operand)
return _EPISODE_OFFSET_UNARY_OPS[type(node.op)](operand)
raise ValueError("集数偏移表达式仅支持数字、EP、括号和基础算术运算符")
def _format_episode_offset(episode_num_str: str, episode_num_offset_int: int) -> str:
"""
按原集数字符串格式返回偏移后的集数字符串。
"""
if not episode_num_str.isdigit():
return cn2an.an2cn(episode_num_offset_int, "low")
width = len(episode_num_str) if _LEADING_ZERO_RE.search(episode_num_str) else 0
if episode_num_offset_int < 0:
return f"-{str(abs(episode_num_offset_int)).zfill(width)}"
return str(episode_num_offset_int).zfill(width)
class WordsMatcher(metaclass=Singleton):
"""
自定义识别词匹配器。
"""
def prepare(self, title: str, custom_words: List[str] = None) -> Tuple[str, List[str]]:
"""
预处理标题,支持三种格式
1:屏蔽词
2:被替换词 => 替换词
3:前定位词 <> 后定位词 >> 偏移量(EP
"""
appley_words = []
# 读取自定义识别词
words: List[str] = custom_words or get_custom_words() or []
for word in words:
if not word or word.startswith("#"):
continue
try:
word_info = self.__parse_word(word)
if not word_info:
continue
word_type, params = word_info
if word_type == "replace_and_offset":
thc, bthc, pyq, pyh, offsets = params
# 替换词
title, message, state = self.__replace_regex(title, thc, bthc)
if state:
# 替换词成功再进行集偏移
title, message, state = self.__episode_offset(title, pyq, pyh, offsets)
elif word_type == "replace":
title, message, state = self.__replace_regex(title, params[0], params[1])
elif word_type == "offset":
title, message, state = self.__episode_offset(title, params[0], params[1], params[2])
else: # block
title, message, state = self.__replace_regex(title, params[0], "")
if state:
appley_words.append(word)
except Exception as err:
logger.warning(f"自定义识别词 {word} 预处理标题失败:{str(err)} - 标题:{title}")
return title, appley_words
@staticmethod
def __parse_word(word: str) -> Optional[Tuple[str, Tuple[str, ...]]]:
"""
解析识别词格式。复杂识别词保留原来的字段含义,只把多次正则提取合并为一次。
"""
if word.count(" => ") and word.count(" && ") and word.count(" >> ") and word.count(" <> "):
word_match = _COMBINED_WORD_RE.match(word)
if not word_match:
raise ValueError("复杂识别词格式不正确")
return "replace_and_offset", tuple(item.strip() for item in word_match.groups())
if word.count(" => "):
strings = word.split(" => ")
return "replace", (strings[0], strings[1])
if word.count(" >> ") and word.count(" <> "):
strings = word.split(" <> ")
offsets = strings[1].split(" >> ")
strings[1] = offsets[0]
return "offset", (strings[0], strings[1], offsets[1])
if not word.strip():
return None
return "block", (word,)
@staticmethod
def __replace_regex(title: str, replaced: str, replace: str) -> Tuple[str, str, bool]:
"""
正则替换
"""
try:
replaced_re = _compile_custom_word_regex(r'%s' % replaced)
title, count = replaced_re.subn(r'%s' % replace, title)
return title, "", count > 0
except Exception as err:
logger.warning(f"自定义识别词正则替换失败:{str(err)} - 标题:{title},被替换词:{replaced},替换词:{replace}")
return title, str(err), False
@staticmethod
def __episode_offset(title: str, front: str, back: str, offset: str) -> Tuple[str, str, bool]:
"""
集数偏移
"""
try:
if back and not _compile_custom_word_regex(r'%s' % back).search(title):
return title, "", False
if front and not _compile_custom_word_regex(r'%s' % front).search(title):
return title, "", False
offset_word_info_re = _compile_custom_word_regex(
r'(?<=%s.*?)[0-9一二三四五六七八九十]+(?=.*?%s)' % (front, back)
)
episode_nums_str = offset_word_info_re.findall(title)
if not episode_nums_str:
return title, "", False
episode_nums_offset_str = []
offset_order_flag = False
for episode_num_str in episode_nums_str:
episode_num_int = int(cn2an.cn2an(episode_num_str, "smart"))
episode_num_offset_int = _calculate_episode_offset(offset, episode_num_int)
# 向前偏移
if episode_num_int > episode_num_offset_int:
offset_order_flag = True
# 向后偏移
elif episode_num_int < episode_num_offset_int:
offset_order_flag = False
episode_num_offset_str = _format_episode_offset(
episode_num_str, episode_num_offset_int
)
episode_nums_offset_str.append(episode_num_offset_str)
episode_nums_dict = dict(zip(episode_nums_str, episode_nums_offset_str))
# 集数向前偏移,集数按升序处理
if offset_order_flag:
episode_nums_list = sorted(episode_nums_dict.items(), key=lambda x: x[1])
# 集数向后偏移,集数按降序处理
else:
episode_nums_list = sorted(episode_nums_dict.items(), key=lambda x: x[1], reverse=True)
for episode_num in episode_nums_list:
episode_offset_re = _compile_custom_word_regex(
r'(?<=%s.*?)%s(?=.*?%s)' % (front, episode_num[0], back)
)
title = episode_offset_re.sub(r'%s' % episode_num[1], title)
return title, "", True
except Exception as err:
logger.warning(f"自定义识别词集数偏移失败:{str(err)} - 标题:{title},前定位词:{front},后定位词:{back},偏移量:{offset}")
return title, str(err), False