Files
MoviePilot/app/api/endpoints/system.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

1180 lines
41 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
提供系统运维、升级、资源代理及兼容设置路由入口。
"""
import asyncio
import io
import json
import zipfile
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Annotated, Any, Optional
from urllib.parse import urlsplit
import anyio
# 导入时注册 AVIF 解码器,因此保留未直接引用的依赖。
import pillow_avif # noqa: F401 # pylint: disable=unused-import
from fastapi import Body, Depends, Header, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from app.adapters.web.security.access import verify_apitoken, verify_resource_token, verify_token
from app.api.context import get_host_runtime
from app.api.dependencies.auth import (
get_current_active_superuser,
get_current_active_superuser_async,
get_current_active_user_async,
)
from app.api.endpoints.identifier import router as system_identifiers_router
from app.api.endpoints.module import router as module_router
from app.api.endpoints.settings import (
catalog_settings,
describe_setting,
get_setting,
query_settings,
set_setting,
update_settings,
)
from app.api.endpoints.settings import router as system_settings_router
from app.api.principal import ApiPrincipal
from app.api.response import CompatibleCountParam, CompatiblePageParam, ResponseAPIRouter
from app.application.backup import DatabaseBackupInProgressError
from app.application.configuration import (
get_configured_system_config,
get_runtime_settings,
)
from app.application.database import get_database_governance
from app.application.image import ImageHelper
from app.application.mediaserver import get_mediaserver_configs
from app.application.messaging.image import verify_wechat_image_url
from app.application.messaging.message import MessageHelper
from app.application.network import get_configured_network_test_service
from app.application.rules import RuleHelper
from app.application.scheduling import get_scheduler
from app.application.security.image import normalize_image_domain, site_image_domains
from app.application.security.url import SecurityUtils
from app.application.site.query import get_configured_site_query_service
# 站点目录由外部资源加载,静态检查无法解析其动态导出。
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
from app.application.system import LogFileData, LogNotFoundError
from app.chain.media import MediaChain
from app.chain.mediaserver import MediaServerChain
from app.chain.search.facade import SearchChain
from app.domain.context import TorrentInfo as _DomainTorrentInfo
from app.domain.metainfo import MetaInfo
from app.foundation.crypto import HashUtils
from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled
from app.runtime.execution import run_in_threadpool_to_completion
from app.runtime.localization import LocaleHelper
from app.runtime.log import logger
from app.runtime.progress import AsyncProgressHelper
from app.runtime.stop import runtime_stop_state
from app.runtime.version import get_app_version, get_frontend_version
from app.schemas.common import JsonObject as _SchemaJsonObject
from app.schemas.common import JsonObjectList as _SchemaJsonObjectList
from app.schemas.common import TimeData as _SchemaTimeData
from app.schemas.common import ValueData as _SchemaValueData
from app.schemas.response import Response as _SchemaResponse
from app.schemas.system import DatabaseBackupArtifactData as _SchemaDatabaseBackupArtifactData
from app.schemas.system import DatabaseBackupVerificationData as _SchemaDatabaseBackupVerificationData
from app.schemas.system import NetTestTarget as _SchemaNetTestTarget
from app.schemas.system import PluginMarketSyncData as _SchemaPluginMarketSyncData
from app.schemas.system import PluginMarketSyncRequest as _SchemaPluginMarketSyncRequest
from app.schemas.system import RuleTestData as _SchemaRuleTestData
from app.schemas.system import SystemEnvironmentUpdateData as _SchemaSystemEnvironmentUpdateData
from app.schemas.system import SystemUpdateRequest as _SchemaSystemUpdateRequest
from app.schemas.system import SystemUpdateStatus as _SchemaSystemUpdateStatus
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.types import SystemConfigKey
from app.startup.composition.context import HostRuntime
router = ResponseAPIRouter()
router.routes.extend(system_identifiers_router.routes)
router.routes.extend(module_router.routes)
# 重导出设置处理函数,兼容既有端点路径的导入和测试。
__all__ = [
"catalog_settings",
"describe_setting",
"get_setting",
"query_settings",
"set_setting",
"update_settings",
]
_PUBLIC_SYSTEM_CONFIG_KEYS = {
item.value: item
for item in (
SystemConfigKey.Directories,
SystemConfigKey.Storages,
SystemConfigKey.IndexerSites,
SystemConfigKey.EpisodeFormatRuleTable,
SystemConfigKey.DefaultMovieSubscribeConfig,
SystemConfigKey.DefaultTvSubscribeConfig,
SystemConfigKey.DefaultMusicSubscribeConfig,
SystemConfigKey.FollowSubscribers,
)
}
_PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"}
def _project_public_storages(value: Any) -> list[dict[str, Any]]:
"""
存储配置只公开名称和类型,与 /storage/options 一致,config 中的连接配置不返回。
"""
if not isinstance(value, list):
return []
return [
{"name": item.get("name") or item["type"], "type": item["type"]}
for item in value
if isinstance(item, dict) and item.get("type")
]
async def _get_image_proxy_allowed_domains() -> set[str]:
"""
合并站点快照与当前图片代理配置,仍逐请求执行 DNS 安全校验。
"""
runtime_settings = get_runtime_settings()
allowed_domains = set(runtime_settings.get("SECURITY_IMAGE_DOMAINS", []))
allowed_domains.update(await site_image_domains.get(get_configured_site_query_service()))
image_keys = [
"TMDB_IMAGE_DOMAIN", "MUSIC_COVER_PROXY",
"WALLPAPER_IMAGE_URL", "CUSTOMIZE_WALLPAPER_API_URL",
]
if runtime_settings.get("BANGUMI_PROXY_ENABLE"):
image_keys.append("BANGUMI_IMAGE_DOMAIN")
for key in image_keys:
domain = normalize_image_domain(runtime_settings.get(key))
if domain:
allowed_domains.add(domain)
return allowed_domains
def _get_image_proxy_trusted_hosts() -> set[str]:
"""
返回已启用媒体服务器的主机(host / play_host),图片代理可直接信任。
"""
trusted_hosts: set[str] = set()
try:
for conf in get_mediaserver_configs():
for key in ("host", "play_host"):
host = str((conf.config or {}).get(key) or "").strip()
if not host:
continue
parsed = urlsplit(host if "://" in host else f"https://{host}")
if parsed.scheme in {"http", "https"} and parsed.netloc:
trusted_hosts.add(parsed.netloc.lower())
# 配置异常时继续执行图片代理的兼容回退,不阻断请求。
except Exception as err: # noqa: BLE001 - 配置不可用时不影响图片代理
logger.debug(f"读取媒体服务器配置失败,图片代理不信任任何媒体服务器主机:{err}")
return set()
return trusted_hosts
def _database_backup_artifact_data(artifact: Any) -> _SchemaDatabaseBackupArtifactData:
"""
将内部备份制品映射为不含宿主路径的 Web DTO。
"""
return _SchemaDatabaseBackupArtifactData(
name=artifact.name,
db_type=artifact.db_type,
created_at=artifact.created_at,
size=artifact.size,
)
def _verify_log_resource_superuser(
token_payload: _SchemaTokenPayload = Depends(verify_resource_token),
) -> _SchemaTokenPayload:
"""
校验日志资源访问权限。
日志接口通过浏览器新窗口和 EventSource 访问,不能依赖普通 API 请求头;
因此这里复用资源 Cookie 完成身份识别,再额外要求管理员身份,避免普通
登录用户读取可能包含敏感信息的日志。
"""
if not token_payload.super_user:
raise HTTPException(status_code=403, detail="用户权限不足")
return token_payload
async def _build_log_zip_response(
name: str,
runtime: HostRuntime,
allow_empty: bool = False,
) -> StreamingResponse:
"""
读取受限日志条目并生成兼容 ZIP 响应。
"""
try:
entries = await runtime.system.collect_logs(name)
except LogNotFoundError as error:
if not allow_empty:
raise HTTPException(status_code=404, detail="Not Found") from error
entries = [LogFileData(name="README.txt", content="暂无执行日志,下一次搜索或 RSS 匹配后开始记录。".encode("utf-8"))]
zip_data, zip_stem = await anyio.to_thread.run_sync(_build_log_zip_data, name, entries)
headers = {"Content-Disposition": f'attachment; filename="{zip_stem}.zip"', "Cache-Control": "no-store"}
return StreamingResponse(
iter([zip_data]),
media_type="application/zip",
headers=headers,
)
def _build_log_zip_data(
name: str,
entries: list[LogFileData],
) -> tuple[bytes, str]:
"""
只负责把应用服务提供的日志内容编码为 ZIP framing。
"""
zip_buffer = io.BytesIO()
filename_time = datetime.now().strftime("%Y%m%d-%H%M%S")
safe_name = (name or "logs").strip().lower().replace("/", "-") or "logs"
zip_stem = f"{safe_name}-logs-{filename_time}"
with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
for entry in entries:
archive.writestr(f"{zip_stem}/{entry.name}", entry.content)
zip_buffer.seek(0)
return zip_buffer.getvalue(), zip_stem
async def fetch_image(
url: str,
proxy: Optional[bool] = None,
use_cache: bool = False,
if_none_match: Optional[str] = None,
cookies: Optional[str | dict[str, str]] = None,
allowed_domains: Optional[set[str]] = None,
) -> Optional[Response]:
"""
处理图片缓存逻辑,支持HTTP缓存和磁盘缓存
"""
if not url:
return None
if allowed_domains is None:
allowed_domains = await _get_image_proxy_allowed_domains()
fetch_url = SecurityUtils.strip_url_signature(url)
private_ranges = get_runtime_settings().get("IMAGE_PROXY_ALLOWED_PRIVATE_RANGES", [])
trusted_hosts = _get_image_proxy_trusted_hosts()
# 验证URL安全性
if not await SecurityUtils.is_safe_image_url_async(
url,
allowed_domains,
allowed_private_ranges=private_ranges,
trusted_hosts=trusted_hosts,
):
return None
async def validate_redirect(target: str) -> bool:
"""
逐跳重新验证目标,不把原图片的签名或主机信任扩散给跳转地址。
"""
return await SecurityUtils.is_safe_image_redirect_url_async(
target,
allowed_domains,
allowed_private_ranges=private_ranges,
trusted_hosts=trusted_hosts,
)
image_result = await ImageHelper().async_fetch_image_with_mime_type(
url=fetch_url,
proxy=proxy,
use_cache=use_cache,
cookies=cookies,
redirect_validator=validate_redirect,
)
if image_result:
content, media_type = image_result
# 检查 If-None-Match
etag = HashUtils.md5(content)
headers = {
"ETag": etag,
"Cache-Control": f"public, max-age={86400 * 7}",
}
headers["Content-Type"] = media_type
headers["X-Content-Type-Options"] = "nosniff"
if if_none_match == etag:
return Response(status_code=304, headers=headers)
# 返回缓存图片
return Response(
content=content,
media_type=media_type,
headers=headers,
)
return None
@router.get(
"/img/{proxy}",
summary="图片代理",
response_model=None,
response_class=Response,
responses={
200: {
"description": "代理图片内容",
"content": {
"image/jpeg": {"schema": {"type": "string", "format": "binary"}},
"image/png": {"schema": {"type": "string", "format": "binary"}},
"image/webp": {"schema": {"type": "string", "format": "binary"}},
},
},
304: {"description": "图片缓存未修改"},
},
)
async def proxy_img(
imgurl: str,
proxy: bool = False,
cache: bool = False,
use_cookies: bool = False,
if_none_match: Annotated[str | None, Header()] = None,
_: _SchemaTokenPayload = Depends(verify_resource_token),
) -> Optional[Response]:
"""
图片代理,可选是否使用代理服务器,支持 HTTP 缓存
"""
allowed_domains = await _get_image_proxy_allowed_domains()
cookies = MediaServerChain().get_image_cookies(server=None, image_url=imgurl) if use_cookies else None
return await fetch_image(
url=imgurl,
proxy=proxy,
use_cache=cache,
cookies=cookies,
if_none_match=if_none_match,
allowed_domains=allowed_domains,
)
@router.get(
"/cache/image",
summary="图片缓存",
response_model=None,
response_class=Response,
responses={
200: {
"description": "缓存图片内容",
"content": {
"image/jpeg": {"schema": {"type": "string", "format": "binary"}},
"image/png": {"schema": {"type": "string", "format": "binary"}},
"image/webp": {"schema": {"type": "string", "format": "binary"}},
},
},
304: {"description": "图片缓存未修改"},
},
)
async def cache_img(
url: str,
if_none_match: Annotated[str | None, Header()] = None,
_: _SchemaTokenPayload = Depends(verify_resource_token),
) -> Optional[Response]:
"""
本地缓存图片文件,支持 HTTP 缓存,如果启用全局图片缓存,则使用磁盘缓存
"""
# 如果没有启用全局图片缓存,则不使用磁盘缓存
return await fetch_image(
url=url,
use_cache=bool(get_runtime_settings().get("GLOBAL_IMAGE_CACHE")),
if_none_match=if_none_match,
)
@router.get(
"/notification-image",
summary="通知图片代理",
response_model=None,
response_class=Response,
responses={
200: {
"description": "通知图片内容",
"content": {
"image/jpeg": {"schema": {"type": "string", "format": "binary"}},
"image/png": {"schema": {"type": "string", "format": "binary"}},
"image/webp": {"schema": {"type": "string", "format": "binary"}},
},
},
304: {"description": "图片缓存未修改"},
},
)
async def notification_image(
url: str,
if_none_match: Annotated[str | None, Header()] = None,
) -> Response:
"""
返回带企业微信通知签名的外部图片。
企业微信取图请求不携带用户 Cookie,因此只接受绑定原图和用途的签名地址,
再复用统一图片代理的域名、DNS 和图片内容校验。
"""
source_url = verify_wechat_image_url(url)
if not source_url:
raise HTTPException(status_code=404, detail="Not Found")
response = await fetch_image(
url=source_url,
use_cache=bool(get_runtime_settings().get("GLOBAL_IMAGE_CACHE")),
if_none_match=if_none_match,
)
if response is None:
raise HTTPException(status_code=502, detail="Failed to fetch the image")
return response
@router.get(
"/global",
summary="查询非敏感系统设置",
response_model=_SchemaResponse[_SchemaJsonObject],
)
def get_global_setting(token: str) -> _SchemaResponse[dict[str, Any]]:
"""
查询非敏感系统设置(默认鉴权)
仅包含登录前UI初始化必需的字段
"""
if token != "moviepilot":
raise HTTPException(status_code=403, detail="Forbidden")
# 白名单模式,仅包含登录前UI初始化必需的字段
runtime_settings = get_runtime_settings()
info = runtime_settings.snapshot(
include={
"BANGUMI_PROXY_ENABLE",
"BANGUMI_IMAGE_DOMAIN",
"TMDB_IMAGE_DOMAIN",
"GLOBAL_IMAGE_CACHE",
"WALLPAPER_ROTATION_INTERVAL",
}
)
# 追加版本信息(用于版本检查)
info.update(
{
"FRONTEND_VERSION": get_frontend_version(),
"BACKEND_VERSION": get_app_version(),
}
)
# 仅在后端开发模式下返回该标记,避免生产环境暴露无意义运行态信息
if runtime_settings.get("DEV"):
info.update({"BACKEND_DEV": True})
return _SchemaResponse(success=True, data=info)
@router.get(
"/global/user",
summary="查询用户相关系统设置",
response_model=_SchemaResponse[_SchemaJsonObject],
)
async def get_user_global_setting(
_: ApiPrincipal = Depends(get_current_active_user_async),
runtime: HostRuntime = Depends(get_host_runtime),
) -> _SchemaResponse[dict[str, Any]]:
"""
查询用户相关系统设置(登录后获取)
包含业务功能相关的配置和用户权限信息
"""
# 业务功能相关的配置字段
runtime_settings = get_runtime_settings()
info = runtime_settings.snapshot(
include={
"AI_AGENT_ENABLE",
"AI_AGENT_HIDE_ENTRY",
"AI_AGENT_PET",
"BANGUMI_PROXY_ENABLE",
"BANGUMI_IMAGE_DOMAIN",
"LLM_SUPPORT_AUDIO_INPUT",
"LLM_SUPPORT_AUDIO_OUTPUT",
"RECOGNIZE_SOURCE",
"SEARCH_SOURCE",
"MUSIC_CUE_ENABLE",
"AI_RECOMMEND_ENABLED",
}
)
# 智能助手总开关未开启,智能推荐状态强制返回False
if not runtime_settings.get("AI_AGENT_ENABLE"):
info["AI_RECOMMEND_ENABLED"] = False
info["LLM_SUPPORT_AUDIO_INPUT"] = False
info["LLM_SUPPORT_AUDIO_OUTPUT"] = False
# 追加用户唯一ID和订阅分享管理权限
info.update(
{
"PYTHON_FREE_THREADED": is_free_threaded_runtime(),
"PYTHON_GIL_ENABLED": is_gil_enabled(),
}
)
info.update(await runtime.system.user_global())
return _SchemaResponse(success=True, data=info)
@router.get("/database/backups", summary="查询受管数据库备份", response_model=list[_SchemaDatabaseBackupArtifactData])
async def list_database_backups(
_: ApiPrincipal = Depends(get_current_active_superuser_async),
page: CompatiblePageParam = None,
count: CompatibleCountParam = None,
) -> list[_SchemaDatabaseBackupArtifactData]:
"""
列出当前备份目录中的正式制品,不触发内容校验。
"""
try:
artifacts = await run_in_threadpool_to_completion(get_database_governance().list_backups)
except Exception as error:
logger.exception("读取数据库备份列表失败")
raise HTTPException(status_code=500, detail="读取数据库备份列表失败,请查看日志") from error
return [_database_backup_artifact_data(artifact) for artifact in artifacts]
@router.post(
"/database/backups",
summary="立即创建数据库备份",
response_model=_SchemaDatabaseBackupArtifactData,
)
async def create_database_backup(
_: ApiPrincipal = Depends(get_current_active_superuser_async),
) -> _SchemaDatabaseBackupArtifactData:
"""
创建、校验并原子发布当前活动数据库的一致快照。
"""
try:
artifact = await run_in_threadpool_to_completion(get_database_governance().create_backup)
except DatabaseBackupInProgressError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
except Exception as error:
logger.exception("创建数据库备份失败")
raise HTTPException(status_code=500, detail="创建数据库备份失败,请查看日志") from error
return _database_backup_artifact_data(artifact)
@router.post(
"/database/backups/{name}/verify",
summary="校验受管数据库备份",
response_model=_SchemaDatabaseBackupVerificationData,
)
async def verify_database_backup(
name: str,
_: ApiPrincipal = Depends(get_current_active_superuser_async),
) -> _SchemaDatabaseBackupVerificationData:
"""
校验一个受管制品,响应不包含宿主路径或适配器错误明细。
"""
try:
verification = await run_in_threadpool_to_completion(
get_database_governance().verify_backup,
name,
)
except ValueError as error:
raise HTTPException(status_code=400, detail="数据库备份文件名无效") from error
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail="数据库备份不存在") from error
except Exception as error:
logger.exception("校验数据库备份失败:%s", name)
raise HTTPException(status_code=500, detail="校验数据库备份失败,请查看日志") from error
return _SchemaDatabaseBackupVerificationData(
valid=verification.valid,
method=verification.method,
)
@router.delete(
"/database/backups/{name}",
summary="删除受管数据库备份",
response_model=_SchemaResponse[None],
)
async def delete_database_backup(
name: str,
_: ApiPrincipal = Depends(get_current_active_superuser_async),
) -> _SchemaResponse[None]:
"""
删除一个受管制品,只接受备份目录内的合法文件名。
"""
try:
await run_in_threadpool_to_completion(
get_database_governance().delete_backup,
name,
)
except ValueError as error:
raise HTTPException(status_code=400, detail="数据库备份文件名无效") from error
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail="数据库备份不存在") from error
except Exception as error:
logger.exception("删除数据库备份失败:%s", name)
raise HTTPException(status_code=500, detail="删除数据库备份失败,请查看日志") from error
return _SchemaResponse(success=True)
@router.get(
"/env",
summary="查询系统配置",
response_model=_SchemaResponse[_SchemaJsonObject],
)
async def get_env_setting(
_: ApiPrincipal = Depends(get_current_active_superuser_async),
runtime: HostRuntime = Depends(get_host_runtime),
) -> _SchemaResponse[dict[str, Any]]:
"""
查询系统环境变量,包括当前版本号(仅管理员)
"""
info = get_runtime_settings().snapshot(exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY"})
# GitHub Token 只通过专用授权状态接口返回脱敏摘要,不能随系统设置快照进入浏览器。
if "GITHUB_TOKEN" in info:
info["GITHUB_TOKEN"] = None
info.update(
{
"VERSION": get_app_version(),
"AUTH_VERSION": SitesHelper().auth_version,
"INDEXER_VERSION": SitesHelper().indexer_version,
"FRONTEND_VERSION": get_frontend_version(),
"PYTHON_FREE_THREADED": is_free_threaded_runtime(),
"PYTHON_GIL_ENABLED": is_gil_enabled(),
}
)
info.update(runtime.system.runtime_features())
return _SchemaResponse(success=True, data=info)
@router.get(
"/usage/statistic",
summary="查询安装版本统计报表",
response_model=_SchemaResponse[_SchemaJsonObject],
)
async def usage_statistic(
_: ApiPrincipal = Depends(get_current_active_user_async),
runtime: HostRuntime = Depends(get_host_runtime),
) -> _SchemaResponse[dict[str, Any]]:
"""
查询安装版本统计报表
"""
return _SchemaResponse(success=True, data=await runtime.system.usage())
@router.get("/ping", summary="服务存活检测", response_model=_SchemaResponse[None])
async def ping(_: ApiPrincipal = Depends(get_current_active_user_async)) -> _SchemaResponse[None]:
"""
检测服务是否可用
"""
return _SchemaResponse(success=True)
@router.post(
"/env",
summary="更新系统配置",
response_model=_SchemaResponse[_SchemaSystemEnvironmentUpdateData],
)
async def set_env_setting(
env: dict[str, Any],
_: ApiPrincipal = Depends(get_current_active_superuser_async),
runtime: HostRuntime = Depends(get_host_runtime),
) -> _SchemaResponse[_SchemaSystemEnvironmentUpdateData]:
"""
更新系统环境变量(仅管理员)
"""
result = await runtime.system.update_environment(env)
return _SchemaResponse(
success=result.success,
message=result.message or "",
data=result.data,
)
@router.get(
"/progress/{process_type}",
summary="实时进度",
response_model=None,
response_class=StreamingResponse,
responses={
200: {
"description": "处理进度 SSE 事件流",
"content": {"text/event-stream": {"schema": {"type": "string"}}},
}
},
)
async def get_progress(
request: Request,
process_type: str,
_: _SchemaTokenPayload = Depends(verify_resource_token),
) -> StreamingResponse:
"""
实时获取处理进度,返回格式为SSE
"""
progress = AsyncProgressHelper(process_type)
locale = LocaleHelper.get_current_locale()
async def event_generator() -> AsyncIterator[str]:
"""
定期输出当前处理进度,在断开或系统停止时退出。
"""
try:
while not runtime_stop_state.is_system_stopped:
if await request.is_disconnected():
break
detail = await progress.get(locale=locale)
yield f"data: {json.dumps(detail)}\n\n"
await asyncio.sleep(0.5)
except asyncio.CancelledError:
return
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get(
"/setting/public/{key}",
summary="查询公开系统设置",
response_model=_SchemaResponse[_SchemaValueData],
)
async def get_public_setting(key: str, _: ApiPrincipal = Depends(get_current_active_user_async)) -> _SchemaResponse[dict[str, Any]]:
"""
查询普通用户可读取的非敏感系统设置,存储配置只返回名称和类型
"""
if key in _PUBLIC_SETTINGS_KEYS:
return _SchemaResponse(
success=True,
data={"value": get_runtime_settings().get(key)},
)
if key not in _PUBLIC_SYSTEM_CONFIG_KEYS:
raise HTTPException(status_code=404, detail="配置项不存在")
config_key = _PUBLIC_SYSTEM_CONFIG_KEYS[key]
value = get_configured_system_config().get(config_key)
if config_key is SystemConfigKey.Storages:
value = _project_public_storages(value)
return _SchemaResponse(success=True, data={"value": value})
@router.post(
"/setting/PLUGIN_MARKET/sync-wiki",
summary="从Wiki同步插件市场仓库",
response_model=_SchemaResponse[_SchemaPluginMarketSyncData],
)
async def sync_plugin_market_from_wiki(
request: Optional[_SchemaPluginMarketSyncRequest] = Body(default=None),
_: ApiPrincipal = Depends(get_current_active_superuser_async),
runtime: HostRuntime = Depends(get_host_runtime),
) -> _SchemaResponse[_SchemaPluginMarketSyncData]:
"""
从 Wiki 插件文档同步插件市场仓库地址。
"""
result = await runtime.system.sync_plugin_market(request.wiki_url if request else None)
return _SchemaResponse(
success=result.success,
message=result.message or "",
data=result.data,
)
@router.get(
"/message",
summary="实时消息",
response_model=None,
response_class=StreamingResponse,
responses={
200: {
"description": "系统消息 SSE 事件流",
"content": {"text/event-stream": {"schema": {"type": "string"}}},
}
},
)
async def get_message(
request: Request,
role: Optional[str] = "system",
_: _SchemaTokenPayload = Depends(verify_resource_token),
) -> StreamingResponse:
"""
实时获取系统消息,返回格式为SSE
"""
message = MessageHelper()
async def event_generator() -> AsyncIterator[str]:
"""
输出积压消息和空闲心跳,在断开或系统停止时退出。
"""
try:
while not runtime_stop_state.is_system_stopped:
if await request.is_disconnected():
break
# 一次推送全部积压消息,避免页面重新打开后按 3 秒一条逐条补弹
details = message.drain(role or "system")
if details:
for detail in details:
yield f"data: {detail}\n\n"
else:
# 空消息作为心跳,维持连接并定期检测客户端是否断开
yield "data: \n\n"
await asyncio.sleep(3)
except asyncio.CancelledError:
return
return StreamingResponse(event_generator(), media_type="text/event-stream")
async def _get_logging_impl(
request: Request,
length: Optional[int] = 50,
logfile: Optional[str] = "moviepilot.log",
_: _SchemaTokenPayload = Depends(_verify_log_resource_superuser),
runtime: HostRuntime = Depends(get_host_runtime),
) -> Response | StreamingResponse:
"""
把应用服务提供的日志数据映射为文本或 SSE 响应。
"""
resolved_logfile = logfile or "moviepilot.log"
if length == -1:
try:
return Response(
content=await runtime.system.read_log(resolved_logfile),
media_type="text/plain",
)
except LogNotFoundError as error:
raise HTTPException(status_code=404, detail="Not Found") from error
except Exception as error:
return Response(content=f"读取日志文件失败: {error}", media_type="text/plain")
try:
source = await runtime.system.follow_log(resolved_logfile, length or 50, request.is_disconnected)
except LogNotFoundError as error:
raise HTTPException(status_code=404, detail="Not Found") from error
async def event_generator() -> AsyncIterator[str]:
"""
仅将原始日志行包装为 SSE 的 data 消息。
"""
try:
async for line in source:
yield f"data: {line}\n\n"
except asyncio.CancelledError:
return
except Exception as error:
logger.error("日志读取异常: %s", error)
yield f"data: 日志读取异常: {error}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get(
"/logging",
summary="实时日志",
response_model=None,
response_class=StreamingResponse,
responses={
200: {
"description": "实时日志流或完整日志文本",
"content": {
"text/event-stream": {"schema": {"type": "string"}},
"text/plain": {"schema": {"type": "string"}},
},
}
},
)
async def get_logging(
request: Request,
length: Optional[int] = 50,
logfile: Optional[str] = "moviepilot.log",
_: _SchemaTokenPayload = Depends(_verify_log_resource_superuser),
runtime: HostRuntime = Depends(get_host_runtime),
) -> Response | StreamingResponse:
"""
实时日志的兼容公开入口。
"""
return await _get_logging_impl(request, length, logfile, _, runtime)
@router.get(
"/logging/download/{name}",
summary="下载日志",
response_model=None,
response_class=StreamingResponse,
responses={
200: {
"description": "日志 ZIP 文件",
"content": {"application/zip": {"schema": {"type": "string", "format": "binary"}}},
}
},
)
async def download_logging(
name: str,
_: _SchemaTokenPayload = Depends(_verify_log_resource_superuser),
runtime: HostRuntime = Depends(get_host_runtime),
) -> StreamingResponse:
"""
按日志标识下载主程序或插件滚动日志,返回 zip 文件。
"""
return await _build_log_zip_response(name, runtime)
@router.get(
"/versions",
summary="查询Github所有Release版本",
response_model=_SchemaResponse[_SchemaJsonObjectList],
)
async def latest_version(
_: _SchemaTokenPayload = Depends(verify_token),
runtime: HostRuntime = Depends(get_host_runtime),
page: CompatiblePageParam = None,
count: CompatibleCountParam = None,
) -> _SchemaResponse[list[dict[str, Any]]]:
"""
查询Github所有Release版本
"""
releases = await runtime.system.releases()
if releases:
return _SchemaResponse(success=True, data=releases)
return _SchemaResponse(success=False)
@router.get(
"/ruletest",
summary="过滤规则测试",
response_model=_SchemaResponse[_SchemaRuleTestData],
)
def ruletest(
title: str,
rulegroup_name: str,
subtitle: Optional[str] = None,
_: _SchemaTokenPayload = Depends(verify_token),
) -> _SchemaResponse[dict[str, Any]]:
"""
过滤规则测试,规则类型 1-订阅,2-洗版,3-搜索
"""
metainfo = MetaInfo(title=title, subtitle=subtitle)
torrent = _DomainTorrentInfo(
title=title,
description=subtitle or "",
)
# 查询规则组详情
rulegroup = RuleHelper().get_rule_group(rulegroup_name)
result_data: dict[str, object] = {
"title": title,
"subtitle": subtitle,
"rulegroup_name": rulegroup_name,
"rulegroup": rulegroup.model_dump() if rulegroup else None,
"meta_info": metainfo.to_dict(),
"media_info": None,
"torrent_info": torrent.to_dict(),
"priority": None,
"matched": False,
}
if not rulegroup:
return _SchemaResponse(
success=False,
message=f"过滤规则组 {rulegroup_name} 不存在!",
data=result_data,
)
# 根据标题查询媒体信息
media_info = MediaChain().recognize_by_meta(
metainfo,
obtain_images=False,
)
result_data["media_info"] = media_info.to_dict() if media_info else None
if not media_info:
return _SchemaResponse(
success=False,
message="未识别到媒体信息!",
data=result_data,
)
# 过滤
result = SearchChain().filter_torrents(
rule_groups=[rulegroup.name or rulegroup_name],
torrent_list=[torrent],
mediainfo=media_info,
)
if not result:
return _SchemaResponse(
success=False,
message="不符合过滤规则!",
data=result_data,
)
result_data.update(
{
"matched": True,
"priority": 100 - result[0].pri_order + 1,
"torrent_info": result[0].to_dict(),
}
)
return _SchemaResponse(
success=True,
data=result_data,
)
@router.get(
"/nettest/targets",
summary="获取网络测试目标",
response_model=_SchemaResponse[list[_SchemaNetTestTarget]],
)
async def nettest_targets(
_: _SchemaTokenPayload = Depends(verify_token), page: CompatiblePageParam = None, count: CompatibleCountParam = None
) -> _SchemaResponse[list[dict[str, Any]]]:
"""
获取网络测试目标。
这里只返回前端渲染所需的最小信息;展示地址只包含来源主机,隐藏请求路径、
查询参数和凭据,内容校验规则与跳转白名单也不会暴露给客户端。
"""
targets = get_configured_network_test_service().list_targets()
return _SchemaResponse(
success=True,
data=[
{
"id": item.id,
"name": item.name,
"address": item.address,
"icon": item.icon,
}
for item in targets
],
)
@router.get(
"/nettest",
summary="测试网络连通性",
response_model=_SchemaResponse[_SchemaTimeData],
)
async def nettest(
target_id: Optional[str] = None,
url: Optional[str] = None,
include: Optional[str] = None,
_: _SchemaTokenPayload = Depends(verify_token),
) -> _SchemaResponse[dict[str, Any]]:
"""
测试内置目标的网络连通性。
`target_id` 是当前前端使用的正式入口。`url/proxy/include` 仅作兼容保留,
其中 `include` 不再参与客户端可控的内容匹配,具体校验由服务端规则决定。
"""
result = await get_configured_network_test_service().execute(
target_id=target_id,
url=url,
include=include,
)
data = {"time": result.elapsed_ms} if result.elapsed_ms is not None else None
return _SchemaResponse(
success=result.success,
message=result.message or "",
data=data if data is not None else None,
)
@router.get("/restart", summary="重启系统", response_model=_SchemaResponse[None])
def restart_system(
_: ApiPrincipal = Depends(get_current_active_superuser),
runtime: HostRuntime = Depends(get_host_runtime),
) -> _SchemaResponse[None]:
"""
重启系统(仅管理员)
"""
result = runtime.system.restart()
return _SchemaResponse(success=result.success, message=result.message or "")
@router.post("/upgrade", summary="Dev 更新并重启系统", response_model=_SchemaResponse[None])
def upgrade_system(
mode: Annotated[str | None, Body()] = None,
_: ApiPrincipal = Depends(get_current_active_superuser),
runtime: HostRuntime = Depends(get_host_runtime),
) -> _SchemaResponse[None]:
"""
保留 Dev 更新入口;Release 更新必须使用后台下载与确认安装流程。
"""
result = runtime.system.upgrade(mode)
return _SchemaResponse(success=result.success, message=result.message or "")
@router.get(
"/update/status",
summary="查询系统更新状态",
response_model=_SchemaResponse[_SchemaSystemUpdateStatus],
)
def system_update_status(
_: ApiPrincipal = Depends(get_current_active_superuser),
runtime: HostRuntime = Depends(get_host_runtime),
) -> _SchemaResponse[_SchemaSystemUpdateStatus]:
"""
返回后台检查、下载或待安装状态(仅管理员)。
"""
return _SchemaResponse(success=True, data=runtime.system.update_status())
@router.post(
"/update/check",
summary="立即检查系统更新",
response_model=_SchemaResponse[_SchemaSystemUpdateStatus],
)
def check_system_update(
_: ApiPrincipal = Depends(get_current_active_superuser),
runtime: HostRuntime = Depends(get_host_runtime),
) -> _SchemaResponse[_SchemaSystemUpdateStatus]:
"""
查询最高正式 V3 版本和站点资源,可刷新旧待安装目标(仅管理员)。
"""
return _SchemaResponse(success=True, data=runtime.system.check_update())
@router.post(
"/update/download",
summary="后台下载系统更新",
response_model=_SchemaResponse[_SchemaSystemUpdateStatus],
)
def download_system_update(
_: ApiPrincipal = Depends(get_current_active_superuser),
runtime: HostRuntime = Depends(get_host_runtime),
request: Annotated[_SchemaSystemUpdateRequest | None, Body()] = None,
) -> _SchemaResponse[_SchemaSystemUpdateStatus]:
"""
单类或合并下载更新并立即返回状态;all 准备全部最新制品(仅管理员)。
"""
result = runtime.system.download_update(request.target if request else "application")
return _SchemaResponse(success=result.success, data=result.data, message=result.message or "")
@router.post(
"/update/install",
summary="确认重启安装系统更新",
response_model=_SchemaResponse[None],
)
def install_system_update(
_: ApiPrincipal = Depends(get_current_active_superuser),
runtime: HostRuntime = Depends(get_host_runtime),
request: Annotated[_SchemaSystemUpdateRequest | None, Body()] = None,
) -> _SchemaResponse[None]:
"""
校验所选制品后重启安装;all 要求全部更新就绪并只重启一次(仅管理员)。
"""
result = runtime.system.install_update(request.target if request else "application")
return _SchemaResponse(success=result.success, message=result.message or "")
@router.get("/runscheduler", summary="运行服务", response_model=_SchemaResponse[None])
def run_scheduler(jobid: str, _: ApiPrincipal = Depends(get_current_active_superuser)) -> _SchemaResponse[None]:
"""
执行命令(仅管理员)
"""
if not jobid:
return _SchemaResponse(success=False, message="命令不能为空!")
if jobid in {"recommend_refresh", "cookiecloud"}:
get_scheduler().start(jobid, manual=True)
else:
get_scheduler().start(jobid)
return _SchemaResponse(success=True)
@router.get("/runscheduler2", summary="运行服务(API_TOKEN)", response_model=_SchemaResponse[None])
def run_scheduler2(jobid: str, _: Annotated[str, Depends(verify_apitoken)]) -> _SchemaResponse[None]:
"""
执行命令(API_TOKEN认证)
"""
if not jobid:
return _SchemaResponse(success=False, message="命令不能为空!")
if jobid in {"recommend_refresh", "cookiecloud"}:
get_scheduler().start(jobid, manual=True)
else:
get_scheduler().start(jobid)
return _SchemaResponse(success=True)
# 设置实现由独立模块维护,路由仍挂载到原有 system 入口。
router.routes.extend(system_settings_router.routes)