From e002806bf68925e6c2a38ffb550ffbdbce4f37a5 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 7 Jul 2026 17:59:20 +0200 Subject: [PATCH] feat(renamer): tag processed torrents with ab:renamed (#147) After a torrent's media files are renamed (or already conform to the target naming), add an ab:renamed tag in the downloader so external scripts (filebot, hlink, ...) can filter tasks AutoBangumi has finished processing. Skips the API call when the tag is already present, skips failed renames, and skips the none/normal pass-through methods. Capability-guarded via the facade, so aria2/mock downloaders are unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pz1EmcTPzjgEpkGQWzb8AQ --- backend/src/module/manager/renamer.py | 27 +++++- backend/src/test/test_renamer.py | 134 ++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) 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"]