feat(dashboard): add system summary endpoint and monthly media statistics

This commit is contained in:
jxxghp
2026-06-28 17:49:09 +08:00
parent 2a89bfd25c
commit 9b1bdb0cb2
8 changed files with 190 additions and 8 deletions
+26 -7
View File
@@ -18,7 +18,7 @@ from app.utils.system import SystemUtils
router = APIRouter() router = APIRouter()
def _build_statistic(name: Optional[str] = None) -> schemas.Statistic: def _build_statistic(db: Session, name: Optional[str] = None) -> schemas.Statistic:
""" """
构建媒体数量统计信息。 构建媒体数量统计信息。
""" """
@@ -39,8 +39,14 @@ def _build_statistic(name: Optional[str] = None) -> schemas.Statistic:
if not has_episode_count: if not has_episode_count:
# 所有媒体服务都未提供剧集统计时,返回 None 供前端展示“未获取”。 # 所有媒体服务都未提供剧集统计时,返回 None 供前端展示“未获取”。
ret_statistic.episode_count = None ret_statistic.episode_count = None
return ret_statistic else:
return schemas.Statistic() ret_statistic = schemas.Statistic()
movie_count_month, tv_count_month, episode_count_month = TransferHistory.monthly_media_statistics(db)
ret_statistic.movie_count_month = movie_count_month
ret_statistic.tv_count_month = tv_count_month
ret_statistic.episode_count_month = episode_count_month
return ret_statistic
def _build_storage() -> schemas.Storage: def _build_storage() -> schemas.Storage:
@@ -84,22 +90,27 @@ def _build_downloader(name: Optional[str] = None) -> schemas.DownloaderInfo:
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic) @router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
def statistic( def statistic(
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser) name: Optional[str] = None,
db: Session = Depends(get_db),
_: Any = Depends(get_current_active_superuser),
) -> Any: ) -> Any:
""" """
查询媒体数量统计信息 查询媒体数量统计信息
""" """
return _build_statistic(name) return _build_statistic(db, name)
@router.get( @router.get(
"/statistic2", summary="媒体数量统计(API_TOKEN", response_model=schemas.Statistic "/statistic2", summary="媒体数量统计(API_TOKEN", response_model=schemas.Statistic
) )
def statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any: def statistic2(
_: Annotated[str, Depends(verify_apitoken)],
db: Session = Depends(get_db),
) -> Any:
""" """
查询媒体数量统计信息 API_TOKEN认证(?token=xxx 查询媒体数量统计信息 API_TOKEN认证(?token=xxx
""" """
return _build_statistic() return _build_statistic(db)
@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage) @router.get("/storage", summary="本地存储空间", response_model=schemas.Storage)
@@ -128,6 +139,14 @@ def processes(_: Any = Depends(get_current_active_superuser)) -> Any:
return SystemUtils.processes() return SystemUtils.processes()
@router.get("/system", summary="系统摘要信息", response_model=schemas.DashboardSystemInfo)
def system_info(_: Any = Depends(get_current_active_superuser)) -> Any:
"""
查询仪表板系统摘要信息
"""
return SystemUtils.dashboard_system_info()
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo) @router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
def downloader( def downloader(
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser) name: Optional[str] = None, _: Any = Depends(get_current_active_superuser)
+45
View File
@@ -1,3 +1,4 @@
import re
import time import time
from typing import Optional from typing import Optional
@@ -6,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db import db_query, db_update, get_id_column, Base, async_db_query from app.db import db_query, db_update, get_id_column, Base, async_db_query
from app.schemas.types import MediaType
def _text_like(column, pattern: str, wildcard: bool = False): def _text_like(column, pattern: str, wildcard: bool = False):
@@ -205,6 +207,49 @@ class TransferHistory(Base):
time.localtime(time.time() - 86400 * days))).subquery() time.localtime(time.time() - 86400 * days))).subquery()
return db.query(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date).all() return db.query(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date).all()
@classmethod
@db_query
def monthly_media_statistics(cls, db: Session):
"""
统计当月成功整理的电影、电视剧和剧集数量。
电影和电视剧按媒体身份去重;剧集优先按历史记录中的集数字段计算,
缺少集数时按单条成功整理记录计数。
"""
month_prefix = time.strftime("%Y-%m-", time.localtime())
histories = db.query(cls).filter(
cls.status.is_(True),
cls.date.like(f"{month_prefix}%"),
cls.type.in_([MediaType.MOVIE.value, MediaType.TV.value]),
).all()
movie_identities = set()
tv_identities = set()
episode_count = 0
for history in histories:
identity = (history.tmdbid or 0, history.title or "", history.year or "")
if history.type == MediaType.MOVIE.value:
movie_identities.add(identity)
continue
tv_identities.add(identity)
episode_count += cls._history_episode_count(history)
return len(movie_identities), len(tv_identities), episode_count
@staticmethod
def _history_episode_count(history: "TransferHistory") -> int:
"""从单条整理历史中估算成功入库的剧集数量。"""
episode_numbers = [int(value) for value in re.findall(r"\d+", history.episodes or "")]
if len(episode_numbers) >= 2 and "-" in (history.episodes or ""):
return max(1, episode_numbers[-1] - episode_numbers[0] + 1)
if episode_numbers:
return len(set(episode_numbers))
if isinstance(history.files, list) and history.files:
return len(history.files)
return 1
@classmethod @classmethod
@async_db_query @async_db_query
async def async_statistic(cls, db: AsyncSession, days: Optional[int] = 7): async def async_statistic(cls, db: AsyncSession, days: Optional[int] = 7):
+29
View File
@@ -4,6 +4,8 @@ from pydantic import BaseModel
class Statistic(BaseModel): class Statistic(BaseModel):
"""媒体库数量统计。"""
# 电影 # 电影
movie_count: Optional[int] = 0 movie_count: Optional[int] = 0
# 电视剧数量 # 电视剧数量
@@ -12,9 +14,17 @@ class Statistic(BaseModel):
episode_count: Optional[int] = 0 episode_count: Optional[int] = 0
# 用户数量 # 用户数量
user_count: Optional[int] = 0 user_count: Optional[int] = 0
# 本月新增电影数量
movie_count_month: Optional[int] = 0
# 本月新增电视剧数量
tv_count_month: Optional[int] = 0
# 本月新增剧集数量
episode_count_month: Optional[int] = 0
class Storage(BaseModel): class Storage(BaseModel):
"""仪表板存储空间统计。"""
# 总存储空间 # 总存储空间
total_storage: Optional[float] = 0.0 total_storage: Optional[float] = 0.0
# 已使用空间 # 已使用空间
@@ -22,6 +32,8 @@ class Storage(BaseModel):
class ProcessInfo(BaseModel): class ProcessInfo(BaseModel):
"""仪表板进程运行信息。"""
# 进程ID # 进程ID
pid: Optional[int] = 0 pid: Optional[int] = 0
# 进程名称 # 进程名称
@@ -39,6 +51,8 @@ class ProcessInfo(BaseModel):
class DownloaderInfo(BaseModel): class DownloaderInfo(BaseModel):
"""仪表板下载器汇总信息。"""
# 下载速度 # 下载速度
download_speed: Optional[float] = 0.0 download_speed: Optional[float] = 0.0
# 上传速度 # 上传速度
@@ -52,6 +66,8 @@ class DownloaderInfo(BaseModel):
class ScheduleInfo(BaseModel): class ScheduleInfo(BaseModel):
"""仪表板后台服务信息。"""
# ID # ID
id: Optional[str] = None id: Optional[str] = None
# 名称 # 名称
@@ -62,3 +78,16 @@ class ScheduleInfo(BaseModel):
status: Optional[str] = None status: Optional[str] = None
# 下次执行时间 # 下次执行时间
next_run: Optional[str] = None next_run: Optional[str] = None
class DashboardSystemInfo(BaseModel):
"""仪表板系统摘要信息。"""
# 主机名称
hostname: str
# 操作系统名称
operating_system: str
# MoviePilot 运行时间,单位秒
runtime: int
# MoviePilot 后端版本
version: str
+33
View File
@@ -4,8 +4,10 @@ import os
import platform import platform
import re import re
import shutil import shutil
import socket
import subprocess import subprocess
import sys import sys
import time
import urllib.parse import urllib.parse
import uuid import uuid
from pathlib import Path from pathlib import Path
@@ -14,6 +16,7 @@ from typing import List, Optional, Tuple, Union
import psutil import psutil
from app import schemas from app import schemas
from version import APP_VERSION
class SystemUtils: class SystemUtils:
@@ -617,6 +620,36 @@ class SystemUtils:
pass pass
return processes return processes
@staticmethod
def dashboard_system_info() -> schemas.DashboardSystemInfo:
"""
获取仪表板展示所需的系统摘要信息。
运行时间以当前 MoviePilot 进程为基准,避免宿主机或容器长期运行时间
掩盖服务最近一次重启。
"""
return schemas.DashboardSystemInfo(
hostname=socket.gethostname(),
operating_system=SystemUtils._operating_system_name(),
runtime=max(0, int(time.time() - psutil.Process().create_time())),
version=APP_VERSION,
)
@staticmethod
def _operating_system_name() -> str:
"""返回适合在仪表板展示的操作系统名称。"""
if SystemUtils.is_windows():
return platform.platform()
if SystemUtils.is_macos():
version = platform.mac_ver()[0]
return f"macOS {version}".strip()
try:
operating_system = platform.freedesktop_os_release()
return operating_system.get("PRETTY_NAME") or operating_system.get("NAME") or platform.platform()
except OSError:
return platform.platform()
@staticmethod @staticmethod
def is_bluray_dir(dir_path: Path) -> bool: def is_bluray_dir(dir_path: Path) -> bool:
""" """
+1
View File
@@ -113,6 +113,7 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| GET | `/api/v1/system/ping` | 登录用户服务存活检测,用于前端重启后轮询恢复状态 | | GET | `/api/v1/system/ping` | 登录用户服务存活检测,用于前端重启后轮询恢复状态 |
| GET | `/api/v1/dashboard/system` | 查询仪表板系统摘要,包括主机名称、操作系统、MoviePilot 运行时间和后端版本 |
| GET | `/api/v1/system/setting/public/{key}` | 登录用户读取白名单内非敏感系统设置,仅支持目录、存储、站点范围、默认订阅规则、Follow 订阅者和插件市场地址等前端必需配置 | | GET | `/api/v1/system/setting/public/{key}` | 登录用户读取白名单内非敏感系统设置,仅支持目录、存储、站点范围、默认订阅规则、Follow 订阅者和插件市场地址等前端必需配置 |
| POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | 管理员从 MoviePilot Wiki 的插件文档同步公开插件仓库清单,和本地 `PLUGIN_MARKET` 合并去重后写入配置 | | POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | 管理员从 MoviePilot Wiki 的插件文档同步公开插件仓库清单,和本地 `PLUGIN_MARKET` 合并去重后写入配置 |
+2 -1
View File
@@ -282,7 +282,7 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON | | POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON |
| GET | `/api/v1/transfer/now` | Run immediate transfer | | GET | `/api/v1/transfer/now` | Run immediate transfer |
### Dashboard (16 endpoints) ### Dashboard (17 endpoints)
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
@@ -291,6 +291,7 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| GET | `/api/v1/dashboard/storage` | Local storage space | | GET | `/api/v1/dashboard/storage` | Local storage space |
| GET | `/api/v1/dashboard/storage2` | Local storage space (API_TOKEN) | | GET | `/api/v1/dashboard/storage2` | Local storage space (API_TOKEN) |
| GET | `/api/v1/dashboard/processes` | Process info | | GET | `/api/v1/dashboard/processes` | Process info |
| GET | `/api/v1/dashboard/system` | Host name, operating system, MoviePilot runtime, and backend version |
| GET | `/api/v1/dashboard/downloader` | Downloader info. Params: `name` | | GET | `/api/v1/dashboard/downloader` | Downloader info. Params: `name` |
| GET | `/api/v1/dashboard/downloader2` | Downloader info (API_TOKEN) | | GET | `/api/v1/dashboard/downloader2` | Downloader info (API_TOKEN) |
| GET | `/api/v1/dashboard/schedule` | Scheduled services | | GET | `/api/v1/dashboard/schedule` | Scheduled services |
+1
View File
@@ -59,6 +59,7 @@ def test_dashboard_endpoints_require_superuser():
assert _dependency_of(dashboard_endpoint.statistic, "_") is get_current_active_superuser assert _dependency_of(dashboard_endpoint.statistic, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.storage, "_") is get_current_active_superuser assert _dependency_of(dashboard_endpoint.storage, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.processes, "_") is get_current_active_superuser assert _dependency_of(dashboard_endpoint.processes, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.system_info, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.downloader, "_") is get_current_active_superuser assert _dependency_of(dashboard_endpoint.downloader, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.schedule, "_") is get_current_active_superuser assert _dependency_of(dashboard_endpoint.schedule, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.transfer, "_") is get_current_active_superuser assert _dependency_of(dashboard_endpoint.transfer, "_") is get_current_active_superuser
+53
View File
@@ -0,0 +1,53 @@
from app.db import SessionFactory
from app.db.models.transferhistory import TransferHistory
from app.schemas.types import MediaType
from app.utils import system as system_module
from app.utils.system import SystemUtils
def test_dashboard_system_info_returns_runtime_environment(monkeypatch):
"""系统摘要应返回主机、系统、进程运行时间和后端版本。"""
class FakeProcess:
"""提供固定启动时间的进程桩。"""
@staticmethod
def create_time() -> float:
"""返回固定的进程启动时间。"""
return 400.0
monkeypatch.setattr(system_module.socket, "gethostname", lambda: "moviepilot-host")
monkeypatch.setattr(system_module.time, "time", lambda: 1000.0)
monkeypatch.setattr(system_module.psutil, "Process", FakeProcess)
monkeypatch.setattr(SystemUtils, "_operating_system_name", staticmethod(lambda: "Ubuntu 24.04.4 LTS"))
monkeypatch.setattr(system_module, "APP_VERSION", "v2.13.16")
result = SystemUtils.dashboard_system_info()
assert result.hostname == "moviepilot-host"
assert result.operating_system == "Ubuntu 24.04.4 LTS"
assert result.runtime == 600
assert result.version == "v2.13.16"
def test_monthly_media_statistics_counts_successful_unique_media():
"""本月新增统计应只计算成功记录,并按媒体去重。"""
month = system_module.time.strftime("%Y-%m-", system_module.time.localtime())
histories = [
TransferHistory(status=True, date=f"{month}01 10:00:00", type=MediaType.MOVIE.value, tmdbid=1, title="电影"),
TransferHistory(status=True, date=f"{month}02 10:00:00", type=MediaType.MOVIE.value, tmdbid=1, title="电影"),
TransferHistory(status=True, date=f"{month}03 10:00:00", type=MediaType.TV.value, tmdbid=2, title="剧集", episodes="E01-E03"),
TransferHistory(status=False, date=f"{month}04 10:00:00", type=MediaType.TV.value, tmdbid=3, title="失败剧集"),
]
db = SessionFactory()
try:
db.add_all(histories)
db.commit()
assert TransferHistory.monthly_media_statistics(db) == (1, 1, 3)
finally:
history_ids = [history.id for history in histories if history.id is not None]
if history_ids:
db.query(TransferHistory).filter(TransferHistory.id.in_(history_ids)).delete(synchronize_session=False)
db.commit()
db.close()