mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
refactor backend module architecture
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""媒体标题解析实现;调用方应从具体子模块导入所需类型。"""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user