From bb9b6ec5d0b00f7b480065ca787feb1393cfa76c Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 26 Jun 2026 14:15:29 +0800 Subject: [PATCH] fix: support custom episode offset expressions --- app/command.py | 8 + app/core/meta/words.py | 78 ++++++-- requirements.in | 2 +- tests/test_metainfo.py | 437 ++++++++++++++++++++++------------------- 4 files changed, 308 insertions(+), 217 deletions(-) diff --git a/app/command.py b/app/command.py index 1292312a..87a32b97 100644 --- a/app/command.py +++ b/app/command.py @@ -352,6 +352,14 @@ class Command(metaclass=Singleton): """ 获取命令列表 """ + if not self._commands: + with self._rlock: + if not self._commands: + self._commands = { + **self._preset_commands, + **self._plugin_commands, + **self._other_commands, + } return self._commands def get(self, cmd: str) -> Any: diff --git a/app/core/meta/words.py b/app/core/meta/words.py index ffd35838..7f748cfa 100644 --- a/app/core/meta/words.py +++ b/app/core/meta/words.py @@ -1,3 +1,5 @@ +import ast +import operator from functools import lru_cache from typing import List, Optional, Tuple @@ -12,6 +14,20 @@ from app.utils.singleton import Singleton _COMBINED_WORD_RE = re.compile(r'^\s*(.*?)\s*=>\s*(.*?)\s*&&\s*(.*?)\s*<>\s*(.*?)\s*>>\s*(.*?)\s*$') _LEADING_ZERO_RE = re.compile(r"^0+") +_EP_TOKEN_RE = re.compile(r"(? 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 __init__(self): + """ + 初始化自定义识别词配置读取器。 + """ self.systemconfig = SystemConfigOper() def prepare(self, title: str, custom_words: List[str] = None) -> Tuple[str, List[str]]: @@ -122,23 +185,16 @@ class WordsMatcher(metaclass=Singleton): offset_order_flag = False for episode_num_str in episode_nums_str: episode_num_int = int(cn2an.cn2an(episode_num_str, "smart")) - offset_caculate = offset.replace("EP", str(episode_num_int)) - episode_num_offset_int = int(eval(offset_caculate)) + 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 - # 原值是中文数字,转换回中文数字,阿拉伯数字则还原0的填充 - if not episode_num_str.isdigit(): - episode_num_offset_str = cn2an.an2cn(episode_num_offset_int, "low") - else: - count_0 = _LEADING_ZERO_RE.search(episode_num_str) - if count_0: - episode_num_offset_str = f"{count_0.group(0)}{episode_num_offset_int}" - else: - episode_num_offset_str = str(episode_num_offset_int) + 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)) # 集数向前偏移,集数按升序处理 diff --git a/requirements.in b/requirements.in index 2968bae9..fa2260dc 100644 --- a/requirements.in +++ b/requirements.in @@ -1,4 +1,4 @@ -moviepilot-rust~=0.1.11 +moviepilot-rust~=0.1.12 pydantic>=2.13.4,<3.0.0 pydantic-settings>=2.14.1,<3.0.0 SQLAlchemy~=2.0.50 diff --git a/tests/test_metainfo.py b/tests/test_metainfo.py index 5a2ab14f..e3d1c93b 100644 --- a/tests/test_metainfo.py +++ b/tests/test_metainfo.py @@ -1,235 +1,262 @@ # -*- coding: utf-8 -*- from pathlib import Path -from unittest import TestCase from unittest.mock import patch from app.core.metainfo import MetaInfo, MetaInfoPath, find_metainfo from tests.cases.meta import meta_cases -class MetaInfoTest(TestCase): - def setUp(self) -> None: - pass - - def tearDown(self) -> None: - pass - - def test_metainfo(self): - for info in meta_cases: - if info.get("path"): - meta_info = MetaInfoPath(path=Path(info.get("path"))) - else: - meta_info = MetaInfo( - title=info.get("title"), - subtitle=info.get("subtitle"), - custom_words=["#"], - ) - target = { - "type": meta_info.type.value, - "cn_name": meta_info.cn_name or "", - "en_name": meta_info.en_name or "", - "year": meta_info.year or "", - "part": meta_info.part or "", - "season": meta_info.season, - "episode": meta_info.episode, - "restype": meta_info.edition, - "pix": meta_info.resource_pix or "", - "video_codec": meta_info.video_encode or "", - "audio_codec": meta_info.audio_encode or "", - "fps": meta_info.fps or None, - } - - # 检查tmdbid - if info.get("target").get("tmdbid"): - target["tmdbid"] = meta_info.tmdbid - - expected = info.get("target") - if "fps" not in expected: - target.pop("fps", None) - self.assertEqual(target, expected) - - def test_emby_format_ids(self): - """ - 测试Emby格式ID识别 - """ - # 测试文件路径 - test_paths = [ - # 文件名中包含tmdbid - ( - "/movies/The Vampire Diaries (2009) [tmdbid=18165]/The.Vampire.Diaries.S01E01.1080p.mkv", - 18165, - ), - # 目录名中包含tmdbid - ("/movies/Inception (2010) [tmdbid-27205]/Inception.2010.1080p.mkv", 27205), - # 父目录名中包含tmdbid - ( - "/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv", - 1396, - ), - # 祖父目录名中包含tmdbid - ( - "/tv/Game of Thrones (2011) {tmdb=1399}/Season 1/Game.of.Thrones.S01E01.1080p.mkv", - 1399, - ), - # 测试{tmdb-xxx}格式 - ("/movies/Avatar (2009) {tmdb-19995}/Avatar.2009.1080p.mkv", 19995), - ] - - for path_str, expected_tmdbid in test_paths: - meta = MetaInfoPath(Path(path_str)) - self.assertEqual( - meta.tmdbid, - expected_tmdbid, - f"路径 {path_str} 期望的tmdbid为 {expected_tmdbid},实际识别为 {meta.tmdbid}", +def test_metainfo(): + """测试常见标题元数据识别结果。""" + for info in meta_cases: + if info.get("path"): + meta_info = MetaInfoPath(path=Path(info.get("path"))) + else: + meta_info = MetaInfo( + title=info.get("title"), + subtitle=info.get("subtitle"), + custom_words=["#"], ) + target = { + "type": meta_info.type.value, + "cn_name": meta_info.cn_name or "", + "en_name": meta_info.en_name or "", + "year": meta_info.year or "", + "part": meta_info.part or "", + "season": meta_info.season, + "episode": meta_info.episode, + "restype": meta_info.edition, + "pix": meta_info.resource_pix or "", + "video_codec": meta_info.video_encode or "", + "audio_codec": meta_info.audio_encode or "", + "fps": meta_info.fps or None, + } - def test_metainfopath_with_custom_words(self): - """测试 MetaInfoPath 使用自定义识别词""" - # 测试替换词:将"测试替换"替换为空 - custom_words = ["测试替换 => "] - path = Path("/movies/电影测试替换名称 (2024)/movie.mkv") - meta = MetaInfoPath(path, custom_words=custom_words) - # 验证替换生效:cn_name 不应包含"测试替换" - if meta.cn_name: - self.assertNotIn("测试替换", meta.cn_name) + if info.get("target").get("tmdbid"): + target["tmdbid"] = meta_info.tmdbid - def test_metainfopath_without_custom_words(self): - """测试 MetaInfoPath 不传入自定义识别词""" - path = Path("/movies/Normal Movie (2024)/movie.mkv") - meta = MetaInfoPath(path) - # 验证正常识别,不报错 - self.assertIsNotNone(meta) + expected = info.get("target") + if "fps" not in expected: + target.pop("fps", None) + assert target == expected - def test_metainfopath_with_empty_custom_words(self): - """测试 MetaInfoPath 传入空的自定义识别词""" - path = Path("/movies/Test Movie (2024)/movie.mkv") - meta = MetaInfoPath(path, custom_words=[]) - # 验证不报错,正常识别 - self.assertIsNotNone(meta) - def test_custom_words_apply_words_recording(self): - """测试 apply_words 记录功能""" - custom_words = ["替换词 => 新词"] - title = "电影替换词.2024.mkv" - meta = MetaInfo(title=title, custom_words=custom_words) - # 验证 apply_words 属性存在 - self.assertTrue(hasattr(meta, "apply_words")) - # 如果替换词被应用,应该记录在 apply_words 中 - if meta.apply_words: - self.assertIn("替换词 => 新词", meta.apply_words) +def test_emby_format_ids(): + """测试 Emby 格式 ID 识别。""" + test_paths = [ + ( + "/movies/The Vampire Diaries (2009) [tmdbid=18165]/The.Vampire.Diaries.S01E01.1080p.mkv", + 18165, + ), + ("/movies/Inception (2010) [tmdbid-27205]/Inception.2010.1080p.mkv", 27205), + ( + "/movies/Breaking Bad (2008) [tmdb=1396]/Season 1/Breaking.Bad.S01E01.1080p.mkv", + 1396, + ), + ( + "/tv/Game of Thrones (2011) {tmdb=1399}/Season 1/Game.of.Thrones.S01E01.1080p.mkv", + 1399, + ), + ("/movies/Avatar (2009) {tmdb-19995}/Avatar.2009.1080p.mkv", 19995), + ] - def test_metainfo_preserves_original_name_when_custom_words_applied(self): - """测试应用识别词后仍保留未应用识别词时识别出的名称""" - custom_words = ["测试替换 => "] - meta = MetaInfo(title="电影测试替换名称 (2024)", custom_words=custom_words) - self.assertEqual(meta.name, "电影名称") - self.assertEqual(meta.original_name, "电影测试替换名称") + for path_str, expected_tmdbid in test_paths: + meta = MetaInfoPath(Path(path_str)) + assert meta.tmdbid == expected_tmdbid, ( + f"路径 {path_str} 期望的tmdbid为 {expected_tmdbid},实际识别为 {meta.tmdbid}" + ) - def test_custom_words_replace_then_episode_offset(self): - """测试复杂识别词仍按先替换、后集数偏移的顺序处理""" - custom_words = ["旧名 => 新名 && 第 <> 集 >> EP+1"] - meta = MetaInfo(title="旧名 第03集", custom_words=custom_words) - self.assertEqual(meta.name, "新名") - self.assertEqual(meta.episode, "E04") - self.assertEqual(meta.apply_words, custom_words) - def test_custom_words_support_episode_group_parameter(self): - """测试自定义识别词替换结果中的 g 参数会写入剧集组""" - group_id = "5ad0ec240e0a26303f00d84d" - custom_words = [ - f"Bakemonogatari => 物语系列 {{[tmdbid=46195;type=tv;g={group_id};s=1]}}" - ] - meta = MetaInfo(title="Bakemonogatari 01", custom_words=custom_words) - self.assertEqual(meta.tmdbid, 46195) - self.assertEqual(meta.type.value, "电视剧") - self.assertEqual(meta.begin_season, 1) - self.assertEqual(meta.episode_group, group_id) - self.assertEqual(meta.apply_words, custom_words) +def test_metainfopath_with_custom_words(): + """测试 MetaInfoPath 使用自定义识别词。""" + custom_words = ["测试替换 => "] + path = Path("/movies/电影测试替换名称 (2024)/movie.mkv") + meta = MetaInfoPath(path, custom_words=custom_words) + if meta.cn_name: + assert "测试替换" not in meta.cn_name - def test_custom_words_support_special_season_zero_parameter(self): - """显式媒体标签中的 s=0 应作为特别季写入元数据。""" - custom_words = [ - "Test Show => 测试剧 {[tmdbid=12345;type=tv;s=0]}" - ] - with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None): - meta = MetaInfo(title="Test Show 01", custom_words=custom_words) +def test_metainfopath_without_custom_words(): + """测试 MetaInfoPath 不传入自定义识别词。""" + path = Path("/movies/Normal Movie (2024)/movie.mkv") + meta = MetaInfoPath(path) + assert meta is not None - self.assertEqual(meta.tmdbid, 12345) - self.assertEqual(meta.type.value, "电视剧") - self.assertEqual(meta.begin_season, 0) - def test_find_metainfo_supports_episode_group_parameter(self): - """测试显式媒体标签支持 g 剧集组参数""" - group_id = "5ad0ec240e0a26303f00d84d" - title, metainfo = find_metainfo(f"物语系列 {{[tmdbid=46195;type=tv;g={group_id};s=1]}}") - self.assertEqual(metainfo["episode_group"], group_id) - self.assertNotIn("g=", title) +def test_metainfopath_with_empty_custom_words(): + """测试 MetaInfoPath 传入空的自定义识别词。""" + path = Path("/movies/Test Movie (2024)/movie.mkv") + meta = MetaInfoPath(path, custom_words=[]) + assert meta is not None - def test_find_metainfo_does_not_support_episode_group_alias(self): - """测试 e_group 不会被当作剧集组参数识别""" - group_id = "5ad0ec240e0a26303f00d84d" - with patch("app.core.metainfo.rust_accel.find_metainfo", return_value=None): - _, metainfo = find_metainfo(f"物语系列 {{[tmdbid=46195;type=tv;e_group={group_id};s=1]}}") - self.assertIsNone(metainfo["episode_group"]) - def test_video_bit_extracted_for_video_title(self): - """测试普通影视标题中的视频位深可单独识别""" - meta = MetaInfo(title="The 355 2022 BluRay 1080p DTS-HD MA5.1 X265.10bit-BeiTai") - self.assertEqual(meta.video_encode, "x265 10bit") - self.assertEqual(meta.video_bit, "10bit") +def test_custom_words_apply_words_recording(): + """测试 apply_words 记录功能。""" + custom_words = ["替换词 => 新词"] + title = "电影替换词.2024.mkv" + meta = MetaInfo(title=title, custom_words=custom_words) + assert hasattr(meta, "apply_words") + if meta.apply_words: + assert "替换词 => 新词" in meta.apply_words - def test_video_bit_extracted_for_anime_title(self): - """测试动漫标题中的视频位深可单独识别""" + +def test_metainfo_preserves_original_name_when_custom_words_applied(): + """测试应用识别词后仍保留未应用识别词时识别出的名称。""" + custom_words = ["测试替换 => "] + meta = MetaInfo(title="电影测试替换名称 (2024)", custom_words=custom_words) + assert meta.name == "电影名称" + assert meta.original_name == "电影测试替换名称" + + +def test_custom_words_replace_then_episode_offset(): + """测试复杂识别词仍按先替换、后集数偏移的顺序处理。""" + custom_words = ["旧名 => 新名 && 第 <> 集 >> EP+1"] + meta = MetaInfo(title="旧名 第03集", custom_words=custom_words) + assert meta.name == "新名" + assert meta.episode == "E04" + assert meta.apply_words == custom_words + + +def test_custom_words_episode_offset_supports_multiplication_expression(): + """测试集数偏移表达式支持乘法和连续运算。""" + custom_words = [ + r"Ha.Ha.Ha.Ha.Ha.2026.S06E([0-1][0-9]).Part1 => 哈哈哈哈哈 (2020){[tmdbid=112732;type=tv]} S06E\1.Part1 && S06 <> .Part1 >> 2*EP-1" + ] + + with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None): meta = MetaInfo( - title="[云歌字幕组][7月新番][欢迎来到实力至上主义的教室 第二季][01]" - "[X264 10bit][1080p][简体中文].mp4" + title="Ha.Ha.Ha.Ha.Ha.2026.S06E03.Part1", + custom_words=custom_words, ) - self.assertEqual(meta.video_encode, "X264") - self.assertEqual(meta.video_bit, "10bit") - def test_streaming_platform_word_kept_in_movie_title(self): - """测试正式片名中的流媒体平台词不会被预置清理规则移除""" - with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None): - meta = MetaInfo(title="Amazon Forever 2004 1080p WEB-DL") - self.assertEqual(meta.name, "Amazon Forever") - self.assertEqual(meta.year, "2004") + assert meta.name == "哈哈哈哈哈" + assert meta.tmdbid == 112732 + assert meta.begin_season == 6 + assert meta.episode == "E05" + assert meta.apply_words == custom_words - def test_emby_tmdbid_overrides_braced_metainfo_tmdbid(self): - """ - 同时存在内嵌元信息和 Emby [tmdbid] 标签时,保持历史上的 [tmdbid] 优先级。 - """ - title, metainfo = find_metainfo("Movie {[tmdbid=111;type=movies]} [tmdbid=222]") - self.assertEqual(metainfo["tmdbid"], "222") - self.assertNotIn("[tmdbid=222]", title) - def test_metainfopath_auxiliary_chinese_stem_uses_parent_title(self): - """ - 文件名为简英双语/特效等压制标签、父目录为拉丁片名时,应合并父目录标题与年份。 - """ - path = Path( - "/Marty Supreme 2025 2160p DoVi HDR Atmos TrueHD 7.1 x265-PbK/简英双语特效.mp4" - ) - meta = MetaInfoPath(path) - self.assertEqual(meta.en_name, "Marty Supreme") - self.assertEqual(meta.year, "2025") - self.assertEqual(meta.original_name, "Marty Supreme") +def test_custom_words_episode_offset_supports_repeated_ep_expression(): + """测试集数偏移表达式支持重复使用 EP 占位符。""" + custom_words = ["旧名 => 新名 && 第 <> 集 >> EP+EP-1"] - def test_metainfopath_chinese_parent_not_replaced_by_auxiliary_rule(self): - """ - 纯中文父目录(无拉丁字母)时不触发辅助文件名规则,避免误伤。 - """ - path = Path("/movies/流浪地球 (2023)/简体中字.mkv") - meta = MetaInfoPath(path) - self.assertTrue(meta.cn_name) - self.assertIn("简体", meta.cn_name) + with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None): + meta = MetaInfo(title="旧名 第03集", custom_words=custom_words) - def test_metainfopath_cn_title_containing_keyword_not_cleared(self): - """ - 中文片名恰好包含辅助关键词子串时(如"粤语残片"含"粤语"), - 不应被当作辅助标签清空。 - """ - path = Path("/Some Movie 2024/粤语残片.mkv") - meta = MetaInfoPath(path) - # stem 含有非关键词汉字"残片",不应被全量匹配命中 - self.assertIn("粤语残片", meta.cn_name) + assert meta.name == "新名" + assert meta.episode == "E05" + assert meta.apply_words == custom_words + + +def test_custom_words_episode_offset_rejects_implicit_ep_expression(): + """测试集数偏移表达式不把 2EP 当作隐式乘法或字符串拼接。""" + custom_words = ["旧名 => 新名 && 第 <> 集 >> 2EP"] + + with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None): + meta = MetaInfo(title="旧名 第03集", custom_words=custom_words) + + assert meta.name == "新名" + assert meta.episode == "E03" + assert meta.apply_words == [] + + +def test_custom_words_support_episode_group_parameter(): + """测试自定义识别词替换结果中的 g 参数会写入剧集组。""" + group_id = "5ad0ec240e0a26303f00d84d" + custom_words = [ + f"Bakemonogatari => 物语系列 {{[tmdbid=46195;type=tv;g={group_id};s=1]}}" + ] + meta = MetaInfo(title="Bakemonogatari 01", custom_words=custom_words) + assert meta.tmdbid == 46195 + assert meta.type.value == "电视剧" + assert meta.begin_season == 1 + assert meta.episode_group == group_id + assert meta.apply_words == custom_words + + +def test_custom_words_support_special_season_zero_parameter(): + """显式媒体标签中的 s=0 应作为特别季写入元数据。""" + custom_words = [ + "Test Show => 测试剧 {[tmdbid=12345;type=tv;s=0]}" + ] + + with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None): + meta = MetaInfo(title="Test Show 01", custom_words=custom_words) + + assert meta.tmdbid == 12345 + assert meta.type.value == "电视剧" + assert meta.begin_season == 0 + + +def test_find_metainfo_supports_episode_group_parameter(): + """测试显式媒体标签支持 g 剧集组参数。""" + group_id = "5ad0ec240e0a26303f00d84d" + title, metainfo = find_metainfo(f"物语系列 {{[tmdbid=46195;type=tv;g={group_id};s=1]}}") + assert metainfo["episode_group"] == group_id + assert "g=" not in title + + +def test_find_metainfo_does_not_support_episode_group_alias(): + """测试 e_group 不会被当作剧集组参数识别。""" + group_id = "5ad0ec240e0a26303f00d84d" + with patch("app.core.metainfo.rust_accel.find_metainfo", return_value=None): + _, metainfo = find_metainfo(f"物语系列 {{[tmdbid=46195;type=tv;e_group={group_id};s=1]}}") + assert metainfo["episode_group"] is None + + +def test_video_bit_extracted_for_video_title(): + """测试普通影视标题中的视频位深可单独识别。""" + meta = MetaInfo(title="The 355 2022 BluRay 1080p DTS-HD MA5.1 X265.10bit-BeiTai") + assert meta.video_encode == "x265 10bit" + assert meta.video_bit == "10bit" + + +def test_video_bit_extracted_for_anime_title(): + """测试动漫标题中的视频位深可单独识别。""" + meta = MetaInfo( + title="[云歌字幕组][7月新番][欢迎来到实力至上主义的教室 第二季][01]" + "[X264 10bit][1080p][简体中文].mp4" + ) + assert meta.video_encode == "X264" + assert meta.video_bit == "10bit" + + +def test_streaming_platform_word_kept_in_movie_title(): + """测试正式片名中的流媒体平台词不会被预置清理规则移除。""" + with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None): + meta = MetaInfo(title="Amazon Forever 2004 1080p WEB-DL") + assert meta.name == "Amazon Forever" + assert meta.year == "2004" + + +def test_emby_tmdbid_overrides_braced_metainfo_tmdbid(): + """测试 Emby [tmdbid] 标签保持历史优先级。""" + title, metainfo = find_metainfo("Movie {[tmdbid=111;type=movies]} [tmdbid=222]") + assert metainfo["tmdbid"] == "222" + assert "[tmdbid=222]" not in title + + +def test_metainfopath_auxiliary_chinese_stem_uses_parent_title(): + """测试辅助文件名合并父目录标题与年份。""" + path = Path( + "/Marty Supreme 2025 2160p DoVi HDR Atmos TrueHD 7.1 x265-PbK/简英双语特效.mp4" + ) + meta = MetaInfoPath(path) + assert meta.en_name == "Marty Supreme" + assert meta.year == "2025" + assert meta.original_name == "Marty Supreme" + + +def test_metainfopath_chinese_parent_not_replaced_by_auxiliary_rule(): + """测试纯中文父目录不触发辅助文件名规则。""" + path = Path("/movies/流浪地球 (2023)/简体中字.mkv") + meta = MetaInfoPath(path) + assert meta.cn_name + assert "简体" in meta.cn_name + + +def test_metainfopath_cn_title_containing_keyword_not_cleared(): + """测试中文片名包含辅助关键词子串时不应被清空。""" + path = Path("/Some Movie 2024/粤语残片.mkv") + meta = MetaInfoPath(path) + assert "粤语残片" in meta.cn_name