diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py index 6b7316a6..ead77950 100644 --- a/backend/src/module/manager/renamer.py +++ b/backend/src/module/manager/renamer.py @@ -30,6 +30,9 @@ _PENDING_RENAME_COOLDOWN = 300 # 5 minutes cooldown before retrying same rename _CLEANUP_INTERVAL = 60 # Clean up pending cache at most once per minute _last_cleanup_time: float = 0 +# 处理完成标记:供外部脚本(filebot、hlink 等)过滤 AB 已重命名的任务 (#147) +_RENAMED_TAG = "ab:renamed" + class Renamer: def __init__(self, client: DownloadClient): @@ -141,6 +144,12 @@ class Renamer: logger.error(f"Unknown rename method: {method}") return file_info.media_path + async def _mark_renamed(self, _hash: str, existing_tags: str | None) -> None: + """给处理完成的种子打 ``ab:renamed`` 标签;已带标签时不再调 API。""" + if _RENAMED_TAG in (t.strip() for t in (existing_tags or "").split(",")): + return + await self.client.add_tag(_hash, _RENAMED_TAG) + async def rename_file( self, torrent_name: str, @@ -152,6 +161,7 @@ class Renamer: episode_offset: int = 0, season_offset: int = 0, episode_type: str = "episode", + existing_tags: str | None = None, **kwargs, ): ep = self._parser.torrent_parser( @@ -168,7 +178,12 @@ class Renamer: episode_offset=episode_offset, season_offset=season_offset, ) - if media_path != new_path: + if media_path == new_path: + # 已符合目标命名(如重启后再次扫描):视为处理完成。 + # none/normal 是直通方法,没有"重命名完成"的语义,不打标 + if method not in ("none", "normal"): + await self._mark_renamed(_hash, existing_tags) + else: # Check if this rename was recently attempted but didn't take effect # (qBittorrent can return 200 but delay actual rename while seeding) pending_key = (_hash, media_path, new_path) @@ -185,6 +200,7 @@ class Renamer: ): # Rename verified successful, remove from pending cache _pending_renames.pop(pending_key, None) + await self._mark_renamed(_hash, existing_tags) # Season comes from folder which already has offset applied # Only apply episode offset return Notification( @@ -228,6 +244,7 @@ class Renamer: season_offset: int = 0, episode_type: str = "episode", file_sizes: dict[str, int] | None = None, + existing_tags: str | None = None, **kwargs, ): # 多文件电影种子(正片 + 特典/花絮):所有文件会解析出同一标题, @@ -240,6 +257,7 @@ class Renamer: movie_primary = max(ep_list, key=lambda p: file_sizes.get(p, 0)) else: movie_primary = ep_list[0] + all_renamed = True for media_path in media_list: if is_ep(media_path): ep = self._parser.torrent_parser( @@ -267,11 +285,16 @@ class Renamer: _hash=_hash, old_path=media_path, new_path=new_path ) if not renamed: + all_renamed = False logger.warning(f"{media_path} rename failed") # Delete bad torrent. if settings.bangumi_manage.remove_bad_torrent: await self.client.delete_torrent(_hash) break + # 全部媒体文件就位(含本轮无需改名的情况)才算处理完成; + # none/normal 直通方法没有"重命名完成"的语义,不打标 + if all_renamed and method not in ("none", "normal"): + await self._mark_renamed(_hash, existing_tags) async def rename_subtitles( self, @@ -579,6 +602,8 @@ class Renamer: "episode_offset": episode_offset, "season_offset": season_offset, "episode_type": episode_type, + # 处理完成后打 ab:renamed 标签用;已带标签则跳过 API 调用 + "existing_tags": info.get("tags"), } # Rename single media file if len(media_list) == 1: diff --git a/backend/src/test/test_renamer.py b/backend/src/test/test_renamer.py index f53324f9..209f038c 100644 --- a/backend/src/test/test_renamer.py +++ b/backend/src/test/test_renamer.py @@ -406,6 +406,98 @@ class TestRenameFile: assert result is not None assert result.episode == 12.5 + async def test_successful_rename_adds_renamed_tag(self, renamer): + """重命名成功后给种子打 ab:renamed 标签,供外部脚本识别 (#147)。""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = True + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash123", + ) + + renamer.client.client.add_tag.assert_awaited_once_with("hash123", "ab:renamed") + + async def test_conformant_file_adds_renamed_tag(self, renamer): + """文件名已符合目标命名(如重启后再扫)也视为处理完成,补标签。""" + ep = EpisodeFile( + media_path="My Anime S01E05.mkv", + title="My Anime", + season=1, + episode=5, + suffix=".mkv", + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="My Anime S01E05.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash123", + ) + + renamer.client.client.torrents_rename_file.assert_not_awaited() + renamer.client.client.add_tag.assert_awaited_once_with("hash123", "ab:renamed") + + async def test_existing_renamed_tag_not_re_added(self, renamer): + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = True + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash123", + existing_tags="ab:42, ab:renamed", + ) + + renamer.client.client.add_tag.assert_not_awaited() + + async def test_failed_rename_does_not_tag(self, renamer): + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = False + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="pn", + season=1, + _hash="hash-fail-tag", + ) + + renamer.client.client.add_tag.assert_not_awaited() + + async def test_none_method_does_not_tag(self, renamer): + """none 方法不做重命名,也不应打处理完成标签。""" + ep = EpisodeFile( + media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + await renamer.rename_file( + torrent_name="[Sub] My Anime - 05.mkv", + media_path="old.mkv", + bangumi_name="My Anime", + method="none", + season=1, + _hash="hash123", + ) + + renamer.client.client.add_tag.assert_not_awaited() + async def test_parse_fails_no_remove(self, renamer): """When parser returns None and remove_bad_torrent=False, returns None.""" with patch.object(renamer._parser, "torrent_parser", return_value=None): @@ -515,6 +607,48 @@ class TestRenameCollection: assert renamer.client.client.torrents_rename_file.call_count == 3 + async def test_collection_success_adds_renamed_tag(self, renamer): + """合集全部重命名成功后打 ab:renamed 标签 (#147)。""" + media_list = ["ep01.mkv", "ep02.mkv"] + + def mock_parser(torrent_path, season, **kwargs): + ep_num = int(torrent_path.replace("ep", "").replace(".mkv", "")) + return EpisodeFile( + media_path=torrent_path, + title="Anime", + season=season, + episode=ep_num, + suffix=".mkv", + ) + + with patch.object(renamer._parser, "torrent_parser", side_effect=mock_parser): + renamer.client.client.torrents_rename_file.return_value = True + await renamer.rename_collection( + media_list=media_list, + bangumi_name="Anime", + season=1, + method="pn", + _hash="hash123", + ) + + renamer.client.client.add_tag.assert_awaited_once_with("hash123", "ab:renamed") + + async def test_collection_failure_does_not_tag(self, renamer): + ep = EpisodeFile( + media_path="ep01.mkv", title="Anime", season=1, episode=1, suffix=".mkv" + ) + with patch.object(renamer._parser, "torrent_parser", return_value=ep): + renamer.client.client.torrents_rename_file.return_value = False + await renamer.rename_collection( + media_list=["ep01.mkv"], + bangumi_name="Anime", + season=1, + method="pn", + _hash="hash-collection-fail", + ) + + renamer.client.client.add_tag.assert_not_awaited() + async def test_skips_deep_files(self, renamer): """Files deeper than 2 levels are skipped (not is_ep).""" media_list = ["ep01.mkv", "extras/bonus/ep_sp.mkv"]