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

@@ -0,0 +1,86 @@
"""
为仪表盘和受限小组件装配同一下载器与本地目录采样口径。
"""
import math
from pathlib import Path
from app.adapters.system.host import SystemUtils
from app.application.directory import DirectoryHelper
from app.chain.dashboard import DashboardChain
from app.schemas.dashboard import DownloaderInfo
def _download_free_space(*, btrfs_fsid_dedup: bool, require_source: bool) -> float | None:
"""
复用目录空间去重,严格读取不把未配置或不完整的容量误报为零。
:param btrfs_fsid_dedup: 原仪表盘使用的文件系统去重配置
:param require_source: 小组件需要可确认的完整本地目录来源
:return: 可确认的剩余字节数;严格模式的未知容量为 None
:raises OSError: 普通仪表盘保留原有的空间读取异常
"""
try:
directories = DirectoryHelper().get_local_download_dirs()
paths = [Path(directory.download_path) for directory in directories if directory.download_path]
if require_source and (not paths or not all(path.exists() for path in paths)):
return None
total, available = SystemUtils.space_usage(paths, btrfs_fsid_dedup=btrfs_fsid_dedup)
if require_source and (
not math.isfinite(total) or not math.isfinite(available) or not 0 <= available <= total or total <= 0
):
return None
return available
except OSError:
if require_source:
return None
raise
def _check_speeds(info: DownloaderInfo) -> None:
"""
拒绝缺失、负数和非有限的原始速度,真实零速率仍是有效采样。
:param info: 单个下载器或汇总后的原始字节统计
:raises ValueError: 速率来源不能代表本次真实采样
"""
if any(
value is None or not math.isfinite(value) or value < 0 for value in (info.download_speed, info.upload_speed)
):
raise ValueError("下载器速度来源暂不可用")
def sample_downloader(
name: str | None = None,
*,
btrfs_fsid_dedup: bool = False,
require_source: bool = False,
) -> DownloaderInfo:
"""
汇总原下载器的字节速率和累计量,包含下载列表之外的做种上传。
:param name: 原仪表盘可选择的下载器,None 表示全部
:param btrfs_fsid_dedup: 本次请求的原目录空间去重配置
:param require_source: 仅小组件严格拒绝缺失或非法速度;普通页面保留零值兼容
:return: 共用口径的统计,严格模式的未知磁盘容量为 None
:raises ValueError: 严格模式下缺少真实下载器采样或速度非法
"""
free_space = _download_free_space(btrfs_fsid_dedup=btrfs_fsid_dedup, require_source=require_source)
transfer_infos = DashboardChain().downloader_info(name)
if not transfer_infos:
if require_source:
raise ValueError("下载器统计来源暂不可用")
return DownloaderInfo()
if require_source:
for info in transfer_infos:
_check_speeds(info)
result = DownloaderInfo(
download_speed=sum(info.download_speed or 0 for info in transfer_infos),
upload_speed=sum(info.upload_speed or 0 for info in transfer_infos),
download_size=sum(info.download_size or 0 for info in transfer_infos),
upload_size=sum(info.upload_size or 0 for info in transfer_infos),
free_space=free_space,
)
if require_source:
_check_speeds(result)
return result

View File

@@ -8,8 +8,10 @@ from typing import NoReturn
from fastapi import Depends, HTTPException, Response
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.api.context import get_host_runtime
from app.api.context import get_api_runtime_config, get_host_runtime
from app.api.dependencies.dashboard import sample_downloader
from app.api.dependencies.history import get_dashboard_query_service, get_history_query_service
from app.application.configuration import ApiRuntimeConfig
from app.application.dashboard import DashboardQueryService
from app.application.history import HistoryQueryService
from app.application.query import get_configured_data_query_service
@@ -74,12 +76,14 @@ def get_widget_grant(
def get_widget_snapshot_service(
dashboard: DashboardQueryService = Depends(get_dashboard_query_service),
history: HistoryQueryService = Depends(get_history_query_service),
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
) -> WidgetSnapshotService:
"""
只连接现有的查询与业务链,普通页面不会调用小组件专用接口。
:param dashboard: 原仪表板管理员查询服务
:param history: 原历史列表查询服务
:param runtime_config: 本次请求的空间去重配置,不从组件凭据或参数读取
:return: 使用原有 owner、计数和成功筛选的快照投影服务
"""
return WidgetSnapshotService(
@@ -87,5 +91,6 @@ def get_widget_snapshot_service(
history=history,
dashboard=dashboard,
downloads=partial(DownloadChain().downloading, require_source=True),
downloader=partial(sample_downloader, btrfs_fsid_dedup=runtime_config.btrfs_fsid_dedup, require_source=True),
storage=partial(DashboardChain().storage, require_source=True),
)

View File

@@ -2,7 +2,6 @@
汇总仪表盘所需的服务统计、系统状态与活动数据。
"""
from pathlib import Path
from typing import Annotated, Any, List, Optional
from fastapi import Depends
@@ -11,6 +10,7 @@ from app.adapters.system.host import SystemUtils
from app.adapters.web.security.access import verify_apitoken
from app.api.context import get_api_runtime_config, resolve_api_runtime_config
from app.api.dependencies.auth import get_current_active_superuser
from app.api.dependencies.dashboard import sample_downloader
from app.api.dependencies.history import get_dashboard_query_service
from app.api.response import (
CompatibleCountParam,
@@ -19,7 +19,6 @@ from app.api.response import (
)
from app.application.configuration import ApiRuntimeConfig
from app.application.dashboard import DashboardQueryService
from app.application.directory import DirectoryHelper
from app.application.scheduling import get_scheduler
from app.chain.dashboard import DashboardChain
from app.runtime.execution import run_in_threadpool
@@ -49,25 +48,13 @@ def _build_downloader(
btrfs_fsid_dedup: bool = False,
) -> _SchemaDownloaderInfo:
"""
构建下载器统计信息。
使用共用采样口径构建下载器统计,保持普通页面的宽容来源语义。
:param name: 下载器名称,None 表示全部
:param btrfs_fsid_dedup: 当前请求的 Btrfs 文件系统去重配置
:return: 原仪表盘的全局字节统计与本地目录可用容量
"""
# 下载目录空间
download_dirs = DirectoryHelper().get_local_download_dirs()
_, free_space = SystemUtils.space_usage(
[Path(d.download_path) for d in download_dirs if d.download_path],
btrfs_fsid_dedup=btrfs_fsid_dedup,
)
# 下载器信息
downloader_info = _SchemaDownloaderInfo()
transfer_infos = DashboardChain().downloader_info(name)
if transfer_infos:
for transfer_info in transfer_infos:
downloader_info.download_speed = (downloader_info.download_speed or 0) + (transfer_info.download_speed or 0)
downloader_info.upload_speed = (downloader_info.upload_speed or 0) + (transfer_info.upload_speed or 0)
downloader_info.download_size = (downloader_info.download_size or 0) + (transfer_info.download_size or 0)
downloader_info.upload_size = (downloader_info.upload_size or 0) + (transfer_info.upload_size or 0)
downloader_info.free_space = free_space
return downloader_info
return sample_downloader(name, btrfs_fsid_dedup=btrfs_fsid_dedup)
@router.get("/statistic", summary="媒体数量统计", response_model=_SchemaStatistic)

View File

@@ -15,7 +15,7 @@ from app.application.security.widget import WidgetGrant, available_widget_conten
from app.foundation.size import format_size
from app.runtime.execution import run_in_threadpool
from app.runtime.log import logger
from app.schemas.dashboard import Storage
from app.schemas.dashboard import DownloaderInfo, Storage
from app.schemas.query import QueryPageRequest, SubscriptionFilter
from app.schemas.transfer import DownloaderTorrent
from app.schemas.types import MediaType
@@ -84,6 +84,7 @@ class WidgetSnapshotService:
history: HistoryQueryService,
dashboard: DashboardQueryService,
downloads: Callable[[], list[DownloaderTorrent] | None],
downloader: Callable[[], DownloaderInfo],
storage: Callable[[], Storage],
clock: Callable[[], float] = time.time,
) -> None:
@@ -94,6 +95,7 @@ class WidgetSnapshotService:
:param history: 既有整理历史查询,保留成功筛选和数据库分页
:param dashboard: 既有管理员媒体统计查询
:param downloads: 原有下载链的采样回调,仅在工作线程内执行
:param downloader: 共用下载器字节统计;只由管理员在工作线程内读取
:param storage: 既有存储汇总的严格读取回调
:param clock: 可信 UTC Unix 秒来源
"""
@@ -101,6 +103,7 @@ class WidgetSnapshotService:
self._history = history
self._dashboard = dashboard
self._downloads = downloads
self._downloader = downloader
self._storage = storage
self._clock = clock
@@ -208,11 +211,13 @@ class WidgetSnapshotService:
async def _download_section(self, user: UserSnapshot) -> WidgetSection:
"""
按原网页精确 owner 规则过滤采样,只投影前六项,不输出 hash 或文件路径。
按原网页精确 owner 过滤任务,仅管理员读取全局速率与本地目录空间。
:param user: 本次重新鉴权的用户与既有权限快照
:return: 最多六项任务及同作用域的真实计数,管理员附加三项全局指标
:raises ValueError: 任务或统计来源暂不可用,或原始进度超出自然范围
"""
rows = await run_in_threadpool(self._downloads)
if rows is None:
raise ValueError("下载来源暂不可用")
rows, info = await run_in_threadpool(self._download_sources, user.is_superuser)
visible = (
rows if user.is_superuser else [row for row in rows if row.userid == user.name or row.username == user.name]
)
@@ -229,7 +234,48 @@ class WidgetSnapshotService:
progress=progress / 100 if progress is not None else None,
)
)
return self._ready("downloads", len(visible), items, icon="download")
section = self._ready("downloads", len(visible), items, icon="download")
if info is not None:
section.metrics = self._download_metrics(info) + section.metrics
# 无下载任务仍可正在做种上传,有效速率不能被空列表状态隐藏。
section.state = "ready"
return section
def _download_sources(self, administrator: bool) -> tuple[list[DownloaderTorrent], DownloaderInfo | None]:
"""
在同一个受管工作线程采样任务与统计,普通用户不触发全局读取。
:param administrator: 当前重新鉴权用户是否为超级管理员
:return: 真实任务列表与仅管理员可读的原始下载器统计
:raises ValueError: 原下载任务来源尚不可用
"""
rows = self._downloads()
if rows is None:
raise ValueError("下载来源暂不可用")
return rows, self._downloader() if administrator else None
def _download_metrics(self, info: DownloaderInfo) -> list[WidgetMetric]:
"""
投影管理员全局速率与剩余空间,未知容量和真实磁盘满明确区分。
:param info: 既有下载器的原始字节采样,不从任务展示文字重算
:return: 固定标识的上传、下载和空间指标
:raises ValueError: 速度缺失、负数或非有限;不能伪造为成功的零值
"""
download, upload, available = info.download_speed, info.upload_speed, info.free_space
if download is None or upload is None or not all(
math.isfinite(value) and value >= 0 for value in (download, upload)
):
raise ValueError("下载器速度来源暂不可用")
free_space = (
format_size(int(available))
if available is not None and math.isfinite(available) and available >= 0 else "—"
)
return [
_metric("downloadSpeed", "下载速度", f"{format_size(int(download))}/s", "download"),
_metric("uploadSpeed", "上传速度", f"{format_size(int(upload))}/s", "upload"),
_metric("freeSpace", "剩余空间", free_space, "storage"),
]
async def _overview(self) -> WidgetSection:
"""

View File

@@ -817,7 +817,7 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 1139 |
| 内部导入边 | 9,813 |
| 内部导入边 | 9,824 |
| 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) |

View File

@@ -47,6 +47,14 @@ Cookie、主登录 JWT、资源令牌或 `API_TOKEN`。小组件凭据也不能
分类;设置中的 `admin` 标记不能授予管理员权限。订阅按数据库 owner/type 分页并返回真实总数,
不把展示的六项摘要当作全部数量。
下载状态对管理员额外返回 `downloadSpeed`(下载速度)、`uploadSpeed`(上传速度)和
`freeSpace`(剩余空间),之后保留 `count`(任务数量)。速率直接汇总原下载器的字节采样,
包含做种上传;不解析任务列表中的格式化速度。空间沿用仪表盘的本地下载目录与
`BTRFS_FSID_DEDUP` 去重口径。无下载任务但统计有效时仍可显示真实的零下载和上传速度。
磁盘未配置、目录缺失或容量读取失败时,空间显示 `—`;有效的零剩余空间显示 `0 B`。
速率来源缺失或非法时,该节为 `unavailable`。普通用户继续只返回本人任务及数量,
不读取或暴露实例全局速度和磁盘信息。App 小尺寸优先显示两种速度,中大尺寸增加剩余空间。
## 快照协议 v1
根字段为 `version`、`owner`、`generatedAt`、`expiresAt`、`availableContents`、`sections`。

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