feat: add downloader incomplete suffix toggles

This commit is contained in:
jxxghp
2026-06-13 08:43:37 +08:00
parent ab9eeedb3e
commit 303e7ee16e
4 changed files with 240 additions and 201 deletions

View File

@@ -23,6 +23,7 @@ class Qbittorrent:
apikey: Optional[str] = None, apikey: Optional[str] = None,
category: Optional[bool] = False, sequentail: Optional[bool] = False, category: Optional[bool] = False, sequentail: Optional[bool] = False,
force_resume: Optional[bool] = False, first_last_piece=False, force_resume: Optional[bool] = False, first_last_piece=False,
incomplete_files_ext: Optional[bool] = True,
**kwargs): **kwargs):
""" """
若不设置参数,则创建配置文件设置的下载器 若不设置参数,则创建配置文件设置的下载器
@@ -42,6 +43,7 @@ class Qbittorrent:
self._sequentail = sequentail self._sequentail = sequentail
self._force_resume = force_resume self._force_resume = force_resume
self._first_last_piece = first_last_piece self._first_last_piece = first_last_piece
self._incomplete_files_ext = incomplete_files_ext
self.qbc = self.__login_qbittorrent() self.qbc = self.__login_qbittorrent()
@staticmethod @staticmethod
@@ -154,18 +156,19 @@ class Qbittorrent:
return False return False
@staticmethod @staticmethod
def __enable_incomplete_file_suffix(qbt: Client) -> None: def __sync_incomplete_file_suffix(qbt: Client, enabled: bool) -> None:
""" """
开启未完成文件后缀,避免监控流程提前整理仍在下载的媒体文件。 同步未完成文件后缀开关,避免监控流程提前整理仍在下载的媒体文件。
""" """
try: try:
preferences = qbt.app_preferences() or {} preferences = qbt.app_preferences() or {}
if isinstance(preferences, dict) and preferences.get("incomplete_files_ext") is True: if isinstance(preferences, dict) and preferences.get("incomplete_files_ext") is enabled:
return return
qbt.app_set_preferences({"incomplete_files_ext": True}) qbt.app_set_preferences({"incomplete_files_ext": enabled})
logger.info("开启 qbittorrent 未完成文件追加 .!qB 后缀") action = "开启" if enabled else "关闭"
logger.info(f"{action} qbittorrent 未完成文件追加 .!qB 后缀")
except Exception as err: except Exception as err:
logger.warning(f"开启 qbittorrent 未完成文件后缀失败:{str(err)}") logger.warning(f"同步 qbittorrent 未完成文件后缀失败:{str(err)}")
def is_inactive(self) -> bool: def is_inactive(self) -> bool:
""" """
@@ -212,7 +215,7 @@ class Qbittorrent:
stack_trace = "".join(traceback.format_exception(None, e, e.__traceback__))[:2000] stack_trace = "".join(traceback.format_exception(None, e, e.__traceback__))[:2000]
logger.error(f"qbittorrent 登录失败:{str(e)}\n{stack_trace}") logger.error(f"qbittorrent 登录失败:{str(e)}\n{stack_trace}")
return None return None
self.__enable_incomplete_file_suffix(qbt) self.__sync_incomplete_file_suffix(qbt, enabled=bool(self._incomplete_files_ext))
return qbt return qbt
except Exception as err: except Exception as err:
logger.error(f"qbittorrent 连接出错:{str(err)}") logger.error(f"qbittorrent 连接出错:{str(err)}")

View File

@@ -20,7 +20,8 @@ class Transmission:
"error", "errorString", "doneDate", "queuePosition", "activityDate", "trackers"] "error", "errorString", "doneDate", "queuePosition", "activityDate", "trackers"]
def __init__(self, host: Optional[str] = None, port: Optional[int] = None, def __init__(self, host: Optional[str] = None, port: Optional[int] = None,
username: Optional[str] = None, password: Optional[str] = None, **kwargs): username: Optional[str] = None, password: Optional[str] = None,
rename_partial_files: Optional[bool] = True, **kwargs):
""" """
若不设置参数,则创建配置文件设置的下载器 若不设置参数,则创建配置文件设置的下载器
""" """
@@ -39,12 +40,13 @@ class Transmission:
return return
self._username = username self._username = username
self._password = password self._password = password
self._rename_partial_files = rename_partial_files
self.trc = self.__login_transmission() self.trc = self.__login_transmission()
@staticmethod @staticmethod
def __enable_incomplete_file_suffix(trt: Client) -> None: def __sync_incomplete_file_suffix(trt: Client, enabled: bool) -> None:
""" """
开启未完成文件后缀,避免监控流程提前整理仍在下载的媒体文件。 同步未完成文件后缀开关,避免监控流程提前整理仍在下载的媒体文件。
""" """
try: try:
session = trt.get_session() session = trt.get_session()
@@ -53,12 +55,13 @@ class Transmission:
rename_partial_files = getter("rename-partial-files") rename_partial_files = getter("rename-partial-files")
else: else:
rename_partial_files = getattr(session, "rename_partial_files", None) rename_partial_files = getattr(session, "rename_partial_files", None)
if rename_partial_files is True: if rename_partial_files is enabled:
return return
trt.set_session(rename_partial_files=True) trt.set_session(rename_partial_files=enabled)
logger.info("开启 transmission 未完成文件追加 .part 后缀") action = "开启" if enabled else "关闭"
logger.info(f"{action} transmission 未完成文件追加 .part 后缀")
except Exception as err: except Exception as err:
logger.warning(f"开启 transmission 未完成文件后缀失败:{str(err)}") logger.warning(f"同步 transmission 未完成文件后缀失败:{str(err)}")
def __login_transmission(self) -> Optional[Client]: def __login_transmission(self) -> Optional[Client]:
""" """
@@ -76,7 +79,7 @@ class Transmission:
username=self._username, username=self._username,
password=self._password, password=self._password,
timeout=60) timeout=60)
self.__enable_incomplete_file_suffix(trt) self.__sync_incomplete_file_suffix(trt, enabled=bool(self._rename_partial_files))
return trt return trt
except Exception as err: except Exception as err:
logger.error(f"transmission 连接出错:{str(err)}") logger.error(f"transmission 连接出错:{str(err)}")

View File

@@ -1,7 +1,6 @@
import importlib.util import importlib.util
import sys import sys
import types import types
import unittest
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -192,187 +191,208 @@ Qbittorrent = qbittorrent_module.Qbittorrent
QbittorrentModule = qbittorrent_package_module.QbittorrentModule QbittorrentModule = qbittorrent_package_module.QbittorrentModule
class TestQbittorrentCompat(unittest.TestCase): def test_login_uses_api_key_header_without_auth_login():
def test_login_uses_api_key_header_without_auth_login(self): """API Key 登录时应使用 Bearer Header 并跳过用户名密码登录。"""
fake_client = MagicMock() fake_client = MagicMock()
fake_client.app_version.return_value = "v5.2.0" fake_client.app_version.return_value = "v5.2.0"
with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client) as client_cls: with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client) as client_cls:
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, apikey="secret-token") downloader = Qbittorrent(host="http://127.0.0.1", port=8080, apikey="secret-token")
self.assertIs(downloader.qbc, fake_client) assert downloader.qbc is fake_client
fake_client.auth_log_in.assert_not_called() fake_client.auth_log_in.assert_not_called()
fake_client.app_version.assert_called_once_with() fake_client.app_version.assert_called_once_with()
self.assertEqual( assert client_cls.call_args.kwargs["EXTRA_HEADERS"] == {"Authorization": "Bearer secret-token"}
client_cls.call_args.kwargs["EXTRA_HEADERS"],
{"Authorization": "Bearer secret-token"},
def test_login_enables_incomplete_file_suffix_by_default():
"""
登录成功后默认开启未完成文件后缀,避免下载中的媒体文件被提前整理。
"""
fake_client = MagicMock()
fake_client.app_preferences.return_value = {"incomplete_files_ext": False}
with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client):
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
assert downloader.qbc is fake_client
fake_client.app_set_preferences.assert_called_once_with({"incomplete_files_ext": True})
def test_login_disables_incomplete_file_suffix_when_configured():
"""
用户关闭配置后应同步关闭 qBittorrent 未完成文件后缀偏好。
"""
fake_client = MagicMock()
fake_client.app_preferences.return_value = {"incomplete_files_ext": True}
with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client):
downloader = Qbittorrent(
host="http://127.0.0.1",
port=8080,
username="admin",
password="adminadmin",
incomplete_files_ext=False,
) )
def test_login_enables_incomplete_file_suffix(self): assert downloader.qbc is fake_client
""" fake_client.app_set_preferences.assert_called_once_with({"incomplete_files_ext": False})
登录成功后应开启未完成文件后缀,避免下载中的媒体文件被提前整理。
"""
fake_client = MagicMock()
fake_client.app_preferences.return_value = {"incomplete_files_ext": False}
with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client):
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
self.assertIs(downloader.qbc, fake_client) def test_login_skips_incomplete_file_suffix_when_already_matches():
fake_client.app_set_preferences.assert_called_once_with({"incomplete_files_ext": True}) """
远端未完成文件后缀状态已匹配配置时不重复写入全局偏好。
"""
fake_client = MagicMock()
fake_client.app_preferences.return_value = {"incomplete_files_ext": True}
def test_login_skips_incomplete_file_suffix_when_already_enabled(self): with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client):
""" downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
远端已开启未完成文件后缀时不重复写入全局偏好。
"""
fake_client = MagicMock()
fake_client.app_preferences.return_value = {"incomplete_files_ext": True}
with patch.object(qbittorrent_module.qbittorrentapi, "Client", return_value=fake_client): assert downloader.qbc is fake_client
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") fake_client.app_set_preferences.assert_not_called()
self.assertIs(downloader.qbc, fake_client)
fake_client.app_set_preferences.assert_not_called()
def test_add_torrent_accepts_structured_success_response(self): def test_add_torrent_accepts_structured_success_response():
fake_client = MagicMock() """新版 qBittorrent API 结构化成功响应应返回新增种子 ID。"""
fake_client.torrents_add.return_value = { fake_client = MagicMock()
"success_count": 1, fake_client.torrents_add.return_value = {
"failure_count": 0, "success_count": 1,
"pending_count": 0, "failure_count": 0,
"added_torrent_ids": ["abc123"], "pending_count": 0,
"added_torrent_ids": ["abc123"],
}
with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client):
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
success, added_torrent_ids = downloader.add_torrent(content="https://example.com/test.torrent")
assert success
assert added_torrent_ids == ["abc123"]
def test_add_torrent_accepts_pending_success_response_without_ids():
"""新版 qBittorrent API 待处理成功响应没有 ID 时仍应视为添加成功。"""
fake_client = MagicMock()
fake_client.torrents_add.return_value = {
"success_count": 0,
"failure_count": 0,
"pending_count": 1,
"added_torrent_ids": [],
}
with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client):
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
success, added_torrent_ids = downloader.add_torrent(content="https://example.com/test.torrent")
assert success
assert added_torrent_ids == []
def test_add_torrent_uses_cookie_api_for_qbittorrent_52():
"""qBittorrent 5.2 对应 Web API 应通过 Cookie API 同步站点 Cookie。"""
fake_client = MagicMock()
fake_client.app_web_api_version.return_value = "2.11.3"
fake_client.app_cookies.return_value = [
{
"domain": "old.example.com",
"path": "/",
"name": "old",
"value": "cookie",
} }
]
fake_client.torrents_add.return_value = "Ok."
with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client):
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
success, added_torrent_ids = downloader.add_torrent(content="https://example.com/test.torrent") success, added_torrent_ids = downloader.add_torrent(
self.assertTrue(success) content="https://tracker.example.com/download?id=1",
self.assertEqual(added_torrent_ids, ["abc123"]) cookie="uid=1; passkey=abc",
)
def test_add_torrent_accepts_pending_success_response_without_ids(self): assert success
fake_client = MagicMock() assert added_torrent_ids == []
fake_client.torrents_add.return_value = { set_cookie_call = fake_client.app_set_cookies.call_args.kwargs["cookies"]
"success_count": 0, assert {
"failure_count": 0, "domain": "tracker.example.com",
"pending_count": 1, "path": "/",
"added_torrent_ids": [], "name": "uid",
} "value": "1",
} in set_cookie_call
with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client): assert {
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin") "domain": "tracker.example.com",
"path": "/",
success, added_torrent_ids = downloader.add_torrent(content="https://example.com/test.torrent") "name": "passkey",
self.assertTrue(success) "value": "abc",
self.assertEqual(added_torrent_ids, []) } in set_cookie_call
assert fake_client.torrents_add.call_args.kwargs["cookie"] is None
def test_add_torrent_uses_cookie_api_for_qbittorrent_52(self):
fake_client = MagicMock()
fake_client.app_web_api_version.return_value = "2.11.3"
fake_client.app_cookies.return_value = [
{
"domain": "old.example.com",
"path": "/",
"name": "old",
"value": "cookie",
}
]
fake_client.torrents_add.return_value = "Ok."
with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client):
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
success, added_torrent_ids = downloader.add_torrent(
content="https://tracker.example.com/download?id=1",
cookie="uid=1; passkey=abc",
)
self.assertTrue(success)
self.assertEqual(added_torrent_ids, [])
set_cookie_call = fake_client.app_set_cookies.call_args.kwargs["cookies"]
self.assertIn(
{
"domain": "tracker.example.com",
"path": "/",
"name": "uid",
"value": "1",
},
set_cookie_call,
)
self.assertIn(
{
"domain": "tracker.example.com",
"path": "/",
"name": "passkey",
"value": "abc",
},
set_cookie_call,
)
self.assertIsNone(fake_client.torrents_add.call_args.kwargs["cookie"])
def test_add_torrent_keeps_legacy_cookie_param_for_old_webapi(self):
fake_client = MagicMock()
fake_client.app_web_api_version.return_value = "2.11.2"
fake_client.torrents_add.return_value = "Ok."
with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client):
downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
success, added_torrent_ids = downloader.add_torrent(
content="https://tracker.example.com/download?id=1",
cookie="uid=1",
)
self.assertTrue(success)
self.assertEqual(added_torrent_ids, [])
fake_client.app_set_cookies.assert_not_called()
self.assertEqual(fake_client.torrents_add.call_args.kwargs["cookie"], "uid=1")
class TestQbittorrentModuleCompat(unittest.TestCase): def test_add_torrent_keeps_legacy_cookie_param_for_old_webapi():
@staticmethod """旧版 qBittorrent Web API 不支持 Cookie API 时保留添加种子 Cookie 参数。"""
def _build_module(server): fake_client = MagicMock()
module = QbittorrentModule.__new__(QbittorrentModule) fake_client.app_web_api_version.return_value = "2.11.2"
module.get_instance = MagicMock(return_value=server) fake_client.torrents_add.return_value = "Ok."
module.normalize_path = MagicMock(side_effect=lambda path, _downloader: path)
module.get_default_config_name = MagicMock(return_value="default-qb")
return module
def test_download_prefers_added_torrent_ids_before_tag_lookup(self): with patch.object(Qbittorrent, "_Qbittorrent__login_qbittorrent", return_value=fake_client):
fake_server = MagicMock() downloader = Qbittorrent(host="http://127.0.0.1", port=8080, username="admin", password="adminadmin")
fake_server.add_torrent.return_value = (True, ["abc123"])
fake_server.get_content_layout.return_value = "Original"
fake_server.is_force_resume.return_value = False
module = self._build_module(fake_server) success, added_torrent_ids = downloader.add_torrent(
result = module.download( content="https://tracker.example.com/download?id=1",
content="magnet:?xt=urn:btih:123", cookie="uid=1",
download_dir=Path("/downloads"), )
cookie="", assert success
downloader="qb", assert added_torrent_ids == []
) fake_client.app_set_cookies.assert_not_called()
assert fake_client.torrents_add.call_args.kwargs["cookie"] == "uid=1"
self.assertEqual(result, ("qb", "abc123", "Original", "添加下载成功"))
fake_server.delete_torrents_tag.assert_called_once_with("abc123", "tmp-tag-01")
fake_server.get_torrent_id_by_tag.assert_not_called()
self.assertEqual(
fake_server.add_torrent.call_args.kwargs["tag"],
["tmp-tag-01", "moviepilot-tag"],
)
def test_download_falls_back_to_tag_lookup_when_added_ids_missing(self): def _build_module(server):
fake_server = MagicMock() """构造仅包含下载所需方法的 QbittorrentModule 测试实例。"""
fake_server.add_torrent.return_value = (True, []) module = QbittorrentModule.__new__(QbittorrentModule)
fake_server.get_content_layout.return_value = "Original" module.get_instance = MagicMock(return_value=server)
fake_server.get_torrent_id_by_tag.return_value = "def456" module.normalize_path = MagicMock(side_effect=lambda path, _downloader: path)
fake_server.is_force_resume.return_value = False module.get_default_config_name = MagicMock(return_value="default-qb")
return module
module = self._build_module(fake_server)
result = module.download(
content="magnet:?xt=urn:btih:456",
download_dir=Path("/downloads"),
cookie="",
downloader="qb",
)
self.assertEqual(result, ("qb", "def456", "Original", "添加下载成功")) def test_download_prefers_added_torrent_ids_before_tag_lookup():
fake_server.delete_torrents_tag.assert_not_called() """添加任务响应包含种子 ID 时应优先使用响应值。"""
fake_server.get_torrent_id_by_tag.assert_called_once_with(tags="tmp-tag-01") fake_server = MagicMock()
fake_server.add_torrent.return_value = (True, ["abc123"])
fake_server.get_content_layout.return_value = "Original"
fake_server.is_force_resume.return_value = False
module = _build_module(fake_server)
result = module.download(
content="magnet:?xt=urn:btih:123",
download_dir=Path("/downloads"),
cookie="",
downloader="qb",
)
assert result == ("qb", "abc123", "Original", "添加下载成功")
fake_server.delete_torrents_tag.assert_called_once_with("abc123", "tmp-tag-01")
fake_server.get_torrent_id_by_tag.assert_not_called()
assert fake_server.add_torrent.call_args.kwargs["tag"] == ["tmp-tag-01", "moviepilot-tag"]
def test_download_falls_back_to_tag_lookup_when_added_ids_missing():
"""添加任务响应缺少种子 ID 时应回退到临时标签查询。"""
fake_server = MagicMock()
fake_server.add_torrent.return_value = (True, [])
fake_server.get_content_layout.return_value = "Original"
fake_server.get_torrent_id_by_tag.return_value = "def456"
fake_server.is_force_resume.return_value = False
module = _build_module(fake_server)
result = module.download(
content="magnet:?xt=urn:btih:456",
download_dir=Path("/downloads"),
cookie="",
downloader="qb",
)
assert result == ("qb", "def456", "Original", "添加下载成功")
fake_server.delete_torrents_tag.assert_not_called()
fake_server.get_torrent_id_by_tag.assert_called_once_with(tags="tmp-tag-01")

View File

@@ -1,7 +1,6 @@
import importlib.util import importlib.util
import sys import sys
import types import types
import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -93,29 +92,43 @@ transmission_module = _load_transmission_client_module()
Transmission = transmission_module.Transmission Transmission = transmission_module.Transmission
class TestTransmissionCompat(unittest.TestCase): def test_login_enables_incomplete_file_suffix_by_default():
def test_login_enables_incomplete_file_suffix(self): """
""" 登录成功后默认开启未完成文件后缀,避免下载中的媒体文件被提前整理。
登录成功后应开启未完成文件后缀,避免下载中的媒体文件被提前整理。 """
""" fake_client = MagicMock()
fake_client = MagicMock() fake_client.get_session.return_value = {"rename-partial-files": False}
fake_client.get_session.return_value = {"rename-partial-files": False}
with patch.object(transmission_module.transmission_rpc, "Client", return_value=fake_client): with patch.object(transmission_module.transmission_rpc, "Client", return_value=fake_client):
downloader = Transmission(host="127.0.0.1", port=9091) downloader = Transmission(host="127.0.0.1", port=9091)
self.assertIs(downloader.trc, fake_client) assert downloader.trc is fake_client
fake_client.set_session.assert_called_once_with(rename_partial_files=True) fake_client.set_session.assert_called_once_with(rename_partial_files=True)
def test_login_skips_incomplete_file_suffix_when_already_enabled(self):
"""
远端已开启未完成文件后缀时不重复写入全局会话配置。
"""
fake_client = MagicMock()
fake_client.get_session.return_value = types.SimpleNamespace(rename_partial_files=True)
with patch.object(transmission_module.transmission_rpc, "Client", return_value=fake_client): def test_login_disables_incomplete_file_suffix_when_configured():
downloader = Transmission(host="127.0.0.1", port=9091) """
用户关闭配置后应同步关闭 Transmission 未完成文件后缀偏好。
"""
fake_client = MagicMock()
fake_client.get_session.return_value = types.SimpleNamespace(rename_partial_files=True)
self.assertIs(downloader.trc, fake_client) with patch.object(transmission_module.transmission_rpc, "Client", return_value=fake_client):
fake_client.set_session.assert_not_called() downloader = Transmission(host="127.0.0.1", port=9091, rename_partial_files=False)
assert downloader.trc is fake_client
fake_client.set_session.assert_called_once_with(rename_partial_files=False)
def test_login_skips_incomplete_file_suffix_when_already_matches():
"""
远端未完成文件后缀状态已匹配配置时不重复写入全局会话配置。
"""
fake_client = MagicMock()
fake_client.get_session.return_value = types.SimpleNamespace(rename_partial_files=True)
with patch.object(transmission_module.transmission_rpc, "Client", return_value=fake_client):
downloader = Transmission(host="127.0.0.1", port=9091)
assert downloader.trc is fake_client
fake_client.set_session.assert_not_called()