mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 12:56:34 +08:00
feat(widget): enrich system and combined media summaries
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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="可展示的固定说明,不输出内部异常或凭据")
|
||||
|
||||
|
||||
@@ -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 业务请求不变。
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user