mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
feat(dashboard): add system summary endpoint and monthly media statistics
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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` 合并去重后写入配置 |
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user