perf: optimize media recognition internals

This commit is contained in:
jxxghp
2026-05-15 13:37:36 +08:00
parent b2a18f9ae4
commit 2831eecbeb
9 changed files with 396 additions and 254 deletions
+7 -2
View File
@@ -14,6 +14,7 @@ class CustomizationMatcher(metaclass=Singleton):
self.systemconfig = SystemConfigOper() self.systemconfig = SystemConfigOper()
self.customization = None self.customization = None
self.custom_separator = None self.custom_separator = None
self._customization_re_cache = {}
@staticmethod @staticmethod
def _normalize_customization(customization): def _normalize_customization(customization):
@@ -42,10 +43,14 @@ class CustomizationMatcher(metaclass=Singleton):
return "" return ""
self.customization = "|".join([f"({item})" for item in customization]) self.customization = "|".join([f"({item})" for item in customization])
customization_re = re.compile(r"%s" % self.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 = {} unique_customization = {}
for item in re.findall(customization_re, title): for item in customization_re.findall(title):
if not isinstance(item, tuple): if not isinstance(item, tuple):
item = (item,) item = (item,)
for i in range(len(item)): for i in range(len(item)):
+3 -3
View File
@@ -105,6 +105,7 @@ class MetaVideo(MetaBase):
tokens = Tokens(title) tokens = Tokens(title)
# 实例化StreamingPlatforms对象 # 实例化StreamingPlatforms对象
streaming_platforms = StreamingPlatforms() streaming_platforms = StreamingPlatforms()
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
# 解析名称、年份、季、集、资源类型、分辨率等 # 解析名称、年份、季、集、资源类型、分辨率等
token = tokens.get_next() token = tokens.get_next()
while token: while token:
@@ -113,7 +114,7 @@ class MetaVideo(MetaBase):
self.__init_part(token, tokens) self.__init_part(token, tokens)
# 标题 # 标题
if self._continue_flag: if self._continue_flag:
self.__init_name(token) self.__init_name(token, media_exts)
# 年份 # 年份
if self._continue_flag: if self._continue_flag:
self.__init_year(token) self.__init_year(token)
@@ -226,7 +227,7 @@ class MetaVideo(MetaBase):
name = None name = None
return name return name
def __init_name(self, token: Optional[str]): def __init_name(self, token: Optional[str], media_exts: list):
""" """
识别名称 识别名称
""" """
@@ -313,7 +314,6 @@ class MetaVideo(MetaBase):
return return
else: else:
# 后缀名不要 # 后缀名不要
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
if ".%s".lower() % token in media_exts: if ".%s".lower() % token in media_exts:
return return
# 英文或者英文+数字,拼装起来 # 英文或者英文+数字,拼装起来
+15 -3
View File
@@ -86,6 +86,18 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
for release_group in site_groups: for release_group in site_groups:
release_groups.append(release_group) release_groups.append(release_group)
self.__release_groups = '|'.join(release_groups) self.__release_groups = '|'.join(release_groups)
self.systemconfig = SystemConfigOper()
self.__groups_re_cache = {}
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): def match(self, title: str = None, groups: str = None):
""" """
@@ -97,7 +109,7 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
return "" return ""
if not groups: if not groups:
# 自定义组 # 自定义组
custom_release_groups = SystemConfigOper().get(SystemConfigKey.CustomReleaseGroups) custom_release_groups = self.systemconfig.get(SystemConfigKey.CustomReleaseGroups)
if isinstance(custom_release_groups, list): if isinstance(custom_release_groups, list):
custom_release_groups = list(filter(None, custom_release_groups)) custom_release_groups = list(filter(None, custom_release_groups))
if custom_release_groups: if custom_release_groups:
@@ -106,9 +118,9 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
else: else:
groups = self.__release_groups groups = self.__release_groups
title = f"{title} " title = f"{title} "
groups_re = re.compile(r"(?<=[-@\[£【&])(?:(?:%s))(?=$|[@.\s\]\[】&])" % groups, re.I) groups_re = self.__get_groups_re(groups)
unique_groups = [] unique_groups = []
for item in re.findall(groups_re, title): for item in groups_re.findall(title):
item_str = item[0] if isinstance(item, tuple) else item item_str = item[0] if isinstance(item, tuple) else item
if item_str not in unique_groups: if item_str not in unique_groups:
unique_groups.append(item_str) unique_groups.append(item_str)
+44 -33
View File
@@ -1,4 +1,4 @@
from typing import List, Tuple from typing import List, Optional, Tuple
import cn2an import cn2an
import regex as re import regex as re
@@ -9,6 +9,10 @@ from app.schemas.types import SystemConfigKey
from app.utils.singleton import Singleton from app.utils.singleton import Singleton
_COMBINED_WORD_RE = re.compile(r'^\s*(.*?)\s*=>\s*(.*?)\s*&&\s*(.*?)\s*<>\s*(.*?)\s*>>\s*(.*?)\s*$')
_LEADING_ZERO_RE = re.compile(r"^0+")
class WordsMatcher(metaclass=Singleton): class WordsMatcher(metaclass=Singleton):
def __init__(self): def __init__(self):
@@ -28,37 +32,23 @@ class WordsMatcher(metaclass=Singleton):
if not word or word.startswith("#"): if not word or word.startswith("#"):
continue continue
try: try:
if word.count(" => ") and word.count(" && ") and word.count(" >> ") and word.count(" <> "): word_info = self.__parse_word(word)
# 替换词 if not word_info:
thc = str(re.findall(r'(.*?)\s*=>', word)[0]).strip() continue
# 被替换词 word_type, params = word_info
bthc = str(re.findall(r'=>\s*(.*?)\s*&&', word)[0]).strip() if word_type == "replace_and_offset":
# 集偏移前字段 thc, bthc, pyq, pyh, offsets = params
pyq = str(re.findall(r'&&\s*(.*?)\s*<>', word)[0]).strip()
# 集偏移后字段
pyh = str(re.findall(r'<>(.*?)\s*>>', word)[0]).strip()
# 集偏移
offsets = str(re.findall(r'>>\s*(.*?)$', word)[0]).strip()
# 替换词 # 替换词
title, message, state = self.__replace_regex(title, thc, bthc) title, message, state = self.__replace_regex(title, thc, bthc)
if state: if state:
# 替换词成功再进行集偏移 # 替换词成功再进行集偏移
title, message, state = self.__episode_offset(title, pyq, pyh, offsets) title, message, state = self.__episode_offset(title, pyq, pyh, offsets)
elif word.count(" => "): elif word_type == "replace":
# 替换词 title, message, state = self.__replace_regex(title, params[0], params[1])
strings = word.split(" => ") elif word_type == "offset":
title, message, state = self.__replace_regex(title, strings[0], strings[1]) title, message, state = self.__episode_offset(title, params[0], params[1], params[2])
elif word.count(" >> ") and word.count(" <> "): else: # block
# 集偏移 title, message, state = self.__replace_regex(title, params[0], "")
strings = word.split(" <> ")
offsets = strings[1].split(" >> ")
strings[1] = offsets[0]
title, message, state = self.__episode_offset(title, strings[0], strings[1], offsets[1])
else:
# 屏蔽词
if not word.strip():
continue
title, message, state = self.__replace_regex(title, word, "")
if state: if state:
appley_words.append(word) appley_words.append(word)
@@ -68,16 +58,37 @@ class WordsMatcher(metaclass=Singleton):
return title, appley_words 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 @staticmethod
def __replace_regex(title: str, replaced: str, replace: str) -> Tuple[str, str, bool]: def __replace_regex(title: str, replaced: str, replace: str) -> Tuple[str, str, bool]:
""" """
正则替换 正则替换
""" """
try: try:
if not re.findall(r'%s' % replaced, title): replaced_re = re.compile(r'%s' % replaced)
return title, "", False title, count = replaced_re.subn(r'%s' % replace, title)
else: return title, "", count > 0
return re.sub(r'%s' % replaced, r'%s' % replace, title), "", True
except Exception as err: except Exception as err:
logger.warn(f"自定义识别词正则替换失败:{str(err)} - 标题:{title},被替换词:{replaced},替换词:{replace}") logger.warn(f"自定义识别词正则替换失败:{str(err)} - 标题:{title},被替换词:{replaced},替换词:{replace}")
return title, str(err), False return title, str(err), False
@@ -112,9 +123,9 @@ class WordsMatcher(metaclass=Singleton):
if not episode_num_str.isdigit(): if not episode_num_str.isdigit():
episode_num_offset_str = cn2an.an2cn(episode_num_offset_int, "low") episode_num_offset_str = cn2an.an2cn(episode_num_offset_int, "low")
else: else:
count_0 = re.findall(r"^0+", episode_num_str) count_0 = _LEADING_ZERO_RE.search(episode_num_str)
if count_0: if count_0:
episode_num_offset_str = f"{count_0[0]}{episode_num_offset_int}" episode_num_offset_str = f"{count_0.group(0)}{episode_num_offset_int}"
else: else:
episode_num_offset_str = str(episode_num_offset_int) episode_num_offset_str = str(episode_num_offset_int)
episode_nums_offset_str.append(episode_num_offset_str) episode_nums_offset_str.append(episode_num_offset_str)
+98 -78
View File
@@ -14,6 +14,60 @@ from app.log import logger
from app.schemas.types import MediaType from app.schemas.types import MediaType
_ANIME_BRACKET_RE = re.compile(r'【[+0-9XVPI-]+】\s*【', re.IGNORECASE)
_ANIME_DASH_EPISODE_RE = re.compile(r'\s+-\s+[\dv]{1,4}\s+', re.IGNORECASE)
_VIDEO_SEASON_EPISODE_RE = re.compile(
r"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|"
r"EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}",
re.IGNORECASE,
)
_ANIME_SQUARE_BRACKET_RE = re.compile(r'\[[+0-9XVPI-]+]\s*\[', re.IGNORECASE)
_BRACED_METAINFO_RE = re.compile(r'(?<={\[)[\W\w]+(?=]})')
_BRACED_TMDBID_RE = re.compile(r'(?<=tmdbid=)\d+')
_BRACED_DOUBANID_RE = re.compile(r'(?<=doubanid=)\d+')
_BRACED_TYPE_RE = re.compile(r'(?<=type=)\w+')
_BRACED_BEGIN_SEASON_RE = re.compile(r'(?<=s=)\d+')
_BRACED_END_SEASON_RE = re.compile(r'(?<=s=\d+-)\d+')
_BRACED_BEGIN_EPISODE_RE = re.compile(r'(?<=e=)\d+')
_BRACED_END_EPISODE_RE = re.compile(r'(?<=e=\d+-)\d+')
_EMBY_TMDB_RE_LIST = (
re.compile(r'\[tmdbid[=\-](\d+)\]'),
re.compile(r'\[tmdb[=\-](\d+)\]'),
re.compile(r'\{tmdbid[=\-](\d+)\}'),
re.compile(r'\{tmdb[=\-](\d+)\}'),
)
def _empty_metainfo() -> dict:
"""
返回媒体标签的默认结构,避免不同识别请求之间共享可变状态。
"""
return {
'tmdbid': None,
'doubanid': None,
'type': None,
'begin_season': None,
'end_season': None,
'total_season': None,
'begin_episode': None,
'end_episode': None,
'total_episode': None,
}
def _apply_range_total(metainfo: dict, begin_key: str, end_key: str, total_key: str) -> None:
"""
计算季/集范围总数;保留原有倒序输入自动交换的兼容行为。
"""
if metainfo.get(begin_key) and metainfo.get(end_key):
if metainfo[begin_key] > metainfo[end_key]:
metainfo[begin_key], metainfo[end_key] = metainfo[end_key], metainfo[begin_key]
metainfo[total_key] = metainfo[end_key] - metainfo[begin_key] + 1
elif metainfo.get(begin_key) and not metainfo.get(end_key):
metainfo[total_key] = 1
def _build_meta_info( def _build_meta_info(
title: str, title: str,
subtitle: Optional[str] = None, subtitle: Optional[str] = None,
@@ -30,10 +84,11 @@ def _build_meta_info(
title, metainfo = find_metainfo(title) title, metainfo = find_metainfo(title)
# 判断是否处理文件 # 判断是否处理文件
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
if title and Path(title).suffix.lower() in media_exts: title_path = Path(title) if title else None
if title_path and title_path.suffix.lower() in media_exts:
isfile = True isfile = True
# 去掉后缀 # 去掉后缀
title = Path(title).stem title = title_path.stem
else: else:
isfile = False isfile = False
# 识别 # 识别
@@ -115,15 +170,13 @@ def is_anime(name: str) -> bool:
""" """
if not name: if not name:
return False return False
if re.search(r'【[+0-9XVPI-]+】\s*【', name, re.IGNORECASE): if _ANIME_BRACKET_RE.search(name):
return True return True
if re.search(r'\s+-\s+[\dv]{1,4}\s+', name, re.IGNORECASE): if _ANIME_DASH_EPISODE_RE.search(name):
return True return True
if re.search(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}", if _VIDEO_SEASON_EPISODE_RE.search(name):
name,
re.IGNORECASE):
return False return False
if re.search(r'\[[+0-9XVPI-]+]\s*\[', name, re.IGNORECASE): if _ANIME_SQUARE_BRACKET_RE.search(name):
return True return True
return False return False
@@ -132,95 +185,62 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
""" """
从标题中提取媒体信息 从标题中提取媒体信息
""" """
metainfo = { metainfo = _empty_metainfo()
'tmdbid': None,
'doubanid': None,
'type': None,
'begin_season': None,
'end_season': None,
'total_season': None,
'begin_episode': None,
'end_episode': None,
'total_episode': None,
}
if not title: if not title:
return title, metainfo return title, metainfo
# 从标题中提取媒体信息 格式为{[tmdbid=xxx;type=xxx;s=xxx;e=xxx]} # 从标题中提取媒体信息 格式为{[tmdbid=xxx;type=xxx;s=xxx;e=xxx]}
results = re.findall(r'(?<={\[)[\W\w]+(?=]})', title) results = _BRACED_METAINFO_RE.findall(title)
if results: if results:
for result in results: for result in results:
# 查找tmdbid信息 # 查找tmdbid信息
tmdbid = re.findall(r'(?<=tmdbid=)\d+', result) tmdbid = _BRACED_TMDBID_RE.search(result)
if tmdbid and tmdbid[0].isdigit(): if tmdbid and tmdbid.group(0).isdigit():
metainfo['tmdbid'] = tmdbid[0] metainfo['tmdbid'] = tmdbid.group(0)
# 查找豆瓣id信息 # 查找豆瓣id信息
doubanid = re.findall(r'(?<=doubanid=)\d+', result) doubanid = _BRACED_DOUBANID_RE.search(result)
if doubanid and doubanid[0].isdigit(): if doubanid and doubanid.group(0).isdigit():
metainfo['doubanid'] = doubanid[0] metainfo['doubanid'] = doubanid.group(0)
# 查找媒体类型 # 查找媒体类型
mtype = re.findall(r'(?<=type=)\w+', result) mtype = _BRACED_TYPE_RE.search(result)
if mtype: if mtype:
if mtype[0] == "movies": media_type = mtype.group(0)
if media_type == "movies":
metainfo['type'] = MediaType.MOVIE metainfo['type'] = MediaType.MOVIE
elif mtype[0] == "tv": elif media_type == "tv":
metainfo['type'] = MediaType.TV metainfo['type'] = MediaType.TV
# 查找季信息 # 查找季信息
begin_season = re.findall(r'(?<=s=)\d+', result) begin_season = _BRACED_BEGIN_SEASON_RE.search(result)
if begin_season and begin_season[0].isdigit(): if begin_season and begin_season.group(0).isdigit():
metainfo['begin_season'] = int(begin_season[0]) metainfo['begin_season'] = int(begin_season.group(0))
end_season = re.findall(r'(?<=s=\d+-)\d+', result) end_season = _BRACED_END_SEASON_RE.search(result)
if end_season and end_season[0].isdigit(): if end_season and end_season.group(0).isdigit():
metainfo['end_season'] = int(end_season[0]) metainfo['end_season'] = int(end_season.group(0))
# 查找集信息 # 查找集信息
begin_episode = re.findall(r'(?<=e=)\d+', result) begin_episode = _BRACED_BEGIN_EPISODE_RE.search(result)
if begin_episode and begin_episode[0].isdigit(): if begin_episode and begin_episode.group(0).isdigit():
metainfo['begin_episode'] = int(begin_episode[0]) metainfo['begin_episode'] = int(begin_episode.group(0))
end_episode = re.findall(r'(?<=e=\d+-)\d+', result) end_episode = _BRACED_END_EPISODE_RE.search(result)
if end_episode and end_episode[0].isdigit(): if end_episode and end_episode.group(0).isdigit():
metainfo['end_episode'] = int(end_episode[0]) metainfo['end_episode'] = int(end_episode.group(0))
# 去除title中该部分 # 去除title中该部分
if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode: if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode:
title = title.replace(f"{{[{result}]}}", '') title = title.replace(f"{{[{result}]}}", '')
# 支持Emby格式的ID标签 # 支持Emby格式的ID标签;第一个 [tmdbid] 历史上始终优先处理,用于覆盖前面 {[...]} 中的旧标签。
# 1. [tmdbid=xxxx] 或 [tmdbid-xxxx] 格式 tmdb_match = _EMBY_TMDB_RE_LIST[0].search(title)
tmdb_match = re.search(r'\[tmdbid[=\-](\d+)\]', title)
if tmdb_match: if tmdb_match:
metainfo['tmdbid'] = tmdb_match.group(1) metainfo['tmdbid'] = tmdb_match.group(1)
title = re.sub(r'\[tmdbid[=\-](\d+)\]', '', title).strip() title = _EMBY_TMDB_RE_LIST[0].sub('', title).strip()
elif not metainfo['tmdbid']:
# 2. [tmdb=xxxx] [tmdb-xxxx] 格式 # 保持原有优先级:[tmdbid] > [tmdb] > {tmdbid} > {tmdb}
if not metainfo['tmdbid']: for tmdb_re in _EMBY_TMDB_RE_LIST[1:]:
tmdb_match = re.search(r'\[tmdb[=\-](\d+)\]', title) tmdb_match = tmdb_re.search(title)
if tmdb_match: if tmdb_match:
metainfo['tmdbid'] = tmdb_match.group(1) metainfo['tmdbid'] = tmdb_match.group(1)
title = re.sub(r'\[tmdb[=\-](\d+)\]', '', title).strip() title = tmdb_re.sub('', title).strip()
break
# 3. {tmdbid=xxxx} 或 {tmdbid-xxxx} 格式
if not metainfo['tmdbid']:
tmdb_match = re.search(r'\{tmdbid[=\-](\d+)\}', title)
if tmdb_match:
metainfo['tmdbid'] = tmdb_match.group(1)
title = re.sub(r'\{tmdbid[=\-](\d+)\}', '', title).strip()
# 4. {tmdb=xxxx} 或 {tmdb-xxxx} 格式
if not metainfo['tmdbid']:
tmdb_match = re.search(r'\{tmdb[=\-](\d+)\}', title)
if tmdb_match:
metainfo['tmdbid'] = tmdb_match.group(1)
title = re.sub(r'\{tmdb[=\-](\d+)\}', '', title).strip()
# 计算季集总数 # 计算季集总数
if metainfo.get('begin_season') and metainfo.get('end_season'): _apply_range_total(metainfo, 'begin_season', 'end_season', 'total_season')
if metainfo['begin_season'] > metainfo['end_season']: _apply_range_total(metainfo, 'begin_episode', 'end_episode', 'total_episode')
metainfo['begin_season'], metainfo['end_season'] = metainfo['end_season'], metainfo['begin_season']
metainfo['total_season'] = metainfo['end_season'] - metainfo['begin_season'] + 1
elif metainfo.get('begin_season') and not metainfo.get('end_season'):
metainfo['total_season'] = 1
if metainfo.get('begin_episode') and metainfo.get('end_episode'):
if metainfo['begin_episode'] > metainfo['end_episode']:
metainfo['begin_episode'], metainfo['end_episode'] = metainfo['end_episode'], metainfo['begin_episode']
metainfo['total_episode'] = metainfo['end_episode'] - metainfo['begin_episode'] + 1
elif metainfo.get('begin_episode') and not metainfo.get('end_episode'):
metainfo['total_episode'] = 1
return title, metainfo return title, metainfo
+42 -48
View File
@@ -71,6 +71,42 @@ class DoubanModule(_ModuleBase):
""" """
return 2 return 2
@staticmethod
def _prepare_search_names(meta: MetaBase) -> List[str]:
"""
准备搜索名称列表,保留中英文名称分别识别且按顺序去重的历史行为。
"""
# 简体名称
zh_name = zhconv.convert(meta.cn_name, "zh-hans") if meta.cn_name else None
# 使用中英文名分别识别,去重去空,但要保持顺序
return list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k]))
@staticmethod
def _build_search_medias_result(meta: MetaBase, items: Optional[List[dict]]) -> List[MediaInfo]:
"""
构建豆瓣搜索结果,并沿用原有的类型、标题包含和季信息处理规则。
"""
if not items:
return []
ret_medias = []
for item_obj in items:
if meta.type and meta.type != MediaType.UNKNOWN and meta.type.value != item_obj.get("type_name"):
continue
if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value):
continue
if meta.name not in item_obj.get("target", {}).get("title"):
continue
ret_medias.append(MediaInfo(douban_info=item_obj.get("target")))
# 将搜索词中的季写入标题中
if ret_medias and meta.begin_season:
# 小写数据转大写
season_str = cn2an.an2cn(meta.begin_season, "low")
for media in ret_medias:
if media.type == MediaType.TV:
media.title = f"{media.title}{season_str}"
media.season = meta.begin_season
return ret_medias
def _recognize_media_core(self, meta: MetaBase = None, def _recognize_media_core(self, meta: MetaBase = None,
mtype: MediaType = None, mtype: MediaType = None,
doubanid: Optional[str] = None, doubanid: Optional[str] = None,
@@ -107,7 +143,7 @@ class DoubanModule(_ModuleBase):
meta.type = mtype meta.type = mtype
if doubanid: if doubanid:
meta.doubanid = doubanid meta.doubanid = doubanid
cache_info = self.cache.get(meta) cache_info = self.cache.get(meta) if cache else {}
cache_hit = False cache_hit = False
# 识别豆瓣信息 # 识别豆瓣信息
@@ -118,11 +154,7 @@ class DoubanModule(_ModuleBase):
info = douban_info_func(doubanid=doubanid, mtype=mtype or meta.type) info = douban_info_func(doubanid=doubanid, mtype=mtype or meta.type)
elif meta: elif meta:
info = {} info = {}
# 简体名称 for name in self._prepare_search_names(meta):
zh_name = zhconv.convert(meta.cn_name, "zh-hans") if meta.cn_name else None
# 使用中英文名分别识别,去重去空,但要保持顺序
names = list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k]))
for name in names:
if meta.begin_season: if meta.begin_season:
logger.info(f"正在识别 {name}{meta.begin_season}季 ...") logger.info(f"正在识别 {name}{meta.begin_season}季 ...")
else: else:
@@ -211,7 +243,7 @@ class DoubanModule(_ModuleBase):
meta.type = mtype meta.type = mtype
if doubanid: if doubanid:
meta.doubanid = doubanid meta.doubanid = doubanid
cache_info = self.cache.get(meta) cache_info = self.cache.get(meta) if cache else {}
cache_hit = False cache_hit = False
# 识别豆瓣信息 # 识别豆瓣信息
@@ -222,11 +254,7 @@ class DoubanModule(_ModuleBase):
info = await async_douban_info_func(doubanid=doubanid, mtype=mtype or meta.type) info = await async_douban_info_func(doubanid=doubanid, mtype=mtype or meta.type)
elif meta: elif meta:
info = {} info = {}
# 简体名称 for name in self._prepare_search_names(meta):
zh_name = zhconv.convert(meta.cn_name, "zh-hans") if meta.cn_name else None
# 使用中英文名分别识别,去重去空,但要保持顺序
names = list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k]))
for name in names:
if meta.begin_season: if meta.begin_season:
logger.info(f"正在识别 {name}{meta.begin_season}季 ...") logger.info(f"正在识别 {name}{meta.begin_season}季 ...")
else: else:
@@ -913,24 +941,7 @@ class DoubanModule(_ModuleBase):
if not result or not result.get("items"): if not result or not result.get("items"):
return [] return []
# 返回数据 # 返回数据
ret_medias = [] return self._build_search_medias_result(meta, result.get("items"))
for item_obj in result.get("items"):
if meta.type and meta.type != MediaType.UNKNOWN and meta.type.value != item_obj.get("type_name"):
continue
if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value):
continue
if meta.name not in item_obj.get("target", {}).get("title"):
continue
ret_medias.append(MediaInfo(douban_info=item_obj.get("target")))
# 将搜索词中的季写入标题中
if ret_medias and meta.begin_season:
# 小写数据转大写
season_str = cn2an.an2cn(meta.begin_season, "low")
for media in ret_medias:
if media.type == MediaType.TV:
media.title = f"{media.title}{season_str}"
media.season = meta.begin_season
return ret_medias
async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]: async def async_search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
""" """
@@ -946,24 +957,7 @@ class DoubanModule(_ModuleBase):
if not result or not result.get("items"): if not result or not result.get("items"):
return [] return []
# 返回数据 # 返回数据
ret_medias = [] return self._build_search_medias_result(meta, result.get("items"))
for item_obj in result.get("items"):
if meta.type and meta.type != MediaType.UNKNOWN and meta.type.value != item_obj.get("type_name"):
continue
if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value):
continue
if meta.name not in item_obj.get("target", {}).get("title"):
continue
ret_medias.append(MediaInfo(douban_info=item_obj.get("target")))
# 将搜索词中的季写入标题中
if ret_medias and meta.begin_season:
# 小写数据转大写
season_str = cn2an.an2cn(meta.begin_season, "low")
for media in ret_medias:
if media.type == MediaType.TV:
media.title = f"{media.title}{season_str}"
media.season = meta.begin_season
return ret_medias
def search_persons(self, name: str) -> Optional[List[MediaPerson]]: def search_persons(self, name: str) -> Optional[List[MediaPerson]]:
""" """
+63 -86
View File
@@ -19,6 +19,8 @@ from app.schemas.types import MediaType, MediaImageType, ModuleType, MediaRecogn
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
class TheMovieDbModule(_ModuleBase): class TheMovieDbModule(_ModuleBase):
""" """
@@ -118,6 +120,59 @@ class TheMovieDbModule(_ModuleBase):
# 使用中英文名分别识别,去重去空,但要保持顺序 # 使用中英文名分别识别,去重去空,但要保持顺序
return list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k])) return list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k]))
@staticmethod
def _fill_group_season_info(mediainfo: MediaInfo, episode_group: Optional[str],
group_seasons: List[dict]) -> None:
"""
将指定剧集组的季、集、年份信息写入 MediaInfo。
"""
seasons = {}
season_info = []
season_years = {}
for group_season in group_seasons:
# 季
season = group_season.get("order")
# 集列表
episodes = group_season.get("episodes")
if not episodes:
continue
seasons[season] = [ep.get("episode_number") for ep in episodes]
season_info.append(group_season)
# 当前季第一集时间
first_date = episodes[0].get("air_date")
if first_date and _DATE_RE.match(first_date):
season_years[season] = str(first_date).split("-")[0]
# 每季集清单
if seasons:
mediainfo.seasons = seasons
mediainfo.number_of_seasons = len(seasons)
# 每季集详情
if season_info:
mediainfo.season_info = season_info
# 每季年份
if season_years:
mediainfo.season_years = season_years
# 所有剧集组
mediainfo.episode_group = episode_group
mediainfo.episode_groups = group_seasons
@staticmethod
def _build_search_medias_result(meta: MetaBase, results: Optional[List[dict]]) -> List[MediaInfo]:
"""
构建搜索结果,并沿用原有逻辑把搜索词中的季写入电视剧标题中。
"""
if not results:
return []
medias = [MediaInfo(tmdb_info=info) for info in results]
if meta.begin_season:
# 小写数据转大写
season_str = cn2an.an2cn(meta.begin_season, "low")
for media in medias:
if media.type == MediaType.TV:
media.title = f"{media.title}{season_str}"
media.season = meta.begin_season
return medias
def _get_info_by_tmdbid(self, tmdbid: int, mtype: Optional[MediaType], def _get_info_by_tmdbid(self, tmdbid: int, mtype: Optional[MediaType],
meta: Optional[MetaBase]) -> Optional[dict]: meta: Optional[MetaBase]) -> Optional[dict]:
""" """
@@ -289,36 +344,7 @@ class TheMovieDbModule(_ModuleBase):
""" """
if mediainfo.type == MediaType.TV and mediainfo.episode_groups: if mediainfo.type == MediaType.TV and mediainfo.episode_groups:
if group_seasons: if group_seasons:
# 指定剧集组时 self._fill_group_season_info(mediainfo, episode_group, group_seasons)
seasons = {}
season_info = []
season_years = {}
for group_season in group_seasons:
# 季
season = group_season.get("order")
# 集列表
episodes = group_season.get("episodes")
if not episodes:
continue
seasons[season] = [ep.get("episode_number") for ep in episodes]
season_info.append(group_season)
# 当前季第一季时间
first_date = episodes[0].get("air_date")
if re.match(r"^\d{4}-\d{2}-\d{2}$", first_date):
season_years[season] = str(first_date).split("-")[0]
# 每季集清单
if seasons:
mediainfo.seasons = seasons
mediainfo.number_of_seasons = len(seasons)
# 每季集详情
if season_info:
mediainfo.season_info = season_info
# 每季年份
if season_years:
mediainfo.season_years = season_years
# 所有剧集组
mediainfo.episode_group = episode_group
mediainfo.episode_groups = group_seasons
else: else:
# 每季年份 # 每季年份
season_years = {} season_years = {}
@@ -337,7 +363,7 @@ class TheMovieDbModule(_ModuleBase):
# 当前季第一季时间 # 当前季第一季时间
first_date = episodes[0].get("air_date") first_date = episodes[0].get("air_date")
# 判断是不是日期格式 # 判断是不是日期格式
if first_date and re.match(r"^\d{4}-\d{2}-\d{2}$", first_date): if first_date and _DATE_RE.match(first_date):
season_years[season] = str(first_date).split("-")[0] season_years[season] = str(first_date).split("-")[0]
if season_years: if season_years:
mediainfo.season_years = season_years mediainfo.season_years = season_years
@@ -350,36 +376,7 @@ class TheMovieDbModule(_ModuleBase):
""" """
if mediainfo.type == MediaType.TV and mediainfo.episode_groups: if mediainfo.type == MediaType.TV and mediainfo.episode_groups:
if group_seasons: if group_seasons:
# 指定剧集组时 self._fill_group_season_info(mediainfo, episode_group, group_seasons)
seasons = {}
season_info = []
season_years = {}
for group_season in group_seasons:
# 季
season = group_season.get("order")
# 集列表
episodes = group_season.get("episodes")
if not episodes:
continue
seasons[season] = [ep.get("episode_number") for ep in episodes]
season_info.append(group_season)
# 当前季第一季时间
first_date = episodes[0].get("air_date")
if re.match(r"^\d{4}-\d{2}-\d{2}$", first_date):
season_years[season] = str(first_date).split("-")[0]
# 每季集清单
if seasons:
mediainfo.seasons = seasons
mediainfo.number_of_seasons = len(seasons)
# 每季集详情
if season_info:
mediainfo.season_info = season_info
# 每季年份
if season_years:
mediainfo.season_years = season_years
# 所有剧集组
mediainfo.episode_group = episode_group
mediainfo.episode_groups = group_seasons
else: else:
# 每季年份 # 每季年份
season_years = {} season_years = {}
@@ -398,7 +395,7 @@ class TheMovieDbModule(_ModuleBase):
# 当前季第一季时间 # 当前季第一季时间
first_date = episodes[0].get("air_date") first_date = episodes[0].get("air_date")
# 判断是不是日期格式 # 判断是不是日期格式
if first_date and re.match(r"^\d{4}-\d{2}-\d{2}$", first_date): if first_date and _DATE_RE.match(first_date):
season_years[season] = str(first_date).split("-")[0] season_years[season] = str(first_date).split("-")[0]
if season_years: if season_years:
mediainfo.season_years = season_years mediainfo.season_years = season_years
@@ -484,7 +481,7 @@ class TheMovieDbModule(_ModuleBase):
meta.type = mtype meta.type = mtype
if tmdbid: if tmdbid:
meta.tmdbid = tmdbid meta.tmdbid = tmdbid
cache_info = self.cache.get(meta) cache_info = self.cache.get(meta) if cache else {}
# 查询剧集组 # 查询剧集组
group_seasons = [] group_seasons = []
@@ -573,7 +570,7 @@ class TheMovieDbModule(_ModuleBase):
meta.type = mtype meta.type = mtype
if tmdbid: if tmdbid:
meta.tmdbid = tmdbid meta.tmdbid = tmdbid
cache_info = self.cache.get(meta) cache_info = self.cache.get(meta) if cache else {}
# 查询剧集组 # 查询剧集组
group_seasons = [] group_seasons = []
@@ -764,17 +761,7 @@ class TheMovieDbModule(_ModuleBase):
else: else:
results = self.tmdb.search_tvs(meta.name, meta.year) results = self.tmdb.search_tvs(meta.name, meta.year)
# 将搜索词中的季写入标题中 # 将搜索词中的季写入标题中
if results: return self._build_search_medias_result(meta, results)
medias = [MediaInfo(tmdb_info=info) for info in results]
if meta.begin_season:
# 小写数据转大写
season_str = cn2an.an2cn(meta.begin_season, "low")
for media in medias:
if media.type == MediaType.TV:
media.title = f"{media.title}{season_str}"
media.season = meta.begin_season
return medias
return []
def search_persons(self, name: str) -> Optional[List[schemas.MediaPerson]]: def search_persons(self, name: str) -> Optional[List[schemas.MediaPerson]]:
""" """
@@ -1206,17 +1193,7 @@ class TheMovieDbModule(_ModuleBase):
else: else:
results = await self.tmdb.async_search_tvs(meta.name, meta.year) results = await self.tmdb.async_search_tvs(meta.name, meta.year)
# 将搜索词中的季写入标题中 # 将搜索词中的季写入标题中
if results: return self._build_search_medias_result(meta, results)
medias = [MediaInfo(tmdb_info=info) for info in results]
if meta.begin_season:
# 小写数据转大写
season_str = cn2an.an2cn(meta.begin_season, "low")
for media in medias:
if media.type == MediaType.TV:
media.title = f"{media.title}{season_str}"
media.season = meta.begin_season
return medias
return []
async def async_tmdb_discover(self, mtype: MediaType, sort_by: str, async def async_tmdb_discover(self, mtype: MediaType, sort_by: str,
with_genres: str, with_genres: str,
+107
View File
@@ -0,0 +1,107 @@
import asyncio
from unittest import TestCase
from unittest.mock import Mock
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.modules.douban import DoubanModule
from app.modules.themoviedb import TheMovieDbModule
from app.schemas.types import MediaType
class MediaRecognizeModulesTest(TestCase):
def test_tmdb_cache_false_skips_cache_lookup(self):
"""cache=False 时应跳过缓存读取,但仍按正常流程查询 TMDB。"""
module = TheMovieDbModule()
meta = MetaBase("测试电影")
meta.name = "测试电影"
meta.type = MediaType.MOVIE
module.cache = Mock()
module.tmdb = Mock()
module.tmdb.get_info.return_value = {
"id": 100,
"media_type": MediaType.MOVIE,
"title": "测试电影",
"genres": [],
}
module.category = Mock()
module.category.get_movie_category.return_value = None
result = module.recognize_media(meta=meta, tmdbid=100, cache=False)
self.assertIsInstance(result, MediaInfo)
self.assertEqual(result.tmdb_id, 100)
module.cache.get.assert_not_called()
module.cache.update.assert_called_once()
def test_async_tmdb_cache_false_skips_cache_lookup(self):
"""异步 cache=False 时也应跳过缓存读取。"""
module = TheMovieDbModule()
meta = MetaBase("测试电影")
meta.name = "测试电影"
meta.type = MediaType.MOVIE
module.cache = Mock()
module.tmdb = Mock()
async def _async_get_info(**kwargs):
return {
"id": 101,
"media_type": MediaType.MOVIE,
"title": "测试电影",
"genres": [],
}
module.tmdb.async_get_info = _async_get_info
module.category = Mock()
module.category.get_movie_category.return_value = None
result = asyncio.run(module.async_recognize_media(meta=meta, tmdbid=101, cache=False))
self.assertIsInstance(result, MediaInfo)
self.assertEqual(result.tmdb_id, 101)
module.cache.get.assert_not_called()
module.cache.update.assert_called_once()
def test_douban_prepare_search_names_deduplicates_simplified_name(self):
"""豆瓣候选名称应保留顺序,并去掉繁简转换后的重复项。"""
meta = MetaBase("流浪地球")
meta.cn_name = "流浪地球"
meta.en_name = "The Wandering Earth"
self.assertEqual(
DoubanModule._prepare_search_names(meta),
["流浪地球", "The Wandering Earth"],
)
def test_douban_search_result_helper_preserves_season_title_rule(self):
"""豆瓣搜索结果 helper 应保留电视剧标题追加季号的旧逻辑。"""
meta = MetaBase("测试剧")
meta.name = "测试剧"
meta.type = MediaType.TV
meta.begin_season = 2
items = [
{
"type_name": MediaType.TV.value,
"target": {
"id": "200",
"title": "测试剧",
"type": "tv",
"year": "2024",
},
},
{
"type_name": MediaType.MOVIE.value,
"target": {
"id": "201",
"title": "测试剧 电影版",
"type": "movie",
"year": "2024",
},
},
]
result = DoubanModule._build_search_medias_result(meta, items)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].title, "测试剧 第二季")
self.assertEqual(result[0].season, 2)
+17 -1
View File
@@ -2,7 +2,7 @@
from pathlib import Path from pathlib import Path
from unittest import TestCase from unittest import TestCase
from app.core.metainfo import MetaInfo, MetaInfoPath from app.core.metainfo import MetaInfo, MetaInfoPath, find_metainfo
from tests.cases.meta import meta_cases from tests.cases.meta import meta_cases
@@ -124,6 +124,22 @@ class MetaInfoTest(TestCase):
self.assertEqual(meta.name, "电影名称") self.assertEqual(meta.name, "电影名称")
self.assertEqual(meta.original_name, "电影测试替换名称") self.assertEqual(meta.original_name, "电影测试替换名称")
def test_custom_words_replace_then_episode_offset(self):
"""测试复杂识别词仍按先替换、后集数偏移的顺序处理"""
custom_words = ["旧名 => 新名 && 第 <> 集 >> EP+1"]
meta = MetaInfo(title="旧名 第03集", custom_words=custom_words)
self.assertEqual(meta.name, "新名")
self.assertEqual(meta.episode, "E04")
self.assertEqual(meta.apply_words, custom_words)
def test_emby_tmdbid_overrides_braced_metainfo_tmdbid(self):
"""
同时存在内嵌元信息和 Emby [tmdbid] 标签时,保持历史上的 [tmdbid] 优先级。
"""
title, metainfo = find_metainfo("Movie {[tmdbid=111;type=movies]} [tmdbid=222]")
self.assertEqual(metainfo["tmdbid"], "222")
self.assertNotIn("[tmdbid=222]", title)
def test_metainfopath_auxiliary_chinese_stem_uses_parent_title(self): def test_metainfopath_auxiliary_chinese_stem_uses_parent_title(self):
""" """
文件名为简英双语/特效等压制标签、父目录为拉丁片名时,应合并父目录标题与年份。 文件名为简英双语/特效等压制标签、父目录为拉丁片名时,应合并父目录标题与年份。