diff --git a/app/modules/transmission/__init__.py b/app/modules/transmission/__init__.py index 122142373..8b31dced3 100644 --- a/app/modules/transmission/__init__.py +++ b/app/modules/transmission/__init__.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, List, Optional, Set, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union from app.domain import torrent as torrent_rules from app.domain.metainfo import MetaInfo @@ -28,6 +28,22 @@ _TRANSMISSION_PAUSED_STATES = { } +def _normalize_transmission_progress( + torrent_data: Any, attr_getter: Callable[..., Any] +) -> float: + """读取 Transmission 任务进度并统一为百分比。""" + progress = attr_getter(torrent_data, "progress") + if progress is not None: + return float(progress or 0) + percent_done = attr_getter(torrent_data, "percent_done") + if percent_done is None: + return 0 + # transmission-rpc 的 percent_done 原始值范围是 0..1; + # 兼容旧适配器时仍可能收到已经归一化的百分比。 + percent_value = float(percent_done) + return percent_value * 100 if 0 <= percent_value <= 1 else percent_value + + class TransmissionModule(_DownloaderModuleBase[Transmission]): """Transmission 下载器模块,负责任务添加、标签和文件选择。""" @@ -252,12 +268,6 @@ class TransmissionModule(_DownloaderModuleBase[Transmission]): continue return None - def __get_torrent_progress(torrent_data) -> float: - """ - 获取任务进度。 - """ - return __get_torrent_attr(torrent_data, "progress", "percent_done") or 0 - def __get_torrent_size(torrent_data) -> int: """ 获取任务大小。 @@ -305,7 +315,7 @@ class TransmissionModule(_DownloaderModuleBase[Transmission]): Path(torrent_data.download_dir), downloader_name ) if getattr(torrent_data, "download_dir", None) else None, content_path=self.normalize_return_path(torrent_path, downloader_name), - progress=__get_torrent_progress(torrent_data), + progress=_normalize_transmission_progress(torrent_data, __get_torrent_attr), size=__get_torrent_size(torrent_data), state=self.__normalize_torrent_state(torrent_data.status), dlspeed=size_tools.format_compact_size(dlspeed), diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 01dc1f865..e7b038335 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -108,7 +108,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 371,964 行 | 本次重新扫描物理行数,排除 `app/plugins/**`;74 个文件超过 1,000 行,12 个超过 2,000 行;源码尺寸仅作观察 | | 长方法 | 309 个超过 80 行 | 本次 AST 扫描排除 `app/plugins/**`;61 个超过 150 行,17 个超过 250 行;新增函数仍受 C901/嵌套深度门禁约束 | -| 全量 mypy 历史债务 | 9,043 / 490 文件 | 通知排版补齐 Slack 消息块类型,历史债务减少 2 项;工作流资源过滤改用领域对象调用过滤链,恢复的节点输出在消费前归一化,并收紧可空资源列表的类型低水位;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降;微信 ClawBot 入站 item_list 媒体解析改为按子项键名分派后,类型错误减少 4 项;站点解析器分享率改用浮点初值,类型错误减少 20 项;本次下载与文件安全修复再减少 3 项,逐文件逐错误码无增长 | +| 全量 mypy 历史债务 | 9,041 / 490 文件 | 通知排版补齐 Slack 消息块类型,历史债务减少 2 项;工作流资源过滤改用领域对象调用过滤链,恢复的节点输出在消费前归一化,并收紧可空资源列表的类型低水位;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降;微信 ClawBot 入站 item_list 媒体解析改为按子项键名分派后,类型错误减少 4 项;站点解析器分享率改用浮点初值,类型错误减少 20 项;本次下载与文件安全修复再减少 3 项;Transmission 进度回退值归一化再减少 2 项,逐文件逐错误码无增长 | | Ruff 历史诊断 | 100 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | diff --git a/tests/fixtures/architecture/complexity-baseline.json b/tests/fixtures/architecture/complexity-baseline.json index 8beabede9..7824a3aac 100644 --- a/tests/fixtures/architecture/complexity-baseline.json +++ b/tests/fixtures/architecture/complexity-baseline.json @@ -106,7 +106,7 @@ "app/modules/slack/__init__.py:SlackModule.message_parser": 16, "app/modules/slack/slack.py:Slack.send_medias_msg": 18, "app/modules/transmission/__init__.py:TransmissionModule.download": 18, - "app/modules/transmission/__init__.py:TransmissionModule.list_torrents": 26, + "app/modules/transmission/__init__.py:TransmissionModule.list_torrents": 25, "app/modules/trimemedia/api.py:Api.request": 16, "app/modules/ugreen/ugreen.py:Ugreen.get_tv_episodes": 21, "app/modules/wechat/__init__.py:WechatModule.message_parser": 20, diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 72e9cf9b9..4c48464d5 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -2175,8 +2175,8 @@ "empty-body": 1, "no-any-return": 1, "no-redef": 2, - "no-untyped-call": 9, - "no-untyped-def": 7, + "no-untyped-call": 8, + "no-untyped-def": 6, "str-bytes-safe": 1, "type-arg": 6 }, diff --git a/tests/test_downloader_path_mapping.py b/tests/test_downloader_path_mapping.py index 205a9becc..036ab95f7 100644 --- a/tests/test_downloader_path_mapping.py +++ b/tests/test_downloader_path_mapping.py @@ -5,8 +5,11 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch +import pytest + def _load_downloader_base(): + """隔离宿主服务依赖,加载真实下载器路径映射实现。""" repo_root = Path(__file__).resolve().parents[1] app_module = types.ModuleType("app") @@ -29,11 +32,15 @@ def _load_downloader_base(): mixins_module = types.ModuleType("app.runtime.reload") class StorageSchema(Enum): + """提供路径映射识别所需的存储类型。""" + Local = "local" Rclone = "rclone" SMB = "smb" class _ConfigReloadMixin: + """隔离配置热重载的运行时依赖。""" + pass class _Logger: @@ -44,16 +51,21 @@ def _load_downloader_base(): pass class _ServiceConfigHelper: + """使用空配置隔离下载器之外的服务初始化。""" + @staticmethod def get_downloader_configs(): + """不从真实配置加载下载器。""" return [] @staticmethod def get_notification_configs(): + """不从真实配置加载通知渠道。""" return [] @staticmethod def get_mediaserver_configs(): + """不从真实配置加载媒体服务器。""" return [] schema_types_module.StorageSchema = StorageSchema @@ -124,6 +136,7 @@ def _load_downloader_base(): def _load_transmission_module(): + """隔离下载器客户端与宿主依赖,加载真实 Transmission 模块。""" repo_root = Path(__file__).resolve().parents[1] app_module = types.ModuleType("app") @@ -163,10 +176,15 @@ def _load_transmission_module(): torrentool_torrent_module = types.ModuleType("torrentool.torrent") class _ModuleBase: + """隔离模块注册及生命周期管理。""" + pass class _DownloaderBase: + """保留下载器模块的泛型基类入口。""" + def __class_getitem__(cls, _item): + """让隔离基类接受生产代码的客户端类型参数。""" return cls class _DownloaderModuleBase(_ModuleBase, _DownloaderBase): @@ -202,15 +220,24 @@ def _load_transmission_module(): return TorrentQueryStatus.ALL class _TransferTorrent: + """保留整理任务构造时传入的字段,供断言检查。""" + def __init__(self, **kwargs): + """保存模块实际返回的整理任务字段。""" self.__dict__.update(kwargs) class _DownloadingTorrent: + """保留下载中任务构造时传入的字段,供断言检查。""" + def __init__(self, **kwargs): + """保存模块实际返回的下载中任务字段。""" self.__dict__.update(kwargs) class _DownloaderTorrent: + """保留统一下载任务构造时传入的字段,供断言检查。""" + def __init__(self, **kwargs): + """保存模块实际返回的统一下载任务字段。""" self.__dict__.update(kwargs) class _DownloaderFile: @@ -227,10 +254,14 @@ def _load_transmission_module(): return instance class TorrentStatus(Enum): + """提供下载器历史查询入口使用的状态。""" + TRANSFER = "transfer" DOWNLOADING = "downloading" class TorrentQueryStatus(Enum): + """提供统一下载器查询入口使用的状态。""" + ALL = "all" TRANSFER = "transfer" DOWNLOADING = "downloading" @@ -238,22 +269,32 @@ def _load_transmission_module(): PAUSED = "paused" class DownloadTaskState(Enum): + """提供统一下载任务返回值使用的状态。""" + DOWNLOADING = "downloading" PAUSED = "paused" COMPLETED = "completed" class _Logger: + """隔离 Transmission 测试的运行日志输出。""" + def debug(self, *_args, **_kwargs): + """忽略测试范围外的调试日志。""" pass def info(self, *_args, **_kwargs): + """忽略测试范围外的普通日志。""" pass def error(self, *_args, **_kwargs): + """忽略测试范围外的错误日志。""" pass class _MetaInfo: + """提供下载任务展示所需的最小媒体元数据。""" + def __init__(self, name): + """保留种子名称并将未解析的媒体字段置空。""" self.name = name self.year = None self.season_episode = "" @@ -272,7 +313,10 @@ def _load_transmission_module(): return str(value) class _FileCache: + """隔离真实文件缓存,避免读取运行时状态。""" + def get(self, *_args, **_kwargs): + """固定返回缓存未命中。""" return None transmission_client_module.Transmission = object @@ -385,6 +429,7 @@ def _build_base(path_mapping): def _build_transmission_module(server): + """注入离线下载器及固定路径映射,避免初始化真实服务。""" module = TransmissionModule.__new__(TransmissionModule) module.get_instances = MagicMock(return_value={"tr": server}) module.get_instance = MagicMock(return_value=server) @@ -486,6 +531,40 @@ def test_completed_torrents_return_moviepilot_accessible_path(): assert torrents[0].content_path == "/media/video/downloads/TV/Show.S01E01.mkv" +@pytest.mark.parametrize( + ("progress_fields", "expected_progress"), + [ + ({"percent_done": 0}, 0), + ({"percent_done": 0.42}, 42), + ({"percent_done": 1}, 100), + ({"percent_done": 42}, 42), + ({"percent_done": 100}, 100), + ({"progress": 42, "percent_done": 1}, 42), + ({"progress": 0, "percent_done": 1}, 0), + ({"progress": None, "percent_done": 1}, 100), + ({}, 0), + ], +) +def test_transmission_progress_uses_percentage_contract(progress_fields, expected_progress): + """原始进度须转换为百分比,已有 progress(含零值)须优先保留。""" + server = MagicMock() + server.get_completed_torrents.return_value = [ + SimpleNamespace( + name="Movie", + download_dir="/mnt/raid5/home_lt999lt/video/downloads/movie", + hashString="hash-tr", + labels=[], + status="seeding", + **progress_fields, + ) + ] + module = _build_transmission_module(server) + + torrents = module.list_torrents(status=TransmissionTorrentStatus.TRANSFER) + + assert torrents[0].progress == expected_progress + + def test_hash_lookup_return_moviepilot_accessible_path(): """Transmission 按 Hash 查询时返回的路径字段均应完成路径映射。""" server = MagicMock()