mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 11:27:43 +08:00
feat(widget): 显示下载上传速度和剩余磁盘空间 (#7177)
This commit is contained in:
86
app/api/dependencies/dashboard.py
Normal file
86
app/api/dependencies/dashboard.py
Normal 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
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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