refactor: normalize downloader file contracts

This commit is contained in:
jxxghp
2026-08-24 02:10:04 +08:00
parent 83318b12c0
commit 52fd495d38
12 changed files with 123 additions and 32 deletions
+3 -3
View File
@@ -21,7 +21,7 @@ from app.runtime.log import logger
from app.schemas.exception import RateLimitExceededException
from app.schemas.transfer import TransferInfo
from app.schemas.mediaserver import ExistMediaInfo
from app.schemas.transfer import DownloaderTorrent
from app.schemas.transfer import DownloaderFile, DownloaderTorrent
from app.schemas.message import IncomingMessage
from app.schemas.mediaserver import WebhookEventInfo
from app.schemas.tmdb import TmdbEpisode
@@ -1023,12 +1023,12 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin,
def torrent_files(
self, tid: str, downloader: Optional[str] = None
) -> Optional[Any]:
) -> Optional[List[DownloaderFile]]:
"""
获取种子文件
:param tid: 种子Hash
:param downloader: 下载器
:return: 种子文件,具体类型由下载器实现决定(链层不引入下载器协议类型)
:return: 与下载器 SDK 解耦的统一文件项列表
"""
return self.run_module("torrent_files", tid=tid, downloader=downloader)
+15 -1
View File
@@ -5,7 +5,8 @@
(任务添加、原始状态映射、任务列表构建)仍留在各模块。
"""
from pathlib import Path
from typing import Optional, Tuple, Union
from collections.abc import Callable
from typing import Any, List, Optional, Tuple, TypeVar, Union
from torrentool.torrent import Torrent
@@ -16,6 +17,9 @@ from app.runtime.log import logger
from app.schemas.types import TorrentQueryStatus, TorrentStatus
TFile = TypeVar("TFile")
class _DownloaderModuleBase(_ModuleBase, _DownloaderBase[TService]):
"""
下载器模块业务样板基类。
@@ -43,6 +47,16 @@ class _DownloaderModuleBase(_ModuleBase, _DownloaderBase[TService]):
logger.info(f"{self.get_name()}下载器 {name} 连接断开,尝试重连 ...")
server.reconnect()
@staticmethod
def _normalize_torrent_files(
files: Any, item_factory: Callable[[Any], TFile]
) -> Optional[List[TFile]]:
"""把 provider 文件集合统一投影为不依赖外部 SDK 的宿主 DTO。"""
if files is None:
return None
source = getattr(files, "data", files)
return [item_factory(item) for item in source]
def _get_torrent_info(self, content: Union[Path, str, bytes]) \
-> Tuple[Optional[Torrent], Optional[bytes]]:
"""
+8 -6
View File
@@ -1,15 +1,13 @@
from pathlib import Path
from typing import Set, Tuple, Optional, Union, List, Dict
from qbittorrentapi import TorrentFilesList
from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo
from app.domain.metainfo import MetaInfo
from app.runtime.log import logger
from app.runtime.settings import RuntimeSettingsCompat
from app.modules._base import _DownloaderModuleBase
from app.modules.qbittorrent.qbittorrent import Qbittorrent
from app.schemas.transfer import DownloaderTorrent
from app.schemas.transfer import DownloaderFile, DownloaderTorrent
from app.schemas.types import (
DownloadTaskState,
DownloaderType,
@@ -515,14 +513,18 @@ class QbittorrentModule(_DownloaderModuleBase[Qbittorrent]):
return None
return server.stop_torrents(ids=hashs)
def torrent_files(self, tid: str, downloader: Optional[str] = None) -> Optional[TorrentFilesList]:
def torrent_files(
self, tid: str, downloader: Optional[str] = None
) -> Optional[List[DownloaderFile]]:
"""
获取种子文件列表
获取种子文件列表,并在模块边界隔离 qBittorrent SDK 集合类型。
"""
server: Qbittorrent = self.get_instance(downloader)
if not server:
return None
return server.get_files(tid=tid)
return self._normalize_torrent_files(
server.get_files(tid=tid), DownloaderFile.model_validate
)
def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[_SchemaDownloaderInfo]]:
"""
+6 -4
View File
@@ -7,7 +7,7 @@ from app.runtime.log import logger
from app.runtime.settings import RuntimeSettingsCompat
from app.modules._base import _DownloaderModuleBase
from app.modules.rtorrent.rtorrent import Rtorrent
from app.schemas.transfer import DownloaderTorrent
from app.schemas.transfer import DownloaderFile, DownloaderTorrent
from app.schemas.types import (
DownloadTaskState,
DownloaderType,
@@ -526,14 +526,16 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]):
def torrent_files(
self, tid: str, downloader: Optional[str] = None
) -> Optional[List[Dict]]:
) -> Optional[List[DownloaderFile]]:
"""
获取种子文件列表
获取种子文件列表,并在模块边界把字典投影为宿主 DTO。
"""
server: Rtorrent = self.get_instance(downloader)
if not server:
return None
return server.get_files(tid=tid)
return self._normalize_torrent_files(
server.get_files(tid=tid), DownloaderFile.model_validate
)
def downloader_info(
self, downloader: Optional[str] = None
+8 -6
View File
@@ -1,15 +1,13 @@
from pathlib import Path
from typing import Set, Tuple, Optional, Union, List, Dict
from transmission_rpc import File
from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo
from app.domain.metainfo import MetaInfo
from app.runtime.log import logger
from app.runtime.settings import RuntimeSettingsCompat
from app.modules._base import _DownloaderModuleBase
from app.modules.transmission.transmission import Transmission
from app.schemas.transfer import DownloaderTorrent
from app.schemas.transfer import DownloaderFile, DownloaderTorrent
from app.schemas.types import (
DownloadTaskState,
DownloaderType,
@@ -530,15 +528,19 @@ class TransmissionModule(_DownloaderModuleBase[Transmission]):
return None
return server.stop_torrents(ids=hashs)
def torrent_files(self, tid: str, downloader: Optional[str] = None) -> Optional[List[File]]:
def torrent_files(
self, tid: str, downloader: Optional[str] = None
) -> Optional[List[DownloaderFile]]:
"""
获取种子文件列表
获取种子文件列表,并在模块边界隔离 Transmission SDK 对象。
"""
# 获取下载器
server: Transmission = self.get_instance(downloader)
if not server:
return None
return server.get_files(tid=tid)
return self._normalize_torrent_files(
server.get_files(tid=tid), DownloaderFile.model_validate
)
def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[_SchemaDownloaderInfo]]:
"""
+1 -1
View File
@@ -225,7 +225,7 @@ _METHOD_CONTRACTS = {
"list_torrents": ModuleMethodContract(family="downloader", input_contract="TorrentListRequest", result_contract="list[DownloaderTorrent]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("status", "hashs", "downloader", "include_all_tags")),
"filter_torrents": ModuleMethodContract(family="downloader", input_contract="TorrentFilterRequest", result_contract="list[TorrentInfo]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("rule_groups", "torrent_list", "mediainfo")),
"refresh_torrents": ModuleMethodContract(family="downloader", input_contract="TorrentRefreshRequest", result_contract="list[TorrentInfo]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("site", "keyword", "cat", "page", "mtype")),
"torrent_files": ModuleMethodContract(family="downloader", input_contract="TorrentFilesRequest", result_contract="DownloaderFileCollection | None", required_parameters=("tid", "downloader")),
"torrent_files": ModuleMethodContract(family="downloader", input_contract="TorrentFilesRequest", result_contract="list[DownloaderFile]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("tid", "downloader")),
"get_torrent_trackers": ModuleMethodContract(family="downloader", input_contract="TorrentTrackersRequest", result_contract="dict[str, list[str]] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.ORDERED_MAPPING_MERGE, required_parameters=("hash_string", "downloader")),
"download": ModuleMethodContract(family="downloader", input_contract="DownloadTaskRequest", result_contract="DownloadTaskResult | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("content", "download_dir", "cookie", "episodes", "category", "label", "downloader")),
"download_added": ModuleMethodContract(family="downloader", input_contract="DownloadAddedHook", result_contract="None", aggregation=ModuleResultAggregation.FAN_OUT, required_parameters=("context", "torrent_content", "download_dir"), plugin_short_circuit=False),
+13 -1
View File
@@ -1,7 +1,7 @@
from pathlib import Path
from typing import List, Optional, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.media import OptionalMediaIdentityMixin
from app.schemas.types import MediaSource, MusicTargetEntityType
@@ -43,6 +43,18 @@ class DownloaderTorrent(BaseModel):
left_time: Optional[str] = None
class DownloaderFile(BaseModel):
"""下载器文件项的宿主投影,隔离各 provider SDK 的对象差异。"""
model_config = ConfigDict(from_attributes=True)
id: Optional[Union[int, str]] = None
name: str
size: Optional[int] = None
priority: Optional[int] = None
progress: Optional[float] = None
class DownloadTaskMedia(OptionalMediaIdentityMixin, BaseModel):
"""下载任务关联的影视或音乐媒体摘要。"""
+3 -3
View File
@@ -6138,7 +6138,7 @@
"version": 1
},
"torrent_files": {
"aggregation": "legacy",
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "downloader",
@@ -6149,8 +6149,8 @@
"downloader",
"tid"
],
"result_contract": "DownloaderFileCollection | None",
"result_shape": "any",
"result_contract": "list[DownloaderFile]",
"result_shape": "list",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
+35
View File
@@ -0,0 +1,35 @@
"""下载器文件项宿主投影的跨 provider 契约测试。"""
from types import SimpleNamespace
from app.modules._base.downloader import _DownloaderModuleBase
from app.schemas.transfer import DownloaderFile
def test_normalize_torrent_files_accepts_mapping_object_and_sdk_wrapper() -> None:
"""共同适配器应归一字典、属性对象和带 data 的 SDK 集合。"""
files = SimpleNamespace(
data=[
{"id": 1, "name": "Movie.mkv", "size": 1024},
SimpleNamespace(id="2", name="Subtitle.srt", progress=100),
]
)
result = _DownloaderModuleBase._normalize_torrent_files(
files, DownloaderFile.model_validate
)
assert result == [
DownloaderFile(id=1, name="Movie.mkv", size=1024),
DownloaderFile(id="2", name="Subtitle.srt", progress=100),
]
def test_normalize_torrent_files_preserves_none_and_empty_collections() -> None:
"""未命中 provider 与已命中空集合必须继续保持不同结果。"""
assert _DownloaderModuleBase._normalize_torrent_files(
None, DownloaderFile.model_validate
) is None
assert _DownloaderModuleBase._normalize_torrent_files(
[], DownloaderFile.model_validate
) == []
@@ -851,7 +851,7 @@ from app.testing.bootstrap import prepare_backend
prepare_backend()
import sys
from typing import Any, Optional, get_type_hints
from typing import List, Optional, get_type_hints
provider_prefixes = ("qbittorrentapi", "transmission_rpc", "pywebpush")
@@ -869,8 +869,9 @@ assert loaded_provider_modules() == []
from app.chain import ChainBase
from app.api.endpoints.message import WebPushError, is_webpush_subscription_gone
from app.schemas.transfer import DownloaderFile
assert get_type_hints(ChainBase.torrent_files)["return"] == Optional[Any]
assert get_type_hints(ChainBase.torrent_files)["return"] == Optional[List[DownloaderFile]]
assert get_type_hints(is_webpush_subscription_gone)["error"] is WebPushError
assert loaded_provider_modules() == []
"""
+5 -5
View File
@@ -317,14 +317,14 @@ def test_side_effect_hooks_use_non_short_circuiting_fan_out_contracts() -> None:
assert contract.plugin_short_circuit is False
def test_heterogeneous_torrent_files_result_remains_legacy_compatible() -> None:
"""下载器文件集合尚未归一前不得声明虚假的列表聚合语义"""
def test_torrent_files_uses_normalized_first_provider_result() -> None:
"""下载器文件项归一后应按目标 provider 返回宿主 DTO 列表"""
contract = get_module_method_contract("torrent_files")
assert contract.required_parameters == ("tid", "downloader")
assert contract.result_contract == "DownloaderFileCollection | None"
assert contract.aggregation is ModuleResultAggregation.LEGACY
assert contract.result_shape is ModuleResultShape.ANY
assert contract.result_contract == "list[DownloaderFile]"
assert contract.aggregation is ModuleResultAggregation.FIRST_NON_EMPTY
assert contract.result_shape is ModuleResultShape.LIST
def test_torrent_tracker_contract_merges_downloader_mappings() -> None:
+23
View File
@@ -113,6 +113,14 @@ def _load_qbittorrent_modules():
def scheduler_job(self):
pass
@staticmethod
def _normalize_torrent_files(files, item_factory):
"""镜像宿主边界的文件集合投影。"""
if files is None:
return None
source = getattr(files, "data", files)
return [item_factory(item) for item in source]
def _get_torrent_info(self, content):
torrent_info, torrent_content = None, None
if isinstance(content, Path):
@@ -148,6 +156,20 @@ def _load_qbittorrent_modules():
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class _DownloaderFile:
"""隔离加载测试使用的最小下载器文件 DTO。"""
def __init__(self, **kwargs):
"""保存文件字段。"""
self.__dict__.update(kwargs)
@classmethod
def model_validate(cls, item):
"""兼容字典和属性对象输入。"""
if isinstance(item, dict):
return cls(**item)
return cls(**vars(item))
class TorrentStatus(Enum):
TRANSFER = "transfer"
DOWNLOADING = "downloading"
@@ -179,6 +201,7 @@ def _load_qbittorrent_modules():
schema_transfer_module.TransferTorrent = object
schema_transfer_module.DownloadingTorrent = object
schema_transfer_module.DownloaderTorrent = _DownloaderTorrent
schema_transfer_module.DownloaderFile = _DownloaderFile
schema_types_module.TorrentStatus = TorrentStatus
schema_types_module.TorrentQueryStatus = TorrentQueryStatus
schema_types_module.DownloadTaskState = DownloadTaskState