mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 13:55:00 +08:00
fix(transmission): normalize raw fallback progress (#7017)
* fix(transmission): normalize raw fallback progress * fix(transmission): satisfy type ratchet for progress helper Annotate and normalize the fallback progress helper so the stricter V3 typing gate does not regress. Co-authored-by: Codex <codex@openai.com> * docs: sync mypy metric after progress fix --------- Co-authored-by: ranzhigg <ranzhigg@users.noreply.github.com> Co-authored-by: Codex <codex@openai.com> Co-authored-by: jxxghp <jxxghp@gmail.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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 未进入包级覆盖率门禁 |
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user