diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e64dc5b..48048373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ ### Fixed - 修复 `gen_save_path` 季度守卫的一处预置 off-by-one 缺陷:合法的 `season=0`(特别篇)此前会被误判并回退为原值并打印多余警告 +- 收紧 `gen_save_path` 季度守卫:普通剧集在 `season_offset` 为负导致季号落到 0 时回退原季号(Season 0 会被 Plex/Jellyfin 当作特别篇),仅 `special` 类型允许合法落入第 0 季 +- 撤销 `gen_path` 在重命名文件名前追加 `[字幕组] ` 前缀的改动(`group_tag` 恢复为仅影响 qBittorrent RSS 规则名):避免已开启 `group_tag` 的用户升级后整个做种媒体库被批量重命名,破坏 Plex/Jellyfin 索引与 cross-seed/硬链接 +- 修复多文件电影种子(正片 + 特典/花絮,BD 常见)所有文件生成相同目标文件名导致重命名互相冲突/覆盖的问题:体积最大的主文件使用「标题 (年份).ext」干净名,其余文件追加原始文件名词干作区分 ## Frontend diff --git a/backend/src/module/downloader/path.py b/backend/src/module/downloader/path.py index 7d12ee51..d579f08a 100644 --- a/backend/src/module/downloader/path.py +++ b/backend/src/module/downloader/path.py @@ -81,8 +81,11 @@ def gen_save_path(data: Bangumi | BangumiUpdate): return str(Path(settings.downloader.path) / folder) # Apply season_offset to get the adjusted season number for the folder adjusted_season = data.season + getattr(data, "season_offset", 0) - if adjusted_season < 0: - adjusted_season = data.season # Safety: don't go below 0 (0 = specials) + # 季号下限:普通剧集最小为 1——偏移到 Season 0 会被 Plex/Jellyfin 当作 + # 特别篇;只有特别篇(special)允许合法落入第 0 季 + min_season = 0 if episode_type == "special" else 1 + if adjusted_season < min_season: + adjusted_season = data.season logger.warning( f"[Path] Season offset would result in invalid season for {data.official_title}, using original season" ) diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index 4b6081b3..743666cc 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -1,6 +1,7 @@ import asyncio import logging import time +from pathlib import PurePath from module.conf import settings from module.database import Database @@ -79,13 +80,9 @@ class Renamer: f"[Renamer] Episode offset {episode_offset} would make episode {original_episode} non-positive, ignoring offset" ) episode = f"0{adjusted_episode}" if adjusted_episode < 10 else adjusted_episode - # 与旧版 downloader/path.py 的 rule_name 行为保持一致:开启 group_tag 时, - # 在文件名前加上 "[字幕组] " 前缀 - group_prefix = ( - f"[{file_info.group}] " - if settings.bangumi_manage.group_tag and file_info.group - else "" - ) + # 注意:group_tag 只影响 qB RSS 规则名(downloader/path.py 的 rule_name), + # 从不写进重命名后的文件名——已有做种媒体库的文件名必须保持稳定, + # 否则升级后会触发整库批量重命名,破坏 Plex/Jellyfin 索引与硬链接 if method == "none" or method == "subtitle_none": return file_info.media_path if file_info.episode_type == "movie": @@ -96,14 +93,12 @@ class Renamer: assert isinstance( file_info, SubtitleFile ), "subtitle methods require a SubtitleFile" - return f"{group_prefix}{base}.{file_info.language}{file_info.suffix}" - return f"{group_prefix}{base}{file_info.suffix}" + return f"{base}.{file_info.language}{file_info.suffix}" + return f"{base}{file_info.suffix}" elif method == "pn": - return ( - f"{group_prefix}{file_info.title} S{season}E{episode}{file_info.suffix}" - ) + return f"{file_info.title} S{season}E{episode}{file_info.suffix}" elif method == "advance": - return f"{group_prefix}{bangumi_name} S{season}E{episode}{file_info.suffix}" + return f"{bangumi_name} S{season}E{episode}{file_info.suffix}" elif method == "normal": logger.warning("[Renamer] Normal rename method is deprecated.") return file_info.media_path @@ -111,12 +106,12 @@ class Renamer: assert isinstance( file_info, SubtitleFile ), "subtitle_pn requires a SubtitleFile" - return f"{group_prefix}{file_info.title} S{season}E{episode}.{file_info.language}{file_info.suffix}" + return f"{file_info.title} S{season}E{episode}.{file_info.language}{file_info.suffix}" elif method == "subtitle_advance": assert isinstance( file_info, SubtitleFile ), "subtitle_advance requires a SubtitleFile" - return f"{group_prefix}{bangumi_name} S{season}E{episode}.{file_info.language}{file_info.suffix}" + return f"{bangumi_name} S{season}E{episode}.{file_info.language}{file_info.suffix}" else: logger.error(f"[Renamer] Unknown rename method: {method}") return file_info.media_path @@ -195,6 +190,19 @@ class Renamer: await self.client.delete_torrent(hashes=_hash) return None + @staticmethod + def _gen_movie_extra_path(new_path: str, media_path: str) -> str: + """多文件电影种子中,非主文件在干净名(Title (Year).ext)基础上追加 + 原始文件名词干作区分,避免与主文件生成相同目标名互相冲突/覆盖; + 词干若已带 "Title (Year) - " 前缀则先剥离,保证重命名幂等。""" + suffix = PurePath(new_path).suffix + base = new_path[: -len(suffix)] if suffix else new_path + stem = PurePath(media_path).stem + prefix = f"{base} - " + if stem.startswith(prefix): + stem = stem[len(prefix) :] + return f"{base} - {stem}{suffix}" + async def rename_collection( self, media_list: list[str], @@ -205,8 +213,19 @@ class Renamer: episode_offset: int = 0, season_offset: int = 0, episode_type: str = "episode", + file_sizes: dict[str, int] | None = None, **kwargs, ): + # 多文件电影种子(正片 + 特典/花絮):所有文件会解析出同一标题, + # 需选出主文件(体积最大者,无体积信息时取首个),其余文件追加区分词干 + movie_primary: str | None = None + if episode_type == "movie": + ep_list = [p for p in media_list if is_ep(p)] + if ep_list: + if file_sizes: + movie_primary = max(ep_list, key=lambda p: file_sizes.get(p, 0)) + else: + movie_primary = ep_list[0] for media_path in media_list: if is_ep(media_path): ep = self._parser.torrent_parser( @@ -222,6 +241,13 @@ class Renamer: episode_offset=episode_offset, season_offset=season_offset, ) + if ( + movie_primary is not None + and media_path != movie_primary + and new_path != media_path + ): + # new_path == media_path 说明是 none 等直通方法,不做区分 + new_path = self._gen_movie_extra_path(new_path, media_path) if media_path != new_path: renamed = await self.client.rename_torrent_file( _hash=_hash, old_path=media_path, new_path=new_path @@ -543,7 +569,11 @@ class Renamer: # Rename collection elif len(media_list) > 1: logger.info("[Renamer] Start rename collection") - await self.rename_collection(media_list=media_list, **kwargs) + # 传入各文件体积,供多文件电影种子选出正片主文件 + file_sizes = {f["name"]: f.get("size") or 0 for f in files} + await self.rename_collection( + media_list=media_list, file_sizes=file_sizes, **kwargs + ) if len(subtitle_list) > 0: await self.rename_subtitles(subtitle_list=subtitle_list, **kwargs) await self.client.set_category(torrent_hash, "BangumiCollection") diff --git a/backend/src/test/test_path.py b/backend/src/test/test_path.py index 8133abc8..0fa198c6 100644 --- a/backend/src/test/test_path.py +++ b/backend/src/test/test_path.py @@ -82,6 +82,52 @@ class TestGenSavePath: assert result == "/downloads/Bangumi/My Anime (2024)/Season 0" + def test_gen_save_path_regular_offset_to_zero_reverts_to_original_season(self): + """普通剧集 season+offset 落到 0 时回退原季号:Season 0 会被 + Plex/Jellyfin 当作特别篇,只有 special 类型才允许落入。""" + bangumi = make_bangumi( + official_title="My Anime", + year="2024", + season=1, + season_offset=-1, + episode_type="episode", + ) + with patch("module.downloader.path.settings") as mock_settings: + mock_settings.downloader.path = "/downloads/Bangumi" + result = gen_save_path(bangumi) + + assert result == "/downloads/Bangumi/My Anime (2024)/Season 1" + + def test_gen_save_path_special_offset_to_zero_lands_in_season_zero(self): + """特别篇(special)经偏移落到第 0 季是合法的(Jellyfin/Plex 惯例)。""" + bangumi = make_bangumi( + official_title="My Anime", + year="2024", + season=1, + season_offset=-1, + episode_type="special", + ) + with patch("module.downloader.path.settings") as mock_settings: + mock_settings.downloader.path = "/downloads/Bangumi" + result = gen_save_path(bangumi) + + assert result == "/downloads/Bangumi/My Anime (2024)/Season 0" + + def test_gen_save_path_special_offset_below_zero_reverts_to_original_season(self): + """特别篇偏移到负季号仍属非法配置,回退原季号。""" + bangumi = make_bangumi( + official_title="My Anime", + year="2024", + season=0, + season_offset=-1, + episode_type="special", + ) + with patch("module.downloader.path.settings") as mock_settings: + mock_settings.downloader.path = "/downloads/Bangumi" + result = gen_save_path(bangumi) + + assert result == "/downloads/Bangumi/My Anime (2024)/Season 0" + # --------------------------------------------------------------------------- # rule_name diff --git a/backend/src/test/test_renamer.py b/backend/src/test/test_renamer.py index eb2a19e4..e71f2127 100644 --- a/backend/src/test/test_renamer.py +++ b/backend/src/test/test_renamer.py @@ -161,7 +161,8 @@ class TestGenPathMovie: result = Renamer.gen_path(sub, "天气之子 (2019)", method="subtitle_advance") assert result == "天气之子 (2019).zh.ass" - def test_group_tag_prefix_applied(self): + def test_gen_path_movie_group_tag_enabled_keeps_clean_name(self): + """group_tag 不影响重命名文件名(只影响 qB RSS 规则名)。""" ep = EpisodeFile( media_path="raw.mkv", group="Lilith-Raws", @@ -173,7 +174,7 @@ class TestGenPathMovie: ) with patch.object(settings.bangumi_manage, "group_tag", True): result = Renamer.gen_path(ep, "天气之子 (2019)", method="advance") - assert result == "[Lilith-Raws] 天气之子 (2019).mkv" + assert result == "天气之子 (2019).mkv" def test_none_method_returns_original_path(self): ep = EpisodeFile( @@ -193,12 +194,13 @@ class TestGenPathMovie: # --------------------------------------------------------------------------- -class TestGenPathWithGroupTag: - """gen_path honors bangumi_manage.group_tag as a "[Group] " prefix, - mirroring the legacy downloader/path.py rule_name behavior.""" +class TestGenPathGroupTagStability: + """group_tag 只影响 qB RSS 规则名(downloader/path.py 的 rule_name), + 从不写进重命名后的文件名:升级后已有做种媒体库的文件名必须保持稳定, + 否则会触发整库批量重命名,破坏 Plex/Jellyfin 索引与 cross-seed/硬链接。""" - def test_group_tag_enabled_prefixes_pn_method(self): - """pn method gets a "[Group] " prefix when group_tag is enabled.""" + def test_gen_path_group_tag_enabled_pn_no_prefix(self): + """pn 方法在 group_tag 开启时也不加 "[Group] " 前缀。""" ep = EpisodeFile( media_path="old.mkv", group="SubGroup", @@ -209,10 +211,10 @@ class TestGenPathWithGroupTag: ) with patch.object(settings.bangumi_manage, "group_tag", True): result = Renamer.gen_path(ep, "Bangumi Name", method="pn") - assert result == "[SubGroup] My Anime S01E05.mkv" + assert result == "My Anime S01E05.mkv" - def test_group_tag_enabled_prefixes_advance_method(self): - """advance method gets a "[Group] " prefix when group_tag is enabled.""" + def test_gen_path_group_tag_enabled_advance_no_prefix(self): + """advance 方法在 group_tag 开启时也不加前缀。""" ep = EpisodeFile( media_path="old.mkv", group="SubGroup", @@ -223,10 +225,10 @@ class TestGenPathWithGroupTag: ) with patch.object(settings.bangumi_manage, "group_tag", True): result = Renamer.gen_path(ep, "Bangumi Name", method="advance") - assert result == "[SubGroup] Bangumi Name S01E05.mkv" + assert result == "Bangumi Name S01E05.mkv" - def test_group_tag_enabled_prefixes_subtitle_methods(self): - """subtitle_pn/subtitle_advance also get the prefix when enabled.""" + def test_gen_path_group_tag_enabled_subtitle_methods_no_prefix(self): + """subtitle_pn/subtitle_advance 在 group_tag 开启时也不加前缀。""" sub = SubtitleFile( media_path="sub.ass", group="SubGroup", @@ -238,10 +240,10 @@ class TestGenPathWithGroupTag: ) with patch.object(settings.bangumi_manage, "group_tag", True): result = Renamer.gen_path(sub, "Bangumi Name", method="subtitle_pn") - assert result == "[SubGroup] My Anime S01E05.zh.ass" + assert result == "My Anime S01E05.zh.ass" - def test_group_tag_disabled_no_prefix(self): - """No prefix is added when group_tag is disabled, even with a group.""" + def test_gen_path_group_tag_disabled_no_prefix(self): + """group_tag 关闭时同样没有前缀。""" ep = EpisodeFile( media_path="old.mkv", group="SubGroup", @@ -254,24 +256,8 @@ class TestGenPathWithGroupTag: result = Renamer.gen_path(ep, "Bangumi Name", method="pn") assert result == "My Anime S01E05.mkv" - def test_group_tag_enabled_but_no_group_no_prefix(self): - """No "[None] " prefix leaks in when group_tag is enabled but the - parsed file has no group (e.g. no [Group] tag in the torrent name).""" - ep = EpisodeFile( - media_path="old.mkv", - group=None, - title="My Anime", - season=1, - episode=5, - suffix=".mkv", - ) - with patch.object(settings.bangumi_manage, "group_tag", True): - result = Renamer.gen_path(ep, "Bangumi Name", method="pn") - assert result == "My Anime S01E05.mkv" - - def test_group_tag_enabled_none_and_subtitle_none_unaffected(self): - """none/subtitle_none methods return the original path unchanged, - regardless of group_tag.""" + def test_gen_path_group_tag_enabled_none_method_returns_original(self): + """none/subtitle_none 方法始终原样返回路径,与 group_tag 无关。""" ep = EpisodeFile( media_path="original/path/file.mkv", group="SubGroup", @@ -467,6 +453,81 @@ class TestRenameCollection: # Only called once for ep01.mkv (depth 1) assert renamer.client.client.torrents_rename_file.call_count == 1 + @staticmethod + def _rename_new_paths(mock_client) -> list[str]: + """提取 torrents_rename_file 每次调用的 new_path 参数。""" + return [ + call.kwargs["new_path"] + for call in mock_client.torrents_rename_file.call_args_list + ] + + async def test_rename_collection_movie_multifile_targets_distinct(self, renamer): + """多文件电影种子(正片 + 特典)不能全部生成同一个目标文件名: + 主文件用干净名,其余文件追加原始文件名词干作区分。""" + media_list = ["Tenki no Ko.mkv", "Tenki no Ko Extra PV.mkv"] + renamer.client.client.torrents_rename_file.return_value = True + await renamer.rename_collection( + media_list=media_list, + bangumi_name="天气之子 (2019)", + season=1, + method="advance", + _hash="hash_movie", + episode_type="movie", + file_sizes={ + "Tenki no Ko.mkv": 8_000_000_000, + "Tenki no Ko Extra PV.mkv": 100_000_000, + }, + ) + + new_paths = self._rename_new_paths(renamer.client.client) + assert len(new_paths) == 2 + assert len(set(new_paths)) == 2 + assert "天气之子 (2019).mkv" in new_paths + + async def test_rename_collection_movie_largest_file_gets_clean_name(self, renamer): + """体积最大的文件是主文件,即使它不在列表首位。""" + media_list = ["Extras Menu.mkv", "Tenki no Ko Main Feature.mkv"] + renamer.client.client.torrents_rename_file.return_value = True + await renamer.rename_collection( + media_list=media_list, + bangumi_name="天气之子 (2019)", + season=1, + method="advance", + _hash="hash_movie", + episode_type="movie", + file_sizes={ + "Extras Menu.mkv": 100_000_000, + "Tenki no Ko Main Feature.mkv": 8_000_000_000, + }, + ) + + calls = renamer.client.client.torrents_rename_file.call_args_list + by_old = {c.kwargs["old_path"]: c.kwargs["new_path"] for c in calls} + assert by_old["Tenki no Ko Main Feature.mkv"] == "天气之子 (2019).mkv" + assert by_old["Extras Menu.mkv"] == "天气之子 (2019) - Extras Menu.mkv" + + async def test_rename_collection_movie_already_renamed_skips_rename(self, renamer): + """区分名是幂等的:已重命名过的多文件电影种子下一轮不再触发重命名。""" + media_list = [ + "天气之子 (2019).mkv", + "天气之子 (2019) - Tenki no Ko Extra PV.mkv", + ] + renamer.client.client.torrents_rename_file.return_value = True + await renamer.rename_collection( + media_list=media_list, + bangumi_name="天气之子 (2019)", + season=1, + method="advance", + _hash="hash_movie", + episode_type="movie", + file_sizes={ + "天气之子 (2019).mkv": 8_000_000_000, + "天气之子 (2019) - Tenki no Ko Extra PV.mkv": 100_000_000, + }, + ) + + renamer.client.client.torrents_rename_file.assert_not_called() + # --------------------------------------------------------------------------- # rename_subtitles @@ -618,6 +679,39 @@ class TestRenameFlow: "h1", "BangumiCollection" ) + async def test_rename_flow_movie_collection_uses_file_sizes(self, renamer): + """多文件电影种子走完整 rename 流程时,按文件体积选出主文件, + 目标文件名互不相同。""" + renamer.client.client.torrents_info.return_value = [ + { + "hash": "h1", + "name": "天气之子 Movie BDRip", + "save_path": "/downloads/Bangumi/天气之子 (2019)", + } + ] + renamer.client.client.torrents_files.return_value = [ + {"name": "Menu PV.mkv", "size": 100_000_000}, + {"name": "Tenki no Ko.mkv", "size": 8_000_000_000}, + ] + renamer.client.client.torrents_rename_file.return_value = True + + with patch.object( + renamer, + "_batch_lookup_offsets", + AsyncMock(return_value={"h1": (0, 0, "movie")}), + ): + with patch("module.manager.renamer.settings") as mock_settings: + mock_settings.bangumi_manage.rename_method = "advance" + mock_settings.bangumi_manage.remove_bad_torrent = False + with patch("module.downloader.path.settings") as mock_path_settings: + mock_path_settings.downloader.path = "/downloads/Bangumi" + await renamer.rename() + + calls = renamer.client.client.torrents_rename_file.call_args_list + by_old = {c.kwargs["old_path"]: c.kwargs["new_path"] for c in calls} + assert by_old["Tenki no Ko.mkv"] == "天气之子 (2019).mkv" + assert by_old["Menu PV.mkv"] == "天气之子 (2019) - Menu PV.mkv" + async def test_no_media_files_no_crash(self, renamer): """When torrent has no media files, logs warning but doesn't crash.""" renamer.client.client.torrents_info.return_value = [