diff --git a/app/api/endpoints/dashboard.py b/app/api/endpoints/dashboard.py index 35ca72b2..accef0b9 100644 --- a/app/api/endpoints/dashboard.py +++ b/app/api/endpoints/dashboard.py @@ -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) diff --git a/app/db/models/transferhistory.py b/app/db/models/transferhistory.py index 576ed508..df83f48a 100644 --- a/app/db/models/transferhistory.py +++ b/app/db/models/transferhistory.py @@ -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): diff --git a/app/schemas/dashboard.py b/app/schemas/dashboard.py index 21d39754..d3631656 100644 --- a/app/schemas/dashboard.py +++ b/app/schemas/dashboard.py @@ -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 diff --git a/app/utils/system.py b/app/utils/system.py index ad8bdac8..b30cedfe 100644 --- a/app/utils/system.py +++ b/app/utils/system.py @@ -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: """ diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 41651a30..4513d23b 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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` 合并去重后写入配置 | diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index d2be6f64..a4a1465d 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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 | diff --git a/tests/test_api_authorization.py b/tests/test_api_authorization.py index 44f65434..c9eabc56 100644 --- a/tests/test_api_authorization.py +++ b/tests/test_api_authorization.py @@ -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 diff --git a/tests/test_dashboard_system_info.py b/tests/test_dashboard_system_info.py new file mode 100644 index 00000000..76701c86 --- /dev/null +++ b/tests/test_dashboard_system_info.py @@ -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()