mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-29 03:31:53 +08:00
Compare commits
2 Commits
chore/bump
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b1bdb0cb2 | ||
|
|
2a89bfd25c |
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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` 合并去重后写入配置 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
53
tests/test_dashboard_system_info.py
Normal file
53
tests/test_dashboard_system_info.py
Normal 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()
|
||||
Reference in New Issue
Block a user