Compare commits

...

2 Commits

Author SHA1 Message Date
jxxghp
9b1bdb0cb2 feat(dashboard): add system summary endpoint and monthly media statistics 2026-06-28 17:49:09 +08:00
DDSRem
2a89bfd25c chore: bump moviepilot-rust to 0.1.14 (#6016)
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2026-06-28 17:31:00 +08:00
9 changed files with 191 additions and 9 deletions

View File

@@ -18,7 +18,7 @@ from app.utils.system import SystemUtils
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:
# 所有媒体服务都未提供剧集统计时,返回 None 供前端展示“未获取”。
ret_statistic.episode_count = None
return ret_statistic
return schemas.Statistic()
else:
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:
@@ -84,22 +90,27 @@ def _build_downloader(name: Optional[str] = None) -> schemas.DownloaderInfo:
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.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:
"""
查询媒体数量统计信息
"""
return _build_statistic(name)
return _build_statistic(db, name)
@router.get(
"/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
"""
return _build_statistic()
return _build_statistic(db)
@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage)
@@ -128,6 +139,14 @@ def processes(_: Any = Depends(get_current_active_superuser)) -> Any:
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)
def downloader(
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser)

View File

@@ -1,3 +1,4 @@
import re
import time
from typing import Optional
@@ -6,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
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):
@@ -205,6 +207,49 @@ class TransferHistory(Base):
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()
@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
@async_db_query
async def async_statistic(cls, db: AsyncSession, days: Optional[int] = 7):

View File

@@ -4,6 +4,8 @@ from pydantic import BaseModel
class Statistic(BaseModel):
"""媒体库数量统计。"""
# 电影
movie_count: Optional[int] = 0
# 电视剧数量
@@ -12,9 +14,17 @@ class Statistic(BaseModel):
episode_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):
"""仪表板存储空间统计。"""
# 总存储空间
total_storage: Optional[float] = 0.0
# 已使用空间
@@ -22,6 +32,8 @@ class Storage(BaseModel):
class ProcessInfo(BaseModel):
"""仪表板进程运行信息。"""
# 进程ID
pid: Optional[int] = 0
# 进程名称
@@ -39,6 +51,8 @@ class ProcessInfo(BaseModel):
class DownloaderInfo(BaseModel):
"""仪表板下载器汇总信息。"""
# 下载速度
download_speed: Optional[float] = 0.0
# 上传速度
@@ -52,6 +66,8 @@ class DownloaderInfo(BaseModel):
class ScheduleInfo(BaseModel):
"""仪表板后台服务信息。"""
# ID
id: Optional[str] = None
# 名称
@@ -62,3 +78,16 @@ class ScheduleInfo(BaseModel):
status: Optional[str] = None
# 下次执行时间
next_run: Optional[str] = None
class DashboardSystemInfo(BaseModel):
"""仪表板系统摘要信息。"""
# 主机名称
hostname: str
# 操作系统名称
operating_system: str
# MoviePilot 运行时间,单位秒
runtime: int
# MoviePilot 后端版本
version: str

View File

@@ -4,8 +4,10 @@ import os
import platform
import re
import shutil
import socket
import subprocess
import sys
import time
import urllib.parse
import uuid
from pathlib import Path
@@ -14,6 +16,7 @@ from typing import List, Optional, Tuple, Union
import psutil
from app import schemas
from version import APP_VERSION
class SystemUtils:
@@ -617,6 +620,36 @@ class SystemUtils:
pass
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
def is_bluray_dir(dir_path: Path) -> bool:
"""

View File

@@ -113,6 +113,7 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
| 方法 | 路径 | 说明 |
| :--- | :--- | :--- |
| GET | `/api/v1/system/ping` | 登录用户服务存活检测,用于前端重启后轮询恢复状态 |
| GET | `/api/v1/dashboard/system` | 查询仪表板系统摘要包括主机名称、操作系统、MoviePilot 运行时间和后端版本 |
| GET | `/api/v1/system/setting/public/{key}` | 登录用户读取白名单内非敏感系统设置仅支持目录、存储、站点范围、默认订阅规则、Follow 订阅者和插件市场地址等前端必需配置 |
| POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | 管理员从 MoviePilot Wiki 的插件文档同步公开插件仓库清单,和本地 `PLUGIN_MARKET` 合并去重后写入配置 |

View File

@@ -1,4 +1,4 @@
moviepilot-rust~=0.1.13
moviepilot-rust~=0.1.14
pydantic>=2.13.4,<3.0.0
pydantic-settings>=2.14.1,<3.0.0
SQLAlchemy~=2.0.50

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 |
| GET | `/api/v1/transfer/now` | Run immediate transfer |
### Dashboard (16 endpoints)
### Dashboard (17 endpoints)
| 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/storage2` | Local storage space (API_TOKEN) |
| 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/downloader2` | Downloader info (API_TOKEN) |
| GET | `/api/v1/dashboard/schedule` | Scheduled services |

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.storage, "_") 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.schedule, "_") is get_current_active_superuser
assert _dependency_of(dashboard_endpoint.transfer, "_") is get_current_active_superuser

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()