From a3295071370136d8cce4ea03671ffa5b256783ce Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 2 Sep 2026 12:46:30 +0800 Subject: [PATCH] fix(qbittorrent): verify temporary tag cleanup --- app/modules/qbittorrent/qbittorrent.py | 44 +++++++++++++++++++++++++- tests/test_qbittorrent_compat.py | 40 +++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/app/modules/qbittorrent/qbittorrent.py b/app/modules/qbittorrent/qbittorrent.py index 1ec728c7a..2edcd551e 100644 --- a/app/modules/qbittorrent/qbittorrent.py +++ b/app/modules/qbittorrent/qbittorrent.py @@ -15,6 +15,10 @@ from app.domain import torrent as torrent_rules from app.foundation import url as url_tools +_TORRENT_TAG_CLEANUP_RETRY_TIMES = 10 +_TORRENT_TAG_CLEANUP_RETRY_INTERVAL = 1 + + class Qbittorrent: """ qbittorrent下载器 @@ -305,6 +309,10 @@ class Qbittorrent: def delete_torrents_tag(self, ids: Optional[Union[str, list]], tag: Union[str, list]) -> bool: """ 从指定种子移除标签,并删除全局标签定义 + + qBittorrent 5.x 添加任务后可能暂时无法按响应中的种子 ID 查询任务, + 移除接口此时会静默成功,因此需读取任务标签确认清理结果。 + :param ids: 种子Hash列表;未知种子Hash时传入None,仅删除全局标签定义 :param tag: 标签内容 :return: 是否删除成功 @@ -313,7 +321,41 @@ class Qbittorrent: return False try: if ids: - self.qbc.torrents_remove_tags(torrent_hashes=ids, tags=tag) + expected_hashes = ( + {torrent_hash.strip().lower() for torrent_hash in ids.split('|') if torrent_hash.strip()} + if isinstance(ids, str) + else {str(torrent_hash).strip().lower() for torrent_hash in ids if torrent_hash} + ) + expected_tags = ( + {tag_name.strip() for tag_name in tag.split(',') if tag_name.strip()} + if isinstance(tag, str) + else {str(tag_name).strip() for tag_name in tag if tag_name} + ) + for attempt in range(_TORRENT_TAG_CLEANUP_RETRY_TIMES): + self.qbc.torrents_remove_tags(torrent_hashes=ids, tags=tag) + torrents = self.qbc.torrents_info(torrent_hashes=ids) or [] + visible_hashes = { + str(torrent.get("hash") or "").strip().lower() + for torrent in torrents + } + tags_removed = all( + not expected_tags.intersection( + { + torrent_tag.strip() + for torrent_tag in str(torrent.get("tags") or "").split(',') + if torrent_tag.strip() + } + ) + for torrent in torrents + ) + if expected_hashes.issubset(visible_hashes) and tags_removed: + break + if attempt < _TORRENT_TAG_CLEANUP_RETRY_TIMES - 1: + time.sleep(_TORRENT_TAG_CLEANUP_RETRY_INTERVAL) + else: + self.qbc.torrents_delete_tags(tags=tag) + logger.warning(f"种子 {ids} 的临时Tag未能在重试期限内确认删除:{tag}") + return False self.qbc.torrents_delete_tags(tags=tag) return True except Exception as err: diff --git a/tests/test_qbittorrent_compat.py b/tests/test_qbittorrent_compat.py index 138b92587..17034d6c8 100644 --- a/tests/test_qbittorrent_compat.py +++ b/tests/test_qbittorrent_compat.py @@ -733,6 +733,7 @@ def test_download_removes_temporary_tag_from_existing_torrent(): def test_delete_torrents_tag_uses_supported_qbittorrent_api_arguments(): """删除标签时应分别调用任务移除接口和全局标签删除接口。""" fake_client = MagicMock() + fake_client.torrents_info.return_value = [{"hash": "abc123", "tags": "moviepilot-tag"}] downloader = Qbittorrent.__new__(Qbittorrent) downloader.qbc = fake_client @@ -741,6 +742,45 @@ def test_delete_torrents_tag_uses_supported_qbittorrent_api_arguments(): torrent_hashes="abc123", tags="tmp-tag-01", ) + fake_client.torrents_info.assert_called_once_with(torrent_hashes="abc123") + fake_client.torrents_delete_tags.assert_called_once_with(tags="tmp-tag-01") + + +def test_delete_torrents_tag_retries_until_new_torrent_tag_is_removed(): + """新添加任务尚未可见或标签状态滞后时应重试并验证删除结果。""" + fake_client = MagicMock() + fake_client.torrents_info.side_effect = [ + [], + [{"hash": "abc123", "tags": "moviepilot-tag,tmp-tag-01"}], + [{"hash": "abc123", "tags": "moviepilot-tag"}], + ] + downloader = Qbittorrent.__new__(Qbittorrent) + downloader.qbc = fake_client + + with patch.object(qbittorrent_module.time, "sleep") as sleep: + cleaned = downloader.delete_torrents_tag("abc123", "tmp-tag-01") + + assert cleaned + assert fake_client.torrents_remove_tags.call_count == 3 + assert fake_client.torrents_info.call_count == 3 + assert sleep.call_args_list == [call(1), call(1)] + fake_client.torrents_delete_tags.assert_called_once_with(tags="tmp-tag-01") + + +def test_delete_torrents_tag_reports_unverified_cleanup(): + """重试后仍无法读取目标任务时不得把标签清理误报为成功。""" + fake_client = MagicMock() + fake_client.torrents_info.return_value = [] + downloader = Qbittorrent.__new__(Qbittorrent) + downloader.qbc = fake_client + + with patch.object(qbittorrent_module.time, "sleep") as sleep: + cleaned = downloader.delete_torrents_tag("abc123", "tmp-tag-01") + + assert not cleaned + assert fake_client.torrents_remove_tags.call_count == 10 + assert fake_client.torrents_info.call_count == 10 + assert sleep.call_count == 9 fake_client.torrents_delete_tags.assert_called_once_with(tags="tmp-tag-01")