feat(widget): 显示下载上传速度和剩余磁盘空间 (#7177)

This commit is contained in:
jxxghp
2026-10-25 11:28:36 +08:00
committed by GitHub
parent 86ededa739
commit d5ecf2927c
10 changed files with 389 additions and 37 deletions

View File

@@ -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
View 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()

View File

@@ -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=[]):

View File

@@ -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):