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

View File

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

View File

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