diff --git a/app/domain/meta/words.py b/app/domain/meta/words.py index 8c1e86790..7ca392998 100644 --- a/app/domain/meta/words.py +++ b/app/domain/meta/words.py @@ -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"(?\d{1,4})\s*-\s*(?P\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"(?[0-9一二三四五六七八九十百零]+)\s*[集话話期幕](?!\s*[全共])", + re.IGNORECASE, +) +_SUBTITLE_EPISODE_TITLE_RE = re.compile( + r"(?\d{1,4})(?![A-Za-z0-9_])", + re.IGNORECASE, +) +_SUBTITLE_EPISODE_TOKEN_RE = re.compile( + r"(?\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 diff --git a/app/domain/metainfo.py b/app/domain/metainfo.py index f340c1b9f..660dae5f1 100644 --- a/app/domain/metainfo.py +++ b/app/domain/metainfo.py @@ -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, diff --git a/pyproject.toml b/pyproject.toml index 8deb89d0b..e8ba7a3d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/test_metainfo.py b/tests/test_metainfo.py index 8b3eb1e07..302599b9f 100644 --- a/tests/test_metainfo.py +++ b/tests/test_metainfo.py @@ -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 = [ diff --git a/uv.lock b/uv.lock index a21aa8e02..7963cad37 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]