refactor(api): standardize responses and restore media config contracts

This commit is contained in:
jxxghp
2026-08-12 23:15:13 +08:00
parent 0ac641c05d
commit baf8850d63
100 changed files with 4422 additions and 1286 deletions

View File

@@ -54,7 +54,8 @@ class UpdateCustomIdentifiersTool(MoviePilotTool):
"4) Combined: '被替换词 => 替换词 && 前定位词 <> 后定位词 >> EP±N'; "
"Lines starting with '#' are comments. "
"The replacement target supports: "
"{[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]}; "
"{[tmdbid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]}; "
"tmdbid may be replaced with doubanid, bangumiid, or anilistid; "
"g is an optional episode group ID for TV recognition."
)
require_admin: bool = True

View File

@@ -1,7 +0,0 @@
from fastapi import APIRouter
from app.api.apiv1 import api_router
api_router_v2 = APIRouter()
api_router_v2.include_router(api_router)

View File

@@ -1,224 +0,0 @@
import json
from typing import Any, Awaitable, Callable
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response as StarletteResponse
from app.schemas.response import Response
API_V2_STR = "/api/v2"
OPENAPI_V2_PATH = f"{API_V2_STR}/openapi.json"
_PROTOCOL_PREFIXES = ("/openai", "/anthropic", "/mcp")
_JSON_CONTENT_TYPES = ("application/json", "+json")
def _is_protocol_path(path: str) -> bool:
"""判断路径是否属于需要保留原始协议响应的接口。"""
relative_path = path.removeprefix(API_V2_STR)
return any(
relative_path == prefix or relative_path.startswith(f"{prefix}/")
for prefix in _PROTOCOL_PREFIXES
)
def _is_json_response(response: StarletteResponse) -> bool:
"""判断响应是否为可安全解析的 JSON 响应。"""
content_type = response.headers.get("content-type", "").split(";", 1)[0]
return any(
content_type == accepted_type or content_type.endswith(accepted_type)
for accepted_type in _JSON_CONTENT_TYPES
)
def _is_response_payload(payload: Any) -> bool:
"""判断响应内容是否已经符合通用 Response 结构。"""
return isinstance(payload, dict) and {
"success",
"message",
"data",
}.issubset(payload)
def _get_error_message(payload: Any) -> str:
"""从旧版错误响应中提取统一的错误消息。"""
if isinstance(payload, dict):
detail = payload.get("detail")
if isinstance(detail, str) and detail:
return detail
if isinstance(detail, list):
messages = [
item.get("msg")
for item in detail
if isinstance(item, dict) and isinstance(item.get("msg"), str)
]
if messages:
return "; ".join(messages)
if detail is not None:
return json.dumps(detail, ensure_ascii=False)
message = payload.get("message")
if isinstance(message, str) and message:
return message
if isinstance(payload, str) and payload:
return payload
return "请求失败"
def _copy_response_headers(source: StarletteResponse, target: StarletteResponse) -> None:
"""复制适配前响应中仍然有效的头信息。"""
for key, value in source.raw_headers:
if key.lower() not in {b"content-length", b"content-type"}:
target.raw_headers.append((key, value))
def _restore_response_body(
source: StarletteResponse,
body: bytes,
) -> StarletteResponse:
"""在检查响应体后恢复原始响应内容和头信息。"""
restored_response = StarletteResponse(
content=body,
status_code=source.status_code,
background=source.background,
)
restored_response.raw_headers = list(source.raw_headers)
return restored_response
class V2ResponseMiddleware(BaseHTTPMiddleware):
"""
为 v2 REST 接口适配统一的 Response 响应结构。
已经返回项目 Response 模型的成功响应保持原样,避免改变既有接口语义;
OpenAI、Anthropic 和 MCP 协议接口也保持原始协议响应。
"""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[StarletteResponse]],
) -> StarletteResponse:
"""处理 v2 请求并在必要时封装 JSON 响应。"""
response = await call_next(request)
if not request.url.path.startswith(f"{API_V2_STR}/"):
return response
if request.url.path == OPENAPI_V2_PATH:
return response
if _is_protocol_path(request.url.path):
return response
if response.status_code in {204, 304} or not _is_json_response(response):
return response
if response.headers.get("content-encoding"):
return response
route = request.scope.get("route")
route_response_model = getattr(route, "response_model", None)
if response.status_code < 400 and route_response_model is Response:
return response
body = b"".join([chunk async for chunk in response.body_iterator])
if not body:
return _restore_response_body(response, body)
try:
payload = json.loads(body)
except (TypeError, ValueError):
return _restore_response_body(response, body)
if _is_response_payload(payload):
return _restore_response_body(response, body)
if response.status_code >= 400:
content = {
"success": False,
"message": _get_error_message(payload),
"data": {},
}
if isinstance(payload, dict) and isinstance(payload.get("detail_i18n"), str):
content["message_i18n"] = payload["detail_i18n"]
else:
content = {
"success": True,
"message": "",
"data": payload,
}
wrapped_response = JSONResponse(
content=content,
status_code=response.status_code,
background=response.background,
)
_copy_response_headers(response, wrapped_response)
return wrapped_response
def configure_v2_openapi(app: FastAPI) -> None:
"""
将 v2 普通 JSON 接口的 OpenAPI 响应模型改为通用 Response。
:param app: 已完成 v1/v2 路由注册的 FastAPI 应用
"""
if getattr(app, "_v2_openapi_configured", False):
return
original_openapi = app.openapi
def custom_openapi() -> dict[str, Any]:
"""生成包含 v2 通用响应模型的 OpenAPI 文档。"""
schema = original_openapi()
components = schema.setdefault("components", {}).setdefault("schemas", {})
components["Response"] = Response.model_json_schema(
ref_template="#/components/schemas/{model}"
)
route_map = {
(route.path, method.lower()): route
for route in app.routes
if isinstance(route, APIRoute)
for method in route.methods
}
response_ref = {"$ref": "#/components/schemas/Response"}
for path, path_item in schema.get("paths", {}).items():
if not path.startswith(f"{API_V2_STR}/"):
continue
for method, operation in path_item.items():
if method not in {
"get",
"post",
"put",
"patch",
"delete",
"options",
"head",
}:
continue
route = route_map.get((path, method))
if (
route is None
or route.response_model is None
or route.response_model is Any
or route.response_model is Response
or _is_protocol_path(path)
):
continue
if route.status_code in {204, 304}:
continue
content_type = getattr(route.response_class, "media_type", None)
if content_type and not (
content_type == "application/json" or content_type.endswith("+json")
):
continue
status_code = str(route.status_code or 200)
response = operation.get("responses", {}).get(status_code)
if response and "content" in response:
json_content = response["content"].get("application/json")
if json_content is not None:
json_content["schema"] = response_ref
app.openapi_schema = schema
return schema
app.openapi = custom_openapi
app._v2_openapi_configured = True

View File

@@ -13,12 +13,13 @@ from pathlib import Path
from threading import Lock
from typing import Any, AsyncIterator, Callable, Optional, Union
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
from fastapi import Depends, File, Form, HTTPException, Request, UploadFile, status
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.api.response import ResponseAPIRouter
from app.agent import MoviePilotAgent, ReplyMode, StreamingHandler, agent_manager
from app.agent.llm.capability import AgentCapabilityManager
from app.agent.mcp import agent_mcp_manager
@@ -40,7 +41,7 @@ from app.helper.locale import LocaleHelper
from app.log import logger
from app.schemas.types import EventType, MessageChannel
router = APIRouter()
router = ResponseAPIRouter()
WEB_AGENT_SESSION_PREFIX = "web-agent:"
WEB_AGENT_SOURCE = "web-agent"
@@ -169,7 +170,11 @@ def _ensure_superuser(user: User) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
@router.get("/mcp/servers", summary="查询 Agent MCP 服务器配置", response_model=schemas.Response)
@router.get(
"/mcp/servers",
summary="查询 Agent MCP 服务器配置",
response_model=schemas.Response[schemas.AgentMcpServerListData],
)
async def list_agent_mcp_servers(
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
@@ -189,7 +194,11 @@ async def list_agent_mcp_servers(
)
@router.post("/mcp/servers", summary="保存 Agent MCP 服务器配置", response_model=schemas.Response)
@router.post(
"/mcp/servers",
summary="保存 Agent MCP 服务器配置",
response_model=schemas.Response[None],
)
async def save_agent_mcp_servers(
request: schemas.AgentMcpServersSaveRequest,
current_user: User = Depends(get_current_active_user),
@@ -205,7 +214,11 @@ async def save_agent_mcp_servers(
)
@router.post("/mcp/servers/test", summary="测试 Agent MCP 服务器", response_model=schemas.Response)
@router.post(
"/mcp/servers/test",
summary="测试 Agent MCP 服务器",
response_model=schemas.Response[schemas.AgentMcpServerTestResult],
)
async def test_agent_mcp_server(
request: schemas.AgentMcpServerTestRequest,
current_user: User = Depends(get_current_active_user),
@@ -1574,7 +1587,22 @@ def _split_web_agent_output(text: str) -> list[dict]:
return events
@router.get("/file/{file_id}", summary="下载 Web 智能助手附件")
@router.get(
"/file/{file_id}",
summary="下载 Web 智能助手附件",
response_model=None,
response_class=FileResponse,
responses={
200: {
"description": "Agent 附件文件",
"content": {
"application/octet-stream": {
"schema": {"type": "string", "format": "binary"}
}
},
}
},
)
async def download_web_agent_file(file_id: str) -> FileResponse:
"""
下载 Web 智能助手本轮生成的临时附件。
@@ -1599,7 +1627,11 @@ async def download_web_agent_file(file_id: str) -> FileResponse:
)
@router.post("/upload", summary="上传 Web 智能助手附件", response_model=schemas.Response)
@router.post(
"/upload",
summary="上传 Web 智能助手附件",
response_model=schemas.Response[schemas.AgentChatUploadAttachment],
)
async def upload_web_agent_file(
file: UploadFile = File(...),
session_id: Optional[str] = Form(None),
@@ -1639,7 +1671,11 @@ async def upload_web_agent_file(
return schemas.Response(success=True, data=attachment)
@router.post("/callback", summary="Web 智能助手按钮回调", response_model=schemas.Response)
@router.post(
"/callback",
summary="Web 智能助手按钮回调",
response_model=schemas.Response[schemas.AgentWebCallbackData],
)
async def web_agent_callback(
payload: schemas.AgentWebChoiceRequest,
current_user: User = Depends(get_current_active_user),
@@ -1673,7 +1709,11 @@ async def web_agent_callback(
return schemas.Response(success=True, data=result)
@router.get("/commands", summary="获取 Web 智能助手可用命令", response_model=schemas.Response)
@router.get(
"/commands",
summary="获取 Web 智能助手可用命令",
response_model=schemas.Response[list[schemas.AgentWebCommandInfo]],
)
async def list_web_agent_commands(
current_user: User = Depends(get_current_active_user),
) -> schemas.Response:
@@ -1689,7 +1729,11 @@ async def list_web_agent_commands(
return schemas.Response(success=True, data=_build_web_agent_command_items())
@router.get("/sessions", summary="获取 Agent 历史会话", response_model=schemas.Response)
@router.get(
"/sessions",
summary="获取 Agent 历史会话",
response_model=schemas.Response[list[schemas.AgentChatSessionSummary]],
)
async def list_agent_chat_sessions(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_async_db),
@@ -1719,7 +1763,11 @@ async def list_agent_chat_sessions(
)
@router.get("/sessions/{session_id}", summary="获取 Agent 历史会话详情", response_model=schemas.Response)
@router.get(
"/sessions/{session_id}",
summary="获取 Agent 历史会话详情",
response_model=schemas.Response[schemas.AgentChatSessionDetail],
)
async def get_agent_chat_session(
session_id: str,
current_user: User = Depends(get_current_active_user),
@@ -1757,7 +1805,11 @@ async def get_agent_chat_session(
return schemas.Response(success=True, data=data)
@router.put("/sessions/{session_id}/display", summary="保存 Agent 展示会话", response_model=schemas.Response)
@router.put(
"/sessions/{session_id}/display",
summary="保存 Agent 展示会话",
response_model=schemas.Response[schemas.AgentChatSessionSummary],
)
async def save_agent_chat_display(
session_id: str,
payload: schemas.AgentChatDisplaySaveRequest,
@@ -1795,7 +1847,11 @@ async def save_agent_chat_display(
return schemas.Response(success=True, data=AgentChatOper.to_summary(chat))
@router.delete("/sessions/{session_id}", summary="删除 Agent 历史会话", response_model=schemas.Response)
@router.delete(
"/sessions/{session_id}",
summary="删除 Agent 历史会话",
response_model=schemas.Response[None],
)
async def delete_agent_chat_session(
session_id: str,
current_user: User = Depends(get_current_active_user),
@@ -1817,7 +1873,11 @@ async def delete_agent_chat_session(
return schemas.Response(success=deleted, message="删除成功" if deleted else "删除失败")
@router.post("/sessions/{session_id}/stop", summary="停止 Web 智能助手当前任务", response_model=schemas.Response)
@router.post(
"/sessions/{session_id}/stop",
summary="停止 Web 智能助手当前任务",
response_model=schemas.Response[schemas.AgentSessionStopData],
)
async def stop_web_agent_session_task(
session_id: str,
current_user: User = Depends(get_current_active_user),
@@ -1848,7 +1908,18 @@ async def stop_web_agent_session_task(
)
@router.post("/stream", summary="Web智能助手流式对话")
@router.post(
"/stream",
summary="Web智能助手流式对话",
response_model=None,
response_class=StreamingResponse,
responses={
200: {
"description": "Agent SSE 事件流",
"content": {"text/event-stream": {"schema": {"type": "string"}}},
}
},
)
async def web_agent_stream(
payload: schemas.AgentWebChatRequest,
request: Request,

View File

@@ -1,13 +1,14 @@
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query
from fastapi import Depends, Query
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.anilist import AniListChain
from app.core.context import MediaInfo
from app.core.security import verify_token
router = APIRouter()
router = ResponseAPIRouter()
PageParam = Annotated[int, Query(ge=1)]
CountParam = Annotated[int, Query(ge=1, le=50)]

View File

@@ -20,7 +20,15 @@ from app.core.config import settings
from app.core.security import anthropic_api_key_header
from app.schemas.types import MessageChannel
router = APIRouter()
ANTHROPIC_ERROR_RESPONSES = {
400: {"model": schemas.AnthropicErrorResponse, "description": "请求格式错误"},
401: {"model": schemas.AnthropicErrorResponse, "description": "认证失败"},
422: {"model": schemas.AnthropicErrorResponse, "description": "请求参数校验失败"},
500: {"model": schemas.AnthropicErrorResponse, "description": "服务内部错误"},
503: {"model": schemas.AnthropicErrorResponse, "description": "AI Agent 不可用"},
}
router = APIRouter(responses=ANTHROPIC_ERROR_RESPONSES)
SESSION_PREFIX = "anthropic:"
@@ -100,6 +108,14 @@ async def _stream_anthropic_response(
"/messages",
summary="Anthropic compatible messages",
response_model=schemas.AnthropicMessagesResponse,
responses={
200: {
"description": "Anthropic message 或 SSE 数据流",
"content": {
"text/event-stream": {"schema": {"type": "string"}},
},
}
},
)
async def messages(
payload: schemas.AnthropicMessagesRequest,

View File

@@ -1,15 +1,16 @@
from typing import Any
from fastapi import APIRouter, HTTPException
from fastapi import HTTPException
from pydantic import BaseModel
from app import schemas
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
from app.core.auth import build_token_response, consume_plugin_auth_ticket
from app.core.plugin import PluginManager
from app.db.models.passkey import PassKey
from app.db.models.user import User
router = APIRouter()
router = ResponseAPIRouter()
class AuthExchangeRequest(BaseModel):
@@ -39,7 +40,11 @@ def _system_auth_providers() -> list[dict[str, Any]]:
]
@router.get("/providers", summary="查询登录认证提供方", response_model=list[dict])
@router.get(
"/providers",
summary="查询登录认证提供方",
response_model=list[schemas.AuthProviderInfo],
)
def auth_providers() -> list[dict[str, Any]]:
"""
查询系统和插件提供的登录认证入口。
@@ -51,7 +56,12 @@ def auth_providers() -> list[dict[str, Any]]:
return [provider for provider in providers if provider.get("enabled", True)]
@router.post("/exchange", summary="兑换插件认证登录票据", response_model=schemas.Token)
@router.post(
"/exchange",
summary="兑换插件认证登录票据",
response_model=schemas.Token,
openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True},
)
def auth_exchange(body: AuthExchangeRequest) -> schemas.Token:
"""
将插件认证成功后生成的一次性票据兑换为系统 Token。

View File

@@ -1,13 +1,14 @@
from typing import List, Any, Optional
from fastapi import APIRouter, Depends
from fastapi import Depends
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.bangumi import BangumiChain
from app.core.context import MediaInfo
from app.core.security import verify_token
router = APIRouter()
router = ResponseAPIRouter()
@router.get(

View File

@@ -1,10 +1,11 @@
from pathlib import Path
from typing import Any, List, Optional, Annotated
from fastapi import APIRouter, Depends
from fastapi import Depends
from sqlalchemy.orm import Session
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.dashboard import DashboardChain
from app.chain.storage import StorageChain
from app.core.config import settings
@@ -16,7 +17,7 @@ from app.helper.directory import DirectoryHelper
from app.scheduler import Scheduler
from app.utils.system import SystemUtils
router = APIRouter()
router = ResponseAPIRouter()
def _build_statistic(db: Session, name: Optional[str] = None) -> schemas.Statistic:
@@ -186,7 +187,7 @@ async def schedule(_: Any = Depends(get_current_active_superuser)) -> Any:
@router.get(
"/schedule/{job_id}/progress",
summary="后台服务进度",
response_model=schemas.Response,
response_model=schemas.Response[schemas.ScheduleProgress],
)
async def schedule_progress(
job_id: str, _: Any = Depends(get_current_active_superuser)
@@ -215,7 +216,7 @@ async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
@router.get(
"/schedule2/{job_id}/progress",
summary="后台服务进度API_TOKEN",
response_model=schemas.Response,
response_model=schemas.Response[schemas.ScheduleProgress],
)
async def schedule_progress2(
job_id: str, _: Annotated[str, Depends(verify_apitoken)]

View File

@@ -1,8 +1,9 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends
from fastapi import Depends
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.bangumi import BangumiChain
from app.chain.douban import DoubanChain
from app.chain.tmdb import TmdbChain
@@ -11,7 +12,7 @@ from app.core.security import verify_token
from app.schemas import DiscoverSourceEventData
from app.schemas.types import ChainEventType, MediaType
router = APIRouter()
router = ResponseAPIRouter()
@router.get(

View File

@@ -1,14 +1,15 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends
from fastapi import Depends
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.douban import DoubanChain
from app.core.context import MediaInfo
from app.core.security import verify_token
from app.schemas import MediaType
router = APIRouter()
router = ResponseAPIRouter()
@router.get(

View File

@@ -1,8 +1,9 @@
from typing import Any, List, Annotated, Optional, Union
from fastapi import APIRouter, Depends, Body
from fastapi import Depends, Body
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
@@ -24,7 +25,7 @@ from app.schemas.types import (
from app.utils.media import is_music_media_source, normalize_music_type
from app.utils.security import SecurityUtils
router = APIRouter()
router = ResponseAPIRouter()
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
@@ -62,7 +63,11 @@ def current(
return DownloadChain().downloading(name)
@router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response)
@router.post(
"/",
summary="添加下载(含媒体信息)",
response_model=schemas.Response[schemas.DownloadAddedData],
)
def download(
media_in: Union[schemas.MusicInfo, schemas.MediaInfo],
torrent_in: schemas.TorrentInfo,
@@ -102,7 +107,9 @@ def download(
@router.post(
"/add", summary="添加下载(不含媒体信息)", response_model=schemas.Response
"/add",
summary="添加下载(不含媒体信息)",
response_model=schemas.Response[schemas.DownloadAddedData],
)
def add(
torrent_in: schemas.TorrentInfo,
@@ -185,7 +192,11 @@ def add(
return schemas.Response(success=True, data={"download_id": did})
@router.post("/subtitle", summary="下载字幕", response_model=schemas.Response)
@router.post(
"/subtitle",
summary="下载字幕",
response_model=schemas.Response[schemas.SubtitleDownloadData],
)
def download_subtitle(
subtitle_in: schemas.SubtitleInfo,
media_source: Annotated[MediaSource, Body()],
@@ -216,7 +227,7 @@ def download_subtitle(
)
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response)
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response[None])
def start(
hashString: str,
name: Optional[str] = None,
@@ -229,7 +240,7 @@ def start(
return schemas.Response(success=True if ret else False)
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response)
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response[None])
def stop(
hashString: str,
name: Optional[str] = None,
@@ -242,7 +253,11 @@ def stop(
return schemas.Response(success=True if ret else False)
@router.get("/clients", summary="查询可用下载器", response_model=List[dict])
@router.get(
"/clients",
summary="查询可用下载器",
response_model=List[schemas.ServiceClientInfo],
)
async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询可用下载器
@@ -282,7 +297,7 @@ def paths(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
]
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response[None])
def delete(
hashString: str,
name: Optional[str] = None,

View File

@@ -3,11 +3,12 @@ import time
from pathlib import Path
from typing import List, Any, Optional
from fastapi import APIRouter, Depends
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.api.response import ResponseAPIRouter
from app.agent import ReplyMode, agent_manager
from app.agent.prompt.transfer_redo import (
build_batch_manual_redo_prompt,
@@ -30,7 +31,7 @@ from app.helper.progress import ProgressHelper
from app.schemas.types import EventType
from app.utils.jieba import cut as jieba_cut
router = APIRouter()
router = ResponseAPIRouter()
def normalize_history_ids(history_ids: list[int]) -> list[int]:
@@ -145,7 +146,11 @@ async def download_history(
return await DownloadHistory.async_list_by_page(db, page, count)
@router.delete("/download", summary="删除下载历史记录", response_model=schemas.Response)
@router.delete(
"/download",
summary="删除下载历史记录",
response_model=schemas.Response[None],
)
async def delete_download_history(
history_in: schemas.DownloadHistory,
db: AsyncSession = Depends(get_async_db),
@@ -166,7 +171,11 @@ def _glob_to_like(pattern: str) -> str:
return result.replace("*", "%").replace("?", "_")
@router.get("/transfer", summary="查询整理记录", response_model=schemas.Response)
@router.get(
"/transfer",
summary="查询整理记录",
response_model=schemas.Response[schemas.TransferHistoryPage],
)
async def transfer_history(
title: Optional[str] = None,
page: Optional[int] = 1,
@@ -218,7 +227,7 @@ async def transfer_history(
)
@router.delete("/transfer", summary="删除整理记录", response_model=schemas.Response)
@router.delete("/transfer", summary="删除整理记录", response_model=schemas.Response[None])
def delete_transfer_history(
history_in: schemas.TransferHistory,
deletesrc: Optional[bool] = False,
@@ -260,7 +269,7 @@ def delete_transfer_history(
@router.post(
"/transfer/{history_id}/ai-redo",
summary="智能助手重新整理",
response_model=schemas.Response,
response_model=schemas.Response[schemas.ProgressKeyData],
)
def ai_redo_transfer_history(
history_id: int,
@@ -289,7 +298,9 @@ def ai_redo_transfer_history(
@router.post(
"/transfer/ai-redo", summary="智能助手批量重新整理", response_model=schemas.Response
"/transfer/ai-redo",
summary="智能助手批量重新整理",
response_model=schemas.Response[schemas.BatchProgressKeyData],
)
def batch_ai_redo_transfer_history(
payload: schemas.BatchTransferHistoryRedoRequest,
@@ -336,7 +347,11 @@ def batch_ai_redo_transfer_history(
)
@router.get("/empty/transfer", summary="清空整理记录", response_model=schemas.Response)
@router.get(
"/empty/transfer",
summary="清空整理记录",
response_model=schemas.Response[None],
)
async def empty_transfer_history(
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async),

View File

@@ -1,11 +1,12 @@
import re
from typing import Annotated, Optional
from fastapi import APIRouter, Body, Depends, Request
from fastapi import Body, Depends, Request, Response
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from app import schemas
from app.api.response import ResponseAPIRouter
from app.agent.llm import (
LLMHelper,
LLMProviderManager,
@@ -20,7 +21,7 @@ from app.db.user_oper import (
)
from app.log import logger
router = APIRouter()
router = ResponseAPIRouter()
class LlmTestRequest(BaseModel):
@@ -85,7 +86,11 @@ def _sanitize_llm_error(message: str, api_key: Optional[str] = None) -> str:
return sanitized
@router.get("/models", summary="获取LLM模型列表", response_model=schemas.Response)
@router.get(
"/models",
summary="获取LLM模型列表",
response_model=schemas.Response[schemas.LLMModelCatalogData],
)
async def get_llm_models(
provider: str,
api_key: Optional[str] = None,
@@ -125,7 +130,11 @@ async def get_llm_models(
)
@router.get("/providers", summary="获取LLM提供商目录", response_model=schemas.Response)
@router.get(
"/providers",
summary="获取LLM提供商目录",
response_model=schemas.Response[list[schemas.LLMProviderInfo]],
)
async def get_llm_providers(
_: User = Depends(get_current_active_user_async),
):
@@ -142,7 +151,7 @@ async def get_llm_providers(
@router.post(
"/provider-auth/start",
summary="启动LLM提供商授权",
response_model=schemas.Response,
response_model=schemas.Response[schemas.LLMProviderAuthSession],
)
async def start_llm_provider_auth(
payload: LlmProviderAuthStartRequest,
@@ -173,7 +182,7 @@ async def start_llm_provider_auth(
@router.get(
"/provider-auth/{session_id}",
summary="获取LLM提供商授权会话状态",
response_model=schemas.Response,
response_model=schemas.Response[schemas.LLMProviderAuthSession],
)
async def get_llm_provider_auth_session(
session_id: str,
@@ -192,7 +201,7 @@ async def get_llm_provider_auth_session(
@router.post(
"/provider-auth/{session_id}/poll",
summary="轮询LLM提供商授权会话",
response_model=schemas.Response,
response_model=schemas.Response[schemas.LLMProviderAuthSession],
)
async def poll_llm_provider_auth_session(
session_id: str,
@@ -211,7 +220,7 @@ async def poll_llm_provider_auth_session(
@router.delete(
"/provider-auth/{provider_id}",
summary="断开LLM提供商授权",
response_model=schemas.Response,
response_model=schemas.Response[None],
)
async def delete_llm_provider_auth(
provider_id: str,
@@ -230,8 +239,15 @@ async def delete_llm_provider_auth(
@router.get(
"/provider-auth/callback/{provider_id}",
summary="LLM提供商OAuth回调",
response_class=HTMLResponse,
response_class=Response,
name="llm_provider_auth_callback",
response_model=None,
responses={
200: {
"description": "OAuth 授权结果页面",
"content": {"text/html": {"schema": {"type": "string"}}},
}
},
)
async def llm_provider_auth_callback(
provider_id: str,
@@ -253,7 +269,11 @@ async def llm_provider_auth_callback(
return HTMLResponse(content=render_auth_result_html(success, message))
@router.post("/test", summary="测试LLM调用", response_model=schemas.Response)
@router.post(
"/test",
summary="测试LLM调用",
response_model=schemas.Response[schemas.LLMTestResult],
)
async def llm_test(
payload: Annotated[Optional[LlmTestRequest], Body()] = None,
_: User = Depends(get_current_active_superuser_async),

View File

@@ -1,11 +1,12 @@
from datetime import timedelta
from typing import Any, List, Annotated
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
from fastapi import Depends, Form, HTTPException, Request, Response
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.responses import JSONResponse
from app import schemas
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
from app.chain.user import MfaRequired, UserChain
from app.core import security
from app.core.config import settings
@@ -14,10 +15,21 @@ from app.helper.sites import SitesHelper # noqa
from app.helper.image import WallpaperHelper
from app.schemas.types import SystemConfigKey
router = APIRouter()
router = ResponseAPIRouter()
@router.post("/access-token", summary="获取token", response_model=schemas.Token)
@router.post(
"/access-token",
summary="获取token",
response_model=schemas.Token,
responses={
401: {
"model": schemas.Response[schemas.MfaChallenge],
"description": "需要二次验证或认证失败",
}
},
openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True},
)
def login_access_token(
request: Request,
response: Response,
@@ -34,12 +46,16 @@ def login_access_token(
if not success:
# 只有密码已经验证通过时才返回 MFA 方法,避免泄露账号安全配置。
if isinstance(user_or_message, MfaRequired):
challenge = schemas.Response[schemas.MfaChallenge](
success=False,
message="需要二次验证",
data=schemas.MfaChallenge(
mfa_methods=list(user_or_message.methods)
),
)
return JSONResponse(
status_code=401,
content={
"detail": "需要二次验证",
"mfa_methods": list(user_or_message.methods),
},
content=challenge.model_dump(mode="json"),
headers={"X-MFA-Required": "true"},
)
raise HTTPException(status_code=401, detail="用户名或密码错误")
@@ -83,7 +99,11 @@ def login_access_token(
)
@router.get("/wallpaper", summary="登录页面电影海报", response_model=schemas.Response)
@router.get(
"/wallpaper",
summary="登录页面电影海报",
response_model=schemas.Response[str],
)
def wallpaper() -> Any:
"""
获取登录页面电影海报

View File

@@ -1,9 +1,10 @@
from typing import List, Any, Dict, Annotated, Union
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import Depends, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from app import schemas
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
from app.agent.tools.manager import moviepilot_tool_manager
from app.core.security import verify_apikey
from app.log import logger
@@ -14,7 +15,7 @@ try:
except ImportError:
APP_VERSION = "unknown"
router = APIRouter()
router = ResponseAPIRouter()
# MCP 协议版本
MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"]
@@ -27,6 +28,15 @@ MCP_HIDDEN_TOOLS = {
"write_file",
"read_file",
}
MCP_JSONRPC_ERROR_RESPONSES = {
400: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 请求错误"},
401: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 认证失败"},
403: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 访问被拒绝"},
404: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 方法不存在"},
409: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 请求冲突"},
422: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 参数校验失败"},
500: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 内部错误"},
}
def list_exposed_tools():
@@ -66,7 +76,24 @@ def create_jsonrpc_error(
return error
@router.post("", summary="MCP JSON-RPC 端点", response_model=None)
@router.post(
"",
summary="MCP JSON-RPC 端点",
response_model=schemas.McpJsonRpcResponse,
openapi_extra={
RAW_RESPONSE_OPENAPI_KEY: True,
"requestBody": {
"required": True,
"content": {
"application/json": {"schema": schemas.MCP_JSONRPC_REQUEST_SCHEMA}
},
},
},
responses={
**MCP_JSONRPC_ERROR_RESPONSES,
204: {"description": "JSON-RPC 通知已接收"},
},
)
async def mcp_jsonrpc(
request: Request, _: Annotated[str, Depends(verify_apikey)] = None
) -> Union[JSONResponse, Response]:
@@ -111,7 +138,9 @@ async def mcp_jsonrpc(
else:
return JSONResponse(
status_code=400,
content={"error": "initialized must be a notification"},
content=create_jsonrpc_error(
request_id, -32600, "initialized must be a notification"
),
)
# 处理工具列表请求
@@ -234,7 +263,17 @@ async def handle_tools_call(params: Dict[str, Any]) -> Dict[str, Any]:
}
@router.delete("", summary="终止 MCP 会话", response_model=None)
@router.delete(
"",
summary="终止 MCP 会话",
status_code=204,
response_class=Response,
response_model=None,
responses={
**MCP_JSONRPC_ERROR_RESPONSES,
204: {"description": "MCP 会话已终止"},
},
)
async def delete_mcp_session(
_: Annotated[str, Depends(verify_apikey)] = None,
) -> Union[JSONResponse, Response]:
@@ -247,7 +286,11 @@ async def delete_mcp_session(
# ==================== 兼容的 RESTful API 端点 ====================
@router.get("/tools", summary="列出所有可用工具", response_model=List[Dict[str, Any]])
@router.get(
"/tools",
summary="列出所有可用工具",
response_model=List[schemas.McpToolInfo],
)
async def list_tools(_: Annotated[str, Depends(verify_apikey)]) -> Any:
"""
获取所有可用的工具列表
@@ -274,7 +317,11 @@ async def list_tools(_: Annotated[str, Depends(verify_apikey)]) -> Any:
raise HTTPException(status_code=500, detail=f"获取工具列表失败: {str(e)}")
@router.post("/tools/call", summary="调用工具", response_model=schemas.ToolCallResponse)
@router.post(
"/tools/call",
summary="调用工具",
response_model=schemas.Response[schemas.ToolCallData],
)
async def call_tool(
request: schemas.ToolCallRequest, _: Annotated[str, Depends(verify_apikey)] = None
) -> Any:
@@ -292,13 +339,20 @@ async def call_tool(
request.tool_name, request.arguments
)
return schemas.ToolCallResponse(success=True, result=result_text)
return schemas.Response(
success=True,
data=schemas.ToolCallData(result=result_text),
)
except Exception as e:
logger.error(f"调用工具 {request.tool_name} 失败: {e}", exc_info=True)
return schemas.ToolCallResponse(success=False, error=f"调用工具失败: {str(e)}")
return schemas.Response(success=False, message="调用工具失败")
@router.get("/tools/{tool_name}", summary="获取工具详情", response_model=Dict[str, Any])
@router.get(
"/tools/{tool_name}",
summary="获取工具详情",
response_model=schemas.McpToolInfo,
)
async def get_tool_info(
tool_name: str, _: Annotated[str, Depends(verify_apikey)]
) -> Any:
@@ -332,7 +386,7 @@ async def get_tool_info(
@router.get(
"/tools/{tool_name}/schema",
summary="获取工具参数Schema",
response_model=Dict[str, Any],
response_model=schemas.McpJsonSchema,
)
async def get_tool_schema(
tool_name: str, _: Annotated[str, Depends(verify_apikey)]

View File

@@ -2,10 +2,11 @@ from pathlib import Path
from typing import Annotated, Any, List, Optional, Union
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from fastapi import Depends, Query
from pydantic import BeforeValidator
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.media import MediaChain
from app.chain.scraping import ScrapingChain
from app.chain.tmdb import TmdbChain
@@ -27,7 +28,7 @@ from app.utils.media import (
resolve_media_identity,
)
router = APIRouter()
router = ResponseAPIRouter()
def _split_media_source_query(value: object) -> tuple[str, ...]:
@@ -235,7 +236,11 @@ async def recognize_file2(
return await recognize_file(path, media_source)
@router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict])
@router.get(
"/search",
summary="搜索媒体/人物信息",
response_model=schemas.MediaSearchResults,
)
async def search(
title: str,
type: Optional[str] = "media",
@@ -316,7 +321,7 @@ async def search(
@router.post(
"/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response
"/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response[None]
)
def scrape(
fileitem: schemas.FileItem,
@@ -426,7 +431,9 @@ def scrape(
@router.get(
"/category/config", summary="获取分类策略配置", response_model=schemas.Response
"/category/config",
summary="获取分类策略配置",
response_model=schemas.Response[schemas.CategoryConfig],
)
def get_category_config(_: User = Depends(get_current_active_user)):
"""
@@ -437,7 +444,7 @@ def get_category_config(_: User = Depends(get_current_active_user)):
@router.post(
"/category/config", summary="保存分类策略配置", response_model=schemas.Response
"/category/config", summary="保存分类策略配置", response_model=schemas.Response[None]
)
def save_category_config(
config: CategoryConfig, _: User = Depends(get_current_active_superuser)
@@ -451,7 +458,11 @@ def save_category_config(
return schemas.Response(success=False, message="保存失败")
@router.get("/category", summary="查询自动分类配置", response_model=dict)
@router.get(
"/category",
summary="查询自动分类配置",
response_model=schemas.MediaCategoryMap,
)
async def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询自动分类配置
@@ -479,7 +490,11 @@ async def group_seasons(
return await TmdbChain().async_tmdb_group_seasons(group_id=normalized_group_id)
@router.get("/groups/{tmdbid}", summary="查询媒体剧集组", response_model=List[dict])
@router.get(
"/groups/{tmdbid}",
summary="查询媒体剧集组",
response_model=List[schemas.MediaEpisodeGroup],
)
async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询媒体剧集组列表themoviedb

View File

@@ -1,9 +1,10 @@
from typing import Any, List, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.download import DownloadChain
from app.chain.mediaserver import MediaServerChain
from app.core.context import MediaInfo
@@ -18,7 +19,7 @@ from app.schemas import MediaType, NotExistMediaInfo
from app.schemas.types import MediaSource, SystemConfigKey
from app.utils.media import build_media_key, resolve_media_identity
router = APIRouter()
router = ResponseAPIRouter()
def _require_mediaserver_result(result: Optional[List[Any]]) -> List[Any]:
@@ -33,7 +34,11 @@ def _require_mediaserver_result(result: Optional[List[Any]]) -> List[Any]:
return result
@router.get("/play/{itemid:path}", summary="在线播放")
@router.get(
"/play/{itemid:path}",
summary="在线播放",
response_model=schemas.Response[schemas.MediaServerPlayData],
)
def play_item(
itemid: str, _: schemas.TokenPayload = Depends(verify_token)
) -> schemas.Response:
@@ -64,7 +69,9 @@ def play_item(
@router.get(
"/exists", summary="查询本地是否存在(数据库)", response_model=schemas.Response
"/exists",
summary="查询本地是否存在(数据库)",
response_model=schemas.Response[schemas.MediaServerExistsData],
)
async def exists_local(
title: Optional[str] = None,
@@ -106,7 +113,7 @@ async def exists_local(
@router.post(
"/exists_remote",
summary="查询已存在的剧集信息(媒体服务器)",
response_model=Dict[int, list],
response_model=schemas.MediaServerExistingEpisodes,
)
def exists(
media_in: schemas.MediaInfo, _: schemas.TokenPayload = Depends(verify_token)
@@ -225,7 +232,11 @@ def library(
)
@router.get("/clients", summary="查询可用媒体服务器", response_model=List[dict])
@router.get(
"/clients",
summary="查询可用媒体服务器",
response_model=List[schemas.ServiceClientInfo],
)
async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询可用媒体服务器

View File

@@ -2,12 +2,13 @@ import json
import time
from typing import Union, Any, List, Optional
from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi import BackgroundTasks, Depends, Request
from pywebpush import WebPushException, webpush
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import PlainTextResponse
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.message import MessageChain
from app.core.config import settings, global_vars
from app.core.security import verify_token, verify_apitoken
@@ -22,7 +23,7 @@ from app.log import logger
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
from app.schemas.types import MessageChannel, SystemConfigKey
router = APIRouter()
router = ResponseAPIRouter()
def _normalize_notification_clear_timestamp(value: Any) -> int:
@@ -69,7 +70,7 @@ def start_message_chain(body: Any, form: Any, args: Any):
MessageChain().process(body=body, form=form, args=args)
@router.post("/", summary="接收用户消息", response_model=schemas.Response)
@router.post("/", summary="接收用户消息", response_model=schemas.Response[None])
async def user_message(
background_tasks: BackgroundTasks,
request: Request,
@@ -109,7 +110,7 @@ async def user_message(
return schemas.Response(success=True)
@router.post("/web", summary="接收WEB消息", response_model=schemas.Response)
@router.post("/web", summary="接收WEB消息", response_model=schemas.Response[None])
async def web_message(
request: Request,
text: Optional[str] = None,
@@ -148,7 +149,7 @@ async def web_message(
return schemas.Response(success=True)
@router.get("/web", summary="获取WEB消息", response_model=List[dict])
@router.get("/web", summary="获取WEB消息", response_model=List[schemas.WebMessageItem])
async def get_web_message(
_: schemas.TokenPayload = Depends(verify_token),
db: AsyncSession = Depends(get_async_db),
@@ -190,7 +191,11 @@ async def get_notification_message(
return [schemas.NotificationHistoryItem(**message.to_dict()) for message in messages]
@router.delete("/notification", summary="清理通知消息", response_model=schemas.Response)
@router.delete(
"/notification",
summary="清理通知消息",
response_model=schemas.Response[schemas.NotificationClearData],
)
async def clear_notification_message(
scope: schemas.NotificationClearScope = schemas.NotificationClearScope.All,
_: schemas.TokenPayload = Depends(verify_token),
@@ -260,7 +265,25 @@ def vocechat_verify() -> Any:
return {"status": "OK"}
@router.get("/", summary="回调请求验证")
@router.get(
"/",
summary="回调请求验证",
response_model=None,
responses={
200: {
"description": "消息平台原生验证响应",
"content": {
"text/plain": {"schema": {"type": "string"}},
"application/json": {
"schema": {
"type": "object",
"properties": {"status": {"type": "string"}},
}
},
},
}
},
)
def incoming_verify(
token: Optional[str] = None,
echostr: Optional[str] = None,
@@ -285,7 +308,7 @@ def incoming_verify(
@router.post(
"/webpush/subscribe",
summary="客户端webpush通知订阅",
response_model=schemas.Response,
response_model=schemas.Response[None],
)
async def subscribe(
subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)
@@ -300,7 +323,7 @@ async def subscribe(
@router.post(
"/webpush/send", summary="发送webpush通知", response_model=schemas.Response
"/webpush/send", summary="发送webpush通知", response_model=schemas.Response[None]
)
def send_notification(
payload: schemas.SubscriptionMessage,

View File

@@ -7,10 +7,11 @@ from datetime import timedelta
from typing import Any, Annotated, Optional
from app.helper.sites import SitesHelper
from fastapi import APIRouter, Depends, HTTPException, Body, Request, Response
from fastapi import Depends, HTTPException, Body, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
from app.core import security
from app.core.config import settings
from app.db import get_async_db
@@ -28,7 +29,7 @@ from app.log import logger
from app.schemas.types import SystemConfigKey
from app.utils.otp import OtpUtils
router = APIRouter()
router = ResponseAPIRouter()
# ==================== 辅助函数 ====================
@@ -117,7 +118,7 @@ class PassKeyDeleteRequest(schemas.BaseModel):
@router.get(
"/status/{username}",
summary="判断用户是否开启二次验证",
response_model=schemas.Response,
response_model=schemas.Response[schemas.MfaStatusData],
)
async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> Any:
"""
@@ -125,19 +126,21 @@ async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) ->
"""
user: User = await User.async_get_by_name(db, username)
if not user:
return schemas.Response(success=False)
return schemas.Response(success=False, message="用户不存在")
# 检查是否启用了OTP
has_otp = user.is_otp
return schemas.Response(success=has_otp)
return schemas.Response(success=True, data={"enabled": bool(has_otp)})
# ==================== OTP 相关接口 ====================
@router.post(
"/otp/generate", summary="生成 OTP 验证 URI", response_model=schemas.Response
"/otp/generate",
summary="生成 OTP 验证 URI",
response_model=schemas.Response[schemas.OtpGenerateData],
)
def otp_generate(
current_user: Annotated[User, Depends(get_current_active_user)],
@@ -147,7 +150,7 @@ def otp_generate(
return schemas.Response(success=secret != "", data={"secret": secret, "uri": uri})
@router.post("/otp/verify", summary="绑定并验证 OTP", response_model=schemas.Response)
@router.post("/otp/verify", summary="绑定并验证 OTP", response_model=schemas.Response[None])
async def otp_verify(
data: OtpVerifyRequest,
db: AsyncSession = Depends(get_async_db),
@@ -163,7 +166,9 @@ async def otp_verify(
@router.post(
"/otp/disable", summary="关闭当前用户的 OTP 验证", response_model=schemas.Response
"/otp/disable",
summary="关闭当前用户的 OTP 验证",
response_model=schemas.Response[None],
)
async def otp_disable(
data: OtpDisableRequest,
@@ -190,7 +195,7 @@ class PassKeyRegistrationStart(schemas.BaseModel):
class PassKeyRegistrationFinish(schemas.BaseModel):
"""PassKey注册完成请求"""
credential: dict
credential: dict[str, schemas.JsonData]
transaction_token: str
name: str = "通行密钥"
@@ -204,14 +209,14 @@ class PassKeyAuthenticationStart(schemas.BaseModel):
class PassKeyAuthenticationFinish(schemas.BaseModel):
"""PassKey认证完成请求"""
credential: dict
credential: dict[str, schemas.JsonData]
transaction_token: str
@router.post(
"/passkey/register/start",
summary="开始注册 PassKey",
response_model=schemas.Response,
response_model=schemas.Response[schemas.PasskeyStartData],
)
def passkey_register_start(
current_user: Annotated[User, Depends(get_current_active_user)],
@@ -251,7 +256,7 @@ def passkey_register_start(
@router.post(
"/passkey/register/finish",
summary="完成注册 PassKey",
response_model=schemas.Response,
response_model=schemas.Response[None],
)
def passkey_register_finish(
passkey_req: PassKeyRegistrationFinish,
@@ -318,7 +323,7 @@ def passkey_register_finish(
@router.post(
"/passkey/authenticate/start",
summary="开始 PassKey 认证",
response_model=schemas.Response,
response_model=schemas.Response[schemas.PasskeyStartData],
)
def passkey_authenticate_start(
passkey_req: PassKeyAuthenticationStart = Body(...),
@@ -364,6 +369,7 @@ def passkey_authenticate_start(
"/passkey/authenticate/finish",
summary="完成 PassKey 认证",
response_model=schemas.Token,
openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True},
)
def passkey_authenticate_finish(
request: Request, response: Response, passkey_req: PassKeyAuthenticationFinish
@@ -453,7 +459,7 @@ def passkey_authenticate_finish(
@router.get(
"/passkey/list",
summary="获取当前用户的 PassKey 列表",
response_model=schemas.Response,
response_model=schemas.Response[list[schemas.PasskeyInfo]],
)
def passkey_list(
current_user: Annotated[User, Depends(get_current_active_user)],
@@ -486,7 +492,11 @@ def passkey_list(
return schemas.Response(success=False, message=f"获取列表失败: {str(e)}")
@router.post("/passkey/delete", summary="删除 PassKey", response_model=schemas.Response)
@router.post(
"/passkey/delete",
summary="删除 PassKey",
response_model=schemas.Response[None],
)
async def passkey_delete(
data: PassKeyDeleteRequest,
current_user: User = Depends(get_current_active_user_async),

View File

@@ -1,8 +1,9 @@
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import Depends, HTTPException, Query
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.media import MediaChain
from app.chain.recommend import RecommendChain
from app.schemas.types import MediaSource, MediaType
@@ -17,7 +18,7 @@ from app.modules.listenbrainz import (
)
from app.modules.musicbrainz.music_cache import MusicBrainzCache
router = APIRouter()
router = ResponseAPIRouter()
CountParam = Annotated[int, Query(ge=1, le=100)]
PageParam = Annotated[int, Query(ge=1)]
@@ -107,7 +108,9 @@ async def recognize_music(
@router.get(
"/cache", summary="查询音乐识别缓存", response_model=schemas.Response
"/cache",
summary="查询音乐识别缓存",
response_model=schemas.Response[schemas.MusicRecognitionCacheData],
)
async def music_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
@@ -129,7 +132,7 @@ async def music_recognition_cache(
@router.delete(
"/cache/{cache_key:path}",
summary="删除指定音乐识别缓存",
response_model=schemas.Response,
response_model=schemas.Response[None],
)
async def delete_music_recognition_cache(
cache_key: str,
@@ -143,7 +146,7 @@ async def delete_music_recognition_cache(
@router.delete(
"/cache", summary="清空音乐识别缓存", response_model=schemas.Response
"/cache", summary="清空音乐识别缓存", response_model=schemas.Response[None]
)
async def clear_music_recognition_cache(
_: User = Depends(get_current_active_superuser_async),

View File

@@ -1,14 +1,15 @@
from typing import Optional
from fastapi import APIRouter, Depends
from fastapi import Depends
from app import schemas
from app.api.response import ResponseAPIRouter
from app.core.module import ModuleManager
from app.db.models import User
from app.db.user_oper import get_current_active_superuser
from app.modules.wechatclawbot.wechatclawbot import WechatClawBot
router = APIRouter()
router = ResponseAPIRouter()
def _build_wechatclawbot_temp_client(
@@ -84,7 +85,7 @@ def _get_wechatclawbot_client(
@router.get(
"/wechatclawbot/status",
summary="查询微信 ClawBot 登录状态",
response_model=schemas.Response,
response_model=schemas.Response[schemas.WechatClawBotData],
)
def wechatclawbot_status(
source: Optional[str] = None,
@@ -121,7 +122,7 @@ def wechatclawbot_status(
@router.post(
"/wechatclawbot/refresh",
summary="刷新微信 ClawBot 二维码",
response_model=schemas.Response,
response_model=schemas.Response[schemas.WechatClawBotData],
)
def refresh_wechatclawbot_qrcode(
source: Optional[str] = None,
@@ -155,7 +156,7 @@ def refresh_wechatclawbot_qrcode(
@router.post(
"/wechatclawbot/logout",
summary="退出微信 ClawBot 登录",
response_model=schemas.Response,
response_model=schemas.Response[schemas.WechatClawBotData],
)
def logout_wechatclawbot(
source: Optional[str] = None,
@@ -189,7 +190,7 @@ def logout_wechatclawbot(
@router.get(
"/wechatclawbot/test",
summary="测试微信 ClawBot 连通性",
response_model=schemas.Response,
response_model=schemas.Response[None],
)
def test_wechatclawbot(
source: Optional[str] = None,
@@ -219,7 +220,7 @@ def test_wechatclawbot(
@router.post(
"/wechatclawbot/migrate",
summary="迁移微信 ClawBot 登录缓存",
response_model=schemas.Response,
response_model=schemas.Response[None],
)
def migrate_wechatclawbot_cache(
old_source: str,

View File

@@ -20,7 +20,15 @@ from app.core.config import settings
from app.core.security import openai_bearer_scheme
from app.schemas.types import MessageChannel
router = APIRouter()
OPENAI_ERROR_RESPONSES = {
400: {"model": schemas.OpenAIErrorResponse, "description": "请求格式错误"},
401: {"model": schemas.OpenAIErrorResponse, "description": "认证失败"},
422: {"model": schemas.OpenAIErrorResponse, "description": "请求参数校验失败"},
500: {"model": schemas.OpenAIErrorResponse, "description": "服务内部错误"},
503: {"model": schemas.OpenAIErrorResponse, "description": "AI Agent 不可用"},
}
router = APIRouter(responses=OPENAI_ERROR_RESPONSES)
MODEL_ID = "moviepilot-agent"
SESSION_PREFIX = "openai:"
@@ -274,6 +282,14 @@ async def list_models(
"/chat/completions",
summary="OpenAI compatible chat completions",
response_model=schemas.OpenAIChatCompletionResponse,
responses={
200: {
"description": "OpenAI chat completion 或 SSE 数据流",
"content": {
"text/event-stream": {"schema": {"type": "string"}},
},
}
},
)
async def chat_completions(
payload: schemas.OpenAIChatCompletionsRequest,

View File

@@ -5,13 +5,13 @@ from typing import Annotated, Any, Dict, List, Optional
import aiofiles
from anyio import Path as AsyncPath
from fastapi import APIRouter, Depends, Header, HTTPException, Security
from fastapi import Depends, Header, HTTPException, Security
from fastapi.concurrency import run_in_threadpool
from starlette import status
from starlette.responses import StreamingResponse
from app import schemas
from app.api.apiv2_utils import API_V2_STR, OPENAPI_V2_PATH
from app.api.response import ResponseAPIRouter
from app.command import Command
from app.core.cache import async_fresh
from app.core.config import settings
@@ -39,15 +39,13 @@ from app.schemas.types import ChainEventType, SystemConfigKey
PROTECTED_ROUTES = {
"/api/v1/openapi.json",
OPENAPI_V2_PATH,
"/docs",
"/docs/oauth2-redirect",
"/redoc",
}
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
PLUGIN_V2_PREFIX = f"{API_V2_STR}/plugin"
router = APIRouter()
router = ResponseAPIRouter()
_plugin_release_refresh_tasks: set[asyncio.Task] = set()
@@ -166,11 +164,8 @@ def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
elif Depends(verify_apikey) not in dependencies:
dependencies.append(Depends(verify_apikey))
app.add_api_route(**api, tags=["plugin"])
v2_api = api.copy()
v2_api["path"] = api_path.replace(PLUGIN_PREFIX, PLUGIN_V2_PREFIX, 1)
app.add_api_route(**v2_api, tags=["plugin"])
is_modified = True
logger.debug(f"Added plugin routes: {api_path}, {v2_api['path']}")
logger.debug(f"Added plugin route: {api_path}")
except Exception as e:
logger.error(f"Error adding plugin route {api_path}: {str(e)}")
@@ -188,12 +183,9 @@ def _remove_routes(plugin_id: str) -> bool:
"""
if not plugin_id:
return False
prefixes = {
f"{PLUGIN_PREFIX}/{plugin_id}/",
f"{PLUGIN_V2_PREFIX}/{plugin_id}/",
}
prefix = f"{PLUGIN_PREFIX}/{plugin_id}/"
routes_to_remove = [
route for route in app.routes if any(route.path.startswith(prefix) for prefix in prefixes)
route for route in app.routes if route.path.startswith(prefix)
]
removed = False
for route in routes_to_remove:
@@ -425,7 +417,11 @@ async def plugin_history(
return plugin
@router.get("/releases/{plugin_id}", summary="获取插件Release版本", response_model=dict)
@router.get(
"/releases/{plugin_id}",
summary="获取插件Release版本",
response_model=schemas.PluginReleaseData,
)
async def plugin_releases(
plugin_id: str,
_: User = Depends(get_current_active_superuser_async),
@@ -484,7 +480,11 @@ async def plugin_releases(
}
@router.get("/statistic", summary="插件安装统计", response_model=dict)
@router.get(
"/statistic",
summary="插件安装统计",
response_model=schemas.JsonObject,
)
async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
插件安装统计
@@ -495,7 +495,7 @@ async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get(
"/rating",
summary="批量查询插件评分",
response_model=Dict[str, schemas.PluginRating],
response_model=schemas.PluginRatingMap,
)
async def plugin_ratings(
plugin_ids: Optional[str] = None,
@@ -531,7 +531,7 @@ async def plugin_rating(
@router.post(
"/rating/{plugin_id}",
summary="提交插件评分",
response_model=schemas.Response,
response_model=schemas.Response[schemas.PluginRating],
)
async def rate_plugin(
plugin_id: str,
@@ -558,7 +558,7 @@ async def rate_plugin(
@router.get(
"/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response
"/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response[None]
)
def reload_plugin(
plugin_id: str, _: User = Depends(get_current_active_superuser)
@@ -573,7 +573,7 @@ def reload_plugin(
return schemas.Response(success=True)
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response[None])
async def install(
plugin_id: str,
repo_url: Optional[str] = "",
@@ -623,7 +623,11 @@ async def install(
return schemas.Response(success=True)
@router.get("/remotes", summary="获取插件联邦组件列表", response_model=List[dict])
@router.get(
"/remotes",
summary="获取插件联邦组件列表",
response_model=List[schemas.PluginRemoteInfo],
)
async def remotes(token: str) -> Any:
"""
获取插件联邦组件列表
@@ -645,7 +649,11 @@ def plugin_sidebar_nav(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
return PluginManager().get_plugin_sidebar_nav()
@router.get("/form/{plugin_id}", summary="获取插件表单页面")
@router.get(
"/form/{plugin_id}",
summary="获取插件表单页面",
response_model=schemas.JsonObject,
)
def plugin_form(
plugin_id: str, _: User = Depends(get_current_active_superuser)
) -> dict:
@@ -677,7 +685,11 @@ def plugin_form(
return {}
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
@router.get(
"/page/{plugin_id}",
summary="获取插件数据页面",
response_model=schemas.JsonObject,
)
def plugin_page(
plugin_id: str, _: User = Depends(get_current_active_superuser)
) -> dict:
@@ -701,7 +713,11 @@ def plugin_page(
return {}
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
@router.get(
"/dashboard/meta",
summary="获取所有插件仪表板元信息",
response_model=List[schemas.PluginDashboardMetaItem],
)
def plugin_dashboard_meta(
_: User = Depends(get_current_active_superuser),
) -> List[dict]:
@@ -737,7 +753,7 @@ def plugin_dashboard(
@router.get(
"/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response
"/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response[None]
)
def reset_plugin(
plugin_id: str, _: User = Depends(get_current_active_superuser)
@@ -761,7 +777,24 @@ def reset_plugin(
return schemas.Response(success=True)
@router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件")
@router.get(
"/file/{plugin_id}/{filepath:path}",
summary="获取插件静态文件",
response_model=None,
response_class=StreamingResponse,
responses={
200: {
"description": "插件静态资源",
"content": {
"application/octet-stream": {
"schema": {"type": "string", "format": "binary"}
},
"application/javascript": {"schema": {"type": "string"}},
"text/css": {"schema": {"type": "string"}},
},
}
},
)
async def plugin_static_file(
plugin_id: str,
filepath: str,
@@ -839,7 +872,11 @@ async def plugin_static_file(
raise HTTPException(status_code=500, detail="Internal Server Error")
@router.get("/folders", summary="获取插件文件夹配置", response_model=dict)
@router.get(
"/folders",
summary="获取插件文件夹配置",
response_model=schemas.PluginFoldersData,
)
async def get_plugin_folders(
_: User = Depends(get_current_active_superuser_async),
) -> dict:
@@ -854,7 +891,7 @@ async def get_plugin_folders(
return {}
@router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response)
@router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response[None])
async def save_plugin_folders(
folders: dict, _: User = Depends(get_current_active_superuser_async)
) -> Any:
@@ -870,7 +907,7 @@ async def save_plugin_folders(
@router.post(
"/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response
"/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response[None]
)
async def create_plugin_folder(
folder_name: str, _: User = Depends(get_current_active_superuser_async)
@@ -890,7 +927,7 @@ async def create_plugin_folder(
@router.delete(
"/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response
"/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response[None]
)
async def delete_plugin_folder(
folder_name: str, _: User = Depends(get_current_active_superuser_async)
@@ -912,7 +949,7 @@ async def delete_plugin_folder(
@router.put(
"/folders/{folder_name}/plugins",
summary="更新文件夹中的插件",
response_model=schemas.Response,
response_model=schemas.Response[None],
)
async def update_folder_plugins(
folder_name: str,
@@ -931,7 +968,7 @@ async def update_folder_plugins(
@router.post(
"/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response
"/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response[None]
)
def clone_plugin(
plugin_id: str, clone_data: dict, _: User = Depends(get_current_active_superuser)
@@ -962,7 +999,11 @@ def clone_plugin(
return schemas.Response(success=False, message=f"创建插件分身失败:{str(e)}")
@router.get("/{plugin_id}", summary="获取插件配置")
@router.get(
"/{plugin_id}",
summary="获取插件配置",
response_model=schemas.JsonObject,
)
async def plugin_config(
plugin_id: str, _: User = Depends(get_current_active_superuser_async)
) -> dict:
@@ -972,7 +1013,7 @@ async def plugin_config(
return PluginManager().get_plugin_config(plugin_id)
@router.put("/{plugin_id}", summary="更新插件配置", response_model=schemas.Response)
@router.put("/{plugin_id}", summary="更新插件配置", response_model=schemas.Response[None])
def set_plugin_config(
plugin_id: str, conf: dict, _: User = Depends(get_current_active_superuser)
) -> Any:
@@ -989,7 +1030,7 @@ def set_plugin_config(
return schemas.Response(success=True)
@router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response)
@router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response[None])
def uninstall_plugin(
plugin_id: str, _: User = Depends(get_current_active_superuser)
) -> Any:

View File

@@ -1,8 +1,9 @@
from typing import Any, Awaitable, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import Depends, HTTPException, status
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.recommend import RecommendChain
from app.core.event import eventmanager
from app.core.security import verify_token
@@ -10,7 +11,7 @@ from app.modules.themoviedb.tmdbv3api.exceptions import TMDbException
from app.schemas import RecommendSourceEventData
from app.schemas.types import ChainEventType
router = APIRouter()
router = ResponseAPIRouter()
async def _require_tmdb_result(operation: Awaitable[List[Any]]) -> List[Any]:

View File

@@ -4,10 +4,11 @@ import time
from typing import Any, AsyncIterator, Iterator, List, Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, Body, Request
from fastapi import Depends, Body, Request
from fastapi.responses import StreamingResponse
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.search import SearchChain
from app.core.security import verify_resource_token, verify_token
from app.helper.locale import LocaleHelper
@@ -16,7 +17,7 @@ from app.schemas.types import MediaSource, MediaType
from app.utils.media import normalize_music_type, resolve_media_identity
from app.utils.security import SecurityUtils
router = APIRouter()
router = ResponseAPIRouter()
_SSE_APPEND_FLUSH_INTERVAL = 1
_SSE_APPEND_MAX_ITEMS = 48
@@ -330,7 +331,11 @@ async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
return [torrent.to_dict() for torrent in torrents]
@router.get("/last/context", summary="查询上次搜索上下文", response_model=schemas.Response)
@router.get(
"/last/context",
summary="查询上次搜索上下文",
response_model=schemas.Response[schemas.SearchLastContextData],
)
async def search_latest_context(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询上次搜索结果及其对应的搜索参数。
@@ -352,7 +357,18 @@ async def search_latest_context(_: schemas.TokenPayload = Depends(verify_token))
)
@router.get("/media/{media_id}/stream", summary="渐进式精确搜索资源")
@router.get(
"/media/{media_id}/stream",
summary="渐进式精确搜索资源",
response_model=None,
response_class=StreamingResponse,
responses={
200: {
"description": "资源搜索 SSE 事件流",
"content": {"text/event-stream": {"schema": {"type": "string"}}},
}
},
)
async def search_by_id_stream(
request: Request,
media_id: str,
@@ -401,7 +417,11 @@ async def search_by_id_stream(
)
@router.get("/media/{media_id}", summary="精确搜索资源", response_model=schemas.Response)
@router.get(
"/media/{media_id}",
summary="精确搜索资源",
response_model=schemas.Response[list[schemas.TorrentInfo]],
)
async def search_by_id(
media_id: str,
media_source: MediaSource,
@@ -440,7 +460,18 @@ async def search_by_id(
)
@router.get("/title/stream", summary="渐进式模糊搜索资源")
@router.get(
"/title/stream",
summary="渐进式模糊搜索资源",
response_model=None,
response_class=StreamingResponse,
responses={
200: {
"description": "资源搜索 SSE 事件流",
"content": {"text/event-stream": {"schema": {"type": "string"}}},
}
},
)
async def search_by_title_stream(
request: Request,
keyword: Optional[str] = None,
@@ -467,7 +498,11 @@ async def search_by_title_stream(
)
@router.get("/title", summary="模糊搜索资源", response_model=schemas.Response)
@router.get(
"/title",
summary="模糊搜索资源",
response_model=schemas.Response[list[schemas.TorrentInfo]],
)
async def search_by_title(
keyword: Optional[str] = None,
mtype: Optional[str] = None,
@@ -492,7 +527,18 @@ async def search_by_title(
)
@router.get("/subtitle/title/stream", summary="渐进式模糊搜索字幕")
@router.get(
"/subtitle/title/stream",
summary="渐进式模糊搜索字幕",
response_model=None,
response_class=StreamingResponse,
responses={
200: {
"description": "字幕搜索 SSE 事件流",
"content": {"text/event-stream": {"schema": {"type": "string"}}},
}
},
)
async def search_subtitle_by_title_stream(
request: Request,
keyword: Optional[str] = None,
@@ -517,7 +563,11 @@ async def search_subtitle_by_title_stream(
)
@router.get("/subtitle/title", summary="模糊搜索字幕", response_model=schemas.Response)
@router.get(
"/subtitle/title",
summary="模糊搜索字幕",
response_model=schemas.Response[list[schemas.SubtitleInfo]],
)
async def search_subtitle_by_title(
keyword: Optional[str] = None,
page: Optional[int] = 0,
@@ -581,7 +631,18 @@ async def _build_subtitle_search_source(
return call_search(**search_params), ""
@router.get("/subtitle/media/{media_id}/stream", summary="渐进式精确搜索字幕")
@router.get(
"/subtitle/media/{media_id}/stream",
summary="渐进式精确搜索字幕",
response_model=None,
response_class=StreamingResponse,
responses={
200: {
"description": "字幕搜索 SSE 事件流",
"content": {"text/event-stream": {"schema": {"type": "string"}}},
}
},
)
async def search_subtitle_by_id_stream(
request: Request,
media_id: str,
@@ -625,7 +686,11 @@ async def search_subtitle_by_id_stream(
)
@router.get("/subtitle/media/{media_id}", summary="精确搜索字幕", response_model=schemas.Response)
@router.get(
"/subtitle/media/{media_id}",
summary="精确搜索字幕",
response_model=schemas.Response[list[schemas.SubtitleInfo]],
)
async def search_subtitle_by_id(
media_id: str,
media_source: MediaSource,
@@ -657,7 +722,11 @@ async def search_subtitle_by_id(
)
@router.post("/recommend", summary="AI推荐资源", response_model=schemas.Response)
@router.post(
"/recommend",
summary="AI推荐资源",
response_model=schemas.Response[schemas.SearchRecommendStatusData],
)
async def recommend_search_results(
filtered_indices: Optional[List[int]] = Body(
None, embed=True, description="筛选后的索引列表"

View File

@@ -1,11 +1,12 @@
from typing import List, Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from starlette.background import BackgroundTasks
from app import schemas
from app.api.response import ResponseAPIRouter
from app.api.endpoints.plugin import register_plugin_api
from app.chain.site import SiteChain
from app.chain.torrents import TorrentsChain
@@ -33,7 +34,7 @@ from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey, EventType, MediaType
from app.utils.string import StringUtils
router = APIRouter()
router = ResponseAPIRouter()
def _indexer_supports_media_type(indexer: dict, media_type: MediaType) -> bool:
@@ -129,7 +130,7 @@ async def read_sites_by_media_type(
]
@router.post("/", summary="新增站点", response_model=schemas.Response)
@router.post("/", summary="新增站点", response_model=schemas.Response[None])
async def add_site(
*,
db: AsyncSession = Depends(get_async_db),
@@ -168,7 +169,7 @@ async def add_site(
return schemas.Response(success=True)
@router.put("/", summary="更新站点", response_model=schemas.Response)
@router.put("/", summary="更新站点", response_model=schemas.Response[None])
async def update_site(
*,
db: AsyncSession = Depends(get_async_db),
@@ -199,7 +200,7 @@ async def update_site(
return schemas.Response(success=True)
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response)
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response[None])
async def cookie_cloud_sync(
background_tasks: BackgroundTasks,
_: User = Depends(get_current_active_superuser_async),
@@ -211,7 +212,7 @@ async def cookie_cloud_sync(
return schemas.Response(success=True, message="CookieCloud同步任务已启动")
@router.get("/reset", summary="重置站点", response_model=schemas.Response)
@router.get("/reset", summary="重置站点", response_model=schemas.Response[None])
def reset(
db: AsyncSession = Depends(get_db), _: User = Depends(get_current_active_superuser)
) -> Any:
@@ -229,7 +230,7 @@ def reset(
@router.post(
"/priorities", summary="批量更新站点优先级", response_model=schemas.Response
"/priorities", summary="批量更新站点优先级", response_model=schemas.Response[None]
)
async def update_sites_priority(
priorities: List[dict],
@@ -281,7 +282,7 @@ def _update_site_cookie(
@router.post(
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response[None]
)
def update_cookie_by_body(
site_id: int,
@@ -302,7 +303,7 @@ def update_cookie_by_body(
@router.get(
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response[None]
)
def update_cookie(
site_id: int,
@@ -325,7 +326,9 @@ def update_cookie(
@router.post(
"/userdata/{site_id}", summary="更新站点用户数据", response_model=schemas.Response
"/userdata/{site_id}",
summary="更新站点用户数据",
response_model=schemas.Response[schemas.SiteUserData],
)
def refresh_userdata(
site_id: int,
@@ -369,7 +372,9 @@ async def read_userdata_latest(
@router.get(
"/userdata/{site_id}", summary="查询某站点用户数据", response_model=schemas.Response
"/userdata/{site_id}",
summary="查询某站点用户数据",
response_model=schemas.Response[list[schemas.SiteUserData]],
)
async def read_userdata(
site_id: int,
@@ -394,7 +399,7 @@ async def read_userdata(
return schemas.Response(success=True, data=[data.to_dict() for data in user_datas])
@router.get("/test/{site_id}", summary="连接测试", response_model=schemas.Response)
@router.get("/test/{site_id}", summary="连接测试", response_model=schemas.Response[None])
def test_site(
site_id: int,
db: Session = Depends(get_db),
@@ -413,7 +418,11 @@ def test_site(
return schemas.Response(success=status, message=message)
@router.get("/icon/{site_id}", summary="站点图标", response_model=schemas.Response)
@router.get(
"/icon/{site_id}",
summary="站点图标",
response_model=schemas.Response[schemas.SiteIconData],
)
async def site_icon(
site_id: int,
db: AsyncSession = Depends(get_async_db),
@@ -576,7 +585,7 @@ async def read_rss_sites(
return rss_sites
@router.get("/auth", summary="查询认证站点", response_model=dict)
@router.get("/auth", summary="查询认证站点", response_model=schemas.JsonObject)
async def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> dict:
"""
获取可认证站点列表
@@ -584,7 +593,7 @@ async def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> di
return SitesHelper().get_authsites()
@router.post("/auth", summary="用户站点认证", response_model=schemas.Response)
@router.post("/auth", summary="用户站点认证", response_model=schemas.Response[None])
def auth_site(
auth_info: schemas.SiteAuth, _: User = Depends(get_current_active_superuser)
) -> Any:
@@ -604,7 +613,9 @@ def auth_site(
@router.get(
"/mapping", summary="获取站点域名到名称的映射", response_model=schemas.Response
"/mapping",
summary="获取站点域名到名称的映射",
response_model=schemas.Response[schemas.SiteMappingData],
)
async def site_mapping(_: User = Depends(get_current_active_superuser_async)):
"""
@@ -620,7 +631,11 @@ async def site_mapping(_: User = Depends(get_current_active_superuser_async)):
return schemas.Response(success=False, message=f"获取映射失败:{str(e)}")
@router.get("/supporting", summary="获取支持的站点列表", response_model=dict)
@router.get(
"/supporting",
summary="获取支持的站点列表",
response_model=schemas.JsonObject,
)
async def support_sites(_: User = Depends(get_current_active_superuser_async)):
"""
获取支持的站点列表
@@ -646,7 +661,7 @@ async def read_site(
return site
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response)
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response[None])
async def delete_site(
site_id: int,
db: AsyncSession = Depends(get_async_db),

View File

@@ -4,10 +4,11 @@ import re
from pathlib import Path
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import Depends, HTTPException
from starlette.responses import FileResponse, Response
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain
@@ -23,10 +24,14 @@ from app.helper.progress import ProgressHelper
from app.schemas.types import ProgressKey
from app.utils.string import StringUtils
router = APIRouter()
router = ResponseAPIRouter()
@router.get("/qrcode/{name}", summary="生成二维码内容", response_model=schemas.Response)
@router.get(
"/qrcode/{name}",
summary="生成二维码内容",
response_model=schemas.Response[schemas.StorageQrCodeData],
)
def qrcode(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
生成二维码
@@ -38,7 +43,9 @@ def qrcode(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get(
"/auth_url/{name}", summary="获取 OAuth2 授权 URL", response_model=schemas.Response
"/auth_url/{name}",
summary="获取 OAuth2 授权 URL",
response_model=schemas.Response[schemas.StorageAuthUrlData],
)
def auth_url(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
@@ -50,7 +57,11 @@ def auth_url(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
return schemas.Response(success=False, message=errmsg)
@router.get("/check/{name}", summary="二维码登录确认", response_model=schemas.Response)
@router.get(
"/check/{name}",
summary="二维码登录确认",
response_model=schemas.Response[schemas.StorageLoginStatusData],
)
def check(
name: str,
ck: Optional[str] = None,
@@ -69,7 +80,7 @@ def check(
return schemas.Response(success=False, message=errmsg)
@router.post("/save/{name}", summary="保存存储配置", response_model=schemas.Response)
@router.post("/save/{name}", summary="保存存储配置", response_model=schemas.Response[None])
def save(name: str, conf: dict, _: User = Depends(get_current_active_superuser)) -> Any:
"""
保存存储配置
@@ -78,7 +89,7 @@ def save(name: str, conf: dict, _: User = Depends(get_current_active_superuser))
return schemas.Response(success=True)
@router.get("/reset/{name}", summary="重置存储配置", response_model=schemas.Response)
@router.get("/reset/{name}", summary="重置存储配置", response_model=schemas.Response[None])
def reset(name: str, _: User = Depends(get_current_active_superuser)) -> Any:
"""
重置存储配置
@@ -114,7 +125,7 @@ def list_files(
return file_list
@router.post("/mkdir", summary="创建目录", response_model=schemas.Response)
@router.post("/mkdir", summary="创建目录", response_model=schemas.Response[None])
def mkdir(
fileitem: schemas.FileItem,
name: str,
@@ -134,7 +145,7 @@ def mkdir(
return schemas.Response(success=False)
@router.post("/delete", summary="删除文件或目录", response_model=schemas.Response)
@router.post("/delete", summary="删除文件或目录", response_model=schemas.Response[None])
def delete(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
@@ -149,7 +160,23 @@ def delete(
return schemas.Response(success=False)
@router.post("/download", summary="下载文件")
@router.post(
"/download",
summary="下载文件",
response_model=None,
response_class=FileResponse,
responses={
200: {
"description": "文件内容",
"content": {
"application/octet-stream": {
"schema": {"type": "string", "format": "binary"}
}
},
},
404: {"model": schemas.Response[None], "description": "文件下载失败"},
},
)
def download(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
@@ -165,7 +192,20 @@ def download(
return schemas.Response(success=False)
@router.post("/image", summary="预览图片")
@router.post(
"/image",
summary="预览图片",
response_model=None,
response_class=Response,
responses={
200: {
"description": "图片内容",
"content": {
"image/jpeg": {"schema": {"type": "string", "format": "binary"}}
},
}
},
)
def image(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
@@ -181,7 +221,7 @@ def image(
return Response(content=tmp_file.read_bytes(), media_type="image/jpeg")
@router.post("/rename", summary="重命名文件或目录", response_model=schemas.Response)
@router.post("/rename", summary="重命名文件或目录", response_model=schemas.Response[None])
def rename(
fileitem: schemas.FileItem,
new_name: str,

View File

@@ -1,11 +1,12 @@
from typing import List, Any, Annotated, Optional
import cn2an
from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header
from fastapi import Request, BackgroundTasks, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.subscribe import SubscribeChain
from app.core.config import settings
from app.core.context import MediaInfo
@@ -32,7 +33,7 @@ from app.schemas.types import (
)
from app.utils.media import normalize_media_source, resolve_media_identity
router = APIRouter()
router = ResponseAPIRouter()
def start_subscribe_add(
@@ -181,7 +182,11 @@ async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
return await Subscribe.async_list()
@router.post("/", summary="新增订阅", response_model=schemas.Response)
@router.post(
"/",
summary="新增订阅",
response_model=schemas.Response[schemas.IdData],
)
async def create_subscribe(
*,
subscribe_in: schemas.Subscribe,
@@ -242,7 +247,7 @@ async def create_subscribe(
return schemas.Response(success=bool(sid), message=message, data={"id": sid})
@router.put("/", summary="更新订阅", response_model=schemas.Response)
@router.put("/", summary="更新订阅", response_model=schemas.Response[None])
async def update_subscribe(
*,
subscribe_in: schemas.Subscribe,
@@ -314,7 +319,7 @@ async def update_subscribe(
return schemas.Response(success=True)
@router.put("/status/{subid}", summary="更新订阅状态", response_model=schemas.Response)
@router.put("/status/{subid}", summary="更新订阅状态", response_model=schemas.Response[None])
async def update_subscribe_status(
subid: int,
state: str,
@@ -367,7 +372,7 @@ async def subscribe_media_identity(
return result if result else Subscribe()
@router.get("/refresh", summary="刷新订阅", response_model=schemas.Response)
@router.get("/refresh", summary="刷新订阅", response_model=schemas.Response[None])
def refresh_subscribes(
current_user: User = Depends(get_current_active_user),
) -> Any:
@@ -380,7 +385,7 @@ def refresh_subscribes(
return schemas.Response(success=True)
@router.get("/reset/{subid}", summary="重置订阅", response_model=schemas.Response)
@router.get("/reset/{subid}", summary="重置订阅", response_model=schemas.Response[None])
async def reset_subscribes(
subid: int,
db: AsyncSession = Depends(get_async_db),
@@ -428,7 +433,7 @@ async def reset_subscribes(
return schemas.Response(success=False, message="订阅不存在")
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response)
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response[None])
def check_subscribes(
current_user: User = Depends(get_current_active_user),
) -> Any:
@@ -441,7 +446,7 @@ def check_subscribes(
return schemas.Response(success=True)
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response)
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response[None])
async def search_subscribes(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_async_db),
@@ -470,7 +475,7 @@ async def search_subscribes(
@router.get(
"/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response
"/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response[None]
)
async def search_subscribe(
subscribe_id: int,
@@ -492,7 +497,7 @@ async def search_subscribe(
return schemas.Response(success=True)
@router.delete("/media/{media_id}", summary="删除订阅", response_model=schemas.Response)
@router.delete("/media/{media_id}", summary="删除订阅", response_model=schemas.Response[None])
async def delete_subscribe_by_media_identity(
media_id: str,
media_source: MediaSource,
@@ -536,7 +541,7 @@ async def delete_subscribe_by_media_identity(
@router.post(
"/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response
"/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response[None]
)
async def seerr_subscribe(
request: Request,
@@ -640,7 +645,7 @@ async def subscribe_history(
@router.delete(
"/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response
"/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response[None]
)
async def delete_subscribe_history(
history_id: int,
@@ -750,7 +755,7 @@ def subscribe_files(
return schemas.SubscrbieInfo()
@router.post("/share", summary="分享订阅", response_model=schemas.Response)
@router.post("/share", summary="分享订阅", response_model=schemas.Response[None])
async def subscribe_share(
sub: schemas.SubscribeShare,
db: AsyncSession = Depends(get_async_db),
@@ -771,7 +776,7 @@ async def subscribe_share(
return schemas.Response(success=state, message=errmsg)
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response)
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response[None])
async def subscribe_share_delete(
share_id: int, _: schemas.TokenPayload = Depends(verify_token)
) -> Any:
@@ -782,7 +787,7 @@ async def subscribe_share_delete(
return schemas.Response(success=state, message=errmsg)
@router.post("/fork", summary="复用订阅", response_model=schemas.Response)
@router.post("/fork", summary="复用订阅", response_model=schemas.Response[None])
async def subscribe_fork(
sub: schemas.SubscribeShare,
current_user: User = Depends(get_current_active_user_async),
@@ -811,7 +816,7 @@ async def followed_subscribers(_: schemas.TokenPayload = Depends(verify_token))
return SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
@router.post("/follow", summary="Follow订阅分享人", response_model=schemas.Response)
@router.post("/follow", summary="Follow订阅分享人", response_model=schemas.Response[None])
async def follow_subscriber(
share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
) -> Any:
@@ -828,7 +833,7 @@ async def follow_subscriber(
@router.delete(
"/follow", summary="取消Follow订阅分享人", response_model=schemas.Response
"/follow", summary="取消Follow订阅分享人", response_model=schemas.Response[None]
)
async def unfollow_subscriber(
share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
@@ -902,7 +907,7 @@ async def read_subscribe(
return subscribe if subscribe else Subscribe()
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response[None])
async def delete_subscribe(
subscribe_id: int,
db: AsyncSession = Depends(get_async_db),

View File

@@ -14,10 +14,11 @@ import anyio
import pillow_avif # noqa 用于自动注册AVIF支持
from anyio import Path as AsyncPath
from app.helper.sites import SitesHelper # noqa # noqa
from fastapi import APIRouter, Body, Depends, HTTPException, Header, Request, Response
from fastapi import Body, Depends, HTTPException, Header, Request, Response
from fastapi.responses import StreamingResponse
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.media import MediaChain
from app.chain.mediaserver import MediaServerChain
from app.chain.search import SearchChain
@@ -58,7 +59,7 @@ from app.utils.security import SecurityUtils
from app.utils.url import UrlUtils
from version import APP_VERSION
router = APIRouter()
router = ResponseAPIRouter()
_NETTEST_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
_PUBLIC_SYSTEM_CONFIG_KEYS = {
@@ -581,7 +582,23 @@ async def fetch_image(
return None
@router.get("/img/{proxy}", summary="图片代理")
@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,
@@ -609,7 +626,23 @@ async def proxy_img(
)
@router.get("/cache/image", summary="图片缓存")
@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,
@@ -624,7 +657,11 @@ async def cache_img(
)
@router.get("/global", summary="查询非敏感系统设置", response_model=schemas.Response)
@router.get(
"/global",
summary="查询非敏感系统设置",
response_model=schemas.Response[schemas.JsonObject],
)
def get_global_setting(token: str):
"""
查询非敏感系统设置(默认鉴权)
@@ -655,7 +692,9 @@ def get_global_setting(token: str):
@router.get(
"/global/user", summary="查询用户相关系统设置", response_model=schemas.Response
"/global/user",
summary="查询用户相关系统设置",
response_model=schemas.Response[schemas.JsonObject],
)
async def get_user_global_setting(_: User = Depends(get_current_active_user_async)):
"""
@@ -692,7 +731,11 @@ async def get_user_global_setting(_: User = Depends(get_current_active_user_asyn
return schemas.Response(success=True, data=info)
@router.get("/env", summary="查询系统配置", response_model=schemas.Response)
@router.get(
"/env",
summary="查询系统配置",
response_model=schemas.Response[schemas.JsonObject],
)
async def get_env_setting(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
@@ -713,7 +756,11 @@ async def get_env_setting(
return schemas.Response(success=True, data=info)
@router.get("/usage/statistic", summary="查询安装版本统计报表", response_model=schemas.Response)
@router.get(
"/usage/statistic",
summary="查询安装版本统计报表",
response_model=schemas.Response[schemas.JsonObject],
)
async def usage_statistic(_: User = Depends(get_current_active_user_async)):
"""
查询安装版本统计报表
@@ -721,7 +768,7 @@ async def usage_statistic(_: User = Depends(get_current_active_user_async)):
return schemas.Response(success=True, data=await MoviePilotServerHelper.async_get_usage_statistic())
@router.get("/ping", summary="服务存活检测", response_model=schemas.Response)
@router.get("/ping", summary="服务存活检测", response_model=schemas.Response[None])
async def ping(_: User = Depends(get_current_active_user_async)) -> schemas.Response:
"""
检测服务是否可用
@@ -729,7 +776,11 @@ async def ping(_: User = Depends(get_current_active_user_async)) -> schemas.Resp
return schemas.Response(success=True)
@router.post("/env", summary="更新系统配置", response_model=schemas.Response)
@router.post(
"/env",
summary="更新系统配置",
response_model=schemas.Response[schemas.SystemEnvironmentUpdateData],
)
async def set_env_setting(
env: dict, _: User = Depends(get_current_active_superuser_async)
):
@@ -768,7 +819,18 @@ async def set_env_setting(
)
@router.get("/progress/{process_type}", summary="实时进度")
@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,
@@ -794,7 +856,11 @@ async def get_progress(
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get("/setting/public/{key}", summary="查询公开系统设置", response_model=schemas.Response)
@router.get(
"/setting/public/{key}",
summary="查询公开系统设置",
response_model=schemas.Response[schemas.ValueData],
)
async def get_public_setting(
key: str, _: User = Depends(get_current_active_user_async)
) -> schemas.Response:
@@ -812,7 +878,7 @@ async def get_public_setting(
@router.post(
"/setting/PLUGIN_MARKET/sync-wiki",
summary="从Wiki同步插件市场仓库",
response_model=schemas.Response,
response_model=schemas.Response[schemas.PluginMarketSyncData],
)
async def sync_plugin_market_from_wiki(
request: Optional[schemas.PluginMarketSyncRequest] = Body(default=None),
@@ -876,7 +942,11 @@ async def sync_plugin_market_from_wiki(
)
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
@router.get(
"/setting/{key}",
summary="查询系统设置",
response_model=schemas.Response[schemas.ValueData],
)
async def get_setting(
key: str, _: User = Depends(get_current_active_superuser_async)
) -> schemas.Response:
@@ -890,7 +960,7 @@ async def get_setting(
return schemas.Response(success=True, data={"value": value})
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response[None])
async def set_setting(
key: str,
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
@@ -926,7 +996,18 @@ async def set_setting(
return schemas.Response(success=False, message=f"配置项 '{key}' 不存在")
@router.get("/message", summary="实时消息")
@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",
@@ -951,7 +1032,21 @@ async def get_message(
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get("/logging", summary="实时日志")
@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,
@@ -1065,7 +1160,22 @@ async def get_logging(
return StreamingResponse(log_generator(), media_type="text/event-stream")
@router.get("/logging/download/{name}", summary="下载日志")
@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,
_: schemas.TokenPayload = Depends(_verify_log_resource_superuser),
@@ -1077,7 +1187,9 @@ async def download_logging(
@router.get(
"/versions", summary="查询Github所有Release版本", response_model=schemas.Response
"/versions",
summary="查询Github所有Release版本",
response_model=schemas.Response[schemas.JsonObjectList],
)
async def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
"""
@@ -1093,7 +1205,11 @@ async def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
return schemas.Response(success=False)
@router.get("/ruletest", summary="过滤规则测试", response_model=schemas.Response)
@router.get(
"/ruletest",
summary="过滤规则测试",
response_model=schemas.Response[schemas.RuleTestData],
)
def ruletest(
title: str,
rulegroup_name: str,
@@ -1165,7 +1281,9 @@ def ruletest(
@router.get(
"/nettest/targets", summary="获取网络测试目标", response_model=schemas.Response
"/nettest/targets",
summary="获取网络测试目标",
response_model=schemas.Response[list[schemas.NetTestTarget]],
)
async def nettest_targets(_: schemas.TokenPayload = Depends(verify_token)):
"""
@@ -1187,7 +1305,11 @@ async def nettest_targets(_: schemas.TokenPayload = Depends(verify_token)):
)
@router.get("/nettest", summary="测试网络连通性")
@router.get(
"/nettest",
summary="测试网络连通性",
response_model=schemas.Response[schemas.TimeData],
)
async def nettest(
target_id: Optional[str] = None,
url: Optional[str] = None,
@@ -1278,7 +1400,9 @@ async def nettest(
@router.get(
"/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response
"/modulelist",
summary="查询已加载的模块ID列表",
response_model=schemas.Response[schemas.SystemModuleListData],
)
def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
"""
@@ -1302,7 +1426,7 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
@router.get(
"/moduletest/{moduleid}", summary="模块可用性测试", response_model=schemas.Response
"/moduletest/{moduleid}", summary="模块可用性测试", response_model=schemas.Response[None]
)
def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):
"""
@@ -1312,7 +1436,7 @@ def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):
return schemas.Response(success=state, message=errmsg)
@router.get("/restart", summary="重启系统", response_model=schemas.Response)
@router.get("/restart", summary="重启系统", response_model=schemas.Response[None])
def restart_system(_: User = Depends(get_current_active_superuser)):
"""
重启系统(仅管理员)
@@ -1323,7 +1447,7 @@ def restart_system(_: User = Depends(get_current_active_superuser)):
return schemas.Response(success=ret, message=msg)
@router.post("/upgrade", summary="升级并重启系统", response_model=schemas.Response)
@router.post("/upgrade", summary="升级并重启系统", response_model=schemas.Response[None])
def upgrade_system(
mode: Annotated[str | None, Body()] = None,
_: User = Depends(get_current_active_superuser),
@@ -1341,7 +1465,7 @@ def upgrade_system(
return schemas.Response(success=ret, message=msg)
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response[None])
def run_scheduler(jobid: str, _: User = Depends(get_current_active_superuser)):
"""
执行命令(仅管理员)
@@ -1356,7 +1480,7 @@ def run_scheduler(jobid: str, _: User = Depends(get_current_active_superuser)):
@router.get(
"/runscheduler2", summary="运行服务API_TOKEN", response_model=schemas.Response
"/runscheduler2", summary="运行服务API_TOKEN", response_model=schemas.Response[None]
)
def run_scheduler2(jobid: str, _: Annotated[str, Depends(verify_apitoken)]):
"""

View File

@@ -1,8 +1,9 @@
from typing import List, Any, Optional
from fastapi import APIRouter, Depends
from fastapi import Depends
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.tmdb import TmdbChain
from app.core.config import settings
from app.core.security import verify_token
@@ -12,11 +13,13 @@ from app.db.user_oper import get_current_active_superuser_async
from app.modules.themoviedb.tmdb_cache import TmdbCache
from app.schemas.types import MediaType, SystemConfigKey
router = APIRouter()
router = ResponseAPIRouter()
@router.get(
"/cache", summary="查询 TheMovieDb 识别缓存", response_model=schemas.Response
"/cache",
summary="查询 TheMovieDb 识别缓存",
response_model=schemas.Response[schemas.TmdbRecognitionCacheData],
)
async def tmdb_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
@@ -42,7 +45,7 @@ async def tmdb_recognition_cache(
@router.delete(
"/cache/{cache_key:path}",
summary="删除指定 TheMovieDb 识别缓存",
response_model=schemas.Response,
response_model=schemas.Response[None],
)
async def delete_tmdb_recognition_cache(
cache_key: str,
@@ -56,7 +59,7 @@ async def delete_tmdb_recognition_cache(
@router.delete(
"/cache", summary="清空 TheMovieDb 识别缓存", response_model=schemas.Response
"/cache", summary="清空 TheMovieDb 识别缓存", response_model=schemas.Response[None]
)
async def clear_tmdb_recognition_cache(
_: User = Depends(get_current_active_superuser_async),

View File

@@ -1,8 +1,9 @@
from typing import Optional
from fastapi import APIRouter, Depends
from fastapi import Depends
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.media import MediaChain
from app.chain.torrents import TorrentsChain
from app.core.config import settings
@@ -27,10 +28,14 @@ from app.utils.media import (
resolve_media_identity,
)
router = APIRouter()
router = ResponseAPIRouter()
@router.get("/cache", summary="获取种子缓存", response_model=schemas.Response)
@router.get(
"/cache",
summary="获取种子缓存",
response_model=schemas.Response[schemas.TorrentCacheData],
)
async def torrents_cache(_: User = Depends(get_current_active_superuser_async)):
"""
获取当前种子缓存数据
@@ -97,7 +102,7 @@ async def torrents_cache(_: User = Depends(get_current_active_superuser_async)):
@router.delete(
"/cache/{domain}/{torrent_hash}",
summary="删除指定种子缓存",
response_model=schemas.Response,
response_model=schemas.Response[None],
)
async def delete_cache(
domain: str,
@@ -145,7 +150,7 @@ async def delete_cache(
return schemas.Response(success=False, message=f"删除失败:{str(e)}")
@router.delete("/cache", summary="清理种子缓存", response_model=schemas.Response)
@router.delete("/cache", summary="清理种子缓存", response_model=schemas.Response[None])
async def clear_cache(_: User = Depends(get_current_active_superuser_async)):
"""
清理所有种子缓存
@@ -159,7 +164,7 @@ async def clear_cache(_: User = Depends(get_current_active_superuser_async)):
return schemas.Response(success=False, message=f"清理失败:{str(e)}")
@router.post("/cache/refresh", summary="刷新种子缓存", response_model=schemas.Response)
@router.post("/cache/refresh", summary="刷新种子缓存", response_model=schemas.Response[None])
def refresh_cache(_: User = Depends(get_current_active_superuser)):
"""
刷新种子缓存
@@ -186,7 +191,7 @@ def refresh_cache(_: User = Depends(get_current_active_superuser)):
@router.post(
"/cache/reidentify/{domain}/{torrent_hash}",
summary="重新识别种子",
response_model=schemas.Response,
response_model=schemas.Response[schemas.TorrentReidentifyData],
)
async def reidentify_cache(
domain: str,

View File

@@ -1,10 +1,11 @@
from pathlib import Path
from typing import Any, List, Annotated, Optional
from fastapi import APIRouter, Depends
from fastapi import Depends
from sqlalchemy.orm import Session
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.media import MediaChain
from app.chain.transfer import TransferChain
from app.core.config import settings, global_vars
@@ -25,10 +26,14 @@ from app.schemas import (
EpisodeFormatRecommendItem,
)
router = APIRouter()
router = ResponseAPIRouter()
@router.get("/name", summary="查询整理后的名称", response_model=schemas.Response)
@router.get(
"/name",
summary="查询整理后的名称",
response_model=schemas.Response[schemas.NameData],
)
def query_name(
path: str, filetype: str, _: schemas.TokenPayload = Depends(verify_token)
) -> Any:
@@ -79,7 +84,7 @@ async def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.delete(
"/queue", summary="从整理队列中删除任务", response_model=schemas.Response
"/queue", summary="从整理队列中删除任务", response_model=schemas.Response[None]
)
async def remove_queue(
fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)
@@ -181,7 +186,7 @@ def _get_manual_transfer_target_key(
@router.post(
"/manual/target-path",
summary="匹配手动转移目的路径",
response_model=schemas.Response,
response_model=schemas.Response[schemas.ManualTransferTargetPath],
)
def match_manual_transfer_target_path(
transer_item: ManualTransferItem,
@@ -244,7 +249,7 @@ def match_manual_transfer_target_path(
@router.post(
"/manual/history",
summary="查询手动转移成功历史",
response_model=schemas.Response,
response_model=schemas.Response[schemas.ManualTransferHistoryInfo],
)
def query_manual_transfer_history(
transer_item: ManualTransferItem,
@@ -275,7 +280,11 @@ def query_manual_transfer_history(
return schemas.Response(success=True, data=history_info.model_dump())
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
@router.post(
"/manual",
summary="手动转移",
response_model=schemas.Response[schemas.ManualTransferResultData],
)
def manual_transfer(
transer_item: ManualTransferItem,
background: Optional[bool] = False,
@@ -574,7 +583,7 @@ def manual_transfer(
@router.post(
"/episode-format/recommend",
summary="推荐集数定位模板",
response_model=schemas.Response,
response_model=schemas.Response[schemas.EpisodeFormatRecommendData],
)
def recommend_episode_format(
recommend_item: EpisodeFormatRecommendItem,
@@ -600,7 +609,7 @@ def recommend_episode_format(
return schemas.Response(success=True, data=data)
@router.get("/now", summary="立即执行下载器文件整理", response_model=schemas.Response)
@router.get("/now", summary="立即执行下载器文件整理", response_model=schemas.Response[None])
def now(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
立即执行下载器文件整理 API_TOKEN认证?token=xxx

View File

@@ -2,10 +2,11 @@ import base64
import re
from typing import Annotated, Any, List, Union
from fastapi import APIRouter, Body, Depends, HTTPException, UploadFile, File
from fastapi import Body, Depends, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.api.response import ResponseAPIRouter
from app.core.security import get_password_hash
from app.db import get_async_db
from app.db.models.user import User
@@ -16,7 +17,7 @@ from app.db.user_oper import (
)
from app.db.userconfig_oper import UserConfigOper
router = APIRouter()
router = ResponseAPIRouter()
@router.get("/", summary="所有用户", response_model=List[schemas.User])
@@ -30,7 +31,7 @@ async def list_users(
return await current_user.async_list(db)
@router.post("/", summary="新增用户", response_model=schemas.Response)
@router.post("/", summary="新增用户", response_model=schemas.Response[None])
async def create_user(
*,
db: AsyncSession = Depends(get_async_db),
@@ -51,7 +52,7 @@ async def create_user(
return schemas.Response(success=True if user else False)
@router.put("/", summary="更新用户", response_model=schemas.Response)
@router.put("/", summary="更新用户", response_model=schemas.Response[None])
async def update_user(
*,
db: AsyncSession = Depends(get_async_db),
@@ -98,7 +99,9 @@ async def read_current_user(
@router.post(
"/avatar/{user_id}", summary="上传用户头像", response_model=schemas.Response
"/avatar/{user_id}",
summary="上传用户头像",
response_model=schemas.Response[schemas.FileNameData],
)
async def upload_avatar(
user_id: int,
@@ -122,7 +125,11 @@ async def upload_avatar(
return schemas.Response(success=True, data={"filename": file.filename})
@router.get("/config/{key}", summary="查询用户配置", response_model=schemas.Response)
@router.get(
"/config/{key}",
summary="查询用户配置",
response_model=schemas.Response[schemas.ValueData],
)
def get_config(key: str, current_user: User = Depends(get_current_active_user)):
"""
查询用户配置
@@ -131,7 +138,7 @@ def get_config(key: str, current_user: User = Depends(get_current_active_user)):
return schemas.Response(success=True, data={"value": value})
@router.post("/config/{key}", summary="更新用户配置", response_model=schemas.Response)
@router.post("/config/{key}", summary="更新用户配置", response_model=schemas.Response[None])
def set_config(
key: str,
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
@@ -144,7 +151,7 @@ def set_config(
return schemas.Response(success=True)
@router.delete("/id/{user_id}", summary="删除用户", response_model=schemas.Response)
@router.delete("/id/{user_id}", summary="删除用户", response_model=schemas.Response[None])
async def delete_user_by_id(
*,
db: AsyncSession = Depends(get_async_db),
@@ -161,7 +168,7 @@ async def delete_user_by_id(
return schemas.Response(success=True)
@router.delete("/name/{user_name}", summary="删除用户", response_model=schemas.Response)
@router.delete("/name/{user_name}", summary="删除用户", response_model=schemas.Response[None])
async def delete_user_by_name(
*,
db: AsyncSession = Depends(get_async_db),

View File

@@ -1,12 +1,13 @@
from typing import Any, Annotated
from fastapi import APIRouter, BackgroundTasks, Request, Depends
from fastapi import BackgroundTasks, Request, Depends
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.webhook import WebhookChain
from app.core.security import verify_apitoken
router = APIRouter()
router = ResponseAPIRouter()
def start_webhook_chain(body: Any, form: Any, args: Any):
@@ -16,7 +17,7 @@ def start_webhook_chain(body: Any, form: Any, args: Any):
WebhookChain().message(body=body, form=form, args=args)
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response)
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response[None])
async def webhook_message(
background_tasks: BackgroundTasks,
request: Request,
@@ -32,7 +33,7 @@ async def webhook_message(
return schemas.Response(success=True)
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response)
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response[None])
async def webhook_message_get(
background_tasks: BackgroundTasks,
request: Request,

View File

@@ -2,11 +2,12 @@ import json
from datetime import datetime
from typing import List, Any, Optional
from fastapi import APIRouter, Depends
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.workflow import WorkflowChain
from app.core.config import global_vars
from app.core.plugin import PluginManager
@@ -23,7 +24,7 @@ from app.helper.server import MoviePilotServerHelper
from app.scheduler import Scheduler
from app.schemas.types import EventType, EVENT_TYPE_NAMES
router = APIRouter()
router = ResponseAPIRouter()
WORKFLOW_TRIGGER_TIMER = "timer"
WORKFLOW_TRIGGER_EVENT = "event"
@@ -41,7 +42,7 @@ async def list_workflows(
return await WorkflowOper(db).async_list()
@router.post("/", summary="创建工作流", response_model=schemas.Response)
@router.post("/", summary="创建工作流", response_model=schemas.Response[None])
async def create_workflow(
workflow: schemas.Workflow,
db: AsyncSession = Depends(get_async_db),
@@ -63,7 +64,11 @@ async def create_workflow(
return schemas.Response(success=True, message="创建工作流成功")
@router.get("/plugin/actions", summary="查询插件动作", response_model=List[dict])
@router.get(
"/plugin/actions",
summary="查询插件动作",
response_model=List[schemas.PluginWorkflowActionGroup],
)
def list_plugin_actions(
plugin_id: str = None, _: User = Depends(get_current_active_manage_user)
) -> Any:
@@ -73,7 +78,11 @@ def list_plugin_actions(
return PluginManager().get_plugin_actions(plugin_id)
@router.get("/actions", summary="所有动作", response_model=List[dict])
@router.get(
"/actions",
summary="所有动作",
response_model=List[schemas.WorkflowActionDefinition],
)
async def list_actions(_: User = Depends(get_current_active_manage_user_async)) -> Any:
"""
获取所有动作
@@ -81,7 +90,11 @@ async def list_actions(_: User = Depends(get_current_active_manage_user_async))
return WorkFlowManager().list_actions()
@router.get("/event_types", summary="获取所有事件类型", response_model=List[dict])
@router.get(
"/event_types",
summary="获取所有事件类型",
response_model=List[schemas.NameValueOption],
)
async def get_event_types(_: User = Depends(get_current_active_manage_user_async)) -> Any:
"""
获取所有事件类型
@@ -95,7 +108,7 @@ async def get_event_types(_: User = Depends(get_current_active_manage_user_async
]
@router.post("/share", summary="分享工作流", response_model=schemas.Response)
@router.post("/share", summary="分享工作流", response_model=schemas.Response[None])
async def workflow_share(
workflow: schemas.WorkflowShare, _: User = Depends(get_current_active_manage_user_async)
) -> Any:
@@ -116,7 +129,7 @@ async def workflow_share(
return schemas.Response(success=state, message=errmsg)
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response)
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response[None])
async def workflow_share_delete(
share_id: int, _: User = Depends(get_current_active_manage_user_async)
) -> Any:
@@ -127,7 +140,7 @@ async def workflow_share_delete(
return schemas.Response(success=state, message=errmsg)
@router.post("/fork", summary="复用工作流", response_model=schemas.Response)
@router.post("/fork", summary="复用工作流", response_model=schemas.Response[None])
async def workflow_fork(
workflow: schemas.WorkflowShare,
db: AsyncSession = Depends(get_async_db),
@@ -206,7 +219,7 @@ async def workflow_shares(
@router.post(
"/{workflow_id}/run", summary="执行工作流", response_model=schemas.Response
"/{workflow_id}/run", summary="执行工作流", response_model=schemas.Response[None]
)
def run_workflow(
workflow_id: int,
@@ -223,7 +236,7 @@ def run_workflow(
@router.post(
"/{workflow_id}/start", summary="启用工作流", response_model=schemas.Response
"/{workflow_id}/start", summary="启用工作流", response_model=schemas.Response[None]
)
def start_workflow(
workflow_id: int,
@@ -257,7 +270,7 @@ def start_workflow(
@router.post(
"/{workflow_id}/pause", summary="停用工作流", response_model=schemas.Response
"/{workflow_id}/pause", summary="停用工作流", response_model=schemas.Response[None]
)
def pause_workflow(
workflow_id: int,
@@ -285,7 +298,7 @@ def pause_workflow(
@router.post(
"/{workflow_id}/reset", summary="重置工作流", response_model=schemas.Response
"/{workflow_id}/reset", summary="重置工作流", response_model=schemas.Response[None]
)
async def reset_workflow(
workflow_id: int,
@@ -319,7 +332,7 @@ async def get_workflow(
return await WorkflowOper(db).async_get(workflow_id)
@router.put("/{workflow_id}", summary="更新工作流", response_model=schemas.Response)
@router.put("/{workflow_id}", summary="更新工作流", response_model=schemas.Response[None])
def update_workflow(
workflow: schemas.Workflow,
db: Session = Depends(get_db),
@@ -349,7 +362,7 @@ def update_workflow(
return schemas.Response(success=True, message="更新成功")
@router.delete("/{workflow_id}", summary="删除工作流", response_model=schemas.Response)
@router.delete("/{workflow_id}", summary="删除工作流", response_model=schemas.Response[None])
def delete_workflow(
workflow_id: int,
db: Session = Depends(get_db),

158
app/api/response.py Normal file
View File

@@ -0,0 +1,158 @@
import inspect
from functools import wraps
from typing import Any, Callable
from fastapi import APIRouter
from fastapi.datastructures import DefaultPlaceholder
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute, get_typed_return_annotation
from starlette.responses import Response as StarletteResponse
from app.schemas.common import JsonData
from app.schemas.response import Response, ValidationIssue
ERROR_RESPONSES: dict[int, dict[str, Any]] = {
400: {"model": Response[None], "description": "请求错误"},
401: {"model": Response[None], "description": "未认证"},
403: {"model": Response[None], "description": "无权限"},
404: {"model": Response[None], "description": "资源不存在"},
409: {"model": Response[None], "description": "资源冲突"},
422: {
"model": Response[list[ValidationIssue]],
"description": "请求参数校验失败",
},
500: {"model": Response[None], "description": "服务器内部错误"},
}
RAW_RESPONSE_OPENAPI_KEY = "x-moviepilot-raw-response"
class ResponseAPIRoute(APIRoute):
"""为普通 JSON 接口统一声明并生成 ``Response[T]`` 响应。"""
def __init__(
self,
path: str,
endpoint: Callable[..., Any],
**kwargs: Any,
) -> None:
"""根据原始响应模型决定是否包装接口定义及运行时返回值。"""
response_model = kwargs.get("response_model")
response_class = kwargs.get("response_class", JSONResponse)
status_code = kwargs.get("status_code")
openapi_extra = kwargs.get("openapi_extra") or {}
force_raw = bool(openapi_extra.get(RAW_RESPONSE_OPENAPI_KEY))
if isinstance(response_model, DefaultPlaceholder):
inferred_model = get_typed_return_annotation(endpoint)
if self._is_native_response_model(inferred_model):
response_model = None
else:
response_model = inferred_model or JsonData
if response_model is Any:
response_model = JsonData
if response_model is Response:
response_model = Response[JsonData]
kwargs["response_model"] = response_model
should_wrap = self._should_wrap_response(
response_model=response_model,
response_class=response_class,
status_code=status_code,
force_raw=force_raw,
)
if should_wrap:
kwargs["response_model"] = Response[response_model]
endpoint = self._wrap_endpoint(endpoint)
kwargs["responses"] = self._merge_error_responses(
kwargs.get("responses")
)
super().__init__(path=path, endpoint=endpoint, **kwargs)
@staticmethod
def _should_wrap_response(
response_model: Any,
response_class: Any,
status_code: int | None,
force_raw: bool,
) -> bool:
"""判断当前路由是否属于需要统一封装的普通 JSON 接口。"""
if force_raw or response_model is None or status_code in {204, 304}:
return False
resolved_response_class = (
response_class.value
if isinstance(response_class, DefaultPlaceholder)
else response_class
)
try:
if not issubclass(resolved_response_class, JSONResponse):
return False
except TypeError:
return False
return not ResponseAPIRoute._is_response_model(response_model)
@staticmethod
def _is_response_model(response_model: Any) -> bool:
"""判断声明模型是否已经是统一响应模型。"""
try:
return issubclass(response_model, Response)
except TypeError:
return False
@staticmethod
def _is_native_response_model(response_model: Any) -> bool:
"""判断返回注解是否声明为 Starlette 原生响应。"""
try:
return issubclass(response_model, StarletteResponse)
except TypeError:
return False
@staticmethod
def _merge_error_responses(
responses: dict[int | str, dict[str, Any]] | None,
) -> dict[int | str, dict[str, Any]]:
"""补齐统一错误模型,并保留端点已经显式声明的响应。"""
merged_responses: dict[int | str, dict[str, Any]] = dict(ERROR_RESPONSES)
merged_responses.update(responses or {})
return merged_responses
@staticmethod
def _wrap_endpoint(endpoint: Callable[..., Any]) -> Callable[..., Any]:
"""包装端点返回值,同时保持原函数签名供 FastAPI 注入依赖。"""
if inspect.iscoroutinefunction(endpoint):
@wraps(endpoint)
async def async_endpoint(*args: Any, **kwargs: Any) -> Any:
"""异步调用端点并封装普通业务数据。"""
result = await endpoint(*args, **kwargs)
return ResponseAPIRoute._wrap_result(result)
return async_endpoint
@wraps(endpoint)
def sync_endpoint(*args: Any, **kwargs: Any) -> Any:
"""同步调用端点并封装普通业务数据。"""
result = endpoint(*args, **kwargs)
return ResponseAPIRoute._wrap_result(result)
return sync_endpoint
@staticmethod
def _wrap_result(result: Any) -> Any:
"""保留已封装或原生响应,其余结果写入统一响应的数据区域。"""
if isinstance(result, (Response, StarletteResponse)):
return result
return Response(success=True, data=result)
class ResponseAPIRouter(APIRouter):
"""默认使用统一响应路由类的 API 路由器。"""
def __init__(self, **kwargs: Any) -> None:
"""初始化路由器并允许调用方显式覆盖路由类。"""
kwargs.setdefault("route_class", ResponseAPIRoute)
super().__init__(**kwargs)

View File

@@ -1,10 +1,11 @@
from typing import Any, List, Annotated
from typing import List, Annotated
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.api.response import ERROR_RESPONSES
from app.chain.media import MediaChain
from app.chain.subscribe import SubscribeChain
from app.chain.tvdb import TvdbChain
@@ -16,7 +17,7 @@ from app.schemas import RadarrMovie, SonarrSeries
from app.schemas.types import MediaSource, MediaType
from version import APP_VERSION
arr_router = APIRouter(tags=["servarr"])
arr_router = APIRouter(tags=["servarr"], responses=ERROR_RESPONSES)
def _subscribe_tmdb_id(subscribe: Subscribe) -> int | None:
@@ -30,12 +31,18 @@ def _subscribe_tmdb_id(subscribe: Subscribe) -> int | None:
return None
@arr_router.get("/system/status", summary="系统状态")
async def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any:
@arr_router.get(
"/system/status",
summary="系统状态",
response_model=schemas.ServarrSystemStatus,
)
async def arr_system_status(
_: Annotated[str, Depends(verify_apikey)],
) -> schemas.ServarrSystemStatus:
"""
模拟Radarr、Sonarr系统状态
"""
return {
return schemas.ServarrSystemStatus.model_validate({
"appName": "MoviePilot",
"instanceName": "moviepilot",
"version": APP_VERSION,
@@ -81,16 +88,22 @@ async def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any:
"packageAuthor": "jxxghp",
"packageUpdateMechanism": "builtIn",
"packageUpdateMechanismMessage": "",
}
})
@arr_router.get("/qualityProfile", summary="质量配置")
async def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
@arr_router.get(
"/qualityProfile",
summary="质量配置",
response_model=List[schemas.ServarrQualityProfile],
)
async def arr_qualityProfile(
_: Annotated[str, Depends(verify_apikey)],
) -> List[schemas.ServarrQualityProfile]:
"""
模拟Radarr、Sonarr质量配置
"""
return [
{
schemas.ServarrQualityProfile.model_validate({
"id": 1,
"name": "默认",
"upgradeAllowed": True,
@@ -112,41 +125,55 @@ async def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
"minFormatScore": 0,
"cutoffFormatScore": 0,
"formatItems": [{"id": 0, "format": 0, "name": "默认", "score": 0}],
}
})
]
@arr_router.get("/rootfolder", summary="根目录")
async def arr_rootfolder(_: Annotated[str, Depends(verify_apikey)]) -> Any:
@arr_router.get(
"/rootfolder",
summary="根目录",
response_model=List[schemas.ServarrRootFolder],
)
async def arr_rootfolder(
_: Annotated[str, Depends(verify_apikey)],
) -> List[schemas.ServarrRootFolder]:
"""
模拟Radarr、Sonarr根目录
"""
return [
{
schemas.ServarrRootFolder.model_validate({
"id": 1,
"path": "/",
"accessible": True,
"freeSpace": 0,
"unmappedFolders": [],
}
})
]
@arr_router.get("/tag", summary="标签")
async def arr_tag(_: Annotated[str, Depends(verify_apikey)]) -> Any:
@arr_router.get("/tag", summary="标签", response_model=List[schemas.ServarrTag])
async def arr_tag(
_: Annotated[str, Depends(verify_apikey)],
) -> List[schemas.ServarrTag]:
"""
模拟Radarr、Sonarr标签
"""
return [{"id": 1, "label": "默认"}]
return [schemas.ServarrTag(id=1, label="默认")]
@arr_router.get("/languageprofile", summary="语言")
async def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
@arr_router.get(
"/languageprofile",
summary="语言",
response_model=List[schemas.ServarrLanguageProfile],
)
async def arr_languageprofile(
_: Annotated[str, Depends(verify_apikey)],
) -> List[schemas.ServarrLanguageProfile]:
"""
模拟Radarr、Sonarr语言
"""
return [
{
schemas.ServarrLanguageProfile.model_validate({
"id": 1,
"name": "默认",
"upgradeAllowed": True,
@@ -154,7 +181,7 @@ async def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
"languages": [
{"id": 1, "language": {"id": 1, "name": "默认"}, "allowed": True}
],
}
})
]
@@ -163,7 +190,7 @@ async def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
)
async def arr_movies(
_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)
) -> Any:
) -> List[schemas.RadarrMovie]:
"""
查询Rardar电影
"""
@@ -259,7 +286,7 @@ async def arr_movies(
)
def arr_movie_lookup(
term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)
) -> Any:
) -> List[schemas.RadarrMovie]:
"""
查询Rardar电影 term: `tmdb:${id}`
存在和不存在均不能返回错误
@@ -319,7 +346,7 @@ async def arr_movie(
mid: int,
_: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db),
) -> Any:
) -> schemas.RadarrMovie:
"""
查询Rardar电影订阅
"""
@@ -340,12 +367,14 @@ async def arr_movie(
raise HTTPException(status_code=404, detail="未找到该电影!")
@arr_router.post("/movie", summary="新增电影订阅")
@arr_router.post(
"/movie", summary="新增电影订阅", response_model=schemas.ServarrIdResponse
)
async def arr_add_movie(
_: Annotated[str, Depends(verify_apikey)],
movie: RadarrMovie,
db: AsyncSession = Depends(get_async_db),
) -> Any:
) -> schemas.ServarrIdResponse:
"""
新增Rardar电影订阅
"""
@@ -354,7 +383,7 @@ async def arr_add_movie(
db, MediaSource.TMDB.value, str(movie.tmdbId)
)
if subscribes:
return {"id": subscribes[0].id}
return schemas.ServarrIdResponse(id=subscribes[0].id)
# 添加订阅
sid, message = await SubscribeChain().async_add(
title=movie.title,
@@ -365,19 +394,19 @@ async def arr_add_movie(
username="Seerr",
)
if sid:
return {"id": sid}
return schemas.ServarrIdResponse(id=sid)
else:
raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}")
@arr_router.delete(
"/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response
"/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response[None]
)
async def arr_remove_movie(
mid: int,
_: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db),
) -> Any:
) -> schemas.Response[None]:
"""
删除Rardar电影订阅
"""
@@ -394,7 +423,7 @@ async def arr_remove_movie(
)
async def arr_series(
_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)
) -> Any:
) -> List[schemas.SonarrSeries]:
"""
查询Sonarr剧集
"""
@@ -531,10 +560,14 @@ async def arr_series(
return result
@arr_router.get("/series/lookup", summary="查询剧集")
@arr_router.get(
"/series/lookup",
summary="查询剧集",
response_model=List[schemas.SonarrSeries],
)
def arr_series_lookup(
term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)
) -> Any:
) -> List[schemas.SonarrSeries]:
"""
查询Sonarr剧集 term: `tvdb:${id}` title
"""
@@ -641,12 +674,14 @@ def arr_series_lookup(
return sonarr_series_list if sonarr_series_list else [SonarrSeries()]
@arr_router.get("/series/{tid}", summary="剧集详情")
@arr_router.get(
"/series/{tid}", summary="剧集详情", response_model=schemas.SonarrSeries
)
async def arr_serie(
tid: int,
_: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db),
) -> Any:
) -> schemas.SonarrSeries:
"""
查询Sonarr剧集
"""
@@ -676,12 +711,14 @@ async def arr_serie(
raise HTTPException(status_code=404, detail="未找到该电视剧!")
@arr_router.post("/series", summary="新增剧集订阅")
@arr_router.post(
"/series", summary="新增剧集订阅", response_model=schemas.ServarrIdResponse
)
async def arr_add_series(
tv: schemas.SonarrSeries,
_: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db),
) -> Any:
) -> schemas.ServarrIdResponse:
"""
新增Sonarr剧集订阅
"""
@@ -692,24 +729,24 @@ async def arr_add_series(
db,
media_source=MediaSource.TMDB.value,
media_id=str(tv.tmdbId),
season=season.get("seasonNumber"),
season=season.seasonNumber,
)
if subscribe:
continue
left_seasons.append(season)
# 全部已存在订阅
if not left_seasons:
return {"id": 1}
return schemas.ServarrIdResponse(id=1)
# 剩下的添加订阅
sid = 0
message = ""
for season in left_seasons:
if not season.get("monitored"):
if not season.monitored:
continue
sid, message = await SubscribeChain().async_add(
title=tv.title,
year=tv.year,
season=season.get("seasonNumber"),
season=season.seasonNumber,
media_source=MediaSource.TMDB,
media_id=str(tv.tmdbId),
mtype=MediaType.TV,
@@ -717,27 +754,33 @@ async def arr_add_series(
)
if sid:
return {"id": sid}
return schemas.ServarrIdResponse(id=sid)
else:
raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}")
@arr_router.put("/series", summary="更新剧集订阅")
@arr_router.put(
"/series", summary="更新剧集订阅", response_model=schemas.ServarrIdResponse
)
async def arr_update_series(
tv: schemas.SonarrSeries, _: Annotated[str, Depends(verify_apikey)]
) -> Any:
tv: schemas.SonarrSeries,
_: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db),
) -> schemas.ServarrIdResponse:
"""
更新Sonarr剧集订阅
"""
return await arr_add_series(tv)
return await arr_add_series(tv=tv, _=_, db=db)
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
@arr_router.delete(
"/series/{tid}", summary="删除剧集订阅", response_model=schemas.Response[None]
)
async def arr_remove_series(
tid: int,
_: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db),
) -> Any:
) -> schemas.Response[None]:
"""
删除Sonarr剧集订阅
"""

View File

@@ -1,7 +1,7 @@
import gzip
import hmac
import json
from typing import Annotated, Callable, Any, Dict, Optional
from typing import Annotated, Callable, Optional
import aiofiles
from anyio import Path as AsyncPath
@@ -10,13 +10,17 @@ from fastapi.responses import PlainTextResponse
from fastapi.routing import APIRoute
from app import schemas
from app.api.response import ERROR_RESPONSES
from app.core.config import settings
from app.log import logger
from app.utils.crypto import CryptoJsUtils, HashUtils
class GzipRequest(Request):
"""按请求头透明解压 gzip 请求体。"""
async def body(self) -> bytes:
"""读取请求体,并在需要时完成 gzip 解压。"""
if not hasattr(self, "_body"):
body = await super().body()
if "gzip" in self.headers.getlist("Content-Encoding"):
@@ -26,17 +30,21 @@ class GzipRequest(Request):
class GzipRoute(APIRoute):
"""为 CookieCloud 路由注入 gzip 请求对象。"""
def get_route_handler(self) -> Callable:
"""返回支持 gzip 请求体的路由处理器。"""
original_route_handler = super().get_route_handler()
async def custom_route_handler(request: Request) -> Response:
"""将原始请求替换为可解压的请求对象后继续处理。"""
request = GzipRequest(request.scope, request.receive)
return await original_route_handler(request)
return custom_route_handler
async def verify_server_enabled():
async def verify_server_enabled() -> bool:
"""
校验CookieCloud服务路由是否打开
"""
@@ -49,7 +57,7 @@ async def verify_update_auth(
x_cookiecloud_auth: Annotated[
Optional[str], Header(alias="X-CookieCloud-Auth")
] = None,
):
) -> bool:
"""
校验CookieCloud上传接口的可选共享认证头。
"""
@@ -67,21 +75,48 @@ cookie_router = APIRouter(
route_class=GzipRoute,
tags=["servcookie"],
dependencies=[Depends(verify_server_enabled)],
responses=ERROR_RESPONSES,
)
@cookie_router.get("/", response_class=PlainTextResponse)
async def get_root():
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
@cookie_router.get(
"/",
response_model=None,
response_class=Response,
responses={
200: {
"description": "CookieCloud 服务说明",
"content": {"text/plain": {"schema": {"type": "string"}}},
}
},
)
async def get_root() -> PlainTextResponse:
"""返回 CookieCloud 兼容服务的根路径说明。"""
return PlainTextResponse("Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud")
@cookie_router.post("/", response_class=PlainTextResponse)
async def post_root():
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
@cookie_router.post(
"/",
response_model=None,
response_class=Response,
responses={
200: {
"description": "CookieCloud 服务说明",
"content": {"text/plain": {"schema": {"type": "string"}}},
}
},
)
async def post_root() -> PlainTextResponse:
"""通过 POST 返回 CookieCloud 兼容服务的根路径说明。"""
return PlainTextResponse("Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud")
@cookie_router.post("/update", dependencies=[Depends(verify_update_auth)])
async def update_cookie(req: schemas.CookieData):
@cookie_router.post(
"/update",
dependencies=[Depends(verify_update_auth)],
response_model=schemas.CookieActionResponse,
)
async def update_cookie(req: schemas.CookieData) -> schemas.CookieActionResponse:
"""
上传Cookie数据
"""
@@ -92,31 +127,31 @@ async def update_cookie(req: schemas.CookieData):
async with aiofiles.open(file_path, encoding="utf-8", errors="replace", mode="r") as file:
read_content = await file.read()
if read_content == content:
return {"action": "done"}
return schemas.CookieActionResponse(action="done")
else:
return {"action": "error"}
return schemas.CookieActionResponse(action="error")
async def load_encrypt_data(uuid: str) -> Dict[str, Any]:
async def load_encrypt_data(uuid: str) -> schemas.CookieEncryptedPayload:
"""
加载本地加密原始数据
"""
file_path = AsyncPath(settings.COOKIE_PATH) / f"{uuid}.json"
# 检查文件是否存在
if not file_path.exists():
if not await file_path.exists():
raise HTTPException(status_code=404, detail="Item not found")
# 读取文件
async with aiofiles.open(file_path, encoding="utf-8", errors="replace", mode="r") as file:
read_content = await file.read()
data = json.loads(read_content.encode("utf-8"))
return data
return schemas.CookieEncryptedPayload.model_validate(data)
def get_decrypted_cookie_data(
uuid: str, password: str, encrypted: str
) -> Optional[Dict[str, Any]]:
) -> Optional[schemas.CookieDecryptedPayload]:
"""
加载本地加密数据并解密为Cookie
"""
@@ -128,7 +163,7 @@ def get_decrypted_cookie_data(
decrypted_data = CryptoJsUtils.decrypt(encrypted, aes_key).decode("utf-8")
decrypted_data = json.loads(decrypted_data)
if "cookie_data" in decrypted_data:
return decrypted_data
return schemas.CookieDecryptedPayload.model_validate(decrypted_data)
except Exception as e:
logger.error(f"解密Cookie数据失败{str(e)}")
return None
@@ -136,26 +171,33 @@ def get_decrypted_cookie_data(
return None
@cookie_router.get("/get/{uuid}")
@cookie_router.get("/get/{uuid}", response_model=schemas.CookieEncryptedPayload)
async def get_cookie(
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")],
):
) -> schemas.CookieEncryptedPayload:
"""
GET 下载加密数据
"""
return await load_encrypt_data(uuid)
return schemas.CookieEncryptedPayload.model_validate(
await load_encrypt_data(uuid)
)
@cookie_router.post("/get/{uuid}")
@cookie_router.post(
"/get/{uuid}",
response_model=schemas.CookieEncryptedPayload | schemas.CookieDecryptedPayload | None,
)
async def post_cookie(
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")],
request: Optional[schemas.CookiePassword] = Body(None),
):
) -> schemas.CookieEncryptedPayload | schemas.CookieDecryptedPayload | None:
"""
POST 下载加密数据
"""
data = await load_encrypt_data(uuid)
data = schemas.CookieEncryptedPayload.model_validate(
await load_encrypt_data(uuid)
)
if request is not None:
return get_decrypted_cookie_data(uuid, request.password, data["encrypted"])
return get_decrypted_cookie_data(uuid, request.password, data.encrypted)
else:
return data

View File

@@ -122,8 +122,18 @@ class WorkflowExecutor:
)
self.actions = {action['id']: Action(**action) for action in workflow.actions}
self.flows = [ActionFlow(**flow) for flow in workflow.flows]
self.execution_config = getattr(workflow, "execution_config", None) or {}
self.restored_execution_state = getattr(workflow, "execution_state", None) or {}
execution_config = getattr(workflow, "execution_config", None) or {}
execution_state = getattr(workflow, "execution_state", None) or {}
self.execution_config = (
execution_config.model_dump(exclude_none=True)
if isinstance(execution_config, BaseModel)
else execution_config
)
self.restored_execution_state = (
execution_state.model_dump(exclude_none=True)
if isinstance(execution_state, BaseModel)
else execution_state
)
self.total_actions = len(self.actions)
self.success = True
self.has_failure = False

View File

@@ -28,8 +28,6 @@ _VIDEO_SEASON_EPISODE_RE = re.compile(
_ANIME_SQUARE_BRACKET_RE = re.compile(r'\[[+0-9XVPI-]+]\s*\[', re.IGNORECASE)
_BRACED_METAINFO_RE = re.compile(r'(?<={\[)[\W\w]+(?=]})')
_BRACED_MEDIA_SOURCE_RE = re.compile(r'(?:^|;)media_source=([^;]+)(?=;|$)', re.IGNORECASE)
_BRACED_MEDIA_ID_RE = re.compile(r'(?:^|;)media_id=([^;]+)(?=;|$)', re.IGNORECASE)
_BRACED_TMDBID_RE = re.compile(r'(?<=tmdbid=)\d+')
_BRACED_DOUBANID_RE = re.compile(r'(?<=doubanid=)\d+')
_BRACED_BANGUMIID_RE = re.compile(r'(?<=bangumiid=)\d+')
@@ -64,7 +62,6 @@ _EXTENDED_MEDIA_ID_TAG_RE = re.compile(
r'(?:bangumi(?:id)?|anilist(?:id)?)[=\-]\d+',
re.IGNORECASE,
)
_GENERIC_MEDIA_ID_TAG_RE = re.compile(r'(?:^|[;\[])media_(?:source|id)=', re.IGNORECASE)
_RUST_PARSE_OPTIONS_CACHE_KEY = "_cache_key"
_LEGACY_BRACED_ID_PATTERNS = (
@@ -161,20 +158,12 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
"""
metainfo = _empty_metainfo()
legacy_identities = {}
generic_identity = (None, None)
if not title:
return title, metainfo
# 当前格式为 {[media_source=...;media_id=...]},历史专用标签仅在此处兼容读取
# 自定义识别词是面向用户的独立语法,继续使用各数据源专用 ID 字段
results = _BRACED_METAINFO_RE.findall(title)
if results:
for result in results:
source_match = _BRACED_MEDIA_SOURCE_RE.search(result)
media_id_match = _BRACED_MEDIA_ID_RE.search(result)
if source_match and media_id_match:
generic_identity = resolve_media_identity(
media_source=source_match.group(1).strip(),
media_id=media_id_match.group(1).strip(),
)
legacy_matches = []
for source, pattern in _LEGACY_BRACED_ID_PATTERNS:
legacy_match = pattern.search(result)
@@ -211,9 +200,7 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
metainfo['end_episode'] = int(end_episode.group(0))
# 去除title中该部分
if (
source_match
or media_id_match
or legacy_matches
legacy_matches
or mtype
or episode_group
or begin_season
@@ -252,12 +239,11 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
title = media_id_re.sub('', title).strip()
break
media_source, media_id = generic_identity
if not media_source:
for source, _ in _LEGACY_ID_KEYS:
if legacy_identities.get(source):
media_source, media_id = source, legacy_identities[source]
break
media_source, media_id = None, None
for source, _ in _LEGACY_ID_KEYS:
if legacy_identities.get(source):
media_source, media_id = source, legacy_identities[source]
break
metainfo['media_source'] = media_source
metainfo['media_id'] = media_id
@@ -432,18 +418,13 @@ def _requires_python_metainfo(
custom_words: Optional[List[str]] = None,
) -> bool:
"""
判断标题或临时识别词是否包含当前 Rust 扩展尚未支持的媒体身份标签。
判断标题或临时识别词是否包含当前 Rust 扩展尚未支持的数据源专用 ID 标签。
:param title: 原始标题
:param custom_words: 临时识别词
:return: 是否必须使用Python解析器
"""
candidates = [title or "", *(custom_words or [])]
contains_generic_id = any(
_GENERIC_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates
)
if contains_generic_id and not rust_accel.supports_unified_media_identity():
return True
contains_extended_id = any(
_EXTENDED_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates
)

View File

@@ -1,13 +1,24 @@
import json
from typing import Any, Awaitable, Callable
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi import FastAPI, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException
from app.api.apiv2_utils import OPENAPI_V2_PATH, V2ResponseMiddleware
from app.api.response import ResponseAPIRoute
from app.core.config import settings
from app.helper.locale import LocaleHelper
from app.log import logger
from app.schemas.openai import (
AnthropicErrorDetail,
AnthropicErrorResponse,
OpenAIErrorDetail,
OpenAIErrorResponse,
)
from app.schemas.mcp import McpJsonRpcError, McpJsonRpcErrorDetail
from app.schemas.response import Response as ApiResponse, ValidationIssue
from app.startup.lifecycle import lifespan
from version import APP_VERSION
@@ -24,29 +35,248 @@ def _get_http_exception_message(detail: Any) -> str:
return str(detail)
def _localize_exception_message(request: Request, message: str) -> str:
"""直接按异常所属请求的语言翻译消息,避免中间件上下文已被恢复。"""
return LocaleHelper.translate_text(
message,
locale=LocaleHelper.get_locale_from_request(request),
)
def _is_mcp_jsonrpc_request(request: Request) -> bool:
"""判断请求是否指向保持原生响应的 MCP JSON-RPC 根端点。"""
request_path = getattr(getattr(request, "url", None), "path", "")
return request_path.rstrip("/") == f"{settings.API_V1_STR}/mcp"
def _get_native_ai_protocol(request: Request) -> str | None:
"""识别需要保持原生错误体的 OpenAI 或 Anthropic 兼容请求。"""
request_path = getattr(getattr(request, "url", None), "path", "")
if request_path.startswith(f"{settings.API_V1_STR}/openai/v1/"):
return "openai"
if request_path.startswith(f"{settings.API_V1_STR}/anthropic/v1/"):
return "anthropic"
return None
def _native_ai_error_response(
protocol: str,
status_code: int,
message: str,
) -> JSONResponse:
"""按 OpenAI 或 Anthropic 兼容协议构造原生错误响应。"""
if protocol == "openai":
error_type = (
"authentication_error"
if status_code in {401, 403}
else "server_error"
if status_code >= 500
else "invalid_request_error"
)
return JSONResponse(
status_code=status_code,
content=OpenAIErrorResponse(
error=OpenAIErrorDetail(
message=message,
type=error_type,
code=error_type,
)
).model_dump(mode="json"),
)
error_type = (
"authentication_error"
if status_code in {401, 403}
else "api_error"
if status_code >= 500
else "invalid_request_error"
)
return JSONResponse(
status_code=status_code,
content=AnthropicErrorResponse(
error=AnthropicErrorDetail(type=error_type, message=message)
).model_dump(mode="json"),
)
def _mcp_jsonrpc_error_response(
status_code: int,
code: int,
message: str,
) -> JSONResponse:
"""构造带 HTTP 状态码的 MCP JSON-RPC 原生错误响应。"""
return JSONResponse(
status_code=status_code,
content=McpJsonRpcError(
jsonrpc="2.0",
id=None,
error=McpJsonRpcErrorDetail(code=code, message=message),
).model_dump(mode="json"),
)
def _protocol_validation_error_response(
request: Request,
exc: RequestValidationError,
) -> JSONResponse | None:
"""为 OpenAI 与 Anthropic 兼容端点生成协议原生的参数错误响应。"""
errors = exc.errors()
first_error = errors[0] if errors else {}
location = ".".join(
str(item)
for item in first_error.get("loc", ())
if item not in {"body"}
)
message = str(first_error.get("msg") or "Invalid request parameters.")
native_ai_protocol = _get_native_ai_protocol(request)
if native_ai_protocol == "openai":
return JSONResponse(
status_code=422,
content=OpenAIErrorResponse(
error=OpenAIErrorDetail(
message=message,
type="invalid_request_error",
param=location or None,
code="invalid_request_error",
)
).model_dump(mode="json"),
)
if native_ai_protocol == "anthropic":
if location:
message = f"{location}: {message}"
return JSONResponse(
status_code=422,
content=AnthropicErrorResponse(
error=AnthropicErrorDetail(
type="invalid_request_error",
message=message,
)
).model_dump(mode="json"),
)
if _is_mcp_jsonrpc_request(request):
if location:
message = f"{location}: {message}"
return _mcp_jsonrpc_error_response(
status_code=422,
code=-32602,
message=message,
)
return None
async def localized_http_exception_handler(
_request: Request,
request: Request,
exc: HTTPException,
) -> JSONResponse:
"""
将 HTTPException 响应统一封装为 Response 结构并保留原始错误消息。
:param _request: 当前 HTTP 请求
:param request: 当前 HTTP 请求
:param exc: FastAPI HTTP 异常
:return: 统一 JSON 错误响应
"""
message = _get_http_exception_message(exc.detail)
message = _localize_exception_message(
request,
_get_http_exception_message(exc.detail),
)
native_ai_protocol = _get_native_ai_protocol(request)
if native_ai_protocol:
return _native_ai_error_response(
protocol=native_ai_protocol,
status_code=exc.status_code,
message=message,
)
if _is_mcp_jsonrpc_request(request):
error_codes = {
400: -32600,
401: -32001,
403: -32001,
404: -32601,
409: -32009,
}
return _mcp_jsonrpc_error_response(
status_code=exc.status_code,
code=error_codes.get(exc.status_code, -32000),
message=message,
)
return JSONResponse(
status_code=exc.status_code,
content={
"success": False,
"message": message,
"data": {},
},
content=ApiResponse[None](success=False, message=message).model_dump(mode="json"),
headers=exc.headers,
)
async def localized_validation_exception_handler(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
"""
将请求参数校验错误转换为统一响应并保留结构化错误数据。
:param request: 当前 HTTP 请求
:param exc: FastAPI 请求参数校验异常
:return: 统一 JSON 错误响应
"""
protocol_response = _protocol_validation_error_response(request, exc)
if protocol_response is not None:
return protocol_response
errors = [
ValidationIssue(
location=list(error.get("loc", ())),
message=str(error.get("msg") or "请求参数错误"),
error_type=str(error.get("type") or "validation_error"),
)
for error in exc.errors()
]
return JSONResponse(
status_code=422,
content=ApiResponse[list[ValidationIssue]](
success=False,
message=_localize_exception_message(request, "请求参数不正确"),
data=errors,
).model_dump(mode="json"),
)
async def localized_unhandled_exception_handler(
request: Request,
exc: Exception,
) -> JSONResponse:
"""
将未捕获异常隐藏为统一的服务器错误响应,避免泄露内部细节。
:param request: 当前 HTTP 请求
:param exc: 未捕获异常
:return: 统一 JSON 错误响应
"""
logger.error(
f"API 请求发生未捕获异常: {exc}",
exc_info=(type(exc), exc, exc.__traceback__),
)
native_ai_protocol = _get_native_ai_protocol(request)
if native_ai_protocol:
return _native_ai_error_response(
protocol=native_ai_protocol,
status_code=500,
message="Internal server error.",
)
if _is_mcp_jsonrpc_request(request):
return _mcp_jsonrpc_error_response(
status_code=500,
code=-32603,
message="Internal error",
)
return JSONResponse(
status_code=500,
content=ApiResponse[None](
success=False,
message=_localize_exception_message(request, "未知错误"),
).model_dump(mode="json"),
)
def create_app() -> FastAPI:
"""
创建并配置 FastAPI 应用实例。
@@ -54,16 +284,18 @@ def create_app() -> FastAPI:
_app = FastAPI(
title=settings.PROJECT_NAME,
version=APP_VERSION,
openapi_url=OPENAPI_V2_PATH,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
lifespan=lifespan
)
@_app.get(f"{settings.API_V1_STR}/openapi.json", include_in_schema=False)
def get_v1_openapi_schema() -> dict[str, Any]:
"""保留旧版 OpenAPI 地址并返回当前完整接口文档。"""
return _app.openapi()
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
_app.add_exception_handler(
RequestValidationError,
localized_validation_exception_handler,
)
_app.add_exception_handler(Exception, localized_unhandled_exception_handler)
# 动态注册的插件接口也必须使用统一响应路由类。
_app.router.route_class = ResponseAPIRoute
# 配置 CORS 中间件
_app.add_middleware(
@@ -73,8 +305,6 @@ def create_app() -> FastAPI:
allow_methods=["*"],
allow_headers=["*"],
)
_app.add_middleware(V2ResponseMiddleware)
@_app.middleware("http")
async def locale_context_middleware(
request: Request,

View File

@@ -97,6 +97,7 @@
}
},
"messages": {
"调用工具失败": "Tool call failed",
"无效的媒体来源": "Invalid media source",
"该媒体来源不支持此音乐接口": "This media source is not supported by this music endpoint",
"媒体来源和媒体 ID 必须同时提供": "Media source and media ID must be provided together",
@@ -1370,6 +1371,10 @@
"source": "同步媒体服务器 - {name}",
"target": "Sync Media Server - {name}"
},
{
"source": "调用工具失败: {error}",
"target": "Tool call failed: {error}"
},
{
"source": "{name} 执行完成",
"target": "{name_i18n} completed"

View File

@@ -97,6 +97,7 @@
}
},
"messages": {
"调用工具失败": "調用工具失敗",
"媒体来源和媒体 ID 必须同时提供": "媒體來源和媒體 ID 必須同時提供",
"media_source 和 media_id 必须同时提供": "media_source 和 media_id 必須同時提供",
"模块不支持测试": "模組不支援測試",
@@ -1366,6 +1367,10 @@
"source": "同步媒体服务器 - {name}",
"target": "同步媒體伺服器 - {name}"
},
{
"source": "调用工具失败: {error}",
"target": "調用工具失敗: {error}"
},
{
"source": "{name} 执行完成",
"target": "{name_i18n} 執行完成"

View File

@@ -1220,12 +1220,16 @@ class TransHandler:
:param file_ext: 文件扩展名
:param episodes_info: 当前季的全部集信息
"""
return TemplateHelper().builder.build(
naming_context = TemplateHelper().builder.build(
meta=meta,
mediainfo=mediainfo,
file_extension=file_ext,
episodes_info=episodes_info,
)
# 重命名格式是独立的用户配置契约,继续只暴露各数据源原有 ID 变量。
naming_context.pop("media_source", None)
naming_context.pop("media_id", None)
return naming_context
@staticmethod
def __delete_version_files(storage_oper: StorageBase, path: Path) -> bool:

View File

@@ -1,4 +1,7 @@
from .agent import *
from .cache import *
from .category import *
from .common import *
from .context import *
from .dashboard import *
from .download import *
@@ -6,13 +9,18 @@ from .event import *
from .exception import *
from .file import *
from .history import *
from .llm import *
from .mediaserver import *
from .message import *
from .mfa import *
from .music import *
from .monitoring import *
from .notification import *
from .plugin import *
from .response import *
from .rule import *
from .search import *
from .storage import *
from .openai import *
from .servarr import *
from .servcookie import *

View File

@@ -6,6 +6,8 @@ from typing import Any, List, Literal, Optional, Union
from langchain_core.messages import BaseMessage
from pydantic import BaseModel, Field, ConfigDict, field_serializer
from app.schemas.common import JsonData
class ConversationMemory(BaseModel):
"""对话记忆模型"""
@@ -97,7 +99,7 @@ class AgentMcpServerToolInfo(BaseModel):
name: str = Field(..., description="原始 MCP 工具名称")
agent_tool_name: str = Field(..., description="注入 Agent 后的工具名称")
description: str = Field(default="", description="工具说明")
input_schema: dict[str, Any] = Field(default_factory=dict, description="工具参数 Schema")
input_schema: dict[str, JsonData] = Field(default_factory=dict, description="工具参数 Schema")
class AgentMcpServerTestResult(BaseModel):
@@ -217,8 +219,8 @@ class AgentChatSession(BaseModel):
username: Optional[str] = Field(None, description="用户名")
original_chat_id: Optional[str] = Field(None, description="原聊天 ID")
message_count: int = Field(default=0, description="展示消息数量")
created_at: Optional[str] = Field(None, description="创建时间")
updated_at: Optional[str] = Field(None, description="更新时间")
created_at: Optional[datetime | str] = Field(None, description="创建时间")
updated_at: Optional[datetime | str] = Field(None, description="更新时间")
messages: list[AgentChatMessage] = Field(default_factory=list, description="展示消息列表")
@@ -229,3 +231,84 @@ class AgentChatDisplaySaveRequest(BaseModel):
messages: list[AgentChatMessage] = Field(default_factory=list, description="展示消息列表")
title: Optional[str] = Field(None, description="会话标题")
class AgentMcpServerListData(BaseModel):
"""Agent MCP 服务器列表与启用统计。"""
servers: list[AgentMcpServerConfig] = Field(default_factory=list, description="服务器列表")
enabled_count: int = Field(default=0, description="已启用服务器数量")
total_count: int = Field(default=0, description="服务器总数")
class AgentChatUploadAttachment(AgentChatAttachment):
"""Web Agent 上传完成后的附件描述。"""
ref: str = Field(description="供 Agent 消费的附件引用")
status: str = Field(default="ready", description="附件处理状态")
class AgentWebChoiceFeedback(BaseModel):
"""Web Agent 选择回调的反馈快照。"""
request_id: str = Field(description="选择请求 ID")
title: Optional[str] = Field(default=None, description="选择标题")
prompt: str = Field(default="", description="选择提示")
selected_label: str = Field(description="已选择文案")
selected_value: str = Field(description="已选择值")
selected_description: Optional[str] = Field(default=None, description="已选择说明")
buttons: list[AgentChatChoiceButton] = Field(default_factory=list, description="按钮列表")
button_rows: list[list[AgentChatChoiceButton]] = Field(default_factory=list, description="按钮行")
class AgentWebCallbackData(BaseModel):
"""Web Agent 按钮回调后供前端继续发送的数据。"""
message: str = Field(description="下一条用户消息")
display_message: str = Field(default="", description="前端展示消息")
session_id: Optional[str] = Field(default=None, description="Agent 会话 ID")
traditional: bool = Field(default=False, description="是否为传统消息链回调")
original_message_id: Optional[str | int] = Field(default=None, description="原消息 ID")
original_chat_id: Optional[str | int] = Field(default=None, description="原聊天 ID")
choice_selection: Optional[AgentChatChoiceSelection] = Field(default=None, description="选择结果快照")
feedback: Optional[AgentWebChoiceFeedback] = Field(default=None, description="选择反馈")
class AgentWebCommandInfo(BaseModel):
"""Web Agent 可用斜杠命令摘要。"""
command: str = Field(description="命令")
description: str = Field(default="", description="命令说明")
category: str = Field(default="其他", description="命令分类")
type: str = Field(default="", description="命令类型")
pid: Optional[str | int] = Field(default=None, description="插件 ID")
class AgentChatSessionSummary(BaseModel):
"""Agent 历史会话摘要。"""
id: Optional[int] = Field(default=None, description="数据库 ID")
session_id: str = Field(description="Agent 内部会话 ID")
client_session_id: Optional[str] = Field(default=None, description="客户端会话 ID")
title: Optional[str] = Field(default=None, description="会话标题")
channel: Optional[str] = Field(default=None, description="消息渠道")
source: Optional[str] = Field(default=None, description="渠道来源")
user_id: Optional[str] = Field(default=None, description="用户 ID")
username: Optional[str] = Field(default=None, description="用户名")
original_chat_id: Optional[str] = Field(default=None, description="原聊天 ID")
message_count: int = Field(default=0, description="展示消息数量")
created_at: Optional[datetime | str] = Field(default=None, description="创建时间")
updated_at: Optional[datetime | str] = Field(default=None, description="更新时间")
class AgentChatSessionDetail(AgentChatSessionSummary):
"""Agent 历史会话详情。"""
messages: list[AgentChatMessage] = Field(default_factory=list, description="展示消息列表")
is_processing: bool = Field(default=False, description="会话是否正在处理")
class AgentSessionStopData(BaseModel):
"""Agent 会话停止结果。"""
stopped: bool = Field(description="是否停止了正在执行的任务")

50
app/schemas/cache.py Normal file
View File

@@ -0,0 +1,50 @@
"""种子缓存 API 输出模型。"""
from typing import Optional
from pydantic import BaseModel, Field
from app.schemas.types import MediaSource
class TorrentCacheItem(BaseModel):
"""单条站点种子缓存。"""
hash: str
domain: str
title: Optional[str] = None
description: Optional[str] = None
size: Optional[int] = None
pubdate: Optional[str] = None
site_name: Optional[str] = None
media_name: Optional[str] = None
media_year: Optional[str | int] = None
media_type: Optional[str] = None
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
music_type: Optional[str] = None
season_episode: Optional[str] = None
resource_term: Optional[str] = None
enclosure: Optional[str] = None
page_url: Optional[str] = None
poster_path: Optional[str] = None
backdrop_path: Optional[str] = None
class TorrentCacheData(BaseModel):
"""种子缓存统计及明细。"""
count: int = 0
sites: int = 0
data: list[TorrentCacheItem] = Field(default_factory=list)
class TorrentReidentifyData(BaseModel):
"""种子重新识别后的媒体身份。"""
media_name: Optional[str] = None
media_year: Optional[str | int] = None
media_type: Optional[str] = None
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
music_type: Optional[str] = None

View File

@@ -1,6 +1,6 @@
from typing import Dict, Optional
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, RootModel
class CategoryRule(BaseModel):
@@ -29,3 +29,7 @@ class CategoryConfig(BaseModel):
movie: Optional[Dict[str, Optional[CategoryRule]]] = {}
# 电视剧分类策略
tv: Optional[Dict[str, Optional[CategoryRule]]] = {}
class MediaCategoryMap(RootModel[Dict[str, list[str]]]):
"""媒体类型与自动分类名称列表的映射。"""

78
app/schemas/common.py Normal file
View File

@@ -0,0 +1,78 @@
"""API 端点共享的小型业务数据模型。"""
from typing import Optional, Union
from pydantic import BaseModel, Field, RootModel
from typing_extensions import TypeAliasType
JsonData = TypeAliasType(
"JsonData",
Union[
dict[str, "JsonData"],
list["JsonData"],
str,
int,
float,
bool,
None,
],
)
"""可递归序列化的 JSON 数据OpenAPI 会展示每一种合法 JSON 结构。"""
class JsonObject(RootModel[dict[str, JsonData]]):
"""字段由运行时扩展点决定的 JSON 对象。"""
class JsonObjectList(RootModel[list[JsonObject]]):
"""字段由运行时扩展点决定的 JSON 对象列表。"""
class IdData(BaseModel):
"""创建资源后返回的资源 ID。"""
id: Optional[int | str] = Field(default=None, description="资源 ID")
class ValueData(BaseModel):
"""单个动态配置值。"""
value: JsonData = Field(default=None, description="配置值")
class FileNameData(BaseModel):
"""文件操作结果中的文件名。"""
filename: Optional[str] = Field(default=None, description="文件名")
class NameData(BaseModel):
"""名称计算结果。"""
name: Optional[str] = Field(default=None, description="名称")
class ServiceClientInfo(BaseModel):
"""可选择的下载器或媒体服务器摘要。"""
name: Optional[str] = Field(default=None, description="实例名称")
type: Optional[str] = Field(default=None, description="服务类型")
class ProgressKeyData(BaseModel):
"""异步任务进度查询标识。"""
progress_key: str = Field(description="进度查询标识")
class BatchProgressKeyData(ProgressKeyData):
"""批量异步任务进度查询标识。"""
history_ids: list[int] = Field(default_factory=list, description="历史记录 ID 列表")
class TimeData(BaseModel):
"""网络请求耗时。"""
time: int | float = Field(description="耗时毫秒数")

View File

@@ -1,7 +1,8 @@
from typing import Optional, Dict, List, Union, Any
from typing import Annotated, Optional, Dict, List, Union, Any
from pydantic import BaseModel, Field
from pydantic import BaseModel, Discriminator, Field, RootModel, Tag
from app.schemas.common import JsonData
from app.schemas.music import MusicInfo, MusicMeta
from app.schemas.media import OptionalMediaIdentityMixin
from app.schemas.types import MediaSource
@@ -73,6 +74,144 @@ class MetaInfo(OptionalMediaIdentityMixin, BaseModel):
media_id: Optional[str] = None
class MediaImageSet(BaseModel):
"""跨媒体源兼容的人物图片尺寸集合。"""
large: Optional[str] = None
common: Optional[str] = None
medium: Optional[str] = None
normal: Optional[str] = None
small: Optional[str] = None
grid: Optional[str] = None
class MediaCredit(BaseModel):
"""影视条目中的演职员摘要。"""
id: Optional[int | str] = None
name: Optional[str] = None
original_name: Optional[str] = None
character: Optional[str] = None
type: Optional[str | int] = None
gender: Optional[str | int] = None
adult: Optional[bool] = None
known_for_department: Optional[str] = None
profile_path: Optional[str] = None
credit_id: Optional[str] = None
cast_id: Optional[int] = None
order: Optional[int] = None
department: Optional[str] = None
job: Optional[str] = None
popularity: Optional[float] = None
roles: list[str] = Field(default_factory=list)
title: Optional[str] = None
url: Optional[str] = None
uri: Optional[str] = None
sharing_url: Optional[str] = None
avatar: Optional[str | MediaImageSet] = None
images: Optional[MediaImageSet] = None
latin_name: Optional[str] = None
career: list[str] = Field(default_factory=list)
relation: Optional[str] = None
user: Optional[JsonData] = None
class MediaGenre(BaseModel):
"""影视风格摘要。"""
id: Optional[int | str] = None
name: Optional[str] = None
class MediaCompany(BaseModel):
"""电视网或制作公司的标准摘要。"""
id: Optional[int | str] = None
name: Optional[str] = None
logo_path: Optional[str] = None
origin_country: Optional[str] = None
class MediaCountry(BaseModel):
"""影视制作国家或地区。"""
id: Optional[int | str] = None
iso_3166_1: Optional[str] = None
name: Optional[str] = None
class MediaLanguage(BaseModel):
"""影视内容使用的语言。"""
english_name: Optional[str] = None
iso_639_1: Optional[str] = None
name: Optional[str] = None
class MediaReleaseDate(BaseModel):
"""电影在单个地区的一次发行记录。"""
date: str
iso_code: Optional[str] = None
note: Optional[str] = None
type: Optional[int] = None
class MediaEpisode(BaseModel):
"""电视剧即将播出的单集摘要。"""
id: Optional[int] = None
air_date: Optional[str] = None
episode_number: Optional[int] = None
episode_type: Optional[str] = None
name: Optional[str] = None
overview: Optional[str] = None
production_code: Optional[str] = None
runtime: Optional[int] = None
season_number: Optional[int] = None
show_id: Optional[int] = None
still_path: Optional[str] = None
vote_average: Optional[float] = None
vote_count: Optional[int] = None
class MediaSeason(BaseModel):
"""标准季信息以及剧集组季信息。"""
id: Optional[int | str] = None
air_date: Optional[str] = None
episode_count: Optional[int] = None
name: Optional[str] = None
overview: Optional[str] = None
poster_path: Optional[str] = None
season_number: Optional[int] = None
vote_average: Optional[float] = None
order: Optional[int] = None
locked: Optional[bool] = None
episodes: list[MediaEpisode] = Field(default_factory=list)
class MediaEpisodeGroupNetwork(BaseModel):
"""TMDB 剧集组所属电视网信息。"""
id: Optional[int] = None
name: Optional[str] = None
logo_path: Optional[str] = None
origin_country: Optional[str] = None
class MediaEpisodeGroup(BaseModel):
"""TMDB 电视剧的剧集分组摘要。"""
description: str = ""
episode_count: int = 0
group_count: int = 0
id: str
name: str
network: Optional[MediaEpisodeGroupNetwork] = None
type: int
class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
"""
识别媒体信息
@@ -87,6 +226,10 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
title: Optional[str] = None
# 英文标题
en_title: Optional[str] = None
# 香港、台湾、新加坡地区标题
hk_title: Optional[str] = None
tw_title: Optional[str] = None
sg_title: Optional[str] = None
# 年份
year: Optional[str] = None
# 标题(年份)
@@ -116,6 +259,8 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
backdrop_path: Optional[str] = None
# 海报图片
poster_path: Optional[str] = None
# 标题 LOGO
logo_path: Optional[str] = None
# 评分
vote_average: Optional[float] = 0.0
# 描述
@@ -123,58 +268,60 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
# 二级分类
category: Optional[str] = ""
# 季季集清单
seasons: Optional[Dict[int, list]] = Field(default_factory=dict)
seasons: Optional[Dict[int, list[int]]] = Field(default_factory=dict)
# 季详情
season_info: Optional[List[dict]] = Field(default_factory=list)
season_info: Optional[List[MediaSeason]] = Field(default_factory=list)
# 各季首播年份
season_years: Optional[Dict[int, str]] = Field(default_factory=dict)
# 别名和译名
names: Optional[list] = Field(default_factory=list)
names: Optional[list[str]] = Field(default_factory=list)
# 演员
actors: Optional[list] = Field(default_factory=list)
actors: Optional[list[MediaCredit]] = Field(default_factory=list)
# 导演
directors: Optional[list] = Field(default_factory=list)
directors: Optional[list[MediaCredit]] = Field(default_factory=list)
# 详情链接
detail_link: Optional[str] = None
# 其它TMDB属性
# 是否成人内容
adult: Optional[bool] = False
# 创建人
created_by: Optional[list] = Field(default_factory=list)
created_by: Optional[list[MediaCredit]] = Field(default_factory=list)
# 集时长
episode_run_time: Optional[list] = Field(default_factory=list)
episode_run_time: Optional[list[int]] = Field(default_factory=list)
# 风格
genres: Optional[List[dict]] = Field(default_factory=list)
genres: Optional[List[MediaGenre]] = Field(default_factory=list)
# 首播日期
first_air_date: Optional[str] = None
# 首页
homepage: Optional[str] = None
# 语种
languages: Optional[list] = Field(default_factory=list)
languages: Optional[list[str]] = Field(default_factory=list)
# 最后上映日期
last_air_date: Optional[str] = None
# 流媒体平台
networks: Optional[list] = Field(default_factory=list)
networks: Optional[list[MediaCompany]] = Field(default_factory=list)
# 集数
number_of_episodes: Optional[int] = 0
# 季数
number_of_seasons: Optional[int] = 0
# 原产国
origin_country: Optional[list] = Field(default_factory=list)
origin_country: Optional[list[str]] = Field(default_factory=list)
# 原名
original_name: Optional[str] = None
# 出品公司
production_companies: Optional[list] = Field(default_factory=list)
production_companies: Optional[list[MediaCompany]] = Field(default_factory=list)
# 出品国
production_countries: Optional[list] = Field(default_factory=list)
production_countries: Optional[list[MediaCountry]] = Field(default_factory=list)
# 语种
spoken_languages: Optional[list] = Field(default_factory=list)
spoken_languages: Optional[list[MediaLanguage]] = Field(default_factory=list)
# 所有发行日期
release_dates: list = Field(default_factory=list)
release_dates: list[MediaReleaseDate] = Field(default_factory=list)
# 状态
status: Optional[str] = None
# 标签
tagline: Optional[str] = None
# 风格ID
genre_ids: Optional[list] = Field(default_factory=list)
genre_ids: Optional[list[int | str]] = Field(default_factory=list)
# 评价数量
vote_count: Optional[int] = 0
# 流行度
@@ -182,11 +329,18 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
# 时长
runtime: Optional[int] = None
# 下一集
next_episode_to_air: Optional[dict] = Field(default_factory=dict)
next_episode_to_air: Optional[MediaEpisode] = None
# 内容分级
content_rating: Optional[str] = None
# 全部剧集组
episode_groups: Optional[list] = Field(default_factory=list)
episode_groups: Optional[list[MediaEpisodeGroup | MediaSeason]] = Field(default_factory=list)
# 剧集组
episode_group: Optional[str] = None
# 各数据源原始信息Core MediaInfo.to_dict() 保留这些键供兼容调用方使用。
tmdb_info: Optional[dict[str, JsonData]] = None
douban_info: Optional[dict[str, JsonData]] = None
bangumi_info: Optional[dict[str, JsonData]] = None
anilist_info: Optional[dict[str, JsonData]] = None
class TorrentInfo(OptionalMediaIdentityMixin, BaseModel):
@@ -239,7 +393,7 @@ class TorrentInfo(OptionalMediaIdentityMixin, BaseModel):
# HR
hit_and_run: Optional[bool] = False
# 种子标签
labels: Optional[list] = Field(default_factory=list)
labels: Optional[list[str]] = Field(default_factory=list)
# 种子优先级
pri_order: Optional[int] = 0
# 种子分类 电影/电视剧/音乐
@@ -326,19 +480,6 @@ class Context(BaseModel):
confirmed_full_coverage: Optional[bool] = False
class MediaSeason(BaseModel):
"""
季信息
"""
air_date: Optional[str] = None
episode_count: Optional[int] = None
name: Optional[str] = None
overview: Optional[str] = None
poster_path: Optional[str] = None
season_number: Optional[int] = None
vote_average: Optional[float] = None
class MediaPerson(BaseModel):
"""
媒体人物信息
@@ -346,17 +487,17 @@ class MediaPerson(BaseModel):
# 来源themoviedb、douban、bangumi、anilist
source: Optional[str] = None
# 公共
id: Optional[int] = None
id: Optional[int | str] = None
type: Optional[Union[str, int]] = 1
name: Optional[str] = None
character: Optional[str] = None
images: Optional[dict] = Field(default_factory=dict)
images: Optional[MediaImageSet] = None
# themoviedb
profile_path: Optional[str] = None
gender: Optional[Union[str, int]] = None
original_name: Optional[str] = None
credit_id: Optional[str] = None
also_known_as: Optional[list] = Field(default_factory=list)
also_known_as: Optional[list[str]] = Field(default_factory=list)
birthday: Optional[str] = None
deathday: Optional[str] = None
imdb_id: Optional[str] = None
@@ -365,11 +506,37 @@ class MediaPerson(BaseModel):
popularity: Optional[float] = None
biography: Optional[str] = None
# douban
roles: Optional[list] = Field(default_factory=list)
roles: Optional[list[str]] = Field(default_factory=list)
title: Optional[str] = None
url: Optional[str] = None
avatar: Optional[Union[str, dict]] = None
avatar: Optional[Union[str, MediaImageSet]] = None
latin_name: Optional[str] = None
# bangumi
career: Optional[list] = Field(default_factory=list)
career: Optional[list[str]] = Field(default_factory=list)
relation: Optional[str] = None
def _media_search_result_kind(value: Any) -> str:
"""按稳定字段区分音乐、人物与影视/合集搜索结果。"""
if isinstance(value, BaseModel):
value = value.model_dump()
if isinstance(value, dict):
if value.get("type") == "音乐" or "music_type" in value:
return "music"
if "source" in value and "media_source" not in value:
return "person"
return "media"
MediaSearchResult = Annotated[
Union[
Annotated[MusicInfo, Tag("music")],
Annotated[MediaPerson, Tag("person")],
Annotated[MediaInfo, Tag("media")],
],
Discriminator(_media_search_result_kind),
]
class MediaSearchResults(RootModel[List[MediaSearchResult]]):
"""媒体、音乐、合集与人物的统一搜索结果列表。"""

View File

@@ -3,6 +3,7 @@ from typing import Optional
from pydantic import BaseModel, Field, model_validator
from app.helper.locale import LocaleHelper
from app.schemas.common import JsonData
class Statistic(BaseModel):
@@ -120,7 +121,7 @@ class ScheduleProgress(BaseModel):
# 多语言错误信息
error_i18n: Optional[str] = None
# 扩展数据
data: Optional[dict] = Field(default_factory=dict)
data: Optional[dict[str, JsonData]] = Field(default_factory=dict)
@model_validator(mode="after")
def fill_i18n_fields(self) -> "ScheduleProgress":

View File

@@ -25,3 +25,15 @@ class DownloadDirectory(BaseModel):
priority: Optional[int] = Field(default=0, description="目录优先级")
media_type: Optional[str] = Field(default=None, description="适用媒体类型")
media_category: Optional[str] = Field(default=None, description="适用媒体分类")
class DownloadAddedData(BaseModel):
"""下载任务添加结果。"""
download_id: Optional[str] = Field(default=None, description="下载任务 ID")
class SubtitleDownloadData(BaseModel):
"""字幕下载结果。"""
files: list[str] = Field(default_factory=list, description="已保存字幕文件列表")

View File

@@ -3,6 +3,7 @@ from typing import Iterable, Optional, Dict, Any, List, Set, Callable
from pydantic import BaseModel, Field, field_validator, model_validator
from app.schemas.common import JsonData
from app.schemas.message import MessageChannel
from app.schemas.file import FileItem
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
@@ -484,11 +485,11 @@ class DiscoverMediaSource(BaseModel):
name: str = Field(..., description="数据源名称")
media_source: MediaSource = Field(..., description="媒体来源枚举")
api_path: str = Field(..., description="媒体数据源API地址")
filter_params: Optional[Dict[str, Any]] = Field(
filter_params: Optional[Dict[str, JsonData]] = Field(
default=None, description="过滤参数"
)
filter_ui: Optional[List[dict]] = Field(default=[], description="过滤参数UI配置")
depends: Optional[Dict[str, list]] = Field(
filter_ui: Optional[List[Dict[str, JsonData]]] = Field(default=[], description="过滤参数UI配置")
depends: Optional[Dict[str, list[str]]] = Field(
default=None, description="UI依赖关系字典"
)

View File

@@ -10,6 +10,8 @@ WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:[\\/]")
class FileURI(BaseModel):
"""带存储类型的文件 URI。"""
# 文件路径
path: Optional[str] = "/"
# 存储类型
@@ -45,6 +47,8 @@ class FileURI(BaseModel):
class FileItem(FileURI):
"""文件或目录条目,目录可递归包含子条目。"""
# 类型 dir/file
type: Optional[str] = None
# 文件名
@@ -58,7 +62,7 @@ class FileItem(FileURI):
# 修改时间
modify_time: Optional[float] = None
# 子节点
children: Optional[list] = Field(default_factory=list)
children: Optional[list["FileItem"]] = Field(default_factory=list)
# ID
fileid: Optional[str] = None
# 父ID
@@ -74,6 +78,8 @@ class FileItem(FileURI):
class StorageUsage(BaseModel):
"""存储空间使用情况。"""
# 总空间
total: float = 0.0
# 剩余空间
@@ -81,5 +87,7 @@ class StorageUsage(BaseModel):
class StorageTransType(BaseModel):
"""存储支持的传输类型及其显示名称。"""
# 传输类型
transtype: Optional[dict] = Field(default_factory=dict)
transtype: Optional[dict[str, str]] = Field(default_factory=dict)

View File

@@ -1,7 +1,8 @@
from typing import Optional, Any
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.common import JsonData
from app.schemas.media import OptionalMediaIdentityMixin
from app.schemas.types import MediaSource
@@ -52,7 +53,7 @@ class DownloadHistory(OptionalMediaIdentityMixin, BaseModel):
# 创建时间
date: Optional[str] = None
# 备注
note: Optional[Any] = None
note: Optional[JsonData] = None
# 自定义媒体类别
media_category: Optional[str] = None
# 自定义剧集组
@@ -121,4 +122,13 @@ class TransferHistory(OptionalMediaIdentityMixin, BaseModel):
class BatchTransferHistoryRedoRequest(BaseModel):
"""批量重新整理历史请求。"""
history_ids: list[int] = Field(default_factory=list)
class TransferHistoryPage(BaseModel):
"""整理历史分页数据。"""
list: List[TransferHistory] = Field(default_factory=list, description="整理历史列表")
total: int = Field(default=0, description="记录总数")

115
app/schemas/llm.py Normal file
View File

@@ -0,0 +1,115 @@
"""LLM 配置、目录和测试 API 输出模型。"""
from typing import Optional
from pydantic import BaseModel, Field
class LLMAuthStatus(BaseModel):
"""LLM 提供商授权状态摘要。"""
connected: bool = False
type: Optional[str] = None
label: Optional[str] = None
expires_at: Optional[int | float | str] = None
updated_at: Optional[int | float | str] = None
class LLMServerToolCapability(BaseModel):
"""模型支持的服务端工具能力。"""
id: str
required_api_protocol: Optional[str] = None
client_adapter: Optional[str] = None
class LLMModelInfo(BaseModel):
"""标准化 LLM 模型目录项。"""
id: str
name: str
family: Optional[str] = None
context_tokens: Optional[int] = None
input_tokens: Optional[int] = None
output_tokens: Optional[int] = None
context_tokens_k: Optional[int] = None
supports_reasoning: bool = False
supports_tools: bool = False
supports_image_input: bool = False
supports_audio_input: bool = False
transport: Optional[str] = None
source: Optional[str] = None
release_date: Optional[str] = None
status: Optional[str] = None
server_tools: list[LLMServerToolCapability] = Field(default_factory=list)
class LLMModelCatalogData(BaseModel):
"""指定提供商的模型目录。"""
provider: str
models: list[LLMModelInfo] = Field(default_factory=list)
auth_status: LLMAuthStatus
class LLMProviderAuthMethod(BaseModel):
"""LLM 提供商可用的交互授权方式。"""
id: str
type: str
label: str
description: Optional[str] = None
class LLMProviderBaseUrlPreset(BaseModel):
"""LLM 提供商预设基础地址。"""
id: str
label: str
value: str
runtime: Optional[str] = None
model_list_strategy: Optional[str] = None
class LLMProviderInfo(BaseModel):
"""前端可配置的 LLM 提供商定义。"""
id: str
name: str
runtime: str
default_base_url: str = ""
base_url_presets: list[LLMProviderBaseUrlPreset] = Field(default_factory=list)
base_url_editable: bool = True
requires_base_url: bool = False
supports_api_key: bool = True
api_key_label: Optional[str] = None
api_key_hint: Optional[str] = None
supports_model_refresh: bool = True
oauth_methods: list[LLMProviderAuthMethod] = Field(default_factory=list)
description: Optional[str] = None
auth_status: LLMAuthStatus
class LLMProviderAuthSession(BaseModel):
"""LLM 提供商交互授权会话。"""
session_id: str
provider_id: Optional[str] = None
flow_type: Optional[str] = None
status: Optional[str] = None
message: Optional[str] = None
authorize_url: Optional[str] = None
verification_url: Optional[str] = None
user_code: Optional[str] = None
instructions: Optional[str] = None
interval_seconds: Optional[int] = None
expires_at: Optional[int | float] = None
class LLMTestResult(BaseModel):
"""LLM 连通性测试结果。"""
provider: str
model: str
duration_ms: Optional[int] = None
reply_preview: Optional[str] = None

View File

@@ -1,16 +1,193 @@
from typing import Any, Dict, Optional
from typing import Dict, Literal, Optional, TypeAlias, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, RootModel, TypeAdapter
from app.schemas.common import JsonData
class ToolCallRequest(BaseModel):
"""工具调用请求模型"""
tool_name: str = Field(..., description="工具名称")
arguments: Dict[str, Any] = Field(default_factory=dict, description="工具参数")
arguments: Dict[str, JsonData] = Field(default_factory=dict, description="工具参数")
class ToolCallResponse(BaseModel):
"""工具调用响应模型"""
success: bool = Field(..., description="是否成功")
result: Optional[str] = Field(None, description="工具执行结果")
error: Optional[str] = Field(None, description="错误信息")
class McpJsonSchema(RootModel[dict[str, JsonData]]):
"""MCP 工具的 JSON Schema。"""
class McpToolInfo(BaseModel):
"""MCP REST 工具摘要。"""
name: str = Field(description="工具名称")
description: str = Field(default="", description="工具说明")
inputSchema: McpJsonSchema = Field(description="工具参数 JSON Schema")
class ToolCallData(BaseModel):
"""MCP REST 工具调用成功后的业务数据。"""
result: str = Field(description="工具执行结果")
class McpJsonRpcClientInfo(BaseModel):
"""MCP JSON-RPC 客户端信息。"""
name: str
version: Optional[str] = None
class McpJsonRpcInitializeParams(BaseModel):
"""MCP initialize 请求参数。"""
protocolVersion: str
capabilities: dict[str, JsonData] = Field(default_factory=dict)
clientInfo: Optional[McpJsonRpcClientInfo] = None
class McpJsonRpcToolCallParams(BaseModel):
"""MCP tools/call 请求参数。"""
name: str
arguments: dict[str, JsonData] = Field(default_factory=dict)
class McpJsonRpcInitializeRequest(BaseModel):
"""MCP initialize JSON-RPC 请求。"""
jsonrpc: Literal["2.0"]
id: str | int
method: Literal["initialize"]
params: McpJsonRpcInitializeParams
class McpJsonRpcInitializedNotification(BaseModel):
"""MCP initialized JSON-RPC 通知。"""
jsonrpc: Literal["2.0"]
method: Literal["notifications/initialized"]
params: dict[str, JsonData] = Field(default_factory=dict)
class McpJsonRpcToolsListRequest(BaseModel):
"""MCP tools/list JSON-RPC 请求。"""
jsonrpc: Literal["2.0"]
id: str | int
method: Literal["tools/list"]
params: dict[str, JsonData] = Field(default_factory=dict)
class McpJsonRpcToolsCallRequest(BaseModel):
"""MCP tools/call JSON-RPC 请求。"""
jsonrpc: Literal["2.0"]
id: str | int
method: Literal["tools/call"]
params: McpJsonRpcToolCallParams
class McpJsonRpcPingRequest(BaseModel):
"""MCP ping JSON-RPC 请求。"""
jsonrpc: Literal["2.0"]
id: str | int
method: Literal["ping"]
params: dict[str, JsonData] = Field(default_factory=dict)
McpJsonRpcRequest: TypeAlias = Union[
McpJsonRpcInitializeRequest,
McpJsonRpcInitializedNotification,
McpJsonRpcToolsListRequest,
McpJsonRpcToolsCallRequest,
McpJsonRpcPingRequest,
]
class McpJsonRpcServerInfo(BaseModel):
"""MCP 服务器信息。"""
name: str
version: str
description: Optional[str] = None
class McpJsonRpcToolsCapability(BaseModel):
"""MCP 工具能力声明。"""
listChanged: bool = False
class McpJsonRpcCapabilities(BaseModel):
"""MCP 服务器能力声明。"""
tools: McpJsonRpcToolsCapability
logging: dict[str, JsonData] = Field(default_factory=dict)
class McpJsonRpcInitializeResult(BaseModel):
"""MCP initialize 响应结果。"""
protocolVersion: str
capabilities: McpJsonRpcCapabilities
serverInfo: McpJsonRpcServerInfo
instructions: str
class McpJsonRpcToolsListResult(BaseModel):
"""MCP tools/list 响应结果。"""
tools: list[McpToolInfo] = Field(default_factory=list)
class McpJsonRpcTextContent(BaseModel):
"""MCP 工具调用文本内容块。"""
type: Literal["text"] = "text"
text: str
class McpJsonRpcToolCallResult(BaseModel):
"""MCP tools/call 响应结果。"""
content: list[McpJsonRpcTextContent] = Field(default_factory=list)
isError: bool = False
class McpJsonRpcEmptyResult(BaseModel):
"""MCP ping 的空结果。"""
class McpJsonRpcSuccess(BaseModel):
"""MCP JSON-RPC 成功响应。"""
jsonrpc: Literal["2.0"] = "2.0"
id: Optional[str | int] = None
result: Union[
McpJsonRpcInitializeResult,
McpJsonRpcToolsListResult,
McpJsonRpcToolCallResult,
McpJsonRpcEmptyResult,
]
class McpJsonRpcErrorDetail(BaseModel):
"""MCP JSON-RPC 错误详情。"""
code: int
message: str
data: Optional[JsonData] = None
class McpJsonRpcError(BaseModel):
"""MCP JSON-RPC 错误响应。"""
jsonrpc: Literal["2.0"] = "2.0"
id: Optional[str | int] = None
error: McpJsonRpcErrorDetail
McpJsonRpcResponse: TypeAlias = Union[McpJsonRpcSuccess, McpJsonRpcError]
# 保持协议端点自行解析并返回 JSON-RPC 错误,同时从同一组 Pydantic 模型生成请求文档。
MCP_JSONRPC_REQUEST_SCHEMA = TypeAdapter(McpJsonRpcRequest).json_schema()

View File

@@ -1,8 +1,9 @@
from pathlib import Path
from typing import Optional, Dict, Union, List, Any
from pydantic import BaseModel, Field, ConfigDict, model_validator
from pydantic import BaseModel, Field, ConfigDict, RootModel, model_validator
from app.schemas.common import JsonData
from app.schemas.media import OptionalMediaIdentityMixin
from app.schemas.types import MediaSource, MediaType
@@ -14,7 +15,7 @@ class ExistMediaInfo(BaseModel):
# 类型 电影、电视剧、音乐
type: Optional[MediaType] = None
# 季
seasons: Optional[Dict[int, list]] = Field(default_factory=dict)
seasons: Optional[Dict[int, List[int]]] = Field(default_factory=dict)
# 媒体服务器类型plex、jellyfin、emby、zspace、trimemedia、ugreen、navidrome
server_type: Optional[str] = None
# 媒体服务器名称
@@ -23,6 +24,25 @@ class ExistMediaInfo(BaseModel):
itemid: Optional[Union[str, int]] = None
class MediaServerPlayData(BaseModel):
"""媒体服务器在线播放地址。"""
url: str = Field(description="播放地址")
item_id: Optional[str] = Field(default=None, description="媒体项目 ID")
server_id: Optional[str] = Field(default=None, description="媒体服务器 ID")
server_type: Optional[str] = Field(default=None, description="媒体服务器类型")
class MediaServerExistsData(BaseModel):
"""本地媒体存在性查询结果。"""
item: Dict[str, str] = Field(default_factory=dict, description="命中的媒体项目")
class MediaServerExistingEpisodes(RootModel[Dict[int, List[int]]]):
"""媒体服务器中按季号归组的已存在集号。"""
class NotExistMediaInfo(BaseModel):
"""
媒体服务器不存在媒体信息
@@ -30,7 +50,7 @@ class NotExistMediaInfo(BaseModel):
# 季
season: Optional[int] = None
# 剧集列表
episodes: Optional[list] = Field(default_factory=list)
episodes: Optional[List[int]] = Field(default_factory=list)
# 总集数
total_episode: Optional[int] = 0
# 开始集
@@ -70,7 +90,7 @@ class MediaServerLibrary(BaseModel):
# 名称
name: Optional[str] = None
# 路径
path: Optional[Union[str, list]] = None
path: Optional[Union[str, List[str]]] = None
# 类型
type: Optional[str] = None
# 媒体库内媒体数量
@@ -88,6 +108,8 @@ class MediaServerLibrary(BaseModel):
class MediaServerItemUserState(BaseModel):
"""媒体服务器条目的用户播放状态。"""
# 已播放
played: Optional[bool] = None
# 继续播放
@@ -128,9 +150,9 @@ class MediaServerItem(OptionalMediaIdentityMixin, BaseModel):
# 路径
path: Optional[str] = None
# 季集
seasoninfo: Optional[Dict[int, list]] = None
seasoninfo: Optional[Dict[int, List[int]]] = None
# 备注
note: Optional[Any] = None
note: Optional[JsonData] = None
# 同步时间
lst_mod_date: Optional[str] = None
user_state: Optional[MediaServerItemUserState] = None
@@ -172,7 +194,7 @@ class WebhookEventInfo(BaseModel):
save_reason: Optional[str] = None
item_isvirtual: Optional[bool] = None
media_type: Optional[str] = None
json_object: Optional[dict] = Field(default_factory=dict)
json_object: Optional[dict[str, JsonData]] = Field(default_factory=dict)
@model_validator(mode="before")
@classmethod
@@ -236,7 +258,7 @@ class MediaServerPlayItem(BaseModel):
image: Optional[str] = None
link: Optional[str] = None
percent: Optional[float] = None
BackdropImageTags: Optional[list] = Field(default_factory=list)
BackdropImageTags: Optional[List[str]] = Field(default_factory=list)
server_type: Optional[str] = None
# 飞牛的图片需要Cookies
use_cookies: Optional[bool] = None

View File

@@ -4,6 +4,7 @@ from typing import Optional, Union, List, Dict, Set, Any
from pydantic import BaseModel, Field, field_validator
from app.schemas.common import JsonData
from app.schemas.types import ContentType, NotificationType, MessageChannel
@@ -47,7 +48,7 @@ class MessageResponse(BaseModel):
# 消息来源
source: Optional[str] = None
# 渠道自定义上下文(如飞书流式卡片 card_id/element_id/sequence
metadata: Optional[Dict[str, Any]] = None
metadata: Optional[Dict[str, JsonData]] = None
# 是否发送成功
success: bool = False
@@ -80,7 +81,17 @@ class NotificationHistoryItem(BaseModel):
# 消息方向0-接收消息1-发送消息
action: Optional[int] = None
# 附件json
note: Optional[Union[list, dict]] = None
note: Optional[JsonData] = None
class WebMessageItem(NotificationHistoryItem):
"""Web 消息历史记录。"""
class NotificationClearData(BaseModel):
"""通知中心各范围的清理时间。"""
clear_before: NotificationClearBefore = Field(description="各范围清理时间")
class CommingMessage(BaseModel):
@@ -300,7 +311,7 @@ class Subscription(BaseModel):
"""
endpoint: Optional[str] = None
keys: Optional[dict] = Field(default_factory=dict)
keys: Optional[dict[str, str]] = Field(default_factory=dict)
class SubscriptionMessage(BaseModel):
@@ -312,7 +323,7 @@ class SubscriptionMessage(BaseModel):
body: Optional[str] = None
icon: Optional[str] = None
url: Optional[str] = None
data: Optional[dict] = Field(default_factory=dict)
data: Optional[dict[str, JsonData]] = Field(default_factory=dict)
class AgentWebChatRequest(BaseModel):
@@ -345,7 +356,7 @@ class AgentWebChatRequest(BaseModel):
# 文件附件列表
files: Optional[List[AgentWebChatFile]] = Field(default_factory=list)
# 用户通过按钮选择时的完整选择快照
choice_selection: Optional[Dict[str, Any]] = Field(default=None)
choice_selection: Optional[Dict[str, JsonData]] = Field(default=None)
# WebAgent 按钮回调关联的原消息 ID用于传统交互原地编辑卡片
original_message_id: Optional[Union[str, int]] = Field(default=None)
# WebAgent 按钮回调关联的原聊天 ID用于传统交互原地编辑卡片

42
app/schemas/mfa.py Normal file
View File

@@ -0,0 +1,42 @@
"""多因素认证 API 输出模型。"""
from typing import Optional
from pydantic import BaseModel, Field, RootModel
from app.schemas.common import JsonData
class PasskeyOptions(RootModel[dict[str, JsonData]]):
"""浏览器 WebAuthn API 使用的动态选项。"""
class OtpGenerateData(BaseModel):
"""OTP 绑定密钥和验证 URI。"""
secret: str = Field(description="OTP 密钥")
uri: str = Field(description="OTP 验证 URI")
class MfaStatusData(BaseModel):
"""用户是否启用多因素认证。"""
enabled: bool = Field(description="是否启用多因素认证")
class PasskeyStartData(BaseModel):
"""PassKey 注册或认证的启动数据。"""
options: PasskeyOptions = Field(description="WebAuthn 选项")
transaction_token: str = Field(description="一次性事务令牌")
class PasskeyInfo(BaseModel):
"""当前用户绑定的 PassKey 摘要。"""
id: int
name: str
created_at: Optional[str] = None
last_used_at: Optional[str] = None
aaguid: Optional[str] = None
transports: Optional[str] = None

View File

@@ -1,7 +1,8 @@
from typing import Any, Literal, Optional, Union
from typing import Literal, Optional, Union
from pydantic import BaseModel, Field
from app.schemas.common import JsonData
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
from app.schemas.types import MediaSource, MusicEntityType, MusicTargetEntityType
@@ -75,7 +76,7 @@ class MusicInfo(OptionalMediaIdentityMixin, BaseModel):
names: list[str] = Field(default_factory=list)
detail_link: Optional[str] = None
listen_count: Optional[int] = None
raw_data: dict[str, Any] = Field(default_factory=dict)
raw_data: dict[str, JsonData] = Field(default_factory=dict)
title_year: Optional[str] = None
poster_path: Optional[str] = None
backdrop_path: Optional[str] = None
@@ -125,7 +126,7 @@ class MusicAlbumInfo(OptionalMediaIdentityMixin, BaseModel):
detail_link: Optional[str] = None
tracks: list[MusicInfo] = Field(default_factory=list)
releases: list[MusicRelease] = Field(default_factory=list)
raw_data: dict[str, Any] = Field(default_factory=dict)
raw_data: dict[str, JsonData] = Field(default_factory=dict)
title_year: Optional[str] = None
poster_path: Optional[str] = None
backdrop_path: Optional[str] = None
@@ -160,7 +161,7 @@ class MusicArtistInfo(OptionalMediaIdentityMixin, BaseModel):
detail_link: Optional[str] = None
external_links: dict[str, str] = Field(default_factory=dict)
album_count: Optional[int] = None
raw_data: dict[str, Any] = Field(default_factory=dict)
raw_data: dict[str, JsonData] = Field(default_factory=dict)
poster_path: Optional[str] = None
overview: Optional[str] = None
@@ -171,3 +172,25 @@ class MusicRecognizeRequest(RequiredMediaIdentityMixin, BaseModel):
media_source: MediaSource
media_id: str
music_type: Optional[MusicTargetEntityType] = None
class MusicRecognitionCacheItem(BaseModel):
"""单条 MusicBrainz 识别缓存。"""
key: str
media_id: str = ""
title: str = ""
artists: list[str] = Field(default_factory=list)
album: str = ""
year: str | int = ""
music_type: str = "recording"
cover_url: str = ""
class MusicRecognitionCacheData(BaseModel):
"""MusicBrainz 识别缓存统计及明细。"""
count: int = 0
recognized: int = 0
unrecognized: int = 0
data: list[MusicRecognitionCacheItem] = Field(default_factory=list)

View File

@@ -0,0 +1,29 @@
"""通知渠道 API 输出模型。"""
from typing import Optional
from pydantic import BaseModel, Field
class WechatClawBotKnownTarget(BaseModel):
"""微信 ClawBot 已知消息目标。"""
userid: str
username: str
last_active: Optional[int | float] = None
class WechatClawBotData(BaseModel):
"""微信 ClawBot 登录状态或操作结果。"""
success: bool
message: Optional[str] = None
connected: Optional[bool] = None
account_id: Optional[str] = None
qrcode: Optional[str] = None
qrcode_url: Optional[str] = None
qrcode_status: Optional[str] = None
qrcode_updated_at: Optional[int | float] = None
known_targets: list[WechatClawBotKnownTarget] = Field(default_factory=list)
default_target: Optional[str] = None
base_url: Optional[str] = None

View File

@@ -1,7 +1,9 @@
from typing import Any, Dict, List, Optional
from typing import Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.common import JsonData
class OpenAIModelInfo(BaseModel):
id: str
@@ -16,14 +18,18 @@ class OpenAIModelListResponse(BaseModel):
class OpenAIChatMessage(BaseModel):
"""OpenAI Chat Completions 请求中的一条消息。"""
role: str
content: Any
content: JsonData
name: Optional[str] = None
model_config = ConfigDict(extra="allow")
class OpenAIChatCompletionsRequest(BaseModel):
"""OpenAI Chat Completions 兼容请求。"""
model: Optional[str] = None
messages: List[OpenAIChatMessage]
user: Optional[str] = None
@@ -33,8 +39,10 @@ class OpenAIChatCompletionsRequest(BaseModel):
class OpenAIResponsesRequest(BaseModel):
"""OpenAI Responses API 兼容请求。"""
model: Optional[str] = None
input: Any
input: JsonData
instructions: Optional[str] = None
user: Optional[str] = None
stream: bool = False
@@ -69,9 +77,30 @@ class OpenAIChatCompletionResponse(BaseModel):
class OpenAIResponsesOutputText(BaseModel):
"""Responses API 输出中的文本内容块。"""
type: str = "output_text"
text: str
annotations: List[Dict[str, Any]] = Field(default_factory=list)
annotations: List["OpenAIResponseAnnotation"] = Field(default_factory=list)
class OpenAIResponseAnnotation(BaseModel):
"""Responses API 文本内容关联的引用或文件注解。"""
type: str
index: Optional[int] = None
start_index: Optional[int] = None
end_index: Optional[int] = None
url: Optional[str] = None
title: Optional[str] = None
file_id: Optional[str] = None
filename: Optional[str] = None
class OpenAIIncompleteDetails(BaseModel):
"""Responses API 未完整结束时的原因。"""
reason: str
class OpenAIResponsesOutputMessage(BaseModel):
@@ -83,14 +112,16 @@ class OpenAIResponsesOutputMessage(BaseModel):
class OpenAIResponsesResponse(BaseModel):
"""OpenAI Responses API 的非流式成功响应。"""
id: str
object: str = "response"
created_at: int
status: str = "completed"
model: str
output: List[OpenAIResponsesOutputMessage] = Field(default_factory=list)
error: Optional[Any] = None
incomplete_details: Optional[Any] = None
error: Optional["OpenAIErrorDetail"] = None
incomplete_details: Optional[OpenAIIncompleteDetails] = None
usage: OpenAIUsage
@@ -105,20 +136,24 @@ class OpenAIErrorResponse(BaseModel):
error: OpenAIErrorDetail
OpenAIChatContentPart = Dict[str, Any]
OpenAIChatContentPart = Dict[str, JsonData]
class AnthropicMessage(BaseModel):
"""Anthropic Messages 请求中的一条消息。"""
role: str
content: Any
content: JsonData
model_config = ConfigDict(extra="allow")
class AnthropicMessagesRequest(BaseModel):
"""Anthropic Messages 兼容请求。"""
model: Optional[str] = None
messages: List[AnthropicMessage]
system: Optional[Any] = None
system: Optional[JsonData] = None
max_tokens: Optional[int] = 1024
stream: bool = False

View File

@@ -1,6 +1,8 @@
from typing import Optional, List, Dict, Any
from typing import Optional, List, Dict
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, RootModel
from app.schemas.common import JsonData
class Plugin(BaseModel):
@@ -51,7 +53,7 @@ class Plugin(BaseModel):
# 安装次数
install_count: Optional[int] = 0
# 更新记录
history: Optional[dict] = Field(default_factory=dict)
history: Optional[dict[str, str]] = Field(default_factory=dict)
# 添加时间,值越小表示越靠后发布
add_time: Optional[int] = 0
# 插件公钥
@@ -70,11 +72,11 @@ class PluginDashboard(Plugin):
# 演染模式
render_mode: Optional[str] = Field(default="vuetify")
# 全局配置
attrs: Optional[dict] = Field(default_factory=dict)
attrs: Optional[dict[str, JsonData]] = Field(default_factory=dict)
# col列数
cols: Optional[dict] = Field(default_factory=dict)
cols: Optional[dict[str, JsonData]] = Field(default_factory=dict)
# 页面元素
elements: Optional[List[dict]] = Field(default_factory=list)
elements: Optional[List[dict[str, JsonData]]] = Field(default_factory=list)
class PluginSidebarNavItem(BaseModel):
@@ -115,6 +117,10 @@ class PluginRating(BaseModel):
user_rating: Optional[float] = Field(default=None, description="当前安装实例评分")
class PluginRatingMap(RootModel[Dict[str, PluginRating]]):
"""插件 ID 与评分结果的映射。"""
class PluginMemoryInfo(BaseModel):
"""插件内存信息"""
plugin_id: str = Field(description="插件ID")
@@ -126,4 +132,46 @@ class PluginMemoryInfo(BaseModel):
calculation_time_ms: float = Field(description="计算耗时(毫秒)")
timestamp: float = Field(description="统计时间戳")
error: Optional[str] = Field(default=None, description="错误信息")
object_details: Optional[List[Dict[str, Any]]] = Field(default=None, description="大对象详情")
object_details: Optional[List[Dict[str, JsonData]]] = Field(default=None, description="大对象详情")
class PluginRemoteInfo(BaseModel):
"""插件模块联邦远程入口。"""
id: str
url: str
name: str
class PluginReleaseItem(BaseModel):
"""可安装的插件 Release 版本。"""
version: str
tag_name: str
name: str
published_at: Optional[str] = None
body: str = ""
asset_name: str
is_latest: bool = False
is_current: bool = False
class PluginReleaseData(BaseModel):
"""插件 Release 能力与版本列表。"""
release_supported: bool = False
latest_version: Optional[str] = None
current_version: Optional[str] = None
items: List[PluginReleaseItem] = Field(default_factory=list)
class PluginFoldersData(RootModel[Dict[str, List[str]]]):
"""插件文件夹与插件 ID 列表映射。"""
class PluginDashboardMetaItem(BaseModel):
"""插件仪表板入口摘要。"""
id: str
name: Optional[str] = None
key: Optional[str] = None

View File

@@ -1,29 +1,48 @@
from typing import Any, Optional
from typing import Any, Generic, Optional, TypeVar
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, ConfigDict, field_validator
from app.helper.locale import LocaleHelper
class Response(BaseModel):
"""通用接口响应结构"""
DataT = TypeVar("DataT")
class Response(BaseModel, Generic[DataT]):
"""统一接口响应结构,仅允许业务数据类型随接口变化。"""
model_config = ConfigDict(
extra="forbid",
json_schema_extra={"required": ["success", "message", "data"]}
)
# 状态
success: bool
# 消息文本
message: Optional[str] = None
# 多语言消息文本
message_i18n: Optional[str] = None
message: str = ""
# 数据
data: Optional[Any] = Field(default_factory=dict)
data: Optional[DataT] = None
@model_validator(mode="after")
def fill_message_i18n(self) -> "Response":
"""
自动补充响应消息的多语言文本。
"""
if self.message and self.message_i18n is None:
self.message_i18n = LocaleHelper.translate_text(
self.message, locale=LocaleHelper.get_current_locale()
)
return self
@field_validator("message", mode="before")
@classmethod
def localize_message(cls, value: Any) -> str:
"""按当前请求语言直接本地化消息文本,并将空消息归一为空字符串。"""
if value is None:
return ""
message = str(value)
if not message:
return ""
return LocaleHelper.translate_text(
message, locale=LocaleHelper.get_current_locale()
)
class ValidationIssue(BaseModel):
"""请求参数校验失败时返回的单项错误信息。"""
# 参数位置
location: list[str | int]
# 错误说明
message: str
# 错误类型
error_type: str

22
app/schemas/search.py Normal file
View File

@@ -0,0 +1,22 @@
"""搜索 API 输出模型。"""
from typing import Literal, Union
from pydantic import BaseModel, Field
from app.schemas.common import JsonData
from app.schemas.context import SubtitleInfo, TorrentInfo
class SearchLastContextData(BaseModel):
"""上一次搜索的请求参数与结果。"""
params: dict[str, JsonData] = Field(default_factory=dict)
results: list[Union[TorrentInfo, SubtitleInfo]] = Field(default_factory=list)
class SearchRecommendStatusData(BaseModel):
"""AI 搜索结果推荐任务状态。"""
status: Literal["disabled", "idle", "running", "completed", "error"]
results: list[int] = Field(default_factory=list)

View File

@@ -1,8 +1,185 @@
from typing import Optional
from pydantic import BaseModel, Field
class ServarrVersion(BaseModel):
"""Servarr 兼容接口使用的版本号结构。"""
major: int = 0
minor: int = 0
build: int = 0
revision: int = 0
majorRevision: int = 0
minorRevision: int = 0
class ServarrSystemStatus(BaseModel):
"""Servarr 系统状态响应。"""
appName: str
instanceName: str
version: str
buildTime: str
isDebug: bool
isProduction: bool
isAdmin: bool
isUserInteractive: bool
startupPath: str
appData: str
osName: str
osVersion: str
isNetCore: bool
isLinux: bool
isOsx: bool
isWindows: bool
isDocker: bool
mode: str
branch: str
databaseType: str
databaseVersion: ServarrVersion
authentication: str
migrationVersion: int
urlBase: str
runtimeVersion: ServarrVersion
runtimeName: str
startTime: str
packageVersion: str
packageAuthor: str
packageUpdateMechanism: str
packageUpdateMechanismMessage: str
class ServarrQuality(BaseModel):
"""Servarr 质量定义。"""
id: int
name: str
source: str
resolution: int
class ServarrQualityProfileItem(BaseModel):
"""Servarr 质量配置中的可选质量项。"""
id: int
name: str
quality: ServarrQuality
items: list[str] = Field(default_factory=list)
allowed: bool
class ServarrFormatItem(BaseModel):
"""Servarr 自定义格式评分项。"""
id: int
format: int
name: str
score: int
class ServarrQualityProfile(BaseModel):
"""Servarr 质量配置响应项。"""
id: int
name: str
upgradeAllowed: bool
cutoff: int
items: list[ServarrQualityProfileItem] = Field(default_factory=list)
minFormatScore: int
cutoffFormatScore: int
formatItems: list[ServarrFormatItem] = Field(default_factory=list)
class ServarrRootFolder(BaseModel):
"""Servarr 根目录响应项。"""
id: int
path: str
accessible: bool
freeSpace: int
unmappedFolders: list[str] = Field(default_factory=list)
class ServarrTag(BaseModel):
"""Servarr 标签响应项。"""
id: int
label: str
class ServarrLanguage(BaseModel):
"""Servarr 语言定义。"""
id: int
name: str
class ServarrLanguageProfileItem(BaseModel):
"""Servarr 语言配置中的可选语言项。"""
id: int
language: ServarrLanguage
allowed: bool
class ServarrLanguageProfile(BaseModel):
"""Servarr 语言配置响应项。"""
id: int
name: str
upgradeAllowed: bool
cutoff: ServarrLanguage
languages: list[ServarrLanguageProfileItem] = Field(default_factory=list)
class ServarrIdResponse(BaseModel):
"""Servarr 新增资源后返回的资源标识。"""
id: int
class ServarrImage(BaseModel):
"""Servarr 媒体图片。"""
coverType: Optional[str] = None
url: Optional[str] = None
remoteUrl: Optional[str] = None
class SonarrStatistics(BaseModel):
"""Sonarr 剧集或季度统计信息。"""
seasonCount: Optional[int] = None
episodeFileCount: Optional[int] = None
episodeCount: Optional[int] = None
totalEpisodeCount: Optional[int] = None
sizeOnDisk: Optional[int] = None
releaseGroups: list[str] = Field(default_factory=list)
percentOfEpisodes: Optional[float] = None
nextAiring: Optional[str] = None
previousAiring: Optional[str] = None
class SonarrSeason(BaseModel):
"""Sonarr 季度监控信息。"""
seasonNumber: Optional[int] = None
monitored: bool = False
statistics: Optional[SonarrStatistics] = None
images: list[ServarrImage] = Field(default_factory=list)
class SonarrRatings(BaseModel):
"""Sonarr 剧集评分信息。"""
votes: Optional[int] = None
value: Optional[float] = None
class RadarrMovie(BaseModel):
"""Radarr 兼容接口的电影结构。"""
id: Optional[int] = None
title: Optional[str] = None
year: Optional[str | int] = None
@@ -20,6 +197,8 @@ class RadarrMovie(BaseModel):
class SonarrSeries(BaseModel):
"""Sonarr 兼容接口的剧集结构。"""
id: Optional[int] = None
title: Optional[str] = None
sortTitle: Optional[str] = None
@@ -28,9 +207,9 @@ class SonarrSeries(BaseModel):
overview: Optional[str] = None
network: Optional[str] = None
airTime: Optional[str] = None
images: list = Field(default_factory=list)
images: list[ServarrImage] = Field(default_factory=list)
remotePoster: Optional[str] = None
seasons: list = Field(default_factory=list)
seasons: list[SonarrSeason] = Field(default_factory=list)
year: Optional[str | int] = None
path: Optional[str] = None
profileId: Optional[int] = None
@@ -49,11 +228,11 @@ class SonarrSeries(BaseModel):
cleanTitle: Optional[str] = None
titleSlug: Optional[str] = None
certification: Optional[str] = None
genres: list = Field(default_factory=list)
tags: list = Field(default_factory=list)
genres: list[str] = Field(default_factory=list)
tags: list[int] = Field(default_factory=list)
added: Optional[str] = None
ratings: Optional[dict] = None
ratings: Optional[SonarrRatings] = None
qualityProfileId: Optional[int] = None
statistics: dict = Field(default_factory=dict)
statistics: SonarrStatistics = Field(default_factory=SonarrStatistics)
isAvailable: Optional[bool] = False
hasFile: Optional[bool] = False

View File

@@ -1,11 +1,38 @@
from fastapi import Query
from pydantic import BaseModel
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.common import JsonData
class CookieData(BaseModel):
encrypted: str = Query(min_length=1, max_length=1024 * 1024 * 50)
uuid: str = Query(min_length=5, pattern="^[a-zA-Z0-9]+$")
"""CookieCloud 上传的加密数据。"""
encrypted: str = Field(min_length=1, max_length=1024 * 1024 * 50)
uuid: str = Field(min_length=5, pattern="^[a-zA-Z0-9]+$")
class CookiePassword(BaseModel):
"""CookieCloud 下载并解密数据所需的密码。"""
password: str
class CookieActionResponse(BaseModel):
"""CookieCloud 上传操作结果。"""
action: Literal["done", "error"]
class CookieEncryptedPayload(BaseModel):
"""CookieCloud 保存和下载的加密载荷。"""
encrypted: str
class CookieDecryptedPayload(BaseModel):
"""CookieCloud 解密后的 Cookie 数据载荷。"""
model_config = ConfigDict(extra="allow")
cookie_data: JsonData

View File

@@ -1,9 +1,20 @@
from typing import Optional, Any, Union, Dict
from typing import Optional, Union, Dict
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, Field, ConfigDict, RootModel
from app.schemas.common import JsonData
SiteUnreadMessage = Union[
tuple[Optional[str], Optional[str], Optional[str]],
tuple[Optional[str], Optional[str], Optional[str], Optional[str]],
]
"""站点未读消息,第四项为部分站点提供的持久化去重来源。"""
class Site(BaseModel):
"""站点配置及运行状态。"""
# ID
id: Optional[int] = None
# 站点名称
@@ -33,7 +44,7 @@ class Site(BaseModel):
# 是否公开站点
public: Optional[int] = 0
# 备注
note: Optional[Any] = None
note: Optional[JsonData] = None
# 超时时间
timeout: Optional[int] = 15
# 流控单位周期
@@ -51,6 +62,8 @@ class Site(BaseModel):
class SiteStatistic(BaseModel):
"""单个站点的访问成功率与耗时统计。"""
# 站点ID
domain: Optional[str] = None
# 成功次数
@@ -64,12 +77,14 @@ class SiteStatistic(BaseModel):
# 最后修改时间
lst_mod_date: Optional[str] = None
# 备注
note: Optional[Any] = None
note: Optional[Dict[str, int]] = None
model_config = ConfigDict(from_attributes=True)
class SiteUserData(BaseModel):
"""站点用户账户、流量、做种和未读消息数据。"""
# 站点域名
domain: Optional[str] = None
# 用户名
@@ -97,11 +112,11 @@ class SiteUserData(BaseModel):
# 下载体积
leeching_size: Optional[int] = 0
# 做种人数, 种子大小
seeding_info: Optional[list] = Field(default_factory=list)
seeding_info: Optional[list[tuple[int, int]]] = Field(default_factory=list)
# 未读消息
message_unread: Optional[int] = 0
# 未读消息内容
message_unread_contents: Optional[list] = Field(default_factory=list)
message_unread_contents: Optional[list[SiteUnreadMessage]] = Field(default_factory=list)
# 错误信息
err_msg: Optional[str] = None
# 更新日期
@@ -111,6 +126,8 @@ class SiteUserData(BaseModel):
class SiteAuth(BaseModel):
"""站点认证模块及其参数。"""
site: Optional[str] = None
params: Optional[Dict[str, Union[int, str]]] = Field(default_factory=dict)
@@ -125,6 +142,18 @@ class SiteCookieUpdate(BaseModel):
class SiteCategory(BaseModel):
"""站点资源分类。"""
id: Optional[int] = None
cat: Optional[str] = None
desc: Optional[str] = None
class SiteIconData(BaseModel):
"""站点图标地址或 Base64 内容。"""
icon: str
class SiteMappingData(RootModel[dict[str, str]]):
"""站点域名到显示名称的映射。"""

26
app/schemas/storage.py Normal file
View File

@@ -0,0 +1,26 @@
"""存储授权 API 输出模型。"""
from typing import Optional
from pydantic import BaseModel, Field
class StorageQrCodeData(BaseModel):
"""云存储扫码授权二维码。"""
codeContent: Optional[str] = Field(default=None, description="二维码原始内容")
codeUrl: Optional[str] = Field(default=None, description="二维码图片地址")
class StorageAuthUrlData(BaseModel):
"""云存储 OAuth 授权入口。"""
authUrl: str = Field(description="授权地址")
state: str = Field(description="授权状态校验值")
class StorageLoginStatusData(BaseModel):
"""云存储扫码或 OAuth 登录状态。"""
status: int | str = Field(description="授权状态")
tip: str = Field(description="状态提示")

View File

@@ -110,7 +110,7 @@ class Subscribe(OptionalMediaIdentityMixin, BaseModel):
# 已完成集数
completed_episode: Optional[int] = None
# 附加信息
note: Optional[Any] = None
note: Optional[List[int]] = None
# 状态N-新建, R-订阅中
state: Optional[str] = None
# 最后更新时间
@@ -197,6 +197,8 @@ class Subscribe(OptionalMediaIdentityMixin, BaseModel):
class SubscribeShare(OptionalMediaIdentityMixin, BaseModel):
"""可供其他用户复用的订阅分享信息。"""
# 分享ID
id: Optional[int] = None
# 订阅ID
@@ -268,6 +270,8 @@ class SubscribeShare(OptionalMediaIdentityMixin, BaseModel):
class SubscribeShareStatistics(BaseModel):
"""单个用户的订阅分享数量与复用统计。"""
# 分享人
share_user: Optional[str] = None
# 分享数量
@@ -277,6 +281,8 @@ class SubscribeShareStatistics(BaseModel):
class SubscribeDownloadFileInfo(BaseModel):
"""订阅剧集关联的下载文件信息。"""
# 种子名称
torrent_title: Optional[str] = None
# 站点名称
@@ -290,6 +296,8 @@ class SubscribeDownloadFileInfo(BaseModel):
class SubscribeLibraryFileInfo(BaseModel):
"""订阅剧集关联的媒体库文件信息。"""
# 存储
storage: Optional[str] = "local"
# 文件路径
@@ -303,6 +311,8 @@ class SubscribeLibraryFileInfo(BaseModel):
class SubscribeEpisodeInfo(BaseModel):
"""订阅单集的元数据及关联文件。"""
# 标题
title: Optional[str] = None
# 描述
@@ -316,6 +326,8 @@ class SubscribeEpisodeInfo(BaseModel):
class SubscrbieInfo(BaseModel):
"""订阅详情及按集号归组的文件信息。"""
# 订阅信息
subscribe: Optional[Subscribe] = None
# 集信息 {集号: {download: 文件路径library: 文件路径, backdrop: url, title: 标题, description: 描述}}

View File

@@ -3,6 +3,9 @@ from typing import Optional, Any
from pydantic import BaseModel, Field, field_validator
from app.schemas.context import MediaInfo, MetaInfo, TorrentInfo
from app.schemas.rule import FilterRuleGroup
@dataclass
class ServiceInfo:
@@ -132,6 +135,61 @@ class StorageConf(BaseModel):
config: Optional[dict] = Field(default_factory=dict)
class SystemEnvironmentUpdateData(BaseModel):
"""环境配置更新的成功项和失败项。"""
success_updates: dict[str, tuple[Optional[bool], str]] = Field(default_factory=dict)
failed_updates: dict[str, tuple[Optional[bool], str]] = Field(default_factory=dict)
class PluginMarketSyncData(BaseModel):
"""Wiki 插件市场仓库同步结果。"""
value: str
repos: list[str] = Field(default_factory=list)
wiki_repos: list[str] = Field(default_factory=list)
added_count: int = 0
total_count: int = 0
source_url: str
class RuleTestData(BaseModel):
"""过滤规则测试的输入、识别和匹配明细。"""
title: str
subtitle: Optional[str] = None
rulegroup_name: str
rulegroup: Optional[FilterRuleGroup] = None
meta_info: MetaInfo
media_info: Optional[MediaInfo] = None
torrent_info: TorrentInfo
priority: Optional[int] = None
matched: bool = False
class NetTestTarget(BaseModel):
"""前端可选择的网络测试目标。"""
id: str
name: str
icon: str
class SystemModuleInfo(BaseModel):
"""已加载系统模块摘要。"""
id: str
name: str
name_i18n: str
name_key: str
class SystemModuleListData(BaseModel):
"""已加载系统模块列表。"""
modules: list[SystemModuleInfo] = Field(default_factory=list)
class TransferDirectoryConf(BaseModel):
"""
文件整理目录配置

View File

@@ -16,6 +16,34 @@ class TmdbSeason(BaseModel):
vote_average: Optional[float] = None
class TmdbEpisodeCredit(BaseModel):
"""TMDB 剧集演职人员的公共信息。"""
adult: Optional[bool] = None
gender: Optional[int] = None
id: Optional[int] = None
known_for_department: Optional[str] = None
name: Optional[str] = None
original_name: Optional[str] = None
popularity: Optional[float] = None
profile_path: Optional[str] = None
credit_id: Optional[str] = None
class TmdbEpisodeCrew(TmdbEpisodeCredit):
"""TMDB 剧集幕后人员信息。"""
department: Optional[str] = None
job: Optional[str] = None
class TmdbEpisodeGuestStar(TmdbEpisodeCredit):
"""TMDB 剧集客串演员信息。"""
character: Optional[str] = None
order: Optional[int] = None
class TmdbEpisode(BaseModel):
"""
TMDB集信息
@@ -29,5 +57,28 @@ class TmdbEpisode(BaseModel):
season_number: Optional[int] = None
still_path: Optional[str] = None
vote_average: Optional[float] = None
crew: Optional[list] = Field(default_factory=list)
guest_stars: Optional[list] = Field(default_factory=list)
crew: Optional[list[TmdbEpisodeCrew]] = Field(default_factory=list)
guest_stars: Optional[list[TmdbEpisodeGuestStar]] = Field(default_factory=list)
class TmdbRecognitionCacheItem(BaseModel):
"""单条 TMDB 识别缓存。"""
key: str
tmdb_id: int = 0
title: str = ""
year: str | int = ""
media_type: str = "unknown"
poster_path: str = ""
backdrop_path: str = ""
class TmdbRecognitionCacheData(BaseModel):
"""TMDB 识别缓存统计及明细。"""
count: int = 0
recognized: int = 0
unrecognized: int = 0
shared_recognized: int = 0
shared_recognize_enabled: bool = False
data: list[TmdbRecognitionCacheItem] = Field(default_factory=list)

View File

@@ -3,7 +3,16 @@ from typing import Optional
from pydantic import BaseModel, Field
class MfaChallenge(BaseModel):
"""密码认证通过后需要继续完成的二次验证信息。"""
# 可用的二次验证方式
mfa_methods: list[str] = Field(default_factory=list)
class Token(BaseModel):
"""OAuth2 登录成功后返回的访问令牌。"""
# 令牌
access_token: str
# 令牌类型
@@ -19,12 +28,14 @@ class Token(BaseModel):
# 权限级别
level: int = 1
# 详细权限
permissions: Optional[dict] = Field(default_factory=dict)
permissions: Optional[dict[str, bool]] = Field(default_factory=dict)
# 是否显示配置向导
wizard: Optional[bool] = None
class TokenPayload(BaseModel):
"""访问令牌中携带的用户身份与授权信息。"""
# 用户ID
sub: Optional[int] = None
# 用户名

View File

@@ -41,12 +41,32 @@ class DownloaderTorrent(BaseModel):
ratio_limit: Optional[float] = None
seeding_time_limit: Optional[int] = None
trackers: Optional[List[str]] = Field(default_factory=list)
media: Optional[dict] = Field(default_factory=dict)
media: Optional["DownloadTaskMedia"] = None
userid: Optional[str] = None
username: Optional[str] = None
left_time: Optional[str] = None
class DownloadTaskMedia(OptionalMediaIdentityMixin, BaseModel):
"""下载任务关联的影视或音乐媒体摘要。"""
type: Optional[str] = None
title: Optional[str] = None
season: Optional[list[int] | int | str] = None
episode: Optional[list[int] | int | str] = None
image: Optional[str] = None
poster: Optional[str] = None
backdrop: Optional[str] = None
media_source: Optional[MediaSource] = None
media_id: Optional[str] = None
music_type: Optional[str] = None
artists: list[str] = Field(default_factory=list)
album: Optional[str] = None
album_id: Optional[str] = None
total_tracks: Optional[int] = None
track_number: Optional[int] = None
class TransferTorrent(DownloaderTorrent):
"""
待转移任务信息
@@ -281,3 +301,60 @@ class ManualTransferTargetPath(BaseModel):
library_type_folder: Optional[bool] = False
# 媒体库类别子目录
library_category_folder: Optional[bool] = False
class ManualTransferPreviewSummary(BaseModel):
"""手动整理预览数量统计。"""
total: int = 0
success: int = 0
failed: int = 0
class ManualTransferPreviewItem(BaseModel):
"""单个文件的手动整理预览。"""
source: Optional[str] = None
target: Optional[str] = None
target_dir: Optional[str] = None
success: bool = False
message: Optional[str] = None
type: Optional[str] = None
title: Optional[str] = None
season: Optional[int] = None
episode: Optional[int] = None
episode_end: Optional[int] = None
part: Optional[int | str] = None
org_string: Optional[str] = None
apply_words: list[str] = Field(default_factory=list)
resource_team: Optional[str] = None
customization: Optional[str] = None
class ManualTransferResultData(BaseModel):
"""手动整理预览或执行结果数据。"""
summary: Optional[ManualTransferPreviewSummary] = None
items: list[ManualTransferPreviewItem] = Field(default_factory=list)
message: Optional[str] = None
class EpisodeFormatRecommendData(BaseModel):
"""集数定位模板推荐结果。"""
rule_name: str
episode_format: str
sample_file: str
pattern: Optional[str] = None
rule_index: Optional[int] = None
min_file_size_mb: Optional[int] = None
sample_count: Optional[int] = None
majority_count: Optional[int] = None
confidence: Optional[str] = None
size_filter_relaxed: Optional[bool] = None
native_verified_count: Optional[int] = None
native_fallback_count: Optional[int] = None
native_conflict_count: Optional[int] = None
reason: Optional[str] = None
reasons: list[str] = Field(default_factory=list)
message: Optional[str] = None

View File

@@ -2,9 +2,13 @@ from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
from app.schemas.common import JsonData
# Shared properties
class UserBase(BaseModel):
"""用户公共资料、权限和个性化设置。"""
# 用户名
name: str
# 邮箱,未启用
@@ -18,33 +22,39 @@ class UserBase(BaseModel):
# 是否开启二次验证
is_otp: Optional[bool] = False
# 权限
permissions: Optional[dict] = Field(default_factory=dict)
permissions: Optional[dict[str, bool]] = Field(default_factory=dict)
# 个性化设置
settings: Optional[dict] = Field(default_factory=dict)
settings: Optional[dict[str, JsonData]] = Field(default_factory=dict)
model_config = ConfigDict(from_attributes=True)
# Properties to receive via API on creation
class UserCreate(UserBase):
"""创建用户时接收的资料和初始凭据。"""
name: str
email: Optional[str] = None
password: Optional[str] = None
settings: Optional[dict] = Field(default_factory=dict)
permissions: Optional[dict] = Field(default_factory=dict)
settings: Optional[dict[str, JsonData]] = Field(default_factory=dict)
permissions: Optional[dict[str, bool]] = Field(default_factory=dict)
# Properties to receive via API on update
class UserUpdate(UserBase):
"""更新用户时接收的完整资料。"""
id: int
name: str
email: Optional[str] = None
password: Optional[str] = None
settings: Optional[dict] = Field(default_factory=dict)
permissions: Optional[dict] = Field(default_factory=dict)
settings: Optional[dict[str, JsonData]] = Field(default_factory=dict)
permissions: Optional[dict[str, bool]] = Field(default_factory=dict)
class UserInDBBase(UserBase):
"""包含数据库主键的用户公共记录。"""
id: Optional[int] = None
model_config = ConfigDict(from_attributes=True)
@@ -52,10 +62,36 @@ class UserInDBBase(UserBase):
# Additional properties to return via API
class User(UserInDBBase):
"""对 API 调用方公开的用户资料。"""
name: str
email: Optional[str] = None
# Additional properties stored in DB
class UserInDB(UserInDBBase):
"""包含密码哈希的内部用户记录。"""
hashed_password: str
class AuthProviderRemote(BaseModel):
"""插件认证提供方的远程组件信息。"""
id: str
url: str
name: str
class AuthProviderInfo(BaseModel):
"""匿名登录页可展示的认证提供方摘要。"""
id: str
type: str
name: str
enabled: bool = True
method: Optional[str] = None
icon: Optional[str] = None
component: Optional[str] = None
plugin_id: Optional[str] = None
remote: Optional[AuthProviderRemote] = None

View File

@@ -2,6 +2,7 @@ from typing import Any, List, Optional
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.common import JsonData
from app.schemas.context import Context, MediaInfo
from app.schemas.download import DownloadTask
from app.schemas.file import FileItem
@@ -9,6 +10,58 @@ from app.schemas.site import Site
from app.schemas.subscribe import Subscribe
class WorkflowExecutionConfig(BaseModel):
"""工作流调度器的执行参数。"""
max_workers: Optional[int] = Field(default=None, ge=1, description="最大并发动作数")
class WorkflowNodeState(BaseModel):
"""工作流单个动作的持久化执行状态。"""
state: Optional[str] = None
attempt: int = 0
started_at: Optional[str] = None
finished_at: Optional[str] = None
message: Optional[str] = None
class WorkflowRuntimeState(BaseModel):
"""工作流调度器的实时进度摘要。"""
progress: int = 0
finished_actions: int = 0
running_tasks: int = 0
errors: dict[str, str] = Field(default_factory=dict)
node_states: dict[str, str] = Field(default_factory=dict)
attempts: dict[str, int] = Field(default_factory=dict)
class WorkflowExecutionState(BaseModel):
"""可恢复的工作流结构化执行快照。"""
version: int = 1
nodes: dict[str, WorkflowNodeState] = Field(default_factory=dict)
outputs: dict[str, JsonData] = Field(default_factory=dict)
errors: dict[str, str] = Field(default_factory=dict)
runtime: WorkflowRuntimeState = Field(default_factory=WorkflowRuntimeState)
class ActionPosition(BaseModel):
"""工作流画布中的动作坐标。"""
x: float = 0
y: float = 0
class ActionRetry(BaseModel):
"""动作失败后的重试策略。"""
max_attempts: int = Field(default=1, ge=1)
interval: float = Field(default=0, ge=0)
backoff: float = Field(default=1, ge=1)
class Workflow(BaseModel):
"""
工作流信息
@@ -19,15 +72,15 @@ class Workflow(BaseModel):
timer: Optional[str] = Field(default=None, description="定时器")
trigger_type: Optional[str] = Field(default='timer', description="触发类型timer-定时触发 event-事件触发 manual-手动触发")
event_type: Optional[str] = Field(default=None, description="事件类型当trigger_type为event时使用")
event_conditions: Optional[dict] = Field(default_factory=dict, description="事件条件JSON格式用于过滤事件")
event_conditions: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="事件条件JSON格式用于过滤事件")
state: Optional[str] = Field(default=None, description="状态")
current_action: Optional[str] = Field(default=None, description="已执行动作")
result: Optional[str] = Field(default=None, description="任务执行结果")
run_count: Optional[int] = Field(default=0, description="已执行次数")
actions: Optional[list] = Field(default_factory=list, description="任务列表")
flows: Optional[list] = Field(default_factory=list, description="任务流")
execution_config: Optional[dict] = Field(default_factory=dict, description="工作流执行配置")
execution_state: Optional[dict] = Field(default_factory=dict, description="工作流结构化执行状态")
actions: Optional[list["Action"]] = Field(default_factory=list, description="任务列表")
flows: Optional[list["ActionFlow"]] = Field(default_factory=list, description="任务流")
execution_config: Optional[WorkflowExecutionConfig] = Field(default_factory=WorkflowExecutionConfig, description="工作流执行配置")
execution_state: Optional[WorkflowExecutionState] = Field(default_factory=WorkflowExecutionState, description="工作流结构化执行状态")
add_time: Optional[str] = Field(default=None, description="创建时间")
last_time: Optional[str] = Field(default=None, description="最后执行时间")
@@ -50,16 +103,16 @@ class Action(BaseModel):
type: Optional[str] = Field(default=None, description="动作类型 (类名)")
name: Optional[str] = Field(default=None, description="动作名称")
description: Optional[str] = Field(default=None, description="动作描述")
position: Optional[dict] = Field(default_factory=dict, description="位置")
data: Optional[dict] = Field(default_factory=dict, description="参数")
position: Optional[ActionPosition] = Field(default_factory=ActionPosition, description="位置")
data: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="参数")
inputs: Optional[List[str]] = Field(default_factory=list, description="动作输入声明")
outputs: Optional[dict] = Field(default_factory=dict, description="动作输出声明")
outputs: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="动作输出声明")
join_policy: Optional[str] = Field(default=None, description="多上游节点汇合策略")
fail_policy: Optional[str] = Field(default=None, description="动作失败后的工作流处理策略")
branch_policy: Optional[str] = Field(default=None, description="多出边分支策略")
concurrency_key: Optional[str] = Field(default=None, description="并发互斥键")
timeout: Optional[int] = Field(default=None, description="动作执行超时时间(秒)")
retry: Optional[dict] = Field(default_factory=dict, description="动作重试策略")
retry: Optional[ActionRetry] = Field(default=None, description="动作重试策略")
class ActionExecution(BaseModel):
@@ -82,10 +135,10 @@ class ActionContext(BaseModel):
downloads: Optional[List[DownloadTask]] = Field(default_factory=list, description="下载任务列表")
sites: Optional[List[Site]] = Field(default_factory=list, description="站点列表")
subscribes: Optional[List[Subscribe]] = Field(default_factory=list, description="订阅列表")
workflow_context: Optional[dict] = Field(default_factory=dict, description="工作流全局上下文")
node_outputs: Optional[dict] = Field(default_factory=dict, description="节点输出数据")
runtime_state: Optional[dict] = Field(default_factory=dict, description="运行期状态")
artifacts: Optional[dict] = Field(default_factory=dict, description="大对象引用与产物数据")
workflow_context: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="工作流全局上下文")
node_outputs: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="节点输出数据")
runtime_state: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="运行期状态")
artifacts: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="大对象引用与产物数据")
execute_history: Optional[List[ActionExecution]] = Field(default_factory=list, description="执行历史")
progress: Optional[int] = Field(default=0, description="执行进度(%")
@@ -97,6 +150,7 @@ class ActionResult(BaseModel):
success: Optional[bool] = Field(default=True, description="动作是否执行成功")
message: Optional[str] = Field(default=None, description="动作执行消息")
context: Optional[ActionContext] = Field(default=None, description="动作执行后的上下文")
# 动作内部可暂存待序列化对象API 输出前由工作流序列化器转为 JsonData。
outputs: Optional[dict[str, Any]] = Field(default_factory=dict, description="当前节点显式输出")
next_policy: Optional[str] = Field(default=None, description="动作完成后的调度策略")
attempts: Optional[int] = Field(default=1, description="动作实际尝试次数")
@@ -110,7 +164,7 @@ class ActionFlow(BaseModel):
source: Optional[str] = Field(default=None, description="源动作")
target: Optional[str] = Field(default=None, description="目标动作")
animated: Optional[bool] = Field(default=True, description="是否动画流程")
data: Optional[dict] = Field(default_factory=dict, description="流程扩展配置")
data: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="流程扩展配置")
condition: Optional[str] = Field(default=None, description="流转条件表达式")
join_policy: Optional[str] = Field(default=None, description="目标节点汇合策略")
branch_policy: Optional[str] = Field(default=None, description="源节点分支策略")
@@ -138,3 +192,47 @@ class WorkflowShare(BaseModel):
count: Optional[int] = Field(default=0, description="复用人次")
model_config = ConfigDict(from_attributes=True)
class PluginWorkflowActionGroup(BaseModel):
"""单个插件声明的工作流动作组。"""
plugin_id: str
plugin_name: str
actions: list[dict[str, JsonData]] = Field(default_factory=list)
class ActionContractField(BaseModel):
"""动作契约中的单个输入或输出字段。"""
name: str
label: str
kind: str = "scalar"
merge: Optional[str] = None
identity: Optional[str] = None
class ActionContract(BaseModel):
"""工作流动作对编辑器公开的输入输出契约。"""
inputs: list[ActionContractField] = Field(default_factory=list)
outputs: list[ActionContractField] = Field(default_factory=list)
condition_fields: list[ActionContractField] = Field(default_factory=list)
concurrency_key: Optional[str] = None
class WorkflowActionDefinition(BaseModel):
"""可用于工作流编辑器的动作定义。"""
type: str
name: str
description: Optional[str] = None
contract: ActionContract = Field(default_factory=ActionContract)
data: dict[str, JsonData] = Field(default_factory=dict)
class NameValueOption(BaseModel):
"""前端选项的显示文本和值。"""
title: str
value: str

View File

@@ -8,15 +8,10 @@ def init_routers(app: FastAPI):
初始化路由
"""
from app.api.apiv1 import api_router
from app.api.apiv2 import api_router_v2
from app.api.apiv2_utils import API_V2_STR, configure_v2_openapi
from app.api.servarr import arr_router
from app.api.servcookie import cookie_router
# API路由
app.include_router(api_router, prefix=settings.API_V1_STR)
# v2 API复用v1路由仅在响应出口统一封装
app.include_router(api_router_v2, prefix=API_V2_STR)
configure_v2_openapi(app)
# Radarr、Sonarr路由
app.include_router(arr_router, prefix="/api/v3")
# CookieCloud路由

View File

@@ -264,27 +264,6 @@ def supports_extended_media_ids() -> bool:
)
@lru_cache(maxsize=1)
def supports_unified_media_identity() -> bool:
"""判断当前 Rust 扩展是否支持固定来源的通用媒体身份标签。"""
if not is_enabled():
return False
try:
result = _moviepilot_rust.find_metainfo_fast(
"test {[media_source=musicbrainz;media_id=recording-1]}"
)
except BaseException as err:
_raise_non_rust_panic(err)
logger.debug(f"检测 Rust 通用媒体身份能力失败:{err}")
return False
metainfo = result.get("metainfo") if isinstance(result, dict) else None
return bool(
metainfo
and metainfo.get("media_source") == "musicbrainz"
and metainfo.get("media_id") == "recording-1"
)
def _raise_non_rust_panic(err: BaseException) -> None:
"""
只吞掉 Rust 扩展 panic/异常,保留用户中断和进程退出语义。

View File

@@ -2,6 +2,8 @@ import threading
from time import monotonic, sleep
from typing import Any, Dict, List, Optional, Tuple
from pydantic import BaseModel
from app.core.config import global_vars
from app.core.event import eventmanager, Event
from app.db.models import Workflow
@@ -212,6 +214,8 @@ class WorkFlowManager(metaclass=Singleton):
def _get_retry_config(self, action: Action) -> dict:
retry_config = action.retry or self._get_action_data_value(action, "retry") or {}
if isinstance(retry_config, BaseModel):
retry_config = retry_config.model_dump(exclude_none=True)
if not isinstance(retry_config, dict):
retry_config = {}
return {