fix(metainfo): apply custom episode offsets to subtitles

This commit is contained in:
jxxghp
2026-09-17 12:39:39 +08:00
parent 97d232af78
commit e2e2758eaa
5 changed files with 171 additions and 22 deletions

View File

@@ -29,6 +29,24 @@ _COMBINED_WORD_RE = re.compile(r'^\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|\()")
_SUBTITLE_EPISODE_RANGE_RE = re.compile(
r"(?<!\d)\[?\s*(?P<begin>\d{1,4})\s*-\s*(?P<end>\d{1,4})\s*"
r"(?:(?:Fin|End)(?![a-z0-9])|完结(?![\u4e00-\u9fff]))"
r"(?:\s*\](?!\d)|(?!\s*(?:\]\d|\d))\s*)",
re.IGNORECASE,
)
_SUBTITLE_EPISODE_RE = re.compile(
r"(?<![全共])(?P<episode>[0-9一二三四五六七八九十百零]+)\s*[集话話期幕](?!\s*[全共])",
re.IGNORECASE,
)
_SUBTITLE_EPISODE_TITLE_RE = re.compile(
r"(?<![A-Za-z0-9_])Episode\s+(?P<episode>\d{1,4})(?![A-Za-z0-9_])",
re.IGNORECASE,
)
_SUBTITLE_EPISODE_TOKEN_RE = re.compile(
r"(?<![A-Za-z0-9_])(?:EP|E)(?P<episode>\d{1,4})(?![A-Za-z0-9_])",
re.IGNORECASE,
)
_EPISODE_OFFSET_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
@@ -104,6 +122,32 @@ class WordsMatcher(metaclass=Singleton):
2:被替换词 => 替换词
3:前定位词 <> 后定位词 >> 偏移量(EP)
"""
title, _, appley_words = self.__prepare(title, None, custom_words)
return title, appley_words
def prepare_with_subtitle(
self,
title: str,
subtitle: Optional[str] = None,
custom_words: List[str] = None,
) -> Tuple[str, Optional[str], List[str]]:
"""
预处理标题和副标题,支持跨字段应用集数偏移规则。
:param title: 主标题、种子名或文件名
:param subtitle: 副标题或站点描述
:param custom_words: 临时自定义识别词列表
:return: 处理后的标题、副标题和已应用的识别词
"""
return self.__prepare(title, subtitle, custom_words)
def __prepare(
self,
title: str,
subtitle: Optional[str],
custom_words: List[str] = None,
) -> Tuple[str, Optional[str], List[str]]:
"""按识别词顺序处理标题,并在需要时更新副标题中的集数。"""
appley_words = []
# 读取自定义识别词
words: List[str] = custom_words or get_custom_words() or []
@@ -121,11 +165,15 @@ class WordsMatcher(metaclass=Singleton):
title, message, state = self.__replace_regex(title, thc, bthc)
if state:
# 替换词成功再进行集偏移
title, message, state = self.__episode_offset(title, pyq, pyh, offsets)
title, subtitle, message, state = self.__episode_offset_with_subtitle(
title, subtitle, 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])
title, subtitle, message, state = self.__episode_offset_with_subtitle(
title, subtitle, params[0], params[1], params[2]
)
else: # block
title, message, state = self.__replace_regex(title, params[0], "")
@@ -135,7 +183,7 @@ class WordsMatcher(metaclass=Singleton):
except Exception as err:
logger.warning(f"自定义识别词 {word} 预处理标题失败:{str(err)} - 标题:{title}")
return title, appley_words
return title, subtitle, appley_words
@staticmethod
def __parse_word(word: str) -> Optional[Tuple[str, Tuple[str, ...]]]:
@@ -219,3 +267,83 @@ class WordsMatcher(metaclass=Singleton):
except Exception as err:
logger.warning(f"自定义识别词集数偏移失败:{str(err)} - 标题:{title},前定位词:{front},后定位词:{back},偏移量:{offset}")
return title, str(err), False
def __episode_offset_with_subtitle(
self,
title: str,
subtitle: Optional[str],
front: str,
back: str,
offset: str,
) -> Tuple[str, Optional[str], str, bool]:
"""
在标题或副标题中应用集数偏移,必要时跨字段定位副标题集数。
"""
title, message, state = self.__episode_offset(title, front, back, offset)
if state:
return title, subtitle, message, True
if not subtitle:
return title, subtitle, message, False
parsed_subtitle, message, state = self.__episode_offset(subtitle, front, back, offset)
if state:
return title, parsed_subtitle, message, True
if not self.__locators_match(f"{title} {subtitle}", front, back):
return title, subtitle, message, False
parsed_subtitle, message, state = self.__episode_offset_subtitle(subtitle, offset)
return title, parsed_subtitle if state else subtitle, message, state
@staticmethod
def __locators_match(text: str, front: str, back: str) -> bool:
"""判断前后定位词是否在标题和副标题组成的上下文中同时出现。"""
try:
if front and not _compile_custom_word_regex(front).search(text):
return False
if back and not _compile_custom_word_regex(back).search(text):
return False
return True
except Exception:
return False
@staticmethod
def __subtitle_episode_spans(subtitle: str) -> List[Tuple[int, int]]:
"""提取副标题中会被 MetaBase 识别为集数的数字范围。"""
spans: List[Tuple[int, int]] = []
patterns = (
(_SUBTITLE_EPISODE_RANGE_RE, ("begin", "end")),
(_SUBTITLE_EPISODE_RE, ("episode",)),
(_SUBTITLE_EPISODE_TITLE_RE, ("episode",)),
(_SUBTITLE_EPISODE_TOKEN_RE, ("episode",)),
)
for pattern, group_names in patterns:
for match in pattern.finditer(subtitle):
for group_name in group_names:
start, end = match.span(group_name)
if start < 0 or end < 0:
continue
if not any(start < old_end and end > old_start for old_start, old_end in spans):
spans.append((start, end))
return sorted(spans)
@staticmethod
def __episode_offset_subtitle(subtitle: str, offset: str) -> Tuple[str, str, bool]:
"""只偏移副标题中的集数表达式,避免修改副标题里的季数或年份。"""
spans = WordsMatcher.__subtitle_episode_spans(subtitle)
if not spans:
return subtitle, "", False
try:
replacements = []
for start, end in spans:
episode_num_str = subtitle[start:end]
episode_num_int = int(cn2an.cn2an(episode_num_str, "smart"))
episode_num_offset_int = calculate_episode_offset(offset, episode_num_int)
replacements.append(
(start, end, _format_episode_offset(episode_num_str, episode_num_offset_int))
)
for start, end, replacement in reversed(replacements):
subtitle = f"{subtitle[:start]}{replacement}{subtitle[end:]}"
return subtitle, "", True
except Exception as err:
logger.warning(f"自定义识别词副标题集数偏移失败:{str(err)} - 副标题:{subtitle},偏移量:{offset}")
return subtitle, str(err), False

View File

@@ -290,7 +290,9 @@ def _prepare_meta_input(
应用识别词、显式标签和文件后缀规则,生成稳定的解析阶段输入。
"""
original_title = title
parsed_title, apply_words = WordsMatcher().prepare(title, custom_words=custom_words)
parsed_title, parsed_subtitle, apply_words = WordsMatcher().prepare_with_subtitle(
title, subtitle, custom_words=custom_words
)
# 完整 Rust 入口已经失败或被禁用,参考实现不得再次跨边界调用部分 Rust 解析器。
parsed_title, explicit_metainfo = _find_metainfo_python(parsed_title)
media_exts = get_media_extensions()
@@ -303,7 +305,7 @@ def _prepare_meta_input(
return _PreparedMetaInput(
original_title=original_title,
parsed_title=parsed_title,
subtitle=subtitle,
subtitle=parsed_subtitle,
isfile=isfile,
apply_words=tuple(apply_words or ()),
explicit_metainfo=explicit_metainfo,

View File

@@ -45,7 +45,7 @@ dependencies = [
"langgraph~=1.2.11",
"langgraph-checkpoint~=4.2.0",
"lark-oapi~=1.6.8",
"moviepilot-rust~=0.3.5",
"moviepilot-rust~=0.3.6",
"mutagen~=1.47.0",
"openai~=2.41.1",
"orjson==3.12.0",

View File

@@ -483,6 +483,25 @@ def test_custom_words_replace_then_episode_offset():
assert meta.apply_words == custom_words
def test_custom_words_episode_offset_applies_to_subtitle():
"""标题无集数时,自定义偏移应只修改副标题中的集数而保留季数。"""
custom_words = [
"BLEACH Thousand-Year Blood War S04 => BLEACH 2004 S02 && S02 <> 1080p >> EP+40"
]
with patch("app.adapters.system.rust.parse_metainfo", return_value=None):
meta = MetaInfo(
title="BLEACH Thousand-Year Blood War S04 1080p Disney+ WEB-DL AAC 2.0 H.264-CHDWEB",
subtitle="死神 千年血战篇 -祸进谭- 第四季 第8集 ...",
custom_words=custom_words,
)
assert meta.begin_season == 2
assert meta.begin_episode == 48
assert meta.subtitle == "死神 千年血战篇 -祸进谭- 第四季 第48集 ..."
assert meta.apply_words == custom_words
def test_get_torrent_episodes_applies_custom_words():
"""种子文件集数解析应使用订阅识别词完成跨季集数映射。"""
custom_words = [

32
uv.lock generated
View File

@@ -1805,7 +1805,7 @@ requires-dist = [
{ name = "langgraph", specifier = "~=1.2.11" },
{ name = "langgraph-checkpoint", specifier = "~=4.2.0" },
{ name = "lark-oapi", specifier = "~=1.6.8" },
{ name = "moviepilot-rust", specifier = "~=0.3.5" },
{ name = "moviepilot-rust", specifier = "~=0.3.6" },
{ name = "mutagen", specifier = "~=1.47.0" },
{ name = "openai", specifier = "~=2.41.1" },
{ name = "orjson", specifier = "==3.12.0" },
@@ -1886,23 +1886,23 @@ runtime-standard = [
[[package]]
name = "moviepilot-rust"
version = "0.3.5"
version = "0.3.6"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/ac/98298e7d71fda151f0790fdd1553f376e7c42da58627e7b042d4c7e87858/moviepilot_rust-0.3.5-cp314-abi3-macosx_10_12_x86_64.whl", hash = "sha256:29bccf31365d245b970a3e6502ff0eceada8d55df2618dc357a7cc9a6a753cf2", size = 5491194, upload-time = "2026-09-06T07:16:07.993Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c1/034a1ce359cd296652f308cdd5a3eb4f6379f46b78ed93e1bc7364253e46/moviepilot_rust-0.3.5-cp314-abi3-macosx_11_0_arm64.whl", hash = "sha256:80c6b88719bb3e05735920ee3e8e503b074da0f2cd5e9603b8539c537ae7aaa0", size = 5371206, upload-time = "2026-09-06T07:16:09.841Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f2/6894c70614c95b1685327c1ad4dcb8ca2060060fb3ed040f6eaf90a605cc/moviepilot_rust-0.3.5-cp314-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1d934a2d2e584b45484394c4b7ce47affdffc338d14e7f450a9b1b9e1fb5f0a", size = 5499853, upload-time = "2026-09-06T07:16:11.433Z" },
{ url = "https://files.pythonhosted.org/packages/de/59/0b555ab00381d3ce8c2ecf40da13f9478332d12f45777f41cae15c2bedf7/moviepilot_rust-0.3.5-cp314-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdaad31f0c78a17aa5542633c926ddbb87968b8d480965076dabee182f689ddf", size = 5698808, upload-time = "2026-09-06T07:16:13.417Z" },
{ url = "https://files.pythonhosted.org/packages/c6/73/9c4a750df69be5c1699473a426b25c35ac4f7d0ec110a1450792adecd746/moviepilot_rust-0.3.5-cp314-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:cf284b914211c277b2e896d6e9a9998dad76e562355103791a2e47ddc6110ad0", size = 5710023, upload-time = "2026-09-06T07:16:14.898Z" },
{ url = "https://files.pythonhosted.org/packages/1c/44/ae5c0b0b707fb0b4f1be64c2d0cb22dfb04170b3ac049f973f5c1bfed8ae/moviepilot_rust-0.3.5-cp314-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0ef6385d76230811718ae4251c024097ff58ced7443226bbc85fbd081235c94b", size = 5933811, upload-time = "2026-09-06T07:16:16.432Z" },
{ url = "https://files.pythonhosted.org/packages/22/75/1faddb73054001f5d9a02513bddf410dd089bd07c4cc68a4225b4a4b5840/moviepilot_rust-0.3.5-cp314-abi3-win_amd64.whl", hash = "sha256:fbfd3eb0f430c404ed995896feec05162d8b0d4f278027611c943e02eb34013e", size = 5493349, upload-time = "2026-09-06T07:16:17.894Z" },
{ url = "https://files.pythonhosted.org/packages/a9/69/b8ed2653e989d0b9d0dff7a9d98cbd324624af878f165e3b38631fe872c9/moviepilot_rust-0.3.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cf892587f77f66fcd20814d9a1d18d18cbc1690a6de6f2975644adc4a8217368", size = 5478979, upload-time = "2026-09-06T07:16:19.562Z" },
{ url = "https://files.pythonhosted.org/packages/5b/6d/920fbd23911d29b468fde5682ecc70c9591c2ae92fd9158e76c755928419/moviepilot_rust-0.3.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d6df221e9999068bb404a04065c4a2d34fb565a0e08b2454e05a30b81321d8ec", size = 5368074, upload-time = "2026-09-06T07:16:21.868Z" },
{ url = "https://files.pythonhosted.org/packages/e8/39/324ec3082c5984d11f0d0e06a87e78c200297bd5700c8d5ad85a155f5e76/moviepilot_rust-0.3.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4e17f29d1dc34e022f5a9113a9a0f92def12afb665a160c8a2d8da106990ee3", size = 5501025, upload-time = "2026-09-06T07:16:23.454Z" },
{ url = "https://files.pythonhosted.org/packages/87/4d/5b54cf411e58c25419556408daaef62d2f9e472c678b84a378c097e8f6f7/moviepilot_rust-0.3.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:341d9ae6169208e48d90447020aee103fff9d139c2a6a0a2932e63a7226e5169", size = 5698313, upload-time = "2026-09-06T07:16:25.188Z" },
{ url = "https://files.pythonhosted.org/packages/6b/5b/9c9851414bd8b0f876cbb6e487e42e67f6a6c99c50cf8ee0be047f1dccc2/moviepilot_rust-0.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:67859c5ae07604d4be762fd3bac29168d4f41438894b65ceffb0066b6fbd8b3d", size = 5712292, upload-time = "2026-09-06T07:16:26.632Z" },
{ url = "https://files.pythonhosted.org/packages/32/bd/32058e2d7a9f5160ec22e82d9d35a61ab77f91ec3696c028d454e39b126c/moviepilot_rust-0.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c4095999480f46ea8b21e71a5386385813b0cd6a6b89262f647fb34eca8e33e0", size = 5934257, upload-time = "2026-09-06T07:16:28.249Z" },
{ url = "https://files.pythonhosted.org/packages/5d/88/d7e99c3f31190e21b91771ae4b14035ca91d26001be7dda1284687a14e68/moviepilot_rust-0.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:112cf1b182edbd218a0867d45e24b0c65600a1ae5d06e45b157205e77f485b40", size = 5496343, upload-time = "2026-09-06T07:16:29.665Z" },
{ url = "https://files.pythonhosted.org/packages/1d/83/8b10192fc1527a97851886e2af7a2eebea5f57f41b53fcf80d32b375ad28/moviepilot_rust-0.3.6-cp314-abi3-macosx_10_12_x86_64.whl", hash = "sha256:8939c662569ca3f083fda88897d2e97e3f3b25873b862c1d897f57f3318cb526", size = 5497771, upload-time = "2026-09-17T04:30:37.869Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/2007cc2333121f1070e8826f53dc24136a1268aa6491c9d8b07a7499810a/moviepilot_rust-0.3.6-cp314-abi3-macosx_11_0_arm64.whl", hash = "sha256:39177201e50d3bef8ef0d17ff60c215c006cd4455baa2f94e580b8168186eded", size = 5376187, upload-time = "2026-09-17T04:30:39.764Z" },
{ url = "https://files.pythonhosted.org/packages/9a/fe/9eddd8c4e43b6b8637d35cbbf4410211a56b489f83459d3c505be58a02be/moviepilot_rust-0.3.6-cp314-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ac2db1ede446b504b07fbfcf96dcd997458a8c72bf15f724717f0ad58bf2db4", size = 5507352, upload-time = "2026-09-17T04:30:41.331Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d6/be831a4140ffd7f10ecfa26f580b639b494bce29d04e0894b4e798c1acb3/moviepilot_rust-0.3.6-cp314-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2045448b062171410f51d5cf6c9a4aa2bced8b9d03f299545c06ffec326ca4c", size = 5702805, upload-time = "2026-09-17T04:30:43.319Z" },
{ url = "https://files.pythonhosted.org/packages/9f/a4/ea830c85c09711b330d0836b73fe2849fc8af382a0561f57c45939fa5906/moviepilot_rust-0.3.6-cp314-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:cf4600b7173636d172c683a64b79f165f7169e021ce0bf5f7139c4ea78b971c5", size = 5715593, upload-time = "2026-09-17T04:30:45.124Z" },
{ url = "https://files.pythonhosted.org/packages/49/93/660511150f6afa6c05ae13b23ed2570e605a30f0e80c0f8c75965938d8a8/moviepilot_rust-0.3.6-cp314-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a8a6cbb1a6353a29da697a45ede5e59c8f75efaab91534269af548fae8a84b19", size = 5940660, upload-time = "2026-09-17T04:30:46.818Z" },
{ url = "https://files.pythonhosted.org/packages/17/84/83fcf560169fabba3edd0b195542a3d51c7fc8c24a78e71238cfbdfcfe51/moviepilot_rust-0.3.6-cp314-abi3-win_amd64.whl", hash = "sha256:11aacd19a16be970039e03bfe272e0fd9126ed147d853019d7f5458cfab9a275", size = 5502328, upload-time = "2026-09-17T04:30:48.436Z" },
{ url = "https://files.pythonhosted.org/packages/11/8b/4f6d5201e324a7af6ea3afe6739eb05188193b771872b6a15a536b5e2608/moviepilot_rust-0.3.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1bdd9f776d4baad8124689dc35779524c6bf3c609de74b522b01029b8baa136d", size = 5487217, upload-time = "2026-09-17T04:30:50.126Z" },
{ url = "https://files.pythonhosted.org/packages/da/40/5f63b67e2947fa144b0e505f7ae88ef5432a32bfc41c565b360e09045f47/moviepilot_rust-0.3.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e1e5c5451ed9633f07c1fa2e562ec677c6813d3e65698558809426da37087bfc", size = 5372692, upload-time = "2026-09-17T04:30:51.793Z" },
{ url = "https://files.pythonhosted.org/packages/28/51/938e5bf3f306d9b9fff893d9f74bface19f7ed5d010250896dab800a89df/moviepilot_rust-0.3.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891df3aa1a61c0d7788d9f67dae6de37208b4650dffb2b70d39830811550046c", size = 5507161, upload-time = "2026-09-17T04:30:53.538Z" },
{ url = "https://files.pythonhosted.org/packages/22/48/51ec81bae2161ad1a958169c0d8924fab2a3f8776015d7f13a9194624d3d/moviepilot_rust-0.3.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33b994d70f83948320408390b437b7fdc5007af319e4c9b84952602f2e7943f2", size = 5702889, upload-time = "2026-09-17T04:30:55.044Z" },
{ url = "https://files.pythonhosted.org/packages/92/d6/709945e1a69766273241fd49fc982823180a14613112faed5fe295990f42/moviepilot_rust-0.3.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8071687b7a7920bfb4be0e7bf20d81da3ec1e514b36dc648e13177b59aefca46", size = 5715400, upload-time = "2026-09-17T04:30:56.54Z" },
{ url = "https://files.pythonhosted.org/packages/93/0a/048f04b23b2c7c15e2b793204b8e0eb67ca5e9268692c7f0ad8559b10cc3/moviepilot_rust-0.3.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0967a2e4408492c5e16d25b3e145e9df7d6e0122a8f73c84003612fb1e2bebbd", size = 5938744, upload-time = "2026-09-17T04:30:58.262Z" },
{ url = "https://files.pythonhosted.org/packages/0c/10/f3495f36cb418447a5e45acb9f97b0b828812ce7132f50f30d0012b44b7d/moviepilot_rust-0.3.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c8e7038d00a04c40dffb2c6db6a25185624e8140cf43a67a693c531b1637b39f", size = 5502882, upload-time = "2026-09-17T04:30:59.85Z" },
]
[[package]]