mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
feat(widget): 显示下载上传速度和剩余磁盘空间 (#7177)
This commit is contained in:
@@ -959,8 +959,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 9813,
|
||||
"edge_sha256": "7024bc0d1b5fafa109cffbc4e75336893fb34ad4cbc1eec31e5172701d3fede2",
|
||||
"edge_count": 9824,
|
||||
"edge_sha256": "fce305ba6e4ef2ac441ef9bf5fc5a9166618f28e92833066ec0adf6b86cc1e90",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -2195,6 +2195,15 @@
|
||||
"app.api.dependencies.auth -> app.startup",
|
||||
"app.api.dependencies.auth -> app.startup.composition",
|
||||
"app.api.dependencies.auth -> app.startup.composition.context",
|
||||
"app.api.dependencies.dashboard -> app.adapters",
|
||||
"app.api.dependencies.dashboard -> app.adapters.system",
|
||||
"app.api.dependencies.dashboard -> app.adapters.system.host",
|
||||
"app.api.dependencies.dashboard -> app.application",
|
||||
"app.api.dependencies.dashboard -> app.application.directory",
|
||||
"app.api.dependencies.dashboard -> app.chain",
|
||||
"app.api.dependencies.dashboard -> app.chain.dashboard",
|
||||
"app.api.dependencies.dashboard -> app.schemas",
|
||||
"app.api.dependencies.dashboard -> app.schemas.dashboard",
|
||||
"app.api.dependencies.data -> app.api",
|
||||
"app.api.dependencies.data -> app.api.data",
|
||||
"app.api.dependencies.github -> app.api",
|
||||
@@ -2289,8 +2298,10 @@
|
||||
"app.api.dependencies.widget -> app.api",
|
||||
"app.api.dependencies.widget -> app.api.context",
|
||||
"app.api.dependencies.widget -> app.api.dependencies",
|
||||
"app.api.dependencies.widget -> app.api.dependencies.dashboard",
|
||||
"app.api.dependencies.widget -> app.api.dependencies.history",
|
||||
"app.api.dependencies.widget -> app.application",
|
||||
"app.api.dependencies.widget -> app.application.configuration",
|
||||
"app.api.dependencies.widget -> app.application.dashboard",
|
||||
"app.api.dependencies.widget -> app.application.history",
|
||||
"app.api.dependencies.widget -> app.application.query",
|
||||
@@ -2444,12 +2455,12 @@
|
||||
"app.api.endpoints.dashboard -> app.api.context",
|
||||
"app.api.endpoints.dashboard -> app.api.dependencies",
|
||||
"app.api.endpoints.dashboard -> app.api.dependencies.auth",
|
||||
"app.api.endpoints.dashboard -> app.api.dependencies.dashboard",
|
||||
"app.api.endpoints.dashboard -> app.api.dependencies.history",
|
||||
"app.api.endpoints.dashboard -> app.api.response",
|
||||
"app.api.endpoints.dashboard -> app.application",
|
||||
"app.api.endpoints.dashboard -> app.application.configuration",
|
||||
"app.api.endpoints.dashboard -> app.application.dashboard",
|
||||
"app.api.endpoints.dashboard -> app.application.directory",
|
||||
"app.api.endpoints.dashboard -> app.application.scheduling",
|
||||
"app.api.endpoints.dashboard -> app.chain",
|
||||
"app.api.endpoints.dashboard -> app.chain.dashboard",
|
||||
@@ -10776,7 +10787,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 1139,
|
||||
"module_count": 1140,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -10977,6 +10988,7 @@
|
||||
"app.api.dependencies",
|
||||
"app.api.dependencies.agent",
|
||||
"app.api.dependencies.auth",
|
||||
"app.api.dependencies.dashboard",
|
||||
"app.api.dependencies.data",
|
||||
"app.api.dependencies.github",
|
||||
"app.api.dependencies.history",
|
||||
|
||||
132
tests/test_downloader.py
Normal file
132
tests/test_downloader.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
共用下载器摘要的原始单位、目录空间与普通仪表盘兼容回归。
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.dependencies import dashboard as sampling
|
||||
from app.api.endpoints import dashboard as endpoint
|
||||
from app.schemas.dashboard import DownloaderInfo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def downloader_sources(tmp_path, monkeypatch):
|
||||
"""
|
||||
将所有目录和下载器 I/O 替换为可核验边界,不访问用户磁盘或网络。
|
||||
"""
|
||||
paths = [tmp_path / "first", tmp_path / "second"]
|
||||
for path in paths:
|
||||
path.mkdir()
|
||||
directories = Mock(return_value=[SimpleNamespace(download_path=str(path)) for path in paths])
|
||||
usage = Mock(return_value=(8192, 4096))
|
||||
transfers = Mock(return_value=[DownloaderInfo(download_speed=0, upload_speed=0)])
|
||||
monkeypatch.setattr(sampling.DirectoryHelper, "get_local_download_dirs", directories)
|
||||
monkeypatch.setattr(sampling.SystemUtils, "space_usage", usage)
|
||||
monkeypatch.setattr(sampling.DashboardChain, "downloader_info", transfers)
|
||||
return SimpleNamespace(paths=paths, directories=directories, usage=usage, transfers=transfers)
|
||||
|
||||
|
||||
def test_downloader_aggregates_original_bytes_and_forwards_disk_policy(downloader_sources):
|
||||
"""
|
||||
多下载器的全局速率包含做种上传,原累计量及目录去重参数不丢失。
|
||||
"""
|
||||
case = downloader_sources
|
||||
case.transfers.return_value = [
|
||||
DownloaderInfo(download_speed=2048, upload_speed=512, download_size=100, upload_size=200),
|
||||
DownloaderInfo(download_speed=0, upload_speed=1536, download_size=300, upload_size=400),
|
||||
]
|
||||
result = sampling.sample_downloader("chosen", btrfs_fsid_dedup=True, require_source=True)
|
||||
assert result.model_dump() == dict(
|
||||
download_speed=2048, upload_speed=2048, download_size=400, upload_size=600, free_space=4096
|
||||
)
|
||||
case.transfers.assert_called_once_with("chosen")
|
||||
case.usage.assert_called_once_with(case.paths, btrfs_fsid_dedup=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transfers", [None, []])
|
||||
def test_downloader_absent_source_is_strict_only(downloader_sources, transfers):
|
||||
"""
|
||||
小组件不能把采样失败当零,普通仪表盘仍保留既有全部零的返回值。
|
||||
"""
|
||||
case = downloader_sources
|
||||
case.transfers.return_value = transfers
|
||||
with pytest.raises(ValueError, match="暂不可用"):
|
||||
sampling.sample_downloader(require_source=True)
|
||||
assert endpoint._build_downloader() == DownloaderInfo()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["download_speed", "upload_speed"])
|
||||
@pytest.mark.parametrize("value", [None, -1, float("nan"), float("inf")])
|
||||
def test_downloader_strict_speed_requires_every_source(downloader_sources, field, value):
|
||||
"""
|
||||
即使另一个下载器有效,也不能将缺失或非法的速率漏计为有效总量。
|
||||
"""
|
||||
case = downloader_sources
|
||||
case.transfers.return_value = [DownloaderInfo(), DownloaderInfo(**{field: value})]
|
||||
with pytest.raises(ValueError, match="速度来源"):
|
||||
sampling.sample_downloader(require_source=True)
|
||||
|
||||
|
||||
def test_downloader_aggregate_overflow_is_not_fresh_data(downloader_sources):
|
||||
"""
|
||||
单项有限但求和溢出的速率仍应拒绝,不向原生端输出无限值。
|
||||
"""
|
||||
downloader_sources.transfers.return_value = [DownloaderInfo(download_speed=1e308) for _ in range(2)]
|
||||
with pytest.raises(ValueError, match="速度来源"):
|
||||
sampling.sample_downloader(require_source=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"usage, expected",
|
||||
[
|
||||
((8192, 0), 0),
|
||||
((0, 0), None),
|
||||
((8192, -1), None),
|
||||
((8192, 8193), None),
|
||||
((float("inf"), 1), None),
|
||||
((8192, float("nan")), None),
|
||||
],
|
||||
)
|
||||
def test_downloader_disk_unknown_is_not_full(downloader_sources, usage, expected):
|
||||
"""
|
||||
有效零剩余代表磁盘满,缺少总容量、越界或非有限容量代表未知。
|
||||
"""
|
||||
downloader_sources.usage.return_value = usage
|
||||
result = sampling.sample_downloader(require_source=True)
|
||||
assert result.download_speed == 0 and result.upload_speed == 0
|
||||
assert result.free_space == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", ["none", "partial", "error"])
|
||||
def test_downloader_missing_disk_preserves_valid_rates(downloader_sources, missing):
|
||||
"""
|
||||
未配置、部分目录失效或磁盘读取失败只标容量未知,速度仍保持可用。
|
||||
"""
|
||||
case = downloader_sources
|
||||
if missing == "none":
|
||||
case.directories.return_value = []
|
||||
elif missing == "partial":
|
||||
case.paths[0].rmdir()
|
||||
else:
|
||||
case.usage.side_effect = OSError("private-path")
|
||||
result = sampling.sample_downloader(require_source=True)
|
||||
assert result.free_space is None and result.download_speed == 0
|
||||
if missing != "error":
|
||||
case.usage.assert_not_called()
|
||||
|
||||
|
||||
def test_downloader_regular_page_retains_nullable_fields_and_disk_error(downloader_sources):
|
||||
"""
|
||||
提取公共采样不改变原网页的空字段聚合及磁盘异常传播行为。
|
||||
"""
|
||||
case = downloader_sources
|
||||
case.transfers.return_value = [DownloaderInfo(download_speed=None, upload_size=None)]
|
||||
assert endpoint._build_downloader().model_dump() == dict(
|
||||
download_speed=0, upload_speed=0, download_size=0, upload_size=0, free_space=4096
|
||||
)
|
||||
case.usage.side_effect = OSError("unreadable")
|
||||
with pytest.raises(OSError, match="unreadable"):
|
||||
endpoint._build_downloader()
|
||||
@@ -1032,10 +1032,11 @@ def test_dashboard_downloader_forwards_btrfs_fsid_setting():
|
||||
"""
|
||||
下载器仪表盘应转交 Btrfs 文件系统去重开关。
|
||||
"""
|
||||
from app.api.dependencies import dashboard as dashboard_dependencies
|
||||
from app.api.endpoints import dashboard as dashboard_module
|
||||
|
||||
download_dir = MagicMock(download_path="/downloads")
|
||||
with patch.object(dashboard_module.DirectoryHelper, "get_local_download_dirs",
|
||||
with patch.object(dashboard_dependencies.DirectoryHelper, "get_local_download_dirs",
|
||||
return_value=[download_dir]), \
|
||||
patch.object(SystemUtils, "space_usage", return_value=(4.0, 2.0)) as usage_mock, \
|
||||
patch.object(dashboard_module.DashboardChain, "downloader_info", return_value=[]):
|
||||
|
||||
@@ -7,7 +7,7 @@ import hashlib
|
||||
import importlib
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial
|
||||
from threading import Barrier
|
||||
from threading import Barrier, get_ident
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import uuid4
|
||||
@@ -26,6 +26,7 @@ from app.api.dependencies import widget as dependencies
|
||||
from app.api.dependencies.auth import get_current_active_user, get_current_user
|
||||
from app.api.dependencies.widget import get_widget_auth_service, get_widget_snapshot_service
|
||||
from app.api.endpoints import widget as endpoint
|
||||
from app.application.configuration import ApiRuntimeConfig
|
||||
from app.application.dashboard import DashboardQueryService
|
||||
from app.application.history import HistoryQueryService
|
||||
from app.application.query import DataQueryService
|
||||
@@ -48,7 +49,7 @@ from app.db.models.subscribe import Subscribe
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.models.user import User
|
||||
from app.db.models.widget import WidgetRegistration
|
||||
from app.schemas.dashboard import Statistic, Storage
|
||||
from app.schemas.dashboard import DownloaderInfo, Statistic, Storage
|
||||
from app.schemas.transfer import DownloaderTorrent
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
@@ -110,9 +111,11 @@ def widget_case(tmp_path):
|
||||
dashboard = DashboardQueryService(repository=Mock(), media_statistics=Mock(return_value=[Statistic(movie_count=7)]))
|
||||
dashboard.statistic = Mock(return_value=Statistic(movie_count=7, tv_count=3, episode_count=None, music_count=2))
|
||||
downloads = Mock(return_value=[])
|
||||
downloader = Mock(return_value=DownloaderInfo(download_speed=2048, upload_speed=1024, free_space=4096))
|
||||
storage = Mock(return_value=Storage(total_storage=1000, used_storage=600))
|
||||
snapshots = WidgetSnapshotService(
|
||||
data=data, history=history, dashboard=dashboard, downloads=downloads, storage=storage, clock=lambda: clock[0]
|
||||
data=data, history=history, dashboard=dashboard, downloads=downloads,
|
||||
downloader=downloader, storage=storage, clock=lambda: clock[0]
|
||||
)
|
||||
case = SimpleNamespace(
|
||||
auth=auth,
|
||||
@@ -122,6 +125,7 @@ def widget_case(tmp_path):
|
||||
snapshots=snapshots,
|
||||
dashboard=dashboard,
|
||||
downloads=downloads,
|
||||
downloader=downloader,
|
||||
storage=storage,
|
||||
data=data,
|
||||
history=history,
|
||||
@@ -378,6 +382,7 @@ def test_widget_snapshot_uses_real_owner_type_counts_and_success_history(widget_
|
||||
assert len(sections["imports"].items) == 6
|
||||
assert sections["downloads"].metrics[0].value == "2"
|
||||
assert sections["downloads"].items[0].progress == 0.25
|
||||
case.downloader.assert_not_called()
|
||||
encoded = result.model_dump_json(exclude_none=True)
|
||||
assert all(secret not in encoded for secret in ("bob", "secret-hash", "/secret", "/private/path", "失败记录"))
|
||||
assert result.expiresAt - result.generatedAt == 86400
|
||||
@@ -430,9 +435,73 @@ def test_widget_admin_overview_and_all_download_owners(widget_case, source_failu
|
||||
assert overview.metrics[2].value == "—"
|
||||
assert len(overview.metrics) == 6
|
||||
assert result.sections[1].items[0].title == "bob 下载"
|
||||
assert [(metric.id, metric.value) for metric in result.sections[1].metrics] == [
|
||||
("downloadSpeed", "2.00 KB/s"), ("uploadSpeed", "1.00 KB/s"), ("freeSpace", "4.00 KB"), ("count", "1")
|
||||
]
|
||||
case.downloader.assert_called_once_with()
|
||||
case.dashboard.statistic.assert_called_once_with(require_source=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("free_space, expected", [(0, "0 B"), (None, "—"), (-1, "—"), (float("nan"), "—")])
|
||||
def test_widget_download_metrics_without_tasks_and_unknown_disk(widget_case, free_space, expected):
|
||||
"""
|
||||
无下载仍展示真实零下载和做种上传,容量未知不误显示磁盘满。
|
||||
"""
|
||||
case = widget_case
|
||||
_update_user(case, is_superuser=True)
|
||||
main_thread = get_ident()
|
||||
|
||||
def sample():
|
||||
"""
|
||||
断言磁盘和下载器采样离开事件循环线程,再返回原始字节。
|
||||
"""
|
||||
assert get_ident() != main_thread
|
||||
return DownloaderInfo(download_speed=0, upload_speed=1048576, free_space=free_space)
|
||||
|
||||
case.downloader.side_effect = sample
|
||||
result = asyncio.run(case.snapshots.snapshot(case.auth.authenticate(_register(case).secret)))
|
||||
section = result.sections[1]
|
||||
assert section.state == "ready" and section.updatedAt == case.clock[0] and not section.items
|
||||
assert [metric.value for metric in section.metrics] == ["0 B/s", "1.00 MB/s", expected, "0"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["download_speed", "upload_speed"])
|
||||
@pytest.mark.parametrize("value", [None, -1, float("nan"), float("inf")])
|
||||
def test_widget_invalid_global_speed_is_independent_unavailable(widget_case, field, value):
|
||||
"""
|
||||
非法全局速率只使下载节不可用,不产生新鲜零值或影响其它成功来源。
|
||||
"""
|
||||
case = widget_case
|
||||
_update_user(case, is_superuser=True)
|
||||
case.downloader.return_value = DownloaderInfo(**{field: value})
|
||||
result = asyncio.run(case.snapshots.snapshot(case.auth.authenticate(_register(case).secret)))
|
||||
section = result.sections[1]
|
||||
assert section.state == "unavailable" and section.updatedAt is None
|
||||
assert not section.metrics and not section.items
|
||||
assert result.sections[0].state == "ready" and result.sections[2].state == "empty"
|
||||
|
||||
|
||||
def test_widget_global_speed_source_failure_does_not_leak_to_ordinary_user(widget_case):
|
||||
"""
|
||||
普通用户只看到本人任务,管理员全局采样失败不影响这个权限边界。
|
||||
"""
|
||||
case = widget_case
|
||||
case.downloader.side_effect = RuntimeError("private-download-host")
|
||||
case.downloads.return_value = [
|
||||
DownloaderTorrent(title="本人", userid="alice"), DownloaderTorrent(title="其他用户", userid="bob")
|
||||
]
|
||||
grant = case.auth.authenticate(_register(case).secret)
|
||||
result = asyncio.run(case.snapshots.snapshot(grant))
|
||||
assert [metric.id for metric in result.sections[0].metrics] == ["count"]
|
||||
assert [item.title for item in result.sections[0].items] == ["本人"]
|
||||
case.downloader.assert_not_called()
|
||||
_update_user(case, is_superuser=True)
|
||||
result = asyncio.run(case.snapshots.snapshot(case.auth.authenticate(_register(case).secret)))
|
||||
assert result.sections[1].state == "unavailable"
|
||||
assert result.sections[0].state == "ready"
|
||||
assert "private-download-host" not in result.model_dump_json()
|
||||
|
||||
|
||||
def test_widget_snapshot_expiry_and_invalid_source_progress(widget_case):
|
||||
"""
|
||||
采样跨过凭据到期不能延长展示期,非有限或越界进度不能进入 Swift 模型。
|
||||
@@ -468,10 +537,16 @@ def test_widget_dependency_assembly_and_absent_capability(widget_case, monkeypat
|
||||
dashboard_chain.storage.return_value = Storage(total_storage=1, used_storage=0)
|
||||
monkeypatch.setattr(dependencies, "DownloadChain", lambda: download_chain)
|
||||
monkeypatch.setattr(dependencies, "DashboardChain", lambda: dashboard_chain)
|
||||
snapshots = dependencies.get_widget_snapshot_service(case.dashboard, case.history)
|
||||
sampler = Mock(return_value=DownloaderInfo())
|
||||
monkeypatch.setattr(dependencies, "sample_downloader", sampler)
|
||||
snapshots = dependencies.get_widget_snapshot_service(case.dashboard, case.history, ApiRuntimeConfig(60, True, False))
|
||||
result = asyncio.run(snapshots.snapshot(case.auth.authenticate(_register(case).secret)))
|
||||
assert result.sections[0].state == "empty"
|
||||
download_chain.downloading.assert_called_once_with(require_source=True)
|
||||
sampler.assert_not_called()
|
||||
_update_user(case, is_superuser=True)
|
||||
asyncio.run(snapshots.snapshot(case.auth.authenticate(_register(case).secret)))
|
||||
sampler.assert_called_once_with(btrfs_fsid_dedup=True, require_source=True)
|
||||
|
||||
|
||||
def test_widget_strict_sources_preserve_regular_page_fallbacks(monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user