diff --git a/app/chain/__init__.py b/app/chain/__init__.py index dbbb4ec86..7c8beadc4 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -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) diff --git a/app/modules/_base/downloader.py b/app/modules/_base/downloader.py index ec60913ab..8adec8226 100644 --- a/app/modules/_base/downloader.py +++ b/app/modules/_base/downloader.py @@ -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]]: """ diff --git a/app/modules/qbittorrent/__init__.py b/app/modules/qbittorrent/__init__.py index 6abcd7744..c713ed7f7 100644 --- a/app/modules/qbittorrent/__init__.py +++ b/app/modules/qbittorrent/__init__.py @@ -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]]: """ diff --git a/app/modules/rtorrent/__init__.py b/app/modules/rtorrent/__init__.py index bca5fca86..9f0330ec1 100644 --- a/app/modules/rtorrent/__init__.py +++ b/app/modules/rtorrent/__init__.py @@ -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 diff --git a/app/modules/transmission/__init__.py b/app/modules/transmission/__init__.py index b7e01a7e2..0ccf054ad 100644 --- a/app/modules/transmission/__init__.py +++ b/app/modules/transmission/__init__.py @@ -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]]: """ diff --git a/app/runtime/extensions/module/contracts.py b/app/runtime/extensions/module/contracts.py index bd030ff69..bbb24a571 100644 --- a/app/runtime/extensions/module/contracts.py +++ b/app/runtime/extensions/module/contracts.py @@ -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), diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index 8e16164f0..b33b05739 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -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): """下载任务关联的影视或音乐媒体摘要。""" diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index c6b78cb82..66af0168a 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -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", diff --git a/tests/test_downloader_file_contract.py b/tests/test_downloader_file_contract.py new file mode 100644 index 000000000..f07dfcdab --- /dev/null +++ b/tests/test_downloader_file_contract.py @@ -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 + ) == [] diff --git a/tests/test_module_manager_capability_adapter.py b/tests/test_module_manager_capability_adapter.py index 3b772aea5..e2c1eb679 100644 --- a/tests/test_module_manager_capability_adapter.py +++ b/tests/test_module_manager_capability_adapter.py @@ -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() == [] """ diff --git a/tests/test_module_method_contracts.py b/tests/test_module_method_contracts.py index 24ba7a181..d8133afe6 100644 --- a/tests/test_module_method_contracts.py +++ b/tests/test_module_method_contracts.py @@ -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: diff --git a/tests/test_qbittorrent_compat.py b/tests/test_qbittorrent_compat.py index 076ac6eed..e2eff82c0 100644 --- a/tests/test_qbittorrent_compat.py +++ b/tests/test_qbittorrent_compat.py @@ -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