feat(widget): enrich system and combined media summaries

This commit is contained in:
jxxghp
2026-10-25 14:53:54 +08:00
parent d5ecf2927c
commit d66b58c475
9 changed files with 247 additions and 102 deletions

View File

@@ -8,7 +8,18 @@ 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
from app.schemas.dashboard import DashboardMemoryInfo, DownloaderInfo
def sample_system() -> tuple[float, DashboardMemoryInfo, list[int]]:
"""
复用仪表盘系统来源,在同一受管工作线程内采样一秒 CPU 与网络区间。
:return: CPU 百分比、现有进程/系统内存事实及上行/下行字节每秒
"""
SystemUtils.cpu_usage()
network = SystemUtils.network_usage()
return SystemUtils.cpu_usage(), SystemUtils.memory_usage(), network
def _download_free_space(*, btrfs_fsid_dedup: bool, require_source: bool) -> float | None:

View File

@@ -9,15 +9,13 @@ from fastapi import Depends, HTTPException, Response
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
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.api.dependencies.dashboard import sample_downloader, sample_system
from app.api.dependencies.history import 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
from app.application.security.widget import WidgetAccessError, WidgetAuthService, WidgetGrant
from app.application.widget import WidgetSnapshotService
from app.chain.dashboard import DashboardChain
from app.chain.download import DownloadChain
from app.startup.composition.context import HostRuntime
@@ -74,14 +72,12 @@ 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、计数和成功筛选的快照投影服务
@@ -89,8 +85,7 @@ def get_widget_snapshot_service(
return WidgetSnapshotService(
data=get_configured_data_query_service(),
history=history,
dashboard=dashboard,
system=sample_system,
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

@@ -144,17 +144,31 @@ def available_widget_contents(user: UserSnapshot) -> list[WidgetContent]:
permissions: tuple[tuple[WidgetContent, str, str], ...] = (
("downloads", "manage", "manage.downloading"),
("imports", "manage", "manage.history"),
("movieSubscriptions", "subscribe", "subscribe.movie"),
("tvSubscriptions", "subscribe", "subscribe.tv"),
("musicSubscriptions", "subscribe", "subscribe.music"),
)
for content, category, feature in permissions:
if user.is_superuser or (user.permissions.get(category) is True and features.get(feature) is not False):
# 元组来自固定服务端枚举,避免让客户端指定权限名称。
contents.append(content)
if widget_subscription_features(user):
contents.append("subscriptions")
return contents
def widget_subscription_features(user: UserSnapshot) -> tuple[str, ...]:
"""
合并订阅组件仍逐类复核权限,禁止把被禁用类型算入总数或摘要。
:param user: 当前激活用户的不可变授权快照
:return: 当前可用的固定订阅功能标识
"""
features = user.permissions.get("features")
features = features if isinstance(features, Mapping) else {}
return tuple(
feature for feature in ("subscribe.movie", "subscribe.tv", "subscribe.music")
if user.is_superuser or (user.permissions.get("subscribe") is True and features.get(feature) is not False)
)
class WidgetAuthService:
"""
通过注入的独占短事务管理专用只读授权,不依赖进程缓存或通用 API_TOKEN。

View File

@@ -7,16 +7,16 @@ import time
import unicodedata
from collections.abc import Callable
from app.application.dashboard import DashboardQueryService
from app.application.history import HistoryQueryService
from app.application.query import DataQueryService
from app.application.security.user import UserSnapshot
from app.application.security.widget import WidgetGrant, available_widget_contents
from app.application.security.widget import WidgetGrant, available_widget_contents, widget_subscription_features
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 DownloaderInfo, Storage
from app.schemas.query import QueryPageRequest, SubscriptionFilter
from app.schemas.dashboard import DashboardMemoryInfo, DownloaderInfo
from app.schemas.query import QueryPageRequest, SubscriptionFilter, SubscriptionSnapshot, TransferHistoryFilter
from app.schemas.subscribe import Subscribe
from app.schemas.transfer import DownloaderTorrent
from app.schemas.types import MediaType
from app.schemas.widget import (
@@ -35,6 +35,7 @@ _TITLES: dict[WidgetContent, str] = {
"overview": "实例概览",
"downloads": "下载状态",
"imports": "近期入库",
"subscriptions": "订阅",
"movieSubscriptions": "电影订阅",
"tvSubscriptions": "剧集订阅",
"musicSubscriptions": "音乐订阅",
@@ -44,6 +45,9 @@ _MEDIA_TYPES: dict[WidgetContent, MediaType] = {
"tvSubscriptions": MediaType.TV,
"musicSubscriptions": MediaType.MUSIC,
}
_FEATURE_TYPES = {
"subscribe.movie": MediaType.MOVIE, "subscribe.tv": MediaType.TV, "subscribe.music": MediaType.MUSIC,
}
def _text(value: str | None, maximum: int, fallback: str = "") -> str:
@@ -82,10 +86,9 @@ class WidgetSnapshotService:
*,
data: DataQueryService,
history: HistoryQueryService,
dashboard: DashboardQueryService,
system: Callable[[], tuple[float, DashboardMemoryInfo, list[int]]],
downloads: Callable[[], list[DownloaderTorrent] | None],
downloader: Callable[[], DownloaderInfo],
storage: Callable[[], Storage],
clock: Callable[[], float] = time.time,
) -> None:
"""
@@ -93,18 +96,16 @@ class WidgetSnapshotService:
:param data: 既有有界订阅查询,包含同作用域的真实总数
:param history: 既有整理历史查询,保留成功筛选和数据库分页
:param dashboard: 既有管理员媒体统计查询
:param system: 既有管理员 CPU、内存和网络系统采样回调
:param downloads: 原有下载链的采样回调,仅在工作线程内执行
:param downloader: 共用下载器字节统计;只由管理员在工作线程内读取
:param storage: 既有存储汇总的严格读取回调
:param clock: 可信 UTC Unix 秒来源
"""
self._data = data
self._history = history
self._dashboard = dashboard
self._system = system
self._downloads = downloads
self._downloader = downloader
self._storage = storage
self._clock = clock
async def snapshot(self, grant: WidgetGrant) -> WidgetSnapshot:
@@ -178,21 +179,54 @@ class WidgetSnapshotService:
"""
复用有界订阅查询的 owner、媒体类型、排序和精确 count,不扫描全量订阅。
"""
media_types = (
tuple(_FEATURE_TYPES[feature] for feature in widget_subscription_features(user))
if content == "subscriptions" else (_MEDIA_TYPES[content],)
)
filters = SubscriptionFilter(usernames=() if user.is_superuser else (user.name,), media_types=media_types)
page = await self._data.async_list_subscriptions(
filters=SubscriptionFilter(
usernames=() if user.is_superuser else (user.name,), media_types=(_MEDIA_TYPES[content],)
),
filters=filters,
page=QueryPageRequest(count=WIDGET_ITEM_LIMIT),
)
items = [
WidgetItem(
id=f"subscription-{row.id}",
title=_text(row.name, 240, "未命名订阅"),
subtitle=_text(" · ".join(filter(None, [row.year, row.type, row.state])), 512),
section = self._ready(content, page.total, [self._subscription_item(row) for row in page.items])
for kind in media_types:
counted = await self._data.async_list_subscriptions(
filters=filters.model_copy(update={"media_types": (kind,)}), page=QueryPageRequest(count=1)
)
for row in page.items
]
return self._ready(content, page.total, items)
section.metrics.append(_metric(f"type{kind.name}", kind.value, f"{counted.total:,}", "items"))
for state, title in (("N", "未开始"), ("R", "订阅中"), ("S", "已暂停"), ("P", "待定")):
counted = await self._data.async_list_subscriptions(
filters=filters.model_copy(update={"states": (state,)}),
page=QueryPageRequest(count=1),
)
section.metrics.append(_metric(f"state{state}", title, f"{counted.total:,}", "items"))
return section
@staticmethod
def _subscription_item(row: SubscriptionSnapshot) -> WidgetItem:
"""
沿用公开订阅响应的集数派生规则,保留季号、目标规格和可确认的进度。
:param row: 已按当前账号和媒体类型过滤的有限快照
:return: 不包含路径、规则或资源地址的媒体摘要
"""
view = Subscribe.model_validate(row.model_dump(exclude_none=True))
details = [row.type, row.year]
if row.type == MediaType.TV.value and row.season is not None:
details.append(f"S{row.season:02d}")
progress = None
if row.type == MediaType.TV.value and row.total_episode and view.completed_episode is not None:
completed = min(max(view.completed_episode, 0), row.total_episode)
details.append(f"{completed}/{row.total_episode} 集")
progress = completed / row.total_episode
elif row.type == MediaType.MUSIC.value and row.total_tracks:
details.append(f"{row.total_tracks} 首曲目")
details.extend([row.quality, row.resolution, row.audio_quality, row.audio_format])
details.append({"N": "未开始", "R": "订阅中", "S": "已暂停", "P": "待定"}.get(row.state or "", "状态未知"))
return WidgetItem(
id=f"subscription-{row.id}", title=_text(row.name, 240, "未命名订阅"),
subtitle=_text(" · ".join(filter(None, details)), 512), progress=progress,
)
async def _imports(self) -> WidgetSection:
"""
@@ -203,11 +237,20 @@ class WidgetSnapshotService:
WidgetItem(
id=f"import-{row.id}",
title=_text(row.title, 240, "未命名媒体"),
subtitle=_text(" · ".join(filter(None, [row.year, row.type, row.seasons, row.episodes])), 512),
subtitle=_text(" · ".join(filter(None, [row.year, row.type, row.seasons, row.episodes, row.date])), 512),
)
for row in page.list
]
return self._ready("imports", page.total, items)
section = self._ready("imports", page.total, items)
types: tuple[tuple[MediaType, WidgetIcon], ...] = (
(MediaType.MOVIE, "movie"), (MediaType.TV, "television"), (MediaType.MUSIC, "music")
)
for media_type, icon in types:
counted = await self._data.async_list_transfer_history(
filters=TransferHistoryFilter(media_types=(media_type,), status=True), page=QueryPageRequest(count=1)
)
section.metrics.append(_metric(f"type{media_type.name}", media_type.value, f"{counted.total:,}", icon))
return section
async def _download_section(self, user: UserSnapshot) -> WidgetSection:
"""
@@ -230,7 +273,7 @@ class WidgetSnapshotService:
WidgetItem(
id=f"download-{index}",
title=_text(row.title or row.name, 240, "未命名下载"),
subtitle=_text(" · ".join(filter(None, [row.dlspeed, row.left_time])), 512),
subtitle=_text(" · ".join(filter(None, [row.season_episode, row.dlspeed, row.left_time])), 512),
progress=progress / 100 if progress is not None else None,
)
)
@@ -239,6 +282,8 @@ class WidgetSnapshotService:
section.metrics = self._download_metrics(info) + section.metrics
# 无下载任务仍可正在做种上传,有效速率不能被空列表状态隐藏。
section.state = "ready"
for state, title in (("downloading", "下载中"), ("paused", "已暂停")):
section.metrics.append(_metric(f"state{state}", title, str(sum(row.state == state for row in visible)), "items"))
return section
def _download_sources(self, administrator: bool) -> tuple[list[DownloaderTorrent], DownloaderInfo | None]:
@@ -279,35 +324,27 @@ class WidgetSnapshotService:
async def _overview(self) -> WidgetSection:
"""
汇总原仪表板媒体与存储;严格来源失败不能投影为成功的零容量。
仅投影原管理员系统监控,不再把媒体数量放入系统概览。
"""
statistic = await run_in_threadpool(self._dashboard.statistic, require_source=True)
storage = await run_in_threadpool(self._storage)
total, used = storage.total_storage, storage.used_storage
cpu, memory, network = await run_in_threadpool(self._system)
if (
total is None
or used is None
or not math.isfinite(total)
or not math.isfinite(used)
or not 0 <= used <= total
not math.isfinite(cpu) or not 0 <= cpu <= 100
or memory.total <= 0 or not 0 <= memory.available <= memory.total
or len(network) != 2 or any(not math.isfinite(value) or value < 0 for value in network)
):
raise ValueError("存储统计超出来源范围")
raise ValueError("系统采样超出来源范围")
used = memory.total - memory.available
return WidgetSection(
content="overview",
state="ready",
updatedAt=self._clock(),
title=_TITLES["overview"],
metrics=[
_metric("movies", "电影", str(statistic.movie_count), "movie"),
_metric("tv", "电视剧", str(statistic.tv_count), "television"),
_metric(
"episodes",
"单集",
str(statistic.episode_count) if statistic.episode_count is not None else "—",
"episodes",
),
_metric("music", "音乐", str(statistic.music_count), "music"),
_metric("used", "已用空间", format_size(int(used)), "storage"),
_metric("total", "总空间", format_size(int(total)), "storage"),
_metric("cpu", "CPU 占用", f"{cpu:.1f}%", "cpu"),
_metric("memory", "内存占用", f"{used / memory.total * 100:.1f}%", "memory"),
_metric("networkDownload", "网络接收", f"{format_size(network[1])}/s", "download"),
_metric("networkUpload", "网络发送", f"{format_size(network[0])}/s", "upload"),
_metric("memoryUsed", "已用内存", format_size(used), "memory"),
_metric("memoryTotal", "总内存", format_size(memory.total), "memory"),
],
)

View File

@@ -8,9 +8,11 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
WidgetContent = Literal[
"overview", "downloads", "imports", "movieSubscriptions", "tvSubscriptions", "musicSubscriptions"
"overview", "downloads", "imports", "subscriptions", "movieSubscriptions", "tvSubscriptions", "musicSubscriptions"
]
WidgetIcon = Literal[
"movie", "television", "episodes", "music", "download", "upload", "storage", "items", "clock", "cpu", "memory"
]
WidgetIcon = Literal["movie", "television", "episodes", "music", "download", "upload", "storage", "items", "clock"]
class WidgetContext(BaseModel):
@@ -85,7 +87,7 @@ class WidgetSection(BaseModel):
default=None, gt=0, allow_inf_nan=False, description="成功读取的 UTC Unix 秒;失败时为空"
)
title: str = Field(min_length=1, max_length=240, description="内容标题,最多 240 UTF-8 字节")
metrics: list[WidgetMetric] = Field(default_factory=list, max_length=6, description="最多六个真实作用域的统计指标")
metrics: list[WidgetMetric] = Field(default_factory=list, max_length=8, description="最多八个真实作用域的统计指标")
items: list[WidgetItem] = Field(default_factory=list, max_length=6, description="最多六项摘要;列表长度不冒充总数")
message: str | None = Field(default=None, max_length=240, description="可展示的固定说明,不输出内部异常或凭据")

View File

@@ -36,12 +36,10 @@ Cookie、主登录 JWT、资源令牌或 `API_TOKEN`。小组件凭据也不能
| 内容 | 授权和数据范围 |
| --- | --- |
| `overview` | 仅超级管理员;复用仪表板媒体统计和存储汇总 |
| `overview` | 仅超级管理员;复用 CPU、系统内存与网络收发采样 |
| `downloads` | `manage`,且 `manage.downloading` 未禁用;普通用户按网页相同的 `userid` / `username` 精确匹配本人 |
| `imports` | `manage`,且 `manage.history` 未禁用;原历史查询的最近成功入库记录 |
| `movieSubscriptions` | `subscribe`,且 `subscribe.movie` 未禁用;本人电影订阅 |
| `tvSubscriptions` | `subscribe`,且 `subscribe.tv` 未禁用;本人剧集订阅 |
| `musicSubscriptions` | `subscribe`,且 `subscribe.music` 未禁用;本人音乐订阅 |
| `subscriptions` | `subscribe`,且至少一个订阅类型未禁用;合并本人有权限的电影、剧集和音乐订阅 |
管理员沿用原页面的全局数据范围。普通用户的分类权限必须明确为 `true`,缺省功能权限继承
分类;设置中的 `admin` 标记不能授予管理员权限。订阅按数据库 owner/type 分页并返回真实总数,
@@ -66,7 +64,7 @@ Cookie、主登录 JWT、资源令牌或 `API_TOKEN`。小组件凭据也不能
失败来源没有数据或伪造的采样时间,不会被转换成成功的零值;其他成功来源仍可展示。
旧记录的时间没有明确时区时,不猜测为 UTC。
每类最多六个指标和六条摘要,完整 JSON 不超过 32 KiB。标题、辅助文字和值按 UTF-8 字节限制,
每类最多八个指标和六条摘要,完整 JSON 不超过 32 KiB。标题、辅助文字和值按 UTF-8 字节限制,
移除控制字符。下载摘要展示当前采样的速度和自然进度;进度为 0…1。
不返回下载 hash、完整路径、tracker、图片 URL、插件配置、主登录令牌或业务命令。
@@ -74,3 +72,14 @@ Cookie、主登录 JWT、资源令牌或 `API_TOKEN`。小组件凭据也不能
401/403 时立即清理本地授权和快照,引导在 App 中重新启用;网络错误可按客户端明确的
过期策略暂用旧快照,不能把失败时刻改成“刚刚更新”。退出时先尝试撤销,断网不能阻止本地清理。
反向代理应保留 Authorization、使用 HTTPS,并避免记录凭据;不应把密钥放入 URL。
## 按尺寸显示的统计与明细
系统概览只含 CPU 占用、系统内存使用百分比(总内存减可用内存)、网络接收/发送速率、已用/总内存;不是 MoviePilot 进程内存比例,不包含媒体库数量。系统采样在受管工作线程完成,网络与 CPU 的区间约一秒;组件显示采样值,不承诺实时。
订阅合为一个 `subscriptions` 内容:`count` 为本人所有可用类型的总数,`typeMOVIE`/`typeTV`/`typeMUSIC` 分别计数,禁用类型完全不返回。`stateN`/`stateR`/`stateS`/`stateP` 是原持久化状态的未开始/订阅中/暂停/待定数量,不是进度或运行任务状态。所有计数来自原有数据库筛选的完整范围,不从六条摘要推算。剧集摘要复用公开响应的已完成集数派生,提供季号、完成/总集数、目标规格和可确认的自然进度。音乐缺少累计曲目事实时只显示目标曲目数,不能伪造进度。旧分类枚举仍可解析历史缓存,新的内容列表不再分开登记三个订阅。
入库保留成功历史的总数,追加按媒体类型计数及最近六条成功记录的季集、年份和原时间文字。小卡片显示总数,中卡片显示类别/状态统计,大卡片才显示真实名称和列表进度;媒体名称默认显示,可由用户关闭。下载小尺寸保留上传/下载速度,中大尺寸增加剩余空间、任务/状态数量,大尺寸追加任务名称、进度、单任务速度与预计剩余时间。
更新后的 App 和前端须一起升级以识别合并订阅内容、系统图标与八指标投影;旧客户端不能把未知指标当媒体数量显示。普通 Web/PWA 业务请求不变。

View File

@@ -340,7 +340,7 @@
"app/application/widget.py:app.runtime.execution.run_in_threadpool": {
"owners": {
"WidgetSnapshotService._download_section": 1,
"WidgetSnapshotService._overview": 2
"WidgetSnapshotService._overview": 1
},
"target": "app.runtime.execution.run_in_threadpool"
},

View File

@@ -959,8 +959,8 @@
"runtime_only": true
}
},
"edge_count": 9824,
"edge_sha256": "fce305ba6e4ef2ac441ef9bf5fc5a9166618f28e92833066ec0adf6b86cc1e90",
"edge_count": 9822,
"edge_sha256": "03e904678af5150034e8f55a64b383c4ce22f6157f21bf2d98e34bb1b6dd3d2d",
"edges": [
"app -> app.foundation",
"app -> app.foundation.environment",
@@ -2302,14 +2302,12 @@
"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",
"app.api.dependencies.widget -> app.application.security",
"app.api.dependencies.widget -> app.application.security.widget",
"app.api.dependencies.widget -> app.application.widget",
"app.api.dependencies.widget -> app.chain",
"app.api.dependencies.widget -> app.chain.dashboard",
"app.api.dependencies.widget -> app.chain.download",
"app.api.dependencies.widget -> app.startup",
"app.api.dependencies.widget -> app.startup.composition",
@@ -4513,7 +4511,6 @@
"app.application.transfer.workflow -> app.schemas.file",
"app.application.transfer.workflow -> app.schemas.transfer",
"app.application.widget -> app.application",
"app.application.widget -> app.application.dashboard",
"app.application.widget -> app.application.history",
"app.application.widget -> app.application.query",
"app.application.widget -> app.application.security",
@@ -4527,6 +4524,7 @@
"app.application.widget -> app.schemas",
"app.application.widget -> app.schemas.dashboard",
"app.application.widget -> app.schemas.query",
"app.application.widget -> app.schemas.subscribe",
"app.application.widget -> app.schemas.transfer",
"app.application.widget -> app.schemas.types",
"app.application.widget -> app.schemas.widget",

View File

@@ -22,6 +22,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.adapters.web.security.access import verify_token
from app.api.dependencies import dashboard as dashboard_dependencies
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
@@ -49,7 +50,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 DownloaderInfo, Statistic, Storage
from app.schemas.dashboard import DashboardMemoryInfo, DownloaderInfo
from app.schemas.transfer import DownloaderTorrent
from app.schemas.types import MediaType
@@ -108,14 +109,12 @@ def widget_case(tmp_path):
history_repository = TransactionalTransferHistoryRepository(sync_session=factory, async_session=async_factory)
data = DataQueryService(subscriptions=adapter, histories=adapter, async_executor=_ThreadExecutor())
history = HistoryQueryService(download_repository=Mock(), transfer_repository=history_repository)
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))
system = Mock(return_value=(24.5, DashboardMemoryInfo(total=1000, available=400), [2048, 4096]))
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,
downloader=downloader, storage=storage, clock=lambda: clock[0]
data=data, history=history, system=system, downloads=downloads,
downloader=downloader, clock=lambda: clock[0]
)
case = SimpleNamespace(
auth=auth,
@@ -123,10 +122,9 @@ def widget_case(tmp_path):
clock=clock,
owners=owners,
snapshots=snapshots,
dashboard=dashboard,
system=system,
downloads=downloads,
downloader=downloader,
storage=storage,
data=data,
history=history,
)
@@ -170,14 +168,14 @@ def test_widget_context_reads_current_permissions_without_issuing_or_rotating(wi
assert result.model_dump() == {
"version": 1,
"owner": case.owners[0],
"availableContents": ["downloads", "imports", "movieSubscriptions", "tvSubscriptions", "musicSubscriptions"],
"availableContents": ["downloads", "imports", "subscriptions"],
}
with case.factory() as session:
assert session.scalar(sa.select(sa.func.count()).select_from(WidgetRegistration)) == 0
issued = _register(case)
_update_user(case, permissions={"subscribe": True, "features": {"subscribe.movie": False, "subscribe.music": False}})
case.clock[0] += 120
assert case.auth.context(101, issued.owner).availableContents == ["tvSubscriptions"]
assert case.auth.context(101, issued.owner).availableContents == ["subscriptions"]
current = case.auth.authenticate(issued.secret)
assert current.registration.registration_id == str(issued.registrationId)
assert current.registration.expires_at == issued.expiresAt
@@ -277,7 +275,7 @@ def test_widget_latest_features_rename_and_forged_secrets(widget_case):
)
current = case.auth.authenticate(issued.secret)
assert current.user.name == "renamed"
assert available_widget_contents(current.user) == ["musicSubscriptions"]
assert available_widget_contents(current.user) == ["subscriptions"]
_expect_error(403, case.auth.revalidate, grant)
for invalid in ("", "jwt.token.parts", issued.secret[:-2] + "xx", f"mpw_{'-' * 36}.{'a' * 43}"):
_expect_error(401, case.auth.authenticate, invalid)
@@ -373,11 +371,14 @@ def test_widget_snapshot_uses_real_owner_type_counts_and_success_history(widget_
result = asyncio.run(case.snapshots.snapshot(case.auth.authenticate(_register(case).secret)))
sections = {section.content: section for section in result.sections}
assert "overview" not in sections
assert sections["movieSubscriptions"].metrics[0].value == "9"
assert sections["tvSubscriptions"].metrics[0].value == "9"
assert len(sections["movieSubscriptions"].items) == 6
assert sections["movieSubscriptions"].items[0].title == "alice-电影-8"
assert sections["musicSubscriptions"].state == "empty"
assert {metric.id: metric.value for metric in sections["subscriptions"].metrics} == {
"count": "18", "typeMOVIE": "9", "typeTV": "9", "typeMUSIC": "0",
"stateN": "18", "stateR": "0", "stateS": "0", "stateP": "0",
}
assert len(sections["subscriptions"].items) == 6
assert sections["subscriptions"].items[0].title.startswith("alice-")
assert sections["subscriptions"].items[0].title.endswith("-8")
assert sections["subscriptions"].state == "ready"
assert sections["imports"].metrics[0].value == "9"
assert len(sections["imports"].items) == 6
assert sections["downloads"].metrics[0].value == "2"
@@ -400,9 +401,9 @@ def test_widget_partial_failures_and_utf8_budget(widget_case):
]
result = asyncio.run(case.snapshots.snapshot(case.auth.authenticate(issued.secret)))
sections = {section.content: section for section in result.sections}
assert sections["movieSubscriptions"].state == "unavailable"
assert sections["movieSubscriptions"].updatedAt is None
assert not sections["movieSubscriptions"].items and not sections["movieSubscriptions"].metrics
assert sections["subscriptions"].state == "unavailable"
assert sections["subscriptions"].updatedAt is None
assert not sections["subscriptions"].items and not sections["subscriptions"].metrics
assert sections["imports"].state == "empty" and sections["imports"].updatedAt == case.clock[0]
item = sections["downloads"].items[0]
assert len(item.title.encode()) <= 240 and len(item.subtitle.encode()) <= 512
@@ -424,22 +425,24 @@ def test_widget_admin_overview_and_all_download_owners(widget_case, source_failu
issued = _register(case)
case.downloads.return_value = [DownloaderTorrent(title="bob 下载", userid="bob")]
if source_failure:
case.storage.return_value = Storage(total_storage=100, used_storage=200)
case.system.return_value = (24.5, DashboardMemoryInfo(total=100, available=200), [1, 2])
result = asyncio.run(case.snapshots.snapshot(case.auth.authenticate(issued.secret)))
overview = result.sections[0]
assert len(result.sections) == 6
assert len(result.sections) == 4
if source_failure:
assert overview.state == "unavailable" and overview.updatedAt is None
else:
assert overview.metrics[0].value == "7"
assert overview.metrics[2].value == "—"
assert overview.metrics[0].value == "24.5%"
assert overview.metrics[1].value == "60.0%"
assert overview.metrics[2].value == "4.00 KB/s"
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")
("downloadSpeed", "2.00 KB/s"), ("uploadSpeed", "1.00 KB/s"), ("freeSpace", "4.00 KB"), ("count", "1"),
("statedownloading", "1"), ("statepaused", "0")
]
case.downloader.assert_called_once_with()
case.dashboard.statistic.assert_called_once_with(require_source=True)
case.system.assert_called_once_with()
@pytest.mark.parametrize("free_space, expected", [(0, "0 B"), (None, "—"), (-1, "—"), (float("nan"), "—")])
@@ -462,7 +465,7 @@ def test_widget_download_metrics_without_tasks_and_unknown_disk(widget_case, fre
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"]
assert [metric.value for metric in section.metrics] == ["0 B/s", "1.00 MB/s", expected, "0", "0", "0"]
@pytest.mark.parametrize("field", ["download_speed", "upload_speed"])
@@ -492,7 +495,7 @@ def test_widget_global_speed_source_failure_does_not_leak_to_ordinary_user(widge
]
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 [metric.id for metric in result.sections[0].metrics] == ["count", "statedownloading", "statepaused"]
assert [item.title for item in result.sections[0].items] == ["本人"]
case.downloader.assert_not_called()
_update_user(case, is_superuser=True)
@@ -533,13 +536,11 @@ def test_widget_dependency_assembly_and_absent_capability(widget_case, monkeypat
monkeypatch.setattr(dependencies, "get_configured_data_query_service", lambda: case.data)
download_chain = Mock()
download_chain.downloading.return_value = []
dashboard_chain = Mock()
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)
monkeypatch.setattr(dependencies, "sample_system", case.system)
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))
snapshots = dependencies.get_widget_snapshot_service(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)
@@ -747,3 +748,81 @@ def test_widget_migration_is_replay_safe_and_preserves_users(monkeypatch, alread
assert not sa.inspect(connection).has_table("widgetregistration")
assert connection.execute(sa.select(User.name)).scalar_one() == "existing"
engine.dispose()
def test_widget_combined_subscription_counts_all_rows_and_excludes_disabled_types(widget_case):
"""
合并组件的类别和状态数量覆盖全库作用域,权限关闭的类型与他人记录不能影响统计。
"""
case = widget_case
with case.factory() as session:
for index in range(12):
session.add(Subscribe(name=f"剧集-{index}", type=MediaType.TV.value, username="alice",
state=("N", "R", "S", "P")[index % 4], season=2, total_episode=20,
lack_episode=7, quality="WEB-DL", resolution="1080p"))
session.add(Subscribe(name="无权限的电影", type=MediaType.MOVIE.value, username="alice"))
session.add(Subscribe(name="另一用户", type=MediaType.TV.value, username="bob"))
session.commit()
_update_user(case, permissions={"subscribe": True, "features": {"subscribe.movie": False}})
result = asyncio.run(case.snapshots.snapshot(case.auth.authenticate(_register(case).secret)))
assert [section.content for section in result.sections] == ["subscriptions"]
section = result.sections[0]
assert {metric.id: metric.value for metric in section.metrics} == {
"count": "12", "typeTV": "12", "typeMUSIC": "0",
"stateN": "3", "stateR": "3", "stateS": "3", "stateP": "3",
}
assert len(section.items) == 6
assert section.items[0].progress == 0.65
assert "S02" in section.items[0].subtitle and "13/20 集" in section.items[0].subtitle
assert "WEB-DL" in section.items[0].subtitle
assert "无权限" not in result.model_dump_json() and "另一用户" not in result.model_dump_json()
def test_widget_import_type_totals_are_not_the_six_recent_rows(widget_case):
"""
入库类型统计使用完整成功历史,失败记录不计入;大卡片仍只投影最近六项。
"""
case = widget_case
with case.factory() as session:
for kind, count in ((MediaType.MOVIE, 8), (MediaType.TV, 7), (MediaType.MUSIC, 3)):
for index in range(count):
session.add(TransferHistory(title=f"{kind.value}-{index}", type=kind.value, status=True))
session.add(TransferHistory(title="失败", type=kind.value, status=False))
session.commit()
result = asyncio.run(case.snapshots.snapshot(case.auth.authenticate(_register(case).secret)))
section = next(section for section in result.sections if section.content == "imports")
assert {metric.id: metric.value for metric in section.metrics} == {
"count": "18", "typeMOVIE": "8", "typeTV": "7", "typeMUSIC": "3",
}
assert len(section.items) == 6
@pytest.mark.parametrize("cpu,total,available,network", [
(float("nan"), 100, 10, [1, 2]), (-1, 100, 10, [1, 2]),
(10, 0, 0, [1, 2]), (10, 100, 101, [1, 2]), (10, 100, 10, [-1, 2]), (10, 100, 10, [1]),
])
def test_widget_invalid_system_samples_do_not_fabricate_values(widget_case, cpu, total, available, network):
"""
系统无效采样必须独立失败,不能用媒体数量或伪造零值代替。
"""
case = widget_case
_update_user(case, is_superuser=True)
case.system.return_value = (cpu, DashboardMemoryInfo(total=total, available=available), network)
result = asyncio.run(case.snapshots.snapshot(case.auth.authenticate(_register(case).secret)))
overview = result.sections[0]
assert overview.state == "unavailable" and not overview.metrics and overview.updatedAt is None
assert result.sections[1].state == "ready"
def test_widget_system_sampler_reuses_existing_sources_in_order(monkeypatch):
"""
CPU 两次读取围绕同一网络采样区间,系统内存沿用仪表盘来源而非进程内存比例。
"""
source = Mock()
source.cpu_usage.side_effect = [0.0, 42.0]
source.network_usage.return_value = [1024, 2048]
source.memory_usage.return_value = DashboardMemoryInfo(total=100, available=30, used=5, usage=5)
monkeypatch.setattr(dashboard_dependencies, "SystemUtils", source)
cpu, memory, network = dashboard_dependencies.sample_system()
assert cpu == 42 and network == [1024, 2048] and memory.available == 30
assert [call[0] for call in source.mock_calls] == ["cpu_usage", "network_usage", "cpu_usage", "memory_usage"]