diff --git a/app/api/dependencies/dashboard.py b/app/api/dependencies/dashboard.py index 984bea4cb..f8ca3b8a8 100644 --- a/app/api/dependencies/dashboard.py +++ b/app/api/dependencies/dashboard.py @@ -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: diff --git a/app/api/dependencies/widget.py b/app/api/dependencies/widget.py index e2956eebc..6d73f5fd3 100644 --- a/app/api/dependencies/widget.py +++ b/app/api/dependencies/widget.py @@ -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), ) diff --git a/app/application/security/widget.py b/app/application/security/widget.py index 942e81baf..8f600340e 100644 --- a/app/application/security/widget.py +++ b/app/application/security/widget.py @@ -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。 diff --git a/app/application/widget.py b/app/application/widget.py index a0bcf9f4f..ad584dcaa 100644 --- a/app/application/widget.py +++ b/app/application/widget.py @@ -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"), ], ) diff --git a/app/schemas/widget.py b/app/schemas/widget.py index fee6ee8c1..679976810 100644 --- a/app/schemas/widget.py +++ b/app/schemas/widget.py @@ -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="可展示的固定说明,不输出内部异常或凭据") diff --git a/docs/widgets.md b/docs/widgets.md index 14640b0fc..1b21a18c7 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -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 业务请求不变。 diff --git a/tests/fixtures/architecture/concurrency-baseline.json b/tests/fixtures/architecture/concurrency-baseline.json index d3172a5b8..309bb25f3 100644 --- a/tests/fixtures/architecture/concurrency-baseline.json +++ b/tests/fixtures/architecture/concurrency-baseline.json @@ -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" }, diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 3e9e5c3e8..134173785 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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", diff --git a/tests/test_widget.py b/tests/test_widget.py index eeb26e1dc..688fdc825 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -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"]