mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-10 09:56:48 +08:00
refactor(api): standardize responses and restore media config contracts
This commit is contained in:
@@ -54,7 +54,8 @@ class UpdateCustomIdentifiersTool(MoviePilotTool):
|
|||||||
"4) Combined: '被替换词 => 替换词 && 前定位词 <> 后定位词 >> EP±N'; "
|
"4) Combined: '被替换词 => 替换词 && 前定位词 <> 后定位词 >> EP±N'; "
|
||||||
"Lines starting with '#' are comments. "
|
"Lines starting with '#' are comments. "
|
||||||
"The replacement target supports: "
|
"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."
|
"g is an optional episode group ID for TV recognition."
|
||||||
)
|
)
|
||||||
require_admin: bool = True
|
require_admin: bool = True
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
+86
-15
@@ -13,12 +13,13 @@ from pathlib import Path
|
|||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import Any, AsyncIterator, Callable, Optional, Union
|
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.concurrency import run_in_threadpool
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.agent import MoviePilotAgent, ReplyMode, StreamingHandler, agent_manager
|
from app.agent import MoviePilotAgent, ReplyMode, StreamingHandler, agent_manager
|
||||||
from app.agent.llm.capability import AgentCapabilityManager
|
from app.agent.llm.capability import AgentCapabilityManager
|
||||||
from app.agent.mcp import agent_mcp_manager
|
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.log import logger
|
||||||
from app.schemas.types import EventType, MessageChannel
|
from app.schemas.types import EventType, MessageChannel
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
WEB_AGENT_SESSION_PREFIX = "web-agent:"
|
WEB_AGENT_SESSION_PREFIX = "web-agent:"
|
||||||
WEB_AGENT_SOURCE = "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")
|
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(
|
async def list_agent_mcp_servers(
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
) -> schemas.Response:
|
) -> 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(
|
async def save_agent_mcp_servers(
|
||||||
request: schemas.AgentMcpServersSaveRequest,
|
request: schemas.AgentMcpServersSaveRequest,
|
||||||
current_user: User = Depends(get_current_active_user),
|
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(
|
async def test_agent_mcp_server(
|
||||||
request: schemas.AgentMcpServerTestRequest,
|
request: schemas.AgentMcpServerTestRequest,
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
@@ -1574,7 +1587,22 @@ def _split_web_agent_output(text: str) -> list[dict]:
|
|||||||
return events
|
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:
|
async def download_web_agent_file(file_id: str) -> FileResponse:
|
||||||
"""
|
"""
|
||||||
下载 Web 智能助手本轮生成的临时附件。
|
下载 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(
|
async def upload_web_agent_file(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
session_id: Optional[str] = Form(None),
|
session_id: Optional[str] = Form(None),
|
||||||
@@ -1639,7 +1671,11 @@ async def upload_web_agent_file(
|
|||||||
return schemas.Response(success=True, data=attachment)
|
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(
|
async def web_agent_callback(
|
||||||
payload: schemas.AgentWebChoiceRequest,
|
payload: schemas.AgentWebChoiceRequest,
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
@@ -1673,7 +1709,11 @@ async def web_agent_callback(
|
|||||||
return schemas.Response(success=True, data=result)
|
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(
|
async def list_web_agent_commands(
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
) -> schemas.Response:
|
) -> schemas.Response:
|
||||||
@@ -1689,7 +1729,11 @@ async def list_web_agent_commands(
|
|||||||
return schemas.Response(success=True, data=_build_web_agent_command_items())
|
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(
|
async def list_agent_chat_sessions(
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
db: AsyncSession = Depends(get_async_db),
|
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(
|
async def get_agent_chat_session(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
current_user: User = Depends(get_current_active_user),
|
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)
|
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(
|
async def save_agent_chat_display(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
payload: schemas.AgentChatDisplaySaveRequest,
|
payload: schemas.AgentChatDisplaySaveRequest,
|
||||||
@@ -1795,7 +1847,11 @@ async def save_agent_chat_display(
|
|||||||
return schemas.Response(success=True, data=AgentChatOper.to_summary(chat))
|
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(
|
async def delete_agent_chat_session(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
current_user: User = Depends(get_current_active_user),
|
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 "删除失败")
|
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(
|
async def stop_web_agent_session_task(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
current_user: User = Depends(get_current_active_user),
|
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(
|
async def web_agent_stream(
|
||||||
payload: schemas.AgentWebChatRequest,
|
payload: schemas.AgentWebChatRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import Depends, Query
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.anilist import AniListChain
|
from app.chain.anilist import AniListChain
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
PageParam = Annotated[int, Query(ge=1)]
|
PageParam = Annotated[int, Query(ge=1)]
|
||||||
CountParam = Annotated[int, Query(ge=1, le=50)]
|
CountParam = Annotated[int, Query(ge=1, le=50)]
|
||||||
|
|||||||
@@ -20,7 +20,15 @@ from app.core.config import settings
|
|||||||
from app.core.security import anthropic_api_key_header
|
from app.core.security import anthropic_api_key_header
|
||||||
from app.schemas.types import MessageChannel
|
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:"
|
SESSION_PREFIX = "anthropic:"
|
||||||
|
|
||||||
@@ -100,6 +108,14 @@ async def _stream_anthropic_response(
|
|||||||
"/messages",
|
"/messages",
|
||||||
summary="Anthropic compatible messages",
|
summary="Anthropic compatible messages",
|
||||||
response_model=schemas.AnthropicMessagesResponse,
|
response_model=schemas.AnthropicMessagesResponse,
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "Anthropic message 或 SSE 数据流",
|
||||||
|
"content": {
|
||||||
|
"text/event-stream": {"schema": {"type": "string"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def messages(
|
async def messages(
|
||||||
payload: schemas.AnthropicMessagesRequest,
|
payload: schemas.AnthropicMessagesRequest,
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app import schemas
|
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.auth import build_token_response, consume_plugin_auth_ticket
|
||||||
from app.core.plugin import PluginManager
|
from app.core.plugin import PluginManager
|
||||||
from app.db.models.passkey import PassKey
|
from app.db.models.passkey import PassKey
|
||||||
from app.db.models.user import User
|
from app.db.models.user import User
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
class AuthExchangeRequest(BaseModel):
|
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]]:
|
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)]
|
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:
|
def auth_exchange(body: AuthExchangeRequest) -> schemas.Token:
|
||||||
"""
|
"""
|
||||||
将插件认证成功后生成的一次性票据兑换为系统 Token。
|
将插件认证成功后生成的一次性票据兑换为系统 Token。
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from typing import List, Any, Optional
|
from typing import List, Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.bangumi import BangumiChain
|
from app.chain.bangumi import BangumiChain
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, Optional, Annotated
|
from typing import Any, List, Optional, Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.dashboard import DashboardChain
|
from app.chain.dashboard import DashboardChain
|
||||||
from app.chain.storage import StorageChain
|
from app.chain.storage import StorageChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -16,7 +17,7 @@ from app.helper.directory import DirectoryHelper
|
|||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
from app.utils.system import SystemUtils
|
from app.utils.system import SystemUtils
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _build_statistic(db: Session, name: Optional[str] = None) -> schemas.Statistic:
|
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(
|
@router.get(
|
||||||
"/schedule/{job_id}/progress",
|
"/schedule/{job_id}/progress",
|
||||||
summary="后台服务进度",
|
summary="后台服务进度",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.ScheduleProgress],
|
||||||
)
|
)
|
||||||
async def schedule_progress(
|
async def schedule_progress(
|
||||||
job_id: str, _: Any = Depends(get_current_active_superuser)
|
job_id: str, _: Any = Depends(get_current_active_superuser)
|
||||||
@@ -215,7 +216,7 @@ async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/schedule2/{job_id}/progress",
|
"/schedule2/{job_id}/progress",
|
||||||
summary="后台服务进度(API_TOKEN)",
|
summary="后台服务进度(API_TOKEN)",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.ScheduleProgress],
|
||||||
)
|
)
|
||||||
async def schedule_progress2(
|
async def schedule_progress2(
|
||||||
job_id: str, _: Annotated[str, Depends(verify_apitoken)]
|
job_id: str, _: Annotated[str, Depends(verify_apitoken)]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.bangumi import BangumiChain
|
from app.chain.bangumi import BangumiChain
|
||||||
from app.chain.douban import DoubanChain
|
from app.chain.douban import DoubanChain
|
||||||
from app.chain.tmdb import TmdbChain
|
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 import DiscoverSourceEventData
|
||||||
from app.schemas.types import ChainEventType, MediaType
|
from app.schemas.types import ChainEventType, MediaType
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.douban import DoubanChain
|
from app.chain.douban import DoubanChain
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from typing import Any, List, Annotated, Optional, Union
|
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 import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
|
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.media import is_music_media_source, normalize_music_type
|
||||||
from app.utils.security import SecurityUtils
|
from app.utils.security import SecurityUtils
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
|
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
|
||||||
@@ -62,7 +63,11 @@ def current(
|
|||||||
return DownloadChain().downloading(name)
|
return DownloadChain().downloading(name)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response)
|
@router.post(
|
||||||
|
"/",
|
||||||
|
summary="添加下载(含媒体信息)",
|
||||||
|
response_model=schemas.Response[schemas.DownloadAddedData],
|
||||||
|
)
|
||||||
def download(
|
def download(
|
||||||
media_in: Union[schemas.MusicInfo, schemas.MediaInfo],
|
media_in: Union[schemas.MusicInfo, schemas.MediaInfo],
|
||||||
torrent_in: schemas.TorrentInfo,
|
torrent_in: schemas.TorrentInfo,
|
||||||
@@ -102,7 +107,9 @@ def download(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/add", summary="添加下载(不含媒体信息)", response_model=schemas.Response
|
"/add",
|
||||||
|
summary="添加下载(不含媒体信息)",
|
||||||
|
response_model=schemas.Response[schemas.DownloadAddedData],
|
||||||
)
|
)
|
||||||
def add(
|
def add(
|
||||||
torrent_in: schemas.TorrentInfo,
|
torrent_in: schemas.TorrentInfo,
|
||||||
@@ -185,7 +192,11 @@ def add(
|
|||||||
return schemas.Response(success=True, data={"download_id": did})
|
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(
|
def download_subtitle(
|
||||||
subtitle_in: schemas.SubtitleInfo,
|
subtitle_in: schemas.SubtitleInfo,
|
||||||
media_source: Annotated[MediaSource, Body()],
|
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(
|
def start(
|
||||||
hashString: str,
|
hashString: str,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
@@ -229,7 +240,7 @@ def start(
|
|||||||
return schemas.Response(success=True if ret else False)
|
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(
|
def stop(
|
||||||
hashString: str,
|
hashString: str,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
@@ -242,7 +253,11 @@ def stop(
|
|||||||
return schemas.Response(success=True if ret else False)
|
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:
|
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(
|
def delete(
|
||||||
hashString: str,
|
hashString: str,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Any, Optional
|
from typing import List, Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.agent import ReplyMode, agent_manager
|
from app.agent import ReplyMode, agent_manager
|
||||||
from app.agent.prompt.transfer_redo import (
|
from app.agent.prompt.transfer_redo import (
|
||||||
build_batch_manual_redo_prompt,
|
build_batch_manual_redo_prompt,
|
||||||
@@ -30,7 +31,7 @@ from app.helper.progress import ProgressHelper
|
|||||||
from app.schemas.types import EventType
|
from app.schemas.types import EventType
|
||||||
from app.utils.jieba import cut as jieba_cut
|
from app.utils.jieba import cut as jieba_cut
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
def normalize_history_ids(history_ids: list[int]) -> list[int]:
|
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)
|
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(
|
async def delete_download_history(
|
||||||
history_in: schemas.DownloadHistory,
|
history_in: schemas.DownloadHistory,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
@@ -166,7 +171,11 @@ def _glob_to_like(pattern: str) -> str:
|
|||||||
return result.replace("*", "%").replace("?", "_")
|
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(
|
async def transfer_history(
|
||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
page: Optional[int] = 1,
|
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(
|
def delete_transfer_history(
|
||||||
history_in: schemas.TransferHistory,
|
history_in: schemas.TransferHistory,
|
||||||
deletesrc: Optional[bool] = False,
|
deletesrc: Optional[bool] = False,
|
||||||
@@ -260,7 +269,7 @@ def delete_transfer_history(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/transfer/{history_id}/ai-redo",
|
"/transfer/{history_id}/ai-redo",
|
||||||
summary="智能助手重新整理",
|
summary="智能助手重新整理",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.ProgressKeyData],
|
||||||
)
|
)
|
||||||
def ai_redo_transfer_history(
|
def ai_redo_transfer_history(
|
||||||
history_id: int,
|
history_id: int,
|
||||||
@@ -289,7 +298,9 @@ def ai_redo_transfer_history(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@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(
|
def batch_ai_redo_transfer_history(
|
||||||
payload: schemas.BatchTransferHistoryRedoRequest,
|
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(
|
async def empty_transfer_history(
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
_: User = Depends(get_current_active_superuser_async),
|
_: User = Depends(get_current_active_superuser_async),
|
||||||
|
|||||||
+30
-10
@@ -1,11 +1,12 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Annotated, Optional
|
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 fastapi.responses import HTMLResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.agent.llm import (
|
from app.agent.llm import (
|
||||||
LLMHelper,
|
LLMHelper,
|
||||||
LLMProviderManager,
|
LLMProviderManager,
|
||||||
@@ -20,7 +21,7 @@ from app.db.user_oper import (
|
|||||||
)
|
)
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
class LlmTestRequest(BaseModel):
|
class LlmTestRequest(BaseModel):
|
||||||
@@ -85,7 +86,11 @@ def _sanitize_llm_error(message: str, api_key: Optional[str] = None) -> str:
|
|||||||
return sanitized
|
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(
|
async def get_llm_models(
|
||||||
provider: str,
|
provider: str,
|
||||||
api_key: Optional[str] = None,
|
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(
|
async def get_llm_providers(
|
||||||
_: User = Depends(get_current_active_user_async),
|
_: User = Depends(get_current_active_user_async),
|
||||||
):
|
):
|
||||||
@@ -142,7 +151,7 @@ async def get_llm_providers(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/provider-auth/start",
|
"/provider-auth/start",
|
||||||
summary="启动LLM提供商授权",
|
summary="启动LLM提供商授权",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.LLMProviderAuthSession],
|
||||||
)
|
)
|
||||||
async def start_llm_provider_auth(
|
async def start_llm_provider_auth(
|
||||||
payload: LlmProviderAuthStartRequest,
|
payload: LlmProviderAuthStartRequest,
|
||||||
@@ -173,7 +182,7 @@ async def start_llm_provider_auth(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/provider-auth/{session_id}",
|
"/provider-auth/{session_id}",
|
||||||
summary="获取LLM提供商授权会话状态",
|
summary="获取LLM提供商授权会话状态",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.LLMProviderAuthSession],
|
||||||
)
|
)
|
||||||
async def get_llm_provider_auth_session(
|
async def get_llm_provider_auth_session(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
@@ -192,7 +201,7 @@ async def get_llm_provider_auth_session(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/provider-auth/{session_id}/poll",
|
"/provider-auth/{session_id}/poll",
|
||||||
summary="轮询LLM提供商授权会话",
|
summary="轮询LLM提供商授权会话",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.LLMProviderAuthSession],
|
||||||
)
|
)
|
||||||
async def poll_llm_provider_auth_session(
|
async def poll_llm_provider_auth_session(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
@@ -211,7 +220,7 @@ async def poll_llm_provider_auth_session(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/provider-auth/{provider_id}",
|
"/provider-auth/{provider_id}",
|
||||||
summary="断开LLM提供商授权",
|
summary="断开LLM提供商授权",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[None],
|
||||||
)
|
)
|
||||||
async def delete_llm_provider_auth(
|
async def delete_llm_provider_auth(
|
||||||
provider_id: str,
|
provider_id: str,
|
||||||
@@ -230,8 +239,15 @@ async def delete_llm_provider_auth(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/provider-auth/callback/{provider_id}",
|
"/provider-auth/callback/{provider_id}",
|
||||||
summary="LLM提供商OAuth回调",
|
summary="LLM提供商OAuth回调",
|
||||||
response_class=HTMLResponse,
|
response_class=Response,
|
||||||
name="llm_provider_auth_callback",
|
name="llm_provider_auth_callback",
|
||||||
|
response_model=None,
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "OAuth 授权结果页面",
|
||||||
|
"content": {"text/html": {"schema": {"type": "string"}}},
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def llm_provider_auth_callback(
|
async def llm_provider_auth_callback(
|
||||||
provider_id: str,
|
provider_id: str,
|
||||||
@@ -253,7 +269,11 @@ async def llm_provider_auth_callback(
|
|||||||
return HTMLResponse(content=render_auth_result_html(success, message))
|
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(
|
async def llm_test(
|
||||||
payload: Annotated[Optional[LlmTestRequest], Body()] = None,
|
payload: Annotated[Optional[LlmTestRequest], Body()] = None,
|
||||||
_: User = Depends(get_current_active_superuser_async),
|
_: User = Depends(get_current_active_superuser_async),
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any, List, Annotated
|
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.security import OAuth2PasswordRequestForm
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
|
||||||
from app.chain.user import MfaRequired, UserChain
|
from app.chain.user import MfaRequired, UserChain
|
||||||
from app.core import security
|
from app.core import security
|
||||||
from app.core.config import settings
|
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.helper.image import WallpaperHelper
|
||||||
from app.schemas.types import SystemConfigKey
|
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(
|
def login_access_token(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
@@ -34,12 +46,16 @@ def login_access_token(
|
|||||||
if not success:
|
if not success:
|
||||||
# 只有密码已经验证通过时才返回 MFA 方法,避免泄露账号安全配置。
|
# 只有密码已经验证通过时才返回 MFA 方法,避免泄露账号安全配置。
|
||||||
if isinstance(user_or_message, MfaRequired):
|
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(
|
return JSONResponse(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
content={
|
content=challenge.model_dump(mode="json"),
|
||||||
"detail": "需要二次验证",
|
|
||||||
"mfa_methods": list(user_or_message.methods),
|
|
||||||
},
|
|
||||||
headers={"X-MFA-Required": "true"},
|
headers={"X-MFA-Required": "true"},
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
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:
|
def wallpaper() -> Any:
|
||||||
"""
|
"""
|
||||||
获取登录页面电影海报
|
获取登录页面电影海报
|
||||||
|
|||||||
+65
-11
@@ -1,9 +1,10 @@
|
|||||||
from typing import List, Any, Dict, Annotated, Union
|
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 fastapi.responses import JSONResponse, Response
|
||||||
|
|
||||||
from app import schemas
|
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.agent.tools.manager import moviepilot_tool_manager
|
||||||
from app.core.security import verify_apikey
|
from app.core.security import verify_apikey
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
@@ -14,7 +15,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
APP_VERSION = "unknown"
|
APP_VERSION = "unknown"
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
# MCP 协议版本
|
# MCP 协议版本
|
||||||
MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"]
|
MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"]
|
||||||
@@ -27,6 +28,15 @@ MCP_HIDDEN_TOOLS = {
|
|||||||
"write_file",
|
"write_file",
|
||||||
"read_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():
|
def list_exposed_tools():
|
||||||
@@ -66,7 +76,24 @@ def create_jsonrpc_error(
|
|||||||
return 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(
|
async def mcp_jsonrpc(
|
||||||
request: Request, _: Annotated[str, Depends(verify_apikey)] = None
|
request: Request, _: Annotated[str, Depends(verify_apikey)] = None
|
||||||
) -> Union[JSONResponse, Response]:
|
) -> Union[JSONResponse, Response]:
|
||||||
@@ -111,7 +138,9 @@ async def mcp_jsonrpc(
|
|||||||
else:
|
else:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=400,
|
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(
|
async def delete_mcp_session(
|
||||||
_: Annotated[str, Depends(verify_apikey)] = None,
|
_: Annotated[str, Depends(verify_apikey)] = None,
|
||||||
) -> Union[JSONResponse, Response]:
|
) -> Union[JSONResponse, Response]:
|
||||||
@@ -247,7 +286,11 @@ async def delete_mcp_session(
|
|||||||
# ==================== 兼容的 RESTful API 端点 ====================
|
# ==================== 兼容的 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:
|
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)}")
|
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(
|
async def call_tool(
|
||||||
request: schemas.ToolCallRequest, _: Annotated[str, Depends(verify_apikey)] = None
|
request: schemas.ToolCallRequest, _: Annotated[str, Depends(verify_apikey)] = None
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -292,13 +339,20 @@ async def call_tool(
|
|||||||
request.tool_name, request.arguments
|
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:
|
except Exception as e:
|
||||||
logger.error(f"调用工具 {request.tool_name} 失败: {e}", exc_info=True)
|
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(
|
async def get_tool_info(
|
||||||
tool_name: str, _: Annotated[str, Depends(verify_apikey)]
|
tool_name: str, _: Annotated[str, Depends(verify_apikey)]
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -332,7 +386,7 @@ async def get_tool_info(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/tools/{tool_name}/schema",
|
"/tools/{tool_name}/schema",
|
||||||
summary="获取工具参数Schema",
|
summary="获取工具参数Schema",
|
||||||
response_model=Dict[str, Any],
|
response_model=schemas.McpJsonSchema,
|
||||||
)
|
)
|
||||||
async def get_tool_schema(
|
async def get_tool_schema(
|
||||||
tool_name: str, _: Annotated[str, Depends(verify_apikey)]
|
tool_name: str, _: Annotated[str, Depends(verify_apikey)]
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ from pathlib import Path
|
|||||||
from typing import Annotated, Any, List, Optional, Union
|
from typing import Annotated, Any, List, Optional, Union
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import Depends, Query
|
||||||
from pydantic import BeforeValidator
|
from pydantic import BeforeValidator
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.chain.scraping import ScrapingChain
|
from app.chain.scraping import ScrapingChain
|
||||||
from app.chain.tmdb import TmdbChain
|
from app.chain.tmdb import TmdbChain
|
||||||
@@ -27,7 +28,7 @@ from app.utils.media import (
|
|||||||
resolve_media_identity,
|
resolve_media_identity,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _split_media_source_query(value: object) -> tuple[str, ...]:
|
def _split_media_source_query(value: object) -> tuple[str, ...]:
|
||||||
@@ -235,7 +236,11 @@ async def recognize_file2(
|
|||||||
return await recognize_file(path, media_source)
|
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(
|
async def search(
|
||||||
title: str,
|
title: str,
|
||||||
type: Optional[str] = "media",
|
type: Optional[str] = "media",
|
||||||
@@ -316,7 +321,7 @@ async def search(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response
|
"/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
def scrape(
|
def scrape(
|
||||||
fileitem: schemas.FileItem,
|
fileitem: schemas.FileItem,
|
||||||
@@ -426,7 +431,9 @@ def scrape(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@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)):
|
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(
|
@router.post(
|
||||||
"/category/config", summary="保存分类策略配置", response_model=schemas.Response
|
"/category/config", summary="保存分类策略配置", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
def save_category_config(
|
def save_category_config(
|
||||||
config: CategoryConfig, _: User = Depends(get_current_active_superuser)
|
config: CategoryConfig, _: User = Depends(get_current_active_superuser)
|
||||||
@@ -451,7 +458,11 @@ def save_category_config(
|
|||||||
return schemas.Response(success=False, message="保存失败")
|
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:
|
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)
|
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:
|
async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询媒体剧集组列表(themoviedb)
|
查询媒体剧集组列表(themoviedb)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from typing import Any, List, Dict, Optional
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
from app.chain.mediaserver import MediaServerChain
|
from app.chain.mediaserver import MediaServerChain
|
||||||
from app.core.context import MediaInfo
|
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.schemas.types import MediaSource, SystemConfigKey
|
||||||
from app.utils.media import build_media_key, resolve_media_identity
|
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]:
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/play/{itemid:path}", summary="在线播放")
|
@router.get(
|
||||||
|
"/play/{itemid:path}",
|
||||||
|
summary="在线播放",
|
||||||
|
response_model=schemas.Response[schemas.MediaServerPlayData],
|
||||||
|
)
|
||||||
def play_item(
|
def play_item(
|
||||||
itemid: str, _: schemas.TokenPayload = Depends(verify_token)
|
itemid: str, _: schemas.TokenPayload = Depends(verify_token)
|
||||||
) -> schemas.Response:
|
) -> schemas.Response:
|
||||||
@@ -64,7 +69,9 @@ def play_item(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/exists", summary="查询本地是否存在(数据库)", response_model=schemas.Response
|
"/exists",
|
||||||
|
summary="查询本地是否存在(数据库)",
|
||||||
|
response_model=schemas.Response[schemas.MediaServerExistsData],
|
||||||
)
|
)
|
||||||
async def exists_local(
|
async def exists_local(
|
||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
@@ -106,7 +113,7 @@ async def exists_local(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/exists_remote",
|
"/exists_remote",
|
||||||
summary="查询已存在的剧集信息(媒体服务器)",
|
summary="查询已存在的剧集信息(媒体服务器)",
|
||||||
response_model=Dict[int, list],
|
response_model=schemas.MediaServerExistingEpisodes,
|
||||||
)
|
)
|
||||||
def exists(
|
def exists(
|
||||||
media_in: schemas.MediaInfo, _: schemas.TokenPayload = Depends(verify_token)
|
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:
|
async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
查询可用媒体服务器
|
查询可用媒体服务器
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import json
|
|||||||
import time
|
import time
|
||||||
from typing import Union, Any, List, Optional
|
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 pywebpush import WebPushException, webpush
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from starlette.responses import PlainTextResponse
|
from starlette.responses import PlainTextResponse
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.message import MessageChain
|
from app.chain.message import MessageChain
|
||||||
from app.core.config import settings, global_vars
|
from app.core.config import settings, global_vars
|
||||||
from app.core.security import verify_token, verify_apitoken
|
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.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||||
from app.schemas.types import MessageChannel, SystemConfigKey
|
from app.schemas.types import MessageChannel, SystemConfigKey
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_notification_clear_timestamp(value: Any) -> int:
|
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)
|
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(
|
async def user_message(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -109,7 +110,7 @@ async def user_message(
|
|||||||
return schemas.Response(success=True)
|
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(
|
async def web_message(
|
||||||
request: Request,
|
request: Request,
|
||||||
text: Optional[str] = None,
|
text: Optional[str] = None,
|
||||||
@@ -148,7 +149,7 @@ async def web_message(
|
|||||||
return schemas.Response(success=True)
|
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(
|
async def get_web_message(
|
||||||
_: schemas.TokenPayload = Depends(verify_token),
|
_: schemas.TokenPayload = Depends(verify_token),
|
||||||
db: AsyncSession = Depends(get_async_db),
|
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]
|
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(
|
async def clear_notification_message(
|
||||||
scope: schemas.NotificationClearScope = schemas.NotificationClearScope.All,
|
scope: schemas.NotificationClearScope = schemas.NotificationClearScope.All,
|
||||||
_: schemas.TokenPayload = Depends(verify_token),
|
_: schemas.TokenPayload = Depends(verify_token),
|
||||||
@@ -260,7 +265,25 @@ def vocechat_verify() -> Any:
|
|||||||
return {"status": "OK"}
|
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(
|
def incoming_verify(
|
||||||
token: Optional[str] = None,
|
token: Optional[str] = None,
|
||||||
echostr: Optional[str] = None,
|
echostr: Optional[str] = None,
|
||||||
@@ -285,7 +308,7 @@ def incoming_verify(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/webpush/subscribe",
|
"/webpush/subscribe",
|
||||||
summary="客户端webpush通知订阅",
|
summary="客户端webpush通知订阅",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[None],
|
||||||
)
|
)
|
||||||
async def subscribe(
|
async def subscribe(
|
||||||
subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)
|
subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)
|
||||||
@@ -300,7 +323,7 @@ async def subscribe(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/webpush/send", summary="发送webpush通知", response_model=schemas.Response
|
"/webpush/send", summary="发送webpush通知", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
def send_notification(
|
def send_notification(
|
||||||
payload: schemas.SubscriptionMessage,
|
payload: schemas.SubscriptionMessage,
|
||||||
|
|||||||
+25
-15
@@ -7,10 +7,11 @@ from datetime import timedelta
|
|||||||
from typing import Any, Annotated, Optional
|
from typing import Any, Annotated, Optional
|
||||||
|
|
||||||
from app.helper.sites import SitesHelper
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
|
||||||
from app.core import security
|
from app.core import security
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db import get_async_db
|
from app.db import get_async_db
|
||||||
@@ -28,7 +29,7 @@ from app.log import logger
|
|||||||
from app.schemas.types import SystemConfigKey
|
from app.schemas.types import SystemConfigKey
|
||||||
from app.utils.otp import OtpUtils
|
from app.utils.otp import OtpUtils
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
# ==================== 辅助函数 ====================
|
# ==================== 辅助函数 ====================
|
||||||
|
|
||||||
@@ -117,7 +118,7 @@ class PassKeyDeleteRequest(schemas.BaseModel):
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/status/{username}",
|
"/status/{username}",
|
||||||
summary="判断用户是否开启二次验证",
|
summary="判断用户是否开启二次验证",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.MfaStatusData],
|
||||||
)
|
)
|
||||||
async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> Any:
|
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)
|
user: User = await User.async_get_by_name(db, username)
|
||||||
if not user:
|
if not user:
|
||||||
return schemas.Response(success=False)
|
return schemas.Response(success=False, message="用户不存在")
|
||||||
|
|
||||||
# 检查是否启用了OTP
|
# 检查是否启用了OTP
|
||||||
has_otp = user.is_otp
|
has_otp = user.is_otp
|
||||||
|
|
||||||
return schemas.Response(success=has_otp)
|
return schemas.Response(success=True, data={"enabled": bool(has_otp)})
|
||||||
|
|
||||||
|
|
||||||
# ==================== OTP 相关接口 ====================
|
# ==================== OTP 相关接口 ====================
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@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(
|
def otp_generate(
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
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})
|
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(
|
async def otp_verify(
|
||||||
data: OtpVerifyRequest,
|
data: OtpVerifyRequest,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
@@ -163,7 +166,9 @@ async def otp_verify(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/otp/disable", summary="关闭当前用户的 OTP 验证", response_model=schemas.Response
|
"/otp/disable",
|
||||||
|
summary="关闭当前用户的 OTP 验证",
|
||||||
|
response_model=schemas.Response[None],
|
||||||
)
|
)
|
||||||
async def otp_disable(
|
async def otp_disable(
|
||||||
data: OtpDisableRequest,
|
data: OtpDisableRequest,
|
||||||
@@ -190,7 +195,7 @@ class PassKeyRegistrationStart(schemas.BaseModel):
|
|||||||
class PassKeyRegistrationFinish(schemas.BaseModel):
|
class PassKeyRegistrationFinish(schemas.BaseModel):
|
||||||
"""PassKey注册完成请求"""
|
"""PassKey注册完成请求"""
|
||||||
|
|
||||||
credential: dict
|
credential: dict[str, schemas.JsonData]
|
||||||
transaction_token: str
|
transaction_token: str
|
||||||
name: str = "通行密钥"
|
name: str = "通行密钥"
|
||||||
|
|
||||||
@@ -204,14 +209,14 @@ class PassKeyAuthenticationStart(schemas.BaseModel):
|
|||||||
class PassKeyAuthenticationFinish(schemas.BaseModel):
|
class PassKeyAuthenticationFinish(schemas.BaseModel):
|
||||||
"""PassKey认证完成请求"""
|
"""PassKey认证完成请求"""
|
||||||
|
|
||||||
credential: dict
|
credential: dict[str, schemas.JsonData]
|
||||||
transaction_token: str
|
transaction_token: str
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/passkey/register/start",
|
"/passkey/register/start",
|
||||||
summary="开始注册 PassKey",
|
summary="开始注册 PassKey",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.PasskeyStartData],
|
||||||
)
|
)
|
||||||
def passkey_register_start(
|
def passkey_register_start(
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||||
@@ -251,7 +256,7 @@ def passkey_register_start(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/passkey/register/finish",
|
"/passkey/register/finish",
|
||||||
summary="完成注册 PassKey",
|
summary="完成注册 PassKey",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[None],
|
||||||
)
|
)
|
||||||
def passkey_register_finish(
|
def passkey_register_finish(
|
||||||
passkey_req: PassKeyRegistrationFinish,
|
passkey_req: PassKeyRegistrationFinish,
|
||||||
@@ -318,7 +323,7 @@ def passkey_register_finish(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/passkey/authenticate/start",
|
"/passkey/authenticate/start",
|
||||||
summary="开始 PassKey 认证",
|
summary="开始 PassKey 认证",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.PasskeyStartData],
|
||||||
)
|
)
|
||||||
def passkey_authenticate_start(
|
def passkey_authenticate_start(
|
||||||
passkey_req: PassKeyAuthenticationStart = Body(...),
|
passkey_req: PassKeyAuthenticationStart = Body(...),
|
||||||
@@ -364,6 +369,7 @@ def passkey_authenticate_start(
|
|||||||
"/passkey/authenticate/finish",
|
"/passkey/authenticate/finish",
|
||||||
summary="完成 PassKey 认证",
|
summary="完成 PassKey 认证",
|
||||||
response_model=schemas.Token,
|
response_model=schemas.Token,
|
||||||
|
openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True},
|
||||||
)
|
)
|
||||||
def passkey_authenticate_finish(
|
def passkey_authenticate_finish(
|
||||||
request: Request, response: Response, passkey_req: PassKeyAuthenticationFinish
|
request: Request, response: Response, passkey_req: PassKeyAuthenticationFinish
|
||||||
@@ -453,7 +459,7 @@ def passkey_authenticate_finish(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/passkey/list",
|
"/passkey/list",
|
||||||
summary="获取当前用户的 PassKey 列表",
|
summary="获取当前用户的 PassKey 列表",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[list[schemas.PasskeyInfo]],
|
||||||
)
|
)
|
||||||
def passkey_list(
|
def passkey_list(
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
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)}")
|
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(
|
async def passkey_delete(
|
||||||
data: PassKeyDeleteRequest,
|
data: PassKeyDeleteRequest,
|
||||||
current_user: User = Depends(get_current_active_user_async),
|
current_user: User = Depends(get_current_active_user_async),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import Depends, HTTPException, Query
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.chain.recommend import RecommendChain
|
from app.chain.recommend import RecommendChain
|
||||||
from app.schemas.types import MediaSource, MediaType
|
from app.schemas.types import MediaSource, MediaType
|
||||||
@@ -17,7 +18,7 @@ from app.modules.listenbrainz import (
|
|||||||
)
|
)
|
||||||
from app.modules.musicbrainz.music_cache import MusicBrainzCache
|
from app.modules.musicbrainz.music_cache import MusicBrainzCache
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
CountParam = Annotated[int, Query(ge=1, le=100)]
|
CountParam = Annotated[int, Query(ge=1, le=100)]
|
||||||
PageParam = Annotated[int, Query(ge=1)]
|
PageParam = Annotated[int, Query(ge=1)]
|
||||||
@@ -107,7 +108,9 @@ async def recognize_music(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/cache", summary="查询音乐识别缓存", response_model=schemas.Response
|
"/cache",
|
||||||
|
summary="查询音乐识别缓存",
|
||||||
|
response_model=schemas.Response[schemas.MusicRecognitionCacheData],
|
||||||
)
|
)
|
||||||
async def music_recognition_cache(
|
async def music_recognition_cache(
|
||||||
_: User = Depends(get_current_active_superuser_async),
|
_: User = Depends(get_current_active_superuser_async),
|
||||||
@@ -129,7 +132,7 @@ async def music_recognition_cache(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/cache/{cache_key:path}",
|
"/cache/{cache_key:path}",
|
||||||
summary="删除指定音乐识别缓存",
|
summary="删除指定音乐识别缓存",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[None],
|
||||||
)
|
)
|
||||||
async def delete_music_recognition_cache(
|
async def delete_music_recognition_cache(
|
||||||
cache_key: str,
|
cache_key: str,
|
||||||
@@ -143,7 +146,7 @@ async def delete_music_recognition_cache(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/cache", summary="清空音乐识别缓存", response_model=schemas.Response
|
"/cache", summary="清空音乐识别缓存", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
async def clear_music_recognition_cache(
|
async def clear_music_recognition_cache(
|
||||||
_: User = Depends(get_current_active_superuser_async),
|
_: User = Depends(get_current_active_superuser_async),
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.core.module import ModuleManager
|
from app.core.module import ModuleManager
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.db.user_oper import get_current_active_superuser
|
from app.db.user_oper import get_current_active_superuser
|
||||||
from app.modules.wechatclawbot.wechatclawbot import WechatClawBot
|
from app.modules.wechatclawbot.wechatclawbot import WechatClawBot
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _build_wechatclawbot_temp_client(
|
def _build_wechatclawbot_temp_client(
|
||||||
@@ -84,7 +85,7 @@ def _get_wechatclawbot_client(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/wechatclawbot/status",
|
"/wechatclawbot/status",
|
||||||
summary="查询微信 ClawBot 登录状态",
|
summary="查询微信 ClawBot 登录状态",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.WechatClawBotData],
|
||||||
)
|
)
|
||||||
def wechatclawbot_status(
|
def wechatclawbot_status(
|
||||||
source: Optional[str] = None,
|
source: Optional[str] = None,
|
||||||
@@ -121,7 +122,7 @@ def wechatclawbot_status(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/wechatclawbot/refresh",
|
"/wechatclawbot/refresh",
|
||||||
summary="刷新微信 ClawBot 二维码",
|
summary="刷新微信 ClawBot 二维码",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.WechatClawBotData],
|
||||||
)
|
)
|
||||||
def refresh_wechatclawbot_qrcode(
|
def refresh_wechatclawbot_qrcode(
|
||||||
source: Optional[str] = None,
|
source: Optional[str] = None,
|
||||||
@@ -155,7 +156,7 @@ def refresh_wechatclawbot_qrcode(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/wechatclawbot/logout",
|
"/wechatclawbot/logout",
|
||||||
summary="退出微信 ClawBot 登录",
|
summary="退出微信 ClawBot 登录",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.WechatClawBotData],
|
||||||
)
|
)
|
||||||
def logout_wechatclawbot(
|
def logout_wechatclawbot(
|
||||||
source: Optional[str] = None,
|
source: Optional[str] = None,
|
||||||
@@ -189,7 +190,7 @@ def logout_wechatclawbot(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/wechatclawbot/test",
|
"/wechatclawbot/test",
|
||||||
summary="测试微信 ClawBot 连通性",
|
summary="测试微信 ClawBot 连通性",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[None],
|
||||||
)
|
)
|
||||||
def test_wechatclawbot(
|
def test_wechatclawbot(
|
||||||
source: Optional[str] = None,
|
source: Optional[str] = None,
|
||||||
@@ -219,7 +220,7 @@ def test_wechatclawbot(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/wechatclawbot/migrate",
|
"/wechatclawbot/migrate",
|
||||||
summary="迁移微信 ClawBot 登录缓存",
|
summary="迁移微信 ClawBot 登录缓存",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[None],
|
||||||
)
|
)
|
||||||
def migrate_wechatclawbot_cache(
|
def migrate_wechatclawbot_cache(
|
||||||
old_source: str,
|
old_source: str,
|
||||||
|
|||||||
@@ -20,7 +20,15 @@ from app.core.config import settings
|
|||||||
from app.core.security import openai_bearer_scheme
|
from app.core.security import openai_bearer_scheme
|
||||||
from app.schemas.types import MessageChannel
|
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"
|
MODEL_ID = "moviepilot-agent"
|
||||||
SESSION_PREFIX = "openai:"
|
SESSION_PREFIX = "openai:"
|
||||||
@@ -274,6 +282,14 @@ async def list_models(
|
|||||||
"/chat/completions",
|
"/chat/completions",
|
||||||
summary="OpenAI compatible chat completions",
|
summary="OpenAI compatible chat completions",
|
||||||
response_model=schemas.OpenAIChatCompletionResponse,
|
response_model=schemas.OpenAIChatCompletionResponse,
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "OpenAI chat completion 或 SSE 数据流",
|
||||||
|
"content": {
|
||||||
|
"text/event-stream": {"schema": {"type": "string"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
async def chat_completions(
|
async def chat_completions(
|
||||||
payload: schemas.OpenAIChatCompletionsRequest,
|
payload: schemas.OpenAIChatCompletionsRequest,
|
||||||
|
|||||||
+76
-35
@@ -5,13 +5,13 @@ from typing import Annotated, Any, Dict, List, Optional
|
|||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from anyio import Path as AsyncPath
|
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 fastapi.concurrency import run_in_threadpool
|
||||||
from starlette import status
|
from starlette import status
|
||||||
from starlette.responses import StreamingResponse
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
from app import schemas
|
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.command import Command
|
||||||
from app.core.cache import async_fresh
|
from app.core.cache import async_fresh
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -39,15 +39,13 @@ from app.schemas.types import ChainEventType, SystemConfigKey
|
|||||||
|
|
||||||
PROTECTED_ROUTES = {
|
PROTECTED_ROUTES = {
|
||||||
"/api/v1/openapi.json",
|
"/api/v1/openapi.json",
|
||||||
OPENAPI_V2_PATH,
|
|
||||||
"/docs",
|
"/docs",
|
||||||
"/docs/oauth2-redirect",
|
"/docs/oauth2-redirect",
|
||||||
"/redoc",
|
"/redoc",
|
||||||
}
|
}
|
||||||
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
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()
|
_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:
|
elif Depends(verify_apikey) not in dependencies:
|
||||||
dependencies.append(Depends(verify_apikey))
|
dependencies.append(Depends(verify_apikey))
|
||||||
app.add_api_route(**api, tags=["plugin"])
|
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
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error adding plugin route {api_path}: {str(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:
|
if not plugin_id:
|
||||||
return False
|
return False
|
||||||
prefixes = {
|
prefix = f"{PLUGIN_PREFIX}/{plugin_id}/"
|
||||||
f"{PLUGIN_PREFIX}/{plugin_id}/",
|
|
||||||
f"{PLUGIN_V2_PREFIX}/{plugin_id}/",
|
|
||||||
}
|
|
||||||
routes_to_remove = [
|
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
|
removed = False
|
||||||
for route in routes_to_remove:
|
for route in routes_to_remove:
|
||||||
@@ -425,7 +417,11 @@ async def plugin_history(
|
|||||||
return plugin
|
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(
|
async def plugin_releases(
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
_: User = Depends(get_current_active_superuser_async),
|
_: 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:
|
async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||||
"""
|
"""
|
||||||
插件安装统计
|
插件安装统计
|
||||||
@@ -495,7 +495,7 @@ async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/rating",
|
"/rating",
|
||||||
summary="批量查询插件评分",
|
summary="批量查询插件评分",
|
||||||
response_model=Dict[str, schemas.PluginRating],
|
response_model=schemas.PluginRatingMap,
|
||||||
)
|
)
|
||||||
async def plugin_ratings(
|
async def plugin_ratings(
|
||||||
plugin_ids: Optional[str] = None,
|
plugin_ids: Optional[str] = None,
|
||||||
@@ -531,7 +531,7 @@ async def plugin_rating(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/rating/{plugin_id}",
|
"/rating/{plugin_id}",
|
||||||
summary="提交插件评分",
|
summary="提交插件评分",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.PluginRating],
|
||||||
)
|
)
|
||||||
async def rate_plugin(
|
async def rate_plugin(
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
@@ -558,7 +558,7 @@ async def rate_plugin(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response
|
"/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
def reload_plugin(
|
def reload_plugin(
|
||||||
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
||||||
@@ -573,7 +573,7 @@ def reload_plugin(
|
|||||||
return schemas.Response(success=True)
|
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(
|
async def install(
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
repo_url: Optional[str] = "",
|
repo_url: Optional[str] = "",
|
||||||
@@ -623,7 +623,11 @@ async def install(
|
|||||||
return schemas.Response(success=True)
|
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:
|
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()
|
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(
|
def plugin_form(
|
||||||
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -677,7 +685,11 @@ def plugin_form(
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
|
@router.get(
|
||||||
|
"/page/{plugin_id}",
|
||||||
|
summary="获取插件数据页面",
|
||||||
|
response_model=schemas.JsonObject,
|
||||||
|
)
|
||||||
def plugin_page(
|
def plugin_page(
|
||||||
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -701,7 +713,11 @@ def plugin_page(
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
|
@router.get(
|
||||||
|
"/dashboard/meta",
|
||||||
|
summary="获取所有插件仪表板元信息",
|
||||||
|
response_model=List[schemas.PluginDashboardMetaItem],
|
||||||
|
)
|
||||||
def plugin_dashboard_meta(
|
def plugin_dashboard_meta(
|
||||||
_: User = Depends(get_current_active_superuser),
|
_: User = Depends(get_current_active_superuser),
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
@@ -737,7 +753,7 @@ def plugin_dashboard(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response
|
"/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
def reset_plugin(
|
def reset_plugin(
|
||||||
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
||||||
@@ -761,7 +777,24 @@ def reset_plugin(
|
|||||||
return schemas.Response(success=True)
|
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(
|
async def plugin_static_file(
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
filepath: str,
|
filepath: str,
|
||||||
@@ -839,7 +872,11 @@ async def plugin_static_file(
|
|||||||
raise HTTPException(status_code=500, detail="Internal Server Error")
|
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(
|
async def get_plugin_folders(
|
||||||
_: User = Depends(get_current_active_superuser_async),
|
_: User = Depends(get_current_active_superuser_async),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -854,7 +891,7 @@ async def get_plugin_folders(
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response)
|
@router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response[None])
|
||||||
async def save_plugin_folders(
|
async def save_plugin_folders(
|
||||||
folders: dict, _: User = Depends(get_current_active_superuser_async)
|
folders: dict, _: User = Depends(get_current_active_superuser_async)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -870,7 +907,7 @@ async def save_plugin_folders(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response
|
"/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
async def create_plugin_folder(
|
async def create_plugin_folder(
|
||||||
folder_name: str, _: User = Depends(get_current_active_superuser_async)
|
folder_name: str, _: User = Depends(get_current_active_superuser_async)
|
||||||
@@ -890,7 +927,7 @@ async def create_plugin_folder(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response
|
"/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
async def delete_plugin_folder(
|
async def delete_plugin_folder(
|
||||||
folder_name: str, _: User = Depends(get_current_active_superuser_async)
|
folder_name: str, _: User = Depends(get_current_active_superuser_async)
|
||||||
@@ -912,7 +949,7 @@ async def delete_plugin_folder(
|
|||||||
@router.put(
|
@router.put(
|
||||||
"/folders/{folder_name}/plugins",
|
"/folders/{folder_name}/plugins",
|
||||||
summary="更新文件夹中的插件",
|
summary="更新文件夹中的插件",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[None],
|
||||||
)
|
)
|
||||||
async def update_folder_plugins(
|
async def update_folder_plugins(
|
||||||
folder_name: str,
|
folder_name: str,
|
||||||
@@ -931,7 +968,7 @@ async def update_folder_plugins(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response
|
"/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
def clone_plugin(
|
def clone_plugin(
|
||||||
plugin_id: str, clone_data: dict, _: User = Depends(get_current_active_superuser)
|
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)}")
|
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(
|
async def plugin_config(
|
||||||
plugin_id: str, _: User = Depends(get_current_active_superuser_async)
|
plugin_id: str, _: User = Depends(get_current_active_superuser_async)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -972,7 +1013,7 @@ async def plugin_config(
|
|||||||
return PluginManager().get_plugin_config(plugin_id)
|
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(
|
def set_plugin_config(
|
||||||
plugin_id: str, conf: dict, _: User = Depends(get_current_active_superuser)
|
plugin_id: str, conf: dict, _: User = Depends(get_current_active_superuser)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -989,7 +1030,7 @@ def set_plugin_config(
|
|||||||
return schemas.Response(success=True)
|
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(
|
def uninstall_plugin(
|
||||||
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from typing import Any, Awaitable, List, Optional
|
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 import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.recommend import RecommendChain
|
from app.chain.recommend import RecommendChain
|
||||||
from app.core.event import eventmanager
|
from app.core.event import eventmanager
|
||||||
from app.core.security import verify_token
|
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 import RecommendSourceEventData
|
||||||
from app.schemas.types import ChainEventType
|
from app.schemas.types import ChainEventType
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
async def _require_tmdb_result(operation: Awaitable[List[Any]]) -> List[Any]:
|
async def _require_tmdb_result(operation: Awaitable[List[Any]]) -> List[Any]:
|
||||||
|
|||||||
+81
-12
@@ -4,10 +4,11 @@ import time
|
|||||||
from typing import Any, AsyncIterator, Iterator, List, Optional
|
from typing import Any, AsyncIterator, Iterator, List, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Body, Request
|
from fastapi import Depends, Body, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.search import SearchChain
|
from app.chain.search import SearchChain
|
||||||
from app.core.security import verify_resource_token, verify_token
|
from app.core.security import verify_resource_token, verify_token
|
||||||
from app.helper.locale import LocaleHelper
|
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.media import normalize_music_type, resolve_media_identity
|
||||||
from app.utils.security import SecurityUtils
|
from app.utils.security import SecurityUtils
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
_SSE_APPEND_FLUSH_INTERVAL = 1
|
_SSE_APPEND_FLUSH_INTERVAL = 1
|
||||||
_SSE_APPEND_MAX_ITEMS = 48
|
_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]
|
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:
|
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(
|
async def search_by_id_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
media_id: str,
|
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(
|
async def search_by_id(
|
||||||
media_id: str,
|
media_id: str,
|
||||||
media_source: MediaSource,
|
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(
|
async def search_by_title_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
keyword: Optional[str] = None,
|
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(
|
async def search_by_title(
|
||||||
keyword: Optional[str] = None,
|
keyword: Optional[str] = None,
|
||||||
mtype: 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(
|
async def search_subtitle_by_title_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
keyword: Optional[str] = None,
|
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(
|
async def search_subtitle_by_title(
|
||||||
keyword: Optional[str] = None,
|
keyword: Optional[str] = None,
|
||||||
page: Optional[int] = 0,
|
page: Optional[int] = 0,
|
||||||
@@ -581,7 +631,18 @@ async def _build_subtitle_search_source(
|
|||||||
return call_search(**search_params), ""
|
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(
|
async def search_subtitle_by_id_stream(
|
||||||
request: Request,
|
request: Request,
|
||||||
media_id: str,
|
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(
|
async def search_subtitle_by_id(
|
||||||
media_id: str,
|
media_id: str,
|
||||||
media_source: MediaSource,
|
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(
|
async def recommend_search_results(
|
||||||
filtered_indices: Optional[List[int]] = Body(
|
filtered_indices: Optional[List[int]] = Body(
|
||||||
None, embed=True, description="筛选后的索引列表"
|
None, embed=True, description="筛选后的索引列表"
|
||||||
|
|||||||
+33
-18
@@ -1,11 +1,12 @@
|
|||||||
from typing import List, Any, Dict, Optional
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.background import BackgroundTasks
|
from starlette.background import BackgroundTasks
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.api.endpoints.plugin import register_plugin_api
|
from app.api.endpoints.plugin import register_plugin_api
|
||||||
from app.chain.site import SiteChain
|
from app.chain.site import SiteChain
|
||||||
from app.chain.torrents import TorrentsChain
|
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.schemas.types import SystemConfigKey, EventType, MediaType
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _indexer_supports_media_type(indexer: dict, media_type: MediaType) -> bool:
|
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(
|
async def add_site(
|
||||||
*,
|
*,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
@@ -168,7 +169,7 @@ async def add_site(
|
|||||||
return schemas.Response(success=True)
|
return schemas.Response(success=True)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/", summary="更新站点", response_model=schemas.Response)
|
@router.put("/", summary="更新站点", response_model=schemas.Response[None])
|
||||||
async def update_site(
|
async def update_site(
|
||||||
*,
|
*,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
@@ -199,7 +200,7 @@ async def update_site(
|
|||||||
return schemas.Response(success=True)
|
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(
|
async def cookie_cloud_sync(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
_: User = Depends(get_current_active_superuser_async),
|
_: User = Depends(get_current_active_superuser_async),
|
||||||
@@ -211,7 +212,7 @@ async def cookie_cloud_sync(
|
|||||||
return schemas.Response(success=True, message="CookieCloud同步任务已启动!")
|
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(
|
def reset(
|
||||||
db: AsyncSession = Depends(get_db), _: User = Depends(get_current_active_superuser)
|
db: AsyncSession = Depends(get_db), _: User = Depends(get_current_active_superuser)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -229,7 +230,7 @@ def reset(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/priorities", summary="批量更新站点优先级", response_model=schemas.Response
|
"/priorities", summary="批量更新站点优先级", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
async def update_sites_priority(
|
async def update_sites_priority(
|
||||||
priorities: List[dict],
|
priorities: List[dict],
|
||||||
@@ -281,7 +282,7 @@ def _update_site_cookie(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@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(
|
def update_cookie_by_body(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
@@ -302,7 +303,7 @@ def update_cookie_by_body(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@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(
|
def update_cookie(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
@@ -325,7 +326,9 @@ def update_cookie(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/userdata/{site_id}", summary="更新站点用户数据", response_model=schemas.Response
|
"/userdata/{site_id}",
|
||||||
|
summary="更新站点用户数据",
|
||||||
|
response_model=schemas.Response[schemas.SiteUserData],
|
||||||
)
|
)
|
||||||
def refresh_userdata(
|
def refresh_userdata(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
@@ -369,7 +372,9 @@ async def read_userdata_latest(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@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(
|
async def read_userdata(
|
||||||
site_id: int,
|
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])
|
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(
|
def test_site(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -413,7 +418,11 @@ def test_site(
|
|||||||
return schemas.Response(success=status, message=message)
|
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(
|
async def site_icon(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
@@ -576,7 +585,7 @@ async def read_rss_sites(
|
|||||||
return 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:
|
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()
|
return SitesHelper().get_authsites()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auth", summary="用户站点认证", response_model=schemas.Response)
|
@router.post("/auth", summary="用户站点认证", response_model=schemas.Response[None])
|
||||||
def auth_site(
|
def auth_site(
|
||||||
auth_info: schemas.SiteAuth, _: User = Depends(get_current_active_superuser)
|
auth_info: schemas.SiteAuth, _: User = Depends(get_current_active_superuser)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -604,7 +613,9 @@ def auth_site(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@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)):
|
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)}")
|
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)):
|
async def support_sites(_: User = Depends(get_current_active_superuser_async)):
|
||||||
"""
|
"""
|
||||||
获取支持的站点列表
|
获取支持的站点列表
|
||||||
@@ -646,7 +661,7 @@ async def read_site(
|
|||||||
return 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(
|
async def delete_site(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import Depends, HTTPException
|
||||||
from starlette.responses import FileResponse, Response
|
from starlette.responses import FileResponse, Response
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.chain.storage import StorageChain
|
from app.chain.storage import StorageChain
|
||||||
from app.chain.transfer import TransferChain
|
from app.chain.transfer import TransferChain
|
||||||
@@ -23,10 +24,14 @@ from app.helper.progress import ProgressHelper
|
|||||||
from app.schemas.types import ProgressKey
|
from app.schemas.types import ProgressKey
|
||||||
from app.utils.string import StringUtils
|
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:
|
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(
|
@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:
|
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)
|
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(
|
def check(
|
||||||
name: str,
|
name: str,
|
||||||
ck: Optional[str] = None,
|
ck: Optional[str] = None,
|
||||||
@@ -69,7 +80,7 @@ def check(
|
|||||||
return schemas.Response(success=False, message=errmsg)
|
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:
|
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)
|
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:
|
def reset(name: str, _: User = Depends(get_current_active_superuser)) -> Any:
|
||||||
"""
|
"""
|
||||||
重置存储配置
|
重置存储配置
|
||||||
@@ -114,7 +125,7 @@ def list_files(
|
|||||||
return file_list
|
return file_list
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mkdir", summary="创建目录", response_model=schemas.Response)
|
@router.post("/mkdir", summary="创建目录", response_model=schemas.Response[None])
|
||||||
def mkdir(
|
def mkdir(
|
||||||
fileitem: schemas.FileItem,
|
fileitem: schemas.FileItem,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -134,7 +145,7 @@ def mkdir(
|
|||||||
return schemas.Response(success=False)
|
return schemas.Response(success=False)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/delete", summary="删除文件或目录", response_model=schemas.Response)
|
@router.post("/delete", summary="删除文件或目录", response_model=schemas.Response[None])
|
||||||
def delete(
|
def delete(
|
||||||
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
|
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -149,7 +160,23 @@ def delete(
|
|||||||
return schemas.Response(success=False)
|
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(
|
def download(
|
||||||
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
|
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -165,7 +192,20 @@ def download(
|
|||||||
return schemas.Response(success=False)
|
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(
|
def image(
|
||||||
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
|
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -181,7 +221,7 @@ def image(
|
|||||||
return Response(content=tmp_file.read_bytes(), media_type="image/jpeg")
|
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(
|
def rename(
|
||||||
fileitem: schemas.FileItem,
|
fileitem: schemas.FileItem,
|
||||||
new_name: str,
|
new_name: str,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from typing import List, Any, Annotated, Optional
|
from typing import List, Any, Annotated, Optional
|
||||||
|
|
||||||
import cn2an
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.subscribe import SubscribeChain
|
from app.chain.subscribe import SubscribeChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.context import MediaInfo
|
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
|
from app.utils.media import normalize_media_source, resolve_media_identity
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
def start_subscribe_add(
|
def start_subscribe_add(
|
||||||
@@ -181,7 +182,11 @@ async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
|||||||
return await Subscribe.async_list()
|
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(
|
async def create_subscribe(
|
||||||
*,
|
*,
|
||||||
subscribe_in: schemas.Subscribe,
|
subscribe_in: schemas.Subscribe,
|
||||||
@@ -242,7 +247,7 @@ async def create_subscribe(
|
|||||||
return schemas.Response(success=bool(sid), message=message, data={"id": sid})
|
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(
|
async def update_subscribe(
|
||||||
*,
|
*,
|
||||||
subscribe_in: schemas.Subscribe,
|
subscribe_in: schemas.Subscribe,
|
||||||
@@ -314,7 +319,7 @@ async def update_subscribe(
|
|||||||
return schemas.Response(success=True)
|
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(
|
async def update_subscribe_status(
|
||||||
subid: int,
|
subid: int,
|
||||||
state: str,
|
state: str,
|
||||||
@@ -367,7 +372,7 @@ async def subscribe_media_identity(
|
|||||||
return result if result else Subscribe()
|
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(
|
def refresh_subscribes(
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -380,7 +385,7 @@ def refresh_subscribes(
|
|||||||
return schemas.Response(success=True)
|
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(
|
async def reset_subscribes(
|
||||||
subid: int,
|
subid: int,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
@@ -428,7 +433,7 @@ async def reset_subscribes(
|
|||||||
return schemas.Response(success=False, message="订阅不存在")
|
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(
|
def check_subscribes(
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -441,7 +446,7 @@ def check_subscribes(
|
|||||||
return schemas.Response(success=True)
|
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(
|
async def search_subscribes(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
@@ -470,7 +475,7 @@ async def search_subscribes(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response
|
"/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
async def search_subscribe(
|
async def search_subscribe(
|
||||||
subscribe_id: int,
|
subscribe_id: int,
|
||||||
@@ -492,7 +497,7 @@ async def search_subscribe(
|
|||||||
return schemas.Response(success=True)
|
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(
|
async def delete_subscribe_by_media_identity(
|
||||||
media_id: str,
|
media_id: str,
|
||||||
media_source: MediaSource,
|
media_source: MediaSource,
|
||||||
@@ -536,7 +541,7 @@ async def delete_subscribe_by_media_identity(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response
|
"/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
async def seerr_subscribe(
|
async def seerr_subscribe(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -640,7 +645,7 @@ async def subscribe_history(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response
|
"/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
async def delete_subscribe_history(
|
async def delete_subscribe_history(
|
||||||
history_id: int,
|
history_id: int,
|
||||||
@@ -750,7 +755,7 @@ def subscribe_files(
|
|||||||
return schemas.SubscrbieInfo()
|
return schemas.SubscrbieInfo()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/share", summary="分享订阅", response_model=schemas.Response)
|
@router.post("/share", summary="分享订阅", response_model=schemas.Response[None])
|
||||||
async def subscribe_share(
|
async def subscribe_share(
|
||||||
sub: schemas.SubscribeShare,
|
sub: schemas.SubscribeShare,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
@@ -771,7 +776,7 @@ async def subscribe_share(
|
|||||||
return schemas.Response(success=state, message=errmsg)
|
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(
|
async def subscribe_share_delete(
|
||||||
share_id: int, _: schemas.TokenPayload = Depends(verify_token)
|
share_id: int, _: schemas.TokenPayload = Depends(verify_token)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -782,7 +787,7 @@ async def subscribe_share_delete(
|
|||||||
return schemas.Response(success=state, message=errmsg)
|
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(
|
async def subscribe_fork(
|
||||||
sub: schemas.SubscribeShare,
|
sub: schemas.SubscribeShare,
|
||||||
current_user: User = Depends(get_current_active_user_async),
|
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 []
|
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(
|
async def follow_subscriber(
|
||||||
share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
|
share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -828,7 +833,7 @@ async def follow_subscriber(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/follow", summary="取消Follow订阅分享人", response_model=schemas.Response
|
"/follow", summary="取消Follow订阅分享人", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
async def unfollow_subscriber(
|
async def unfollow_subscriber(
|
||||||
share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
|
share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
|
||||||
@@ -902,7 +907,7 @@ async def read_subscribe(
|
|||||||
return subscribe if subscribe else 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(
|
async def delete_subscribe(
|
||||||
subscribe_id: int,
|
subscribe_id: int,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
|
|||||||
+152
-28
@@ -14,10 +14,11 @@ import anyio
|
|||||||
import pillow_avif # noqa 用于自动注册AVIF支持
|
import pillow_avif # noqa 用于自动注册AVIF支持
|
||||||
from anyio import Path as AsyncPath
|
from anyio import Path as AsyncPath
|
||||||
from app.helper.sites import SitesHelper # noqa # noqa
|
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 fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.chain.mediaserver import MediaServerChain
|
from app.chain.mediaserver import MediaServerChain
|
||||||
from app.chain.search import SearchChain
|
from app.chain.search import SearchChain
|
||||||
@@ -58,7 +59,7 @@ from app.utils.security import SecurityUtils
|
|||||||
from app.utils.url import UrlUtils
|
from app.utils.url import UrlUtils
|
||||||
from version import APP_VERSION
|
from version import APP_VERSION
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
_NETTEST_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
|
_NETTEST_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
|
||||||
_PUBLIC_SYSTEM_CONFIG_KEYS = {
|
_PUBLIC_SYSTEM_CONFIG_KEYS = {
|
||||||
@@ -581,7 +582,23 @@ async def fetch_image(
|
|||||||
return None
|
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(
|
async def proxy_img(
|
||||||
imgurl: str,
|
imgurl: str,
|
||||||
proxy: bool = False,
|
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(
|
async def cache_img(
|
||||||
url: str,
|
url: str,
|
||||||
if_none_match: Annotated[str | None, Header()] = None,
|
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):
|
def get_global_setting(token: str):
|
||||||
"""
|
"""
|
||||||
查询非敏感系统设置(默认鉴权)
|
查询非敏感系统设置(默认鉴权)
|
||||||
@@ -655,7 +692,9 @@ def get_global_setting(token: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@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)):
|
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)
|
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(
|
async def get_env_setting(
|
||||||
_: User = Depends(get_current_active_superuser_async),
|
_: User = Depends(get_current_active_superuser_async),
|
||||||
) -> schemas.Response:
|
) -> schemas.Response:
|
||||||
@@ -713,7 +756,11 @@ async def get_env_setting(
|
|||||||
return schemas.Response(success=True, data=info)
|
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)):
|
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())
|
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:
|
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)
|
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(
|
async def set_env_setting(
|
||||||
env: dict, _: User = Depends(get_current_active_superuser_async)
|
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(
|
async def get_progress(
|
||||||
request: Request,
|
request: Request,
|
||||||
process_type: str,
|
process_type: str,
|
||||||
@@ -794,7 +856,11 @@ async def get_progress(
|
|||||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
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(
|
async def get_public_setting(
|
||||||
key: str, _: User = Depends(get_current_active_user_async)
|
key: str, _: User = Depends(get_current_active_user_async)
|
||||||
) -> schemas.Response:
|
) -> schemas.Response:
|
||||||
@@ -812,7 +878,7 @@ async def get_public_setting(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/setting/PLUGIN_MARKET/sync-wiki",
|
"/setting/PLUGIN_MARKET/sync-wiki",
|
||||||
summary="从Wiki同步插件市场仓库",
|
summary="从Wiki同步插件市场仓库",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.PluginMarketSyncData],
|
||||||
)
|
)
|
||||||
async def sync_plugin_market_from_wiki(
|
async def sync_plugin_market_from_wiki(
|
||||||
request: Optional[schemas.PluginMarketSyncRequest] = Body(default=None),
|
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(
|
async def get_setting(
|
||||||
key: str, _: User = Depends(get_current_active_superuser_async)
|
key: str, _: User = Depends(get_current_active_superuser_async)
|
||||||
) -> schemas.Response:
|
) -> schemas.Response:
|
||||||
@@ -890,7 +960,7 @@ async def get_setting(
|
|||||||
return schemas.Response(success=True, data={"value": value})
|
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(
|
async def set_setting(
|
||||||
key: str,
|
key: str,
|
||||||
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
|
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}' 不存在")
|
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(
|
async def get_message(
|
||||||
request: Request,
|
request: Request,
|
||||||
role: Optional[str] = "system",
|
role: Optional[str] = "system",
|
||||||
@@ -951,7 +1032,21 @@ async def get_message(
|
|||||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
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(
|
async def get_logging(
|
||||||
request: Request,
|
request: Request,
|
||||||
length: Optional[int] = 50,
|
length: Optional[int] = 50,
|
||||||
@@ -1065,7 +1160,22 @@ async def get_logging(
|
|||||||
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
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(
|
async def download_logging(
|
||||||
name: str,
|
name: str,
|
||||||
_: schemas.TokenPayload = Depends(_verify_log_resource_superuser),
|
_: schemas.TokenPayload = Depends(_verify_log_resource_superuser),
|
||||||
@@ -1077,7 +1187,9 @@ async def download_logging(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@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)):
|
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)
|
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(
|
def ruletest(
|
||||||
title: str,
|
title: str,
|
||||||
rulegroup_name: str,
|
rulegroup_name: str,
|
||||||
@@ -1165,7 +1281,9 @@ def ruletest(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@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)):
|
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(
|
async def nettest(
|
||||||
target_id: Optional[str] = None,
|
target_id: Optional[str] = None,
|
||||||
url: Optional[str] = None,
|
url: Optional[str] = None,
|
||||||
@@ -1278,7 +1400,9 @@ async def nettest(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@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)):
|
def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
|
||||||
"""
|
"""
|
||||||
@@ -1302,7 +1426,7 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@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)):
|
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)
|
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)):
|
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)
|
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(
|
def upgrade_system(
|
||||||
mode: Annotated[str | None, Body()] = None,
|
mode: Annotated[str | None, Body()] = None,
|
||||||
_: User = Depends(get_current_active_superuser),
|
_: User = Depends(get_current_active_superuser),
|
||||||
@@ -1341,7 +1465,7 @@ def upgrade_system(
|
|||||||
return schemas.Response(success=ret, message=msg)
|
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)):
|
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(
|
@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)]):
|
def run_scheduler2(jobid: str, _: Annotated[str, Depends(verify_apitoken)]):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from typing import List, Any, Optional
|
from typing import List, Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.tmdb import TmdbChain
|
from app.chain.tmdb import TmdbChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import verify_token
|
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.modules.themoviedb.tmdb_cache import TmdbCache
|
||||||
from app.schemas.types import MediaType, SystemConfigKey
|
from app.schemas.types import MediaType, SystemConfigKey
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/cache", summary="查询 TheMovieDb 识别缓存", response_model=schemas.Response
|
"/cache",
|
||||||
|
summary="查询 TheMovieDb 识别缓存",
|
||||||
|
response_model=schemas.Response[schemas.TmdbRecognitionCacheData],
|
||||||
)
|
)
|
||||||
async def tmdb_recognition_cache(
|
async def tmdb_recognition_cache(
|
||||||
_: User = Depends(get_current_active_superuser_async),
|
_: User = Depends(get_current_active_superuser_async),
|
||||||
@@ -42,7 +45,7 @@ async def tmdb_recognition_cache(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/cache/{cache_key:path}",
|
"/cache/{cache_key:path}",
|
||||||
summary="删除指定 TheMovieDb 识别缓存",
|
summary="删除指定 TheMovieDb 识别缓存",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[None],
|
||||||
)
|
)
|
||||||
async def delete_tmdb_recognition_cache(
|
async def delete_tmdb_recognition_cache(
|
||||||
cache_key: str,
|
cache_key: str,
|
||||||
@@ -56,7 +59,7 @@ async def delete_tmdb_recognition_cache(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/cache", summary="清空 TheMovieDb 识别缓存", response_model=schemas.Response
|
"/cache", summary="清空 TheMovieDb 识别缓存", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
async def clear_tmdb_recognition_cache(
|
async def clear_tmdb_recognition_cache(
|
||||||
_: User = Depends(get_current_active_superuser_async),
|
_: User = Depends(get_current_active_superuser_async),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import Depends
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.chain.torrents import TorrentsChain
|
from app.chain.torrents import TorrentsChain
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -27,10 +28,14 @@ from app.utils.media import (
|
|||||||
resolve_media_identity,
|
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)):
|
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(
|
@router.delete(
|
||||||
"/cache/{domain}/{torrent_hash}",
|
"/cache/{domain}/{torrent_hash}",
|
||||||
summary="删除指定种子缓存",
|
summary="删除指定种子缓存",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[None],
|
||||||
)
|
)
|
||||||
async def delete_cache(
|
async def delete_cache(
|
||||||
domain: str,
|
domain: str,
|
||||||
@@ -145,7 +150,7 @@ async def delete_cache(
|
|||||||
return schemas.Response(success=False, message=f"删除失败:{str(e)}")
|
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)):
|
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)}")
|
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)):
|
def refresh_cache(_: User = Depends(get_current_active_superuser)):
|
||||||
"""
|
"""
|
||||||
刷新种子缓存
|
刷新种子缓存
|
||||||
@@ -186,7 +191,7 @@ def refresh_cache(_: User = Depends(get_current_active_superuser)):
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/cache/reidentify/{domain}/{torrent_hash}",
|
"/cache/reidentify/{domain}/{torrent_hash}",
|
||||||
summary="重新识别种子",
|
summary="重新识别种子",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.TorrentReidentifyData],
|
||||||
)
|
)
|
||||||
async def reidentify_cache(
|
async def reidentify_cache(
|
||||||
domain: str,
|
domain: str,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, Annotated, Optional
|
from typing import Any, List, Annotated, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.chain.transfer import TransferChain
|
from app.chain.transfer import TransferChain
|
||||||
from app.core.config import settings, global_vars
|
from app.core.config import settings, global_vars
|
||||||
@@ -25,10 +26,14 @@ from app.schemas import (
|
|||||||
EpisodeFormatRecommendItem,
|
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(
|
def query_name(
|
||||||
path: str, filetype: str, _: schemas.TokenPayload = Depends(verify_token)
|
path: str, filetype: str, _: schemas.TokenPayload = Depends(verify_token)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -79,7 +84,7 @@ async def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/queue", summary="从整理队列中删除任务", response_model=schemas.Response
|
"/queue", summary="从整理队列中删除任务", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
async def remove_queue(
|
async def remove_queue(
|
||||||
fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)
|
fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)
|
||||||
@@ -181,7 +186,7 @@ def _get_manual_transfer_target_key(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/manual/target-path",
|
"/manual/target-path",
|
||||||
summary="匹配手动转移目的路径",
|
summary="匹配手动转移目的路径",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.ManualTransferTargetPath],
|
||||||
)
|
)
|
||||||
def match_manual_transfer_target_path(
|
def match_manual_transfer_target_path(
|
||||||
transer_item: ManualTransferItem,
|
transer_item: ManualTransferItem,
|
||||||
@@ -244,7 +249,7 @@ def match_manual_transfer_target_path(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/manual/history",
|
"/manual/history",
|
||||||
summary="查询手动转移成功历史",
|
summary="查询手动转移成功历史",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.ManualTransferHistoryInfo],
|
||||||
)
|
)
|
||||||
def query_manual_transfer_history(
|
def query_manual_transfer_history(
|
||||||
transer_item: ManualTransferItem,
|
transer_item: ManualTransferItem,
|
||||||
@@ -275,7 +280,11 @@ def query_manual_transfer_history(
|
|||||||
return schemas.Response(success=True, data=history_info.model_dump())
|
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(
|
def manual_transfer(
|
||||||
transer_item: ManualTransferItem,
|
transer_item: ManualTransferItem,
|
||||||
background: Optional[bool] = False,
|
background: Optional[bool] = False,
|
||||||
@@ -574,7 +583,7 @@ def manual_transfer(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/episode-format/recommend",
|
"/episode-format/recommend",
|
||||||
summary="推荐集数定位模板",
|
summary="推荐集数定位模板",
|
||||||
response_model=schemas.Response,
|
response_model=schemas.Response[schemas.EpisodeFormatRecommendData],
|
||||||
)
|
)
|
||||||
def recommend_episode_format(
|
def recommend_episode_format(
|
||||||
recommend_item: EpisodeFormatRecommendItem,
|
recommend_item: EpisodeFormatRecommendItem,
|
||||||
@@ -600,7 +609,7 @@ def recommend_episode_format(
|
|||||||
return schemas.Response(success=True, data=data)
|
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:
|
def now(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||||
"""
|
"""
|
||||||
立即执行下载器文件整理 API_TOKEN认证(?token=xxx)
|
立即执行下载器文件整理 API_TOKEN认证(?token=xxx)
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import base64
|
|||||||
import re
|
import re
|
||||||
from typing import Annotated, Any, List, Union
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.core.security import get_password_hash
|
from app.core.security import get_password_hash
|
||||||
from app.db import get_async_db
|
from app.db import get_async_db
|
||||||
from app.db.models.user import User
|
from app.db.models.user import User
|
||||||
@@ -16,7 +17,7 @@ from app.db.user_oper import (
|
|||||||
)
|
)
|
||||||
from app.db.userconfig_oper import UserConfigOper
|
from app.db.userconfig_oper import UserConfigOper
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", summary="所有用户", response_model=List[schemas.User])
|
@router.get("/", summary="所有用户", response_model=List[schemas.User])
|
||||||
@@ -30,7 +31,7 @@ async def list_users(
|
|||||||
return await current_user.async_list(db)
|
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(
|
async def create_user(
|
||||||
*,
|
*,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
@@ -51,7 +52,7 @@ async def create_user(
|
|||||||
return schemas.Response(success=True if user else False)
|
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(
|
async def update_user(
|
||||||
*,
|
*,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
@@ -98,7 +99,9 @@ async def read_current_user(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/avatar/{user_id}", summary="上传用户头像", response_model=schemas.Response
|
"/avatar/{user_id}",
|
||||||
|
summary="上传用户头像",
|
||||||
|
response_model=schemas.Response[schemas.FileNameData],
|
||||||
)
|
)
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -122,7 +125,11 @@ async def upload_avatar(
|
|||||||
return schemas.Response(success=True, data={"filename": file.filename})
|
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)):
|
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})
|
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(
|
def set_config(
|
||||||
key: str,
|
key: str,
|
||||||
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
|
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
|
||||||
@@ -144,7 +151,7 @@ def set_config(
|
|||||||
return schemas.Response(success=True)
|
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(
|
async def delete_user_by_id(
|
||||||
*,
|
*,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
@@ -161,7 +168,7 @@ async def delete_user_by_id(
|
|||||||
return schemas.Response(success=True)
|
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(
|
async def delete_user_by_name(
|
||||||
*,
|
*,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from typing import Any, Annotated
|
from typing import Any, Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Request, Depends
|
from fastapi import BackgroundTasks, Request, Depends
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.webhook import WebhookChain
|
from app.chain.webhook import WebhookChain
|
||||||
from app.core.security import verify_apitoken
|
from app.core.security import verify_apitoken
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
|
||||||
def start_webhook_chain(body: Any, form: Any, args: Any):
|
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)
|
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(
|
async def webhook_message(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -32,7 +33,7 @@ async def webhook_message(
|
|||||||
return schemas.Response(success=True)
|
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(
|
async def webhook_message_get(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import json
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Any, Optional
|
from typing import List, Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
from app.chain.workflow import WorkflowChain
|
from app.chain.workflow import WorkflowChain
|
||||||
from app.core.config import global_vars
|
from app.core.config import global_vars
|
||||||
from app.core.plugin import PluginManager
|
from app.core.plugin import PluginManager
|
||||||
@@ -23,7 +24,7 @@ from app.helper.server import MoviePilotServerHelper
|
|||||||
from app.scheduler import Scheduler
|
from app.scheduler import Scheduler
|
||||||
from app.schemas.types import EventType, EVENT_TYPE_NAMES
|
from app.schemas.types import EventType, EVENT_TYPE_NAMES
|
||||||
|
|
||||||
router = APIRouter()
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
WORKFLOW_TRIGGER_TIMER = "timer"
|
WORKFLOW_TRIGGER_TIMER = "timer"
|
||||||
WORKFLOW_TRIGGER_EVENT = "event"
|
WORKFLOW_TRIGGER_EVENT = "event"
|
||||||
@@ -41,7 +42,7 @@ async def list_workflows(
|
|||||||
return await WorkflowOper(db).async_list()
|
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(
|
async def create_workflow(
|
||||||
workflow: schemas.Workflow,
|
workflow: schemas.Workflow,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
@@ -63,7 +64,11 @@ async def create_workflow(
|
|||||||
return schemas.Response(success=True, message="创建工作流成功")
|
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(
|
def list_plugin_actions(
|
||||||
plugin_id: str = None, _: User = Depends(get_current_active_manage_user)
|
plugin_id: str = None, _: User = Depends(get_current_active_manage_user)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -73,7 +78,11 @@ def list_plugin_actions(
|
|||||||
return PluginManager().get_plugin_actions(plugin_id)
|
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:
|
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()
|
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:
|
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(
|
async def workflow_share(
|
||||||
workflow: schemas.WorkflowShare, _: User = Depends(get_current_active_manage_user_async)
|
workflow: schemas.WorkflowShare, _: User = Depends(get_current_active_manage_user_async)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -116,7 +129,7 @@ async def workflow_share(
|
|||||||
return schemas.Response(success=state, message=errmsg)
|
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(
|
async def workflow_share_delete(
|
||||||
share_id: int, _: User = Depends(get_current_active_manage_user_async)
|
share_id: int, _: User = Depends(get_current_active_manage_user_async)
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@@ -127,7 +140,7 @@ async def workflow_share_delete(
|
|||||||
return schemas.Response(success=state, message=errmsg)
|
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(
|
async def workflow_fork(
|
||||||
workflow: schemas.WorkflowShare,
|
workflow: schemas.WorkflowShare,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
@@ -206,7 +219,7 @@ async def workflow_shares(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{workflow_id}/run", summary="执行工作流", response_model=schemas.Response
|
"/{workflow_id}/run", summary="执行工作流", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
def run_workflow(
|
def run_workflow(
|
||||||
workflow_id: int,
|
workflow_id: int,
|
||||||
@@ -223,7 +236,7 @@ def run_workflow(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{workflow_id}/start", summary="启用工作流", response_model=schemas.Response
|
"/{workflow_id}/start", summary="启用工作流", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
def start_workflow(
|
def start_workflow(
|
||||||
workflow_id: int,
|
workflow_id: int,
|
||||||
@@ -257,7 +270,7 @@ def start_workflow(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{workflow_id}/pause", summary="停用工作流", response_model=schemas.Response
|
"/{workflow_id}/pause", summary="停用工作流", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
def pause_workflow(
|
def pause_workflow(
|
||||||
workflow_id: int,
|
workflow_id: int,
|
||||||
@@ -285,7 +298,7 @@ def pause_workflow(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{workflow_id}/reset", summary="重置工作流", response_model=schemas.Response
|
"/{workflow_id}/reset", summary="重置工作流", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
async def reset_workflow(
|
async def reset_workflow(
|
||||||
workflow_id: int,
|
workflow_id: int,
|
||||||
@@ -319,7 +332,7 @@ async def get_workflow(
|
|||||||
return await WorkflowOper(db).async_get(workflow_id)
|
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(
|
def update_workflow(
|
||||||
workflow: schemas.Workflow,
|
workflow: schemas.Workflow,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -349,7 +362,7 @@ def update_workflow(
|
|||||||
return schemas.Response(success=True, message="更新成功")
|
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(
|
def delete_workflow(
|
||||||
workflow_id: int,
|
workflow_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -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)
|
||||||
+91
-48
@@ -1,10 +1,11 @@
|
|||||||
from typing import Any, List, Annotated
|
from typing import List, Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ERROR_RESPONSES
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.chain.subscribe import SubscribeChain
|
from app.chain.subscribe import SubscribeChain
|
||||||
from app.chain.tvdb import TvdbChain
|
from app.chain.tvdb import TvdbChain
|
||||||
@@ -16,7 +17,7 @@ from app.schemas import RadarrMovie, SonarrSeries
|
|||||||
from app.schemas.types import MediaSource, MediaType
|
from app.schemas.types import MediaSource, MediaType
|
||||||
from version import APP_VERSION
|
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:
|
def _subscribe_tmdb_id(subscribe: Subscribe) -> int | None:
|
||||||
@@ -30,12 +31,18 @@ def _subscribe_tmdb_id(subscribe: Subscribe) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@arr_router.get("/system/status", summary="系统状态")
|
@arr_router.get(
|
||||||
async def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
"/system/status",
|
||||||
|
summary="系统状态",
|
||||||
|
response_model=schemas.ServarrSystemStatus,
|
||||||
|
)
|
||||||
|
async def arr_system_status(
|
||||||
|
_: Annotated[str, Depends(verify_apikey)],
|
||||||
|
) -> schemas.ServarrSystemStatus:
|
||||||
"""
|
"""
|
||||||
模拟Radarr、Sonarr系统状态
|
模拟Radarr、Sonarr系统状态
|
||||||
"""
|
"""
|
||||||
return {
|
return schemas.ServarrSystemStatus.model_validate({
|
||||||
"appName": "MoviePilot",
|
"appName": "MoviePilot",
|
||||||
"instanceName": "moviepilot",
|
"instanceName": "moviepilot",
|
||||||
"version": APP_VERSION,
|
"version": APP_VERSION,
|
||||||
@@ -81,16 +88,22 @@ async def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
|||||||
"packageAuthor": "jxxghp",
|
"packageAuthor": "jxxghp",
|
||||||
"packageUpdateMechanism": "builtIn",
|
"packageUpdateMechanism": "builtIn",
|
||||||
"packageUpdateMechanismMessage": "",
|
"packageUpdateMechanismMessage": "",
|
||||||
}
|
})
|
||||||
|
|
||||||
|
|
||||||
@arr_router.get("/qualityProfile", summary="质量配置")
|
@arr_router.get(
|
||||||
async def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
"/qualityProfile",
|
||||||
|
summary="质量配置",
|
||||||
|
response_model=List[schemas.ServarrQualityProfile],
|
||||||
|
)
|
||||||
|
async def arr_qualityProfile(
|
||||||
|
_: Annotated[str, Depends(verify_apikey)],
|
||||||
|
) -> List[schemas.ServarrQualityProfile]:
|
||||||
"""
|
"""
|
||||||
模拟Radarr、Sonarr质量配置
|
模拟Radarr、Sonarr质量配置
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
{
|
schemas.ServarrQualityProfile.model_validate({
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "默认",
|
"name": "默认",
|
||||||
"upgradeAllowed": True,
|
"upgradeAllowed": True,
|
||||||
@@ -112,41 +125,55 @@ async def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
|||||||
"minFormatScore": 0,
|
"minFormatScore": 0,
|
||||||
"cutoffFormatScore": 0,
|
"cutoffFormatScore": 0,
|
||||||
"formatItems": [{"id": 0, "format": 0, "name": "默认", "score": 0}],
|
"formatItems": [{"id": 0, "format": 0, "name": "默认", "score": 0}],
|
||||||
}
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@arr_router.get("/rootfolder", summary="根目录")
|
@arr_router.get(
|
||||||
async def arr_rootfolder(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
"/rootfolder",
|
||||||
|
summary="根目录",
|
||||||
|
response_model=List[schemas.ServarrRootFolder],
|
||||||
|
)
|
||||||
|
async def arr_rootfolder(
|
||||||
|
_: Annotated[str, Depends(verify_apikey)],
|
||||||
|
) -> List[schemas.ServarrRootFolder]:
|
||||||
"""
|
"""
|
||||||
模拟Radarr、Sonarr根目录
|
模拟Radarr、Sonarr根目录
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
{
|
schemas.ServarrRootFolder.model_validate({
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"accessible": True,
|
"accessible": True,
|
||||||
"freeSpace": 0,
|
"freeSpace": 0,
|
||||||
"unmappedFolders": [],
|
"unmappedFolders": [],
|
||||||
}
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@arr_router.get("/tag", summary="标签")
|
@arr_router.get("/tag", summary="标签", response_model=List[schemas.ServarrTag])
|
||||||
async def arr_tag(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
async def arr_tag(
|
||||||
|
_: Annotated[str, Depends(verify_apikey)],
|
||||||
|
) -> List[schemas.ServarrTag]:
|
||||||
"""
|
"""
|
||||||
模拟Radarr、Sonarr标签
|
模拟Radarr、Sonarr标签
|
||||||
"""
|
"""
|
||||||
return [{"id": 1, "label": "默认"}]
|
return [schemas.ServarrTag(id=1, label="默认")]
|
||||||
|
|
||||||
|
|
||||||
@arr_router.get("/languageprofile", summary="语言")
|
@arr_router.get(
|
||||||
async def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
"/languageprofile",
|
||||||
|
summary="语言",
|
||||||
|
response_model=List[schemas.ServarrLanguageProfile],
|
||||||
|
)
|
||||||
|
async def arr_languageprofile(
|
||||||
|
_: Annotated[str, Depends(verify_apikey)],
|
||||||
|
) -> List[schemas.ServarrLanguageProfile]:
|
||||||
"""
|
"""
|
||||||
模拟Radarr、Sonarr语言
|
模拟Radarr、Sonarr语言
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
{
|
schemas.ServarrLanguageProfile.model_validate({
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "默认",
|
"name": "默认",
|
||||||
"upgradeAllowed": True,
|
"upgradeAllowed": True,
|
||||||
@@ -154,7 +181,7 @@ async def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
|||||||
"languages": [
|
"languages": [
|
||||||
{"id": 1, "language": {"id": 1, "name": "默认"}, "allowed": True}
|
{"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(
|
async def arr_movies(
|
||||||
_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)
|
_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)
|
||||||
) -> Any:
|
) -> List[schemas.RadarrMovie]:
|
||||||
"""
|
"""
|
||||||
查询Rardar电影
|
查询Rardar电影
|
||||||
"""
|
"""
|
||||||
@@ -259,7 +286,7 @@ async def arr_movies(
|
|||||||
)
|
)
|
||||||
def arr_movie_lookup(
|
def arr_movie_lookup(
|
||||||
term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)
|
term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)
|
||||||
) -> Any:
|
) -> List[schemas.RadarrMovie]:
|
||||||
"""
|
"""
|
||||||
查询Rardar电影 term: `tmdb:${id}`
|
查询Rardar电影 term: `tmdb:${id}`
|
||||||
存在和不存在均不能返回错误
|
存在和不存在均不能返回错误
|
||||||
@@ -319,7 +346,7 @@ async def arr_movie(
|
|||||||
mid: int,
|
mid: int,
|
||||||
_: Annotated[str, Depends(verify_apikey)],
|
_: Annotated[str, Depends(verify_apikey)],
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
) -> Any:
|
) -> schemas.RadarrMovie:
|
||||||
"""
|
"""
|
||||||
查询Rardar电影订阅
|
查询Rardar电影订阅
|
||||||
"""
|
"""
|
||||||
@@ -340,12 +367,14 @@ async def arr_movie(
|
|||||||
raise HTTPException(status_code=404, detail="未找到该电影!")
|
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(
|
async def arr_add_movie(
|
||||||
_: Annotated[str, Depends(verify_apikey)],
|
_: Annotated[str, Depends(verify_apikey)],
|
||||||
movie: RadarrMovie,
|
movie: RadarrMovie,
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
) -> Any:
|
) -> schemas.ServarrIdResponse:
|
||||||
"""
|
"""
|
||||||
新增Rardar电影订阅
|
新增Rardar电影订阅
|
||||||
"""
|
"""
|
||||||
@@ -354,7 +383,7 @@ async def arr_add_movie(
|
|||||||
db, MediaSource.TMDB.value, str(movie.tmdbId)
|
db, MediaSource.TMDB.value, str(movie.tmdbId)
|
||||||
)
|
)
|
||||||
if subscribes:
|
if subscribes:
|
||||||
return {"id": subscribes[0].id}
|
return schemas.ServarrIdResponse(id=subscribes[0].id)
|
||||||
# 添加订阅
|
# 添加订阅
|
||||||
sid, message = await SubscribeChain().async_add(
|
sid, message = await SubscribeChain().async_add(
|
||||||
title=movie.title,
|
title=movie.title,
|
||||||
@@ -365,19 +394,19 @@ async def arr_add_movie(
|
|||||||
username="Seerr",
|
username="Seerr",
|
||||||
)
|
)
|
||||||
if sid:
|
if sid:
|
||||||
return {"id": sid}
|
return schemas.ServarrIdResponse(id=sid)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}")
|
raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}")
|
||||||
|
|
||||||
|
|
||||||
@arr_router.delete(
|
@arr_router.delete(
|
||||||
"/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response
|
"/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response[None]
|
||||||
)
|
)
|
||||||
async def arr_remove_movie(
|
async def arr_remove_movie(
|
||||||
mid: int,
|
mid: int,
|
||||||
_: Annotated[str, Depends(verify_apikey)],
|
_: Annotated[str, Depends(verify_apikey)],
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
) -> Any:
|
) -> schemas.Response[None]:
|
||||||
"""
|
"""
|
||||||
删除Rardar电影订阅
|
删除Rardar电影订阅
|
||||||
"""
|
"""
|
||||||
@@ -394,7 +423,7 @@ async def arr_remove_movie(
|
|||||||
)
|
)
|
||||||
async def arr_series(
|
async def arr_series(
|
||||||
_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)
|
_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)
|
||||||
) -> Any:
|
) -> List[schemas.SonarrSeries]:
|
||||||
"""
|
"""
|
||||||
查询Sonarr剧集
|
查询Sonarr剧集
|
||||||
"""
|
"""
|
||||||
@@ -531,10 +560,14 @@ async def arr_series(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@arr_router.get("/series/lookup", summary="查询剧集")
|
@arr_router.get(
|
||||||
|
"/series/lookup",
|
||||||
|
summary="查询剧集",
|
||||||
|
response_model=List[schemas.SonarrSeries],
|
||||||
|
)
|
||||||
def arr_series_lookup(
|
def arr_series_lookup(
|
||||||
term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)
|
term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)
|
||||||
) -> Any:
|
) -> List[schemas.SonarrSeries]:
|
||||||
"""
|
"""
|
||||||
查询Sonarr剧集 term: `tvdb:${id}` title
|
查询Sonarr剧集 term: `tvdb:${id}` title
|
||||||
"""
|
"""
|
||||||
@@ -641,12 +674,14 @@ def arr_series_lookup(
|
|||||||
return sonarr_series_list if sonarr_series_list else [SonarrSeries()]
|
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(
|
async def arr_serie(
|
||||||
tid: int,
|
tid: int,
|
||||||
_: Annotated[str, Depends(verify_apikey)],
|
_: Annotated[str, Depends(verify_apikey)],
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
) -> Any:
|
) -> schemas.SonarrSeries:
|
||||||
"""
|
"""
|
||||||
查询Sonarr剧集
|
查询Sonarr剧集
|
||||||
"""
|
"""
|
||||||
@@ -676,12 +711,14 @@ async def arr_serie(
|
|||||||
raise HTTPException(status_code=404, detail="未找到该电视剧!")
|
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(
|
async def arr_add_series(
|
||||||
tv: schemas.SonarrSeries,
|
tv: schemas.SonarrSeries,
|
||||||
_: Annotated[str, Depends(verify_apikey)],
|
_: Annotated[str, Depends(verify_apikey)],
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
) -> Any:
|
) -> schemas.ServarrIdResponse:
|
||||||
"""
|
"""
|
||||||
新增Sonarr剧集订阅
|
新增Sonarr剧集订阅
|
||||||
"""
|
"""
|
||||||
@@ -692,24 +729,24 @@ async def arr_add_series(
|
|||||||
db,
|
db,
|
||||||
media_source=MediaSource.TMDB.value,
|
media_source=MediaSource.TMDB.value,
|
||||||
media_id=str(tv.tmdbId),
|
media_id=str(tv.tmdbId),
|
||||||
season=season.get("seasonNumber"),
|
season=season.seasonNumber,
|
||||||
)
|
)
|
||||||
if subscribe:
|
if subscribe:
|
||||||
continue
|
continue
|
||||||
left_seasons.append(season)
|
left_seasons.append(season)
|
||||||
# 全部已存在订阅
|
# 全部已存在订阅
|
||||||
if not left_seasons:
|
if not left_seasons:
|
||||||
return {"id": 1}
|
return schemas.ServarrIdResponse(id=1)
|
||||||
# 剩下的添加订阅
|
# 剩下的添加订阅
|
||||||
sid = 0
|
sid = 0
|
||||||
message = ""
|
message = ""
|
||||||
for season in left_seasons:
|
for season in left_seasons:
|
||||||
if not season.get("monitored"):
|
if not season.monitored:
|
||||||
continue
|
continue
|
||||||
sid, message = await SubscribeChain().async_add(
|
sid, message = await SubscribeChain().async_add(
|
||||||
title=tv.title,
|
title=tv.title,
|
||||||
year=tv.year,
|
year=tv.year,
|
||||||
season=season.get("seasonNumber"),
|
season=season.seasonNumber,
|
||||||
media_source=MediaSource.TMDB,
|
media_source=MediaSource.TMDB,
|
||||||
media_id=str(tv.tmdbId),
|
media_id=str(tv.tmdbId),
|
||||||
mtype=MediaType.TV,
|
mtype=MediaType.TV,
|
||||||
@@ -717,27 +754,33 @@ async def arr_add_series(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if sid:
|
if sid:
|
||||||
return {"id": sid}
|
return schemas.ServarrIdResponse(id=sid)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}")
|
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(
|
async def arr_update_series(
|
||||||
tv: schemas.SonarrSeries, _: Annotated[str, Depends(verify_apikey)]
|
tv: schemas.SonarrSeries,
|
||||||
) -> Any:
|
_: Annotated[str, Depends(verify_apikey)],
|
||||||
|
db: AsyncSession = Depends(get_async_db),
|
||||||
|
) -> schemas.ServarrIdResponse:
|
||||||
"""
|
"""
|
||||||
更新Sonarr剧集订阅
|
更新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(
|
async def arr_remove_series(
|
||||||
tid: int,
|
tid: int,
|
||||||
_: Annotated[str, Depends(verify_apikey)],
|
_: Annotated[str, Depends(verify_apikey)],
|
||||||
db: AsyncSession = Depends(get_async_db),
|
db: AsyncSession = Depends(get_async_db),
|
||||||
) -> Any:
|
) -> schemas.Response[None]:
|
||||||
"""
|
"""
|
||||||
删除Sonarr剧集订阅
|
删除Sonarr剧集订阅
|
||||||
"""
|
"""
|
||||||
|
|||||||
+67
-25
@@ -1,7 +1,7 @@
|
|||||||
import gzip
|
import gzip
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
from typing import Annotated, Callable, Any, Dict, Optional
|
from typing import Annotated, Callable, Optional
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from anyio import Path as AsyncPath
|
from anyio import Path as AsyncPath
|
||||||
@@ -10,13 +10,17 @@ from fastapi.responses import PlainTextResponse
|
|||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.api.response import ERROR_RESPONSES
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.utils.crypto import CryptoJsUtils, HashUtils
|
from app.utils.crypto import CryptoJsUtils, HashUtils
|
||||||
|
|
||||||
|
|
||||||
class GzipRequest(Request):
|
class GzipRequest(Request):
|
||||||
|
"""按请求头透明解压 gzip 请求体。"""
|
||||||
|
|
||||||
async def body(self) -> bytes:
|
async def body(self) -> bytes:
|
||||||
|
"""读取请求体,并在需要时完成 gzip 解压。"""
|
||||||
if not hasattr(self, "_body"):
|
if not hasattr(self, "_body"):
|
||||||
body = await super().body()
|
body = await super().body()
|
||||||
if "gzip" in self.headers.getlist("Content-Encoding"):
|
if "gzip" in self.headers.getlist("Content-Encoding"):
|
||||||
@@ -26,17 +30,21 @@ class GzipRequest(Request):
|
|||||||
|
|
||||||
|
|
||||||
class GzipRoute(APIRoute):
|
class GzipRoute(APIRoute):
|
||||||
|
"""为 CookieCloud 路由注入 gzip 请求对象。"""
|
||||||
|
|
||||||
def get_route_handler(self) -> Callable:
|
def get_route_handler(self) -> Callable:
|
||||||
|
"""返回支持 gzip 请求体的路由处理器。"""
|
||||||
original_route_handler = super().get_route_handler()
|
original_route_handler = super().get_route_handler()
|
||||||
|
|
||||||
async def custom_route_handler(request: Request) -> Response:
|
async def custom_route_handler(request: Request) -> Response:
|
||||||
|
"""将原始请求替换为可解压的请求对象后继续处理。"""
|
||||||
request = GzipRequest(request.scope, request.receive)
|
request = GzipRequest(request.scope, request.receive)
|
||||||
return await original_route_handler(request)
|
return await original_route_handler(request)
|
||||||
|
|
||||||
return custom_route_handler
|
return custom_route_handler
|
||||||
|
|
||||||
|
|
||||||
async def verify_server_enabled():
|
async def verify_server_enabled() -> bool:
|
||||||
"""
|
"""
|
||||||
校验CookieCloud服务路由是否打开
|
校验CookieCloud服务路由是否打开
|
||||||
"""
|
"""
|
||||||
@@ -49,7 +57,7 @@ async def verify_update_auth(
|
|||||||
x_cookiecloud_auth: Annotated[
|
x_cookiecloud_auth: Annotated[
|
||||||
Optional[str], Header(alias="X-CookieCloud-Auth")
|
Optional[str], Header(alias="X-CookieCloud-Auth")
|
||||||
] = None,
|
] = None,
|
||||||
):
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
校验CookieCloud上传接口的可选共享认证头。
|
校验CookieCloud上传接口的可选共享认证头。
|
||||||
"""
|
"""
|
||||||
@@ -67,21 +75,48 @@ cookie_router = APIRouter(
|
|||||||
route_class=GzipRoute,
|
route_class=GzipRoute,
|
||||||
tags=["servcookie"],
|
tags=["servcookie"],
|
||||||
dependencies=[Depends(verify_server_enabled)],
|
dependencies=[Depends(verify_server_enabled)],
|
||||||
|
responses=ERROR_RESPONSES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@cookie_router.get("/", response_class=PlainTextResponse)
|
@cookie_router.get(
|
||||||
async def get_root():
|
"/",
|
||||||
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
|
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)
|
@cookie_router.post(
|
||||||
async def post_root():
|
"/",
|
||||||
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
|
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)])
|
@cookie_router.post(
|
||||||
async def update_cookie(req: schemas.CookieData):
|
"/update",
|
||||||
|
dependencies=[Depends(verify_update_auth)],
|
||||||
|
response_model=schemas.CookieActionResponse,
|
||||||
|
)
|
||||||
|
async def update_cookie(req: schemas.CookieData) -> schemas.CookieActionResponse:
|
||||||
"""
|
"""
|
||||||
上传Cookie数据
|
上传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:
|
async with aiofiles.open(file_path, encoding="utf-8", errors="replace", mode="r") as file:
|
||||||
read_content = await file.read()
|
read_content = await file.read()
|
||||||
if read_content == content:
|
if read_content == content:
|
||||||
return {"action": "done"}
|
return schemas.CookieActionResponse(action="done")
|
||||||
else:
|
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"
|
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")
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
# 读取文件
|
# 读取文件
|
||||||
async with aiofiles.open(file_path, encoding="utf-8", errors="replace", mode="r") as file:
|
async with aiofiles.open(file_path, encoding="utf-8", errors="replace", mode="r") as file:
|
||||||
read_content = await file.read()
|
read_content = await file.read()
|
||||||
data = json.loads(read_content.encode("utf-8"))
|
data = json.loads(read_content.encode("utf-8"))
|
||||||
return data
|
return schemas.CookieEncryptedPayload.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
def get_decrypted_cookie_data(
|
def get_decrypted_cookie_data(
|
||||||
uuid: str, password: str, encrypted: str
|
uuid: str, password: str, encrypted: str
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[schemas.CookieDecryptedPayload]:
|
||||||
"""
|
"""
|
||||||
加载本地加密数据并解密为Cookie
|
加载本地加密数据并解密为Cookie
|
||||||
"""
|
"""
|
||||||
@@ -128,7 +163,7 @@ def get_decrypted_cookie_data(
|
|||||||
decrypted_data = CryptoJsUtils.decrypt(encrypted, aes_key).decode("utf-8")
|
decrypted_data = CryptoJsUtils.decrypt(encrypted, aes_key).decode("utf-8")
|
||||||
decrypted_data = json.loads(decrypted_data)
|
decrypted_data = json.loads(decrypted_data)
|
||||||
if "cookie_data" in decrypted_data:
|
if "cookie_data" in decrypted_data:
|
||||||
return decrypted_data
|
return schemas.CookieDecryptedPayload.model_validate(decrypted_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"解密Cookie数据失败:{str(e)}")
|
logger.error(f"解密Cookie数据失败:{str(e)}")
|
||||||
return None
|
return None
|
||||||
@@ -136,26 +171,33 @@ def get_decrypted_cookie_data(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@cookie_router.get("/get/{uuid}")
|
@cookie_router.get("/get/{uuid}", response_model=schemas.CookieEncryptedPayload)
|
||||||
async def get_cookie(
|
async def get_cookie(
|
||||||
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")],
|
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")],
|
||||||
):
|
) -> schemas.CookieEncryptedPayload:
|
||||||
"""
|
"""
|
||||||
GET 下载加密数据
|
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(
|
async def post_cookie(
|
||||||
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")],
|
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")],
|
||||||
request: Optional[schemas.CookiePassword] = Body(None),
|
request: Optional[schemas.CookiePassword] = Body(None),
|
||||||
):
|
) -> schemas.CookieEncryptedPayload | schemas.CookieDecryptedPayload | None:
|
||||||
"""
|
"""
|
||||||
POST 下载加密数据
|
POST 下载加密数据
|
||||||
"""
|
"""
|
||||||
data = await load_encrypt_data(uuid)
|
data = schemas.CookieEncryptedPayload.model_validate(
|
||||||
|
await load_encrypt_data(uuid)
|
||||||
|
)
|
||||||
if request is not None:
|
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:
|
else:
|
||||||
return data
|
return data
|
||||||
|
|||||||
+12
-2
@@ -122,8 +122,18 @@ class WorkflowExecutor:
|
|||||||
)
|
)
|
||||||
self.actions = {action['id']: Action(**action) for action in workflow.actions}
|
self.actions = {action['id']: Action(**action) for action in workflow.actions}
|
||||||
self.flows = [ActionFlow(**flow) for flow in workflow.flows]
|
self.flows = [ActionFlow(**flow) for flow in workflow.flows]
|
||||||
self.execution_config = getattr(workflow, "execution_config", None) or {}
|
execution_config = getattr(workflow, "execution_config", None) or {}
|
||||||
self.restored_execution_state = getattr(workflow, "execution_state", 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.total_actions = len(self.actions)
|
||||||
self.success = True
|
self.success = True
|
||||||
self.has_failure = False
|
self.has_failure = False
|
||||||
|
|||||||
+4
-23
@@ -28,8 +28,6 @@ _VIDEO_SEASON_EPISODE_RE = re.compile(
|
|||||||
_ANIME_SQUARE_BRACKET_RE = re.compile(r'\[[+0-9XVPI-]+]\s*\[', re.IGNORECASE)
|
_ANIME_SQUARE_BRACKET_RE = re.compile(r'\[[+0-9XVPI-]+]\s*\[', re.IGNORECASE)
|
||||||
|
|
||||||
_BRACED_METAINFO_RE = re.compile(r'(?<={\[)[\W\w]+(?=]})')
|
_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_TMDBID_RE = re.compile(r'(?<=tmdbid=)\d+')
|
||||||
_BRACED_DOUBANID_RE = re.compile(r'(?<=doubanid=)\d+')
|
_BRACED_DOUBANID_RE = re.compile(r'(?<=doubanid=)\d+')
|
||||||
_BRACED_BANGUMIID_RE = re.compile(r'(?<=bangumiid=)\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+',
|
r'(?:bangumi(?:id)?|anilist(?:id)?)[=\-]\d+',
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_GENERIC_MEDIA_ID_TAG_RE = re.compile(r'(?:^|[;\[])media_(?:source|id)=', re.IGNORECASE)
|
|
||||||
_RUST_PARSE_OPTIONS_CACHE_KEY = "_cache_key"
|
_RUST_PARSE_OPTIONS_CACHE_KEY = "_cache_key"
|
||||||
|
|
||||||
_LEGACY_BRACED_ID_PATTERNS = (
|
_LEGACY_BRACED_ID_PATTERNS = (
|
||||||
@@ -161,20 +158,12 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
|
|||||||
"""
|
"""
|
||||||
metainfo = _empty_metainfo()
|
metainfo = _empty_metainfo()
|
||||||
legacy_identities = {}
|
legacy_identities = {}
|
||||||
generic_identity = (None, None)
|
|
||||||
if not title:
|
if not title:
|
||||||
return title, metainfo
|
return title, metainfo
|
||||||
# 当前格式为 {[media_source=...;media_id=...]},历史专用标签仅在此处兼容读取。
|
# 自定义识别词是面向用户的独立语法,继续使用各数据源专用 ID 字段。
|
||||||
results = _BRACED_METAINFO_RE.findall(title)
|
results = _BRACED_METAINFO_RE.findall(title)
|
||||||
if results:
|
if results:
|
||||||
for result in 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 = []
|
legacy_matches = []
|
||||||
for source, pattern in _LEGACY_BRACED_ID_PATTERNS:
|
for source, pattern in _LEGACY_BRACED_ID_PATTERNS:
|
||||||
legacy_match = pattern.search(result)
|
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))
|
metainfo['end_episode'] = int(end_episode.group(0))
|
||||||
# 去除title中该部分
|
# 去除title中该部分
|
||||||
if (
|
if (
|
||||||
source_match
|
legacy_matches
|
||||||
or media_id_match
|
|
||||||
or legacy_matches
|
|
||||||
or mtype
|
or mtype
|
||||||
or episode_group
|
or episode_group
|
||||||
or begin_season
|
or begin_season
|
||||||
@@ -252,8 +239,7 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
|
|||||||
title = media_id_re.sub('', title).strip()
|
title = media_id_re.sub('', title).strip()
|
||||||
break
|
break
|
||||||
|
|
||||||
media_source, media_id = generic_identity
|
media_source, media_id = None, None
|
||||||
if not media_source:
|
|
||||||
for source, _ in _LEGACY_ID_KEYS:
|
for source, _ in _LEGACY_ID_KEYS:
|
||||||
if legacy_identities.get(source):
|
if legacy_identities.get(source):
|
||||||
media_source, media_id = source, legacy_identities[source]
|
media_source, media_id = source, legacy_identities[source]
|
||||||
@@ -432,18 +418,13 @@ def _requires_python_metainfo(
|
|||||||
custom_words: Optional[List[str]] = None,
|
custom_words: Optional[List[str]] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
判断标题或临时识别词是否包含当前 Rust 扩展尚未支持的媒体身份标签。
|
判断标题或临时识别词是否包含当前 Rust 扩展尚未支持的数据源专用 ID 标签。
|
||||||
|
|
||||||
:param title: 原始标题
|
:param title: 原始标题
|
||||||
:param custom_words: 临时识别词
|
:param custom_words: 临时识别词
|
||||||
:return: 是否必须使用Python解析器
|
:return: 是否必须使用Python解析器
|
||||||
"""
|
"""
|
||||||
candidates = [title or "", *(custom_words or [])]
|
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(
|
contains_extended_id = any(
|
||||||
_EXTENDED_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates
|
_EXTENDED_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates
|
||||||
)
|
)
|
||||||
|
|||||||
+248
-18
@@ -1,13 +1,24 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any, Awaitable, Callable
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
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.core.config import settings
|
||||||
from app.helper.locale import LocaleHelper
|
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 app.startup.lifecycle import lifespan
|
||||||
from version import APP_VERSION
|
from version import APP_VERSION
|
||||||
|
|
||||||
@@ -24,29 +35,248 @@ def _get_http_exception_message(detail: Any) -> str:
|
|||||||
return str(detail)
|
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(
|
async def localized_http_exception_handler(
|
||||||
_request: Request,
|
request: Request,
|
||||||
exc: HTTPException,
|
exc: HTTPException,
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
将 HTTPException 响应统一封装为 Response 结构并保留原始错误消息。
|
将 HTTPException 响应统一封装为 Response 结构并保留原始错误消息。
|
||||||
|
|
||||||
:param _request: 当前 HTTP 请求
|
:param request: 当前 HTTP 请求
|
||||||
:param exc: FastAPI HTTP 异常
|
:param exc: FastAPI HTTP 异常
|
||||||
:return: 统一 JSON 错误响应
|
: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(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
content={
|
content=ApiResponse[None](success=False, message=message).model_dump(mode="json"),
|
||||||
"success": False,
|
|
||||||
"message": message,
|
|
||||||
"data": {},
|
|
||||||
},
|
|
||||||
headers=exc.headers,
|
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:
|
def create_app() -> FastAPI:
|
||||||
"""
|
"""
|
||||||
创建并配置 FastAPI 应用实例。
|
创建并配置 FastAPI 应用实例。
|
||||||
@@ -54,16 +284,18 @@ def create_app() -> FastAPI:
|
|||||||
_app = FastAPI(
|
_app = FastAPI(
|
||||||
title=settings.PROJECT_NAME,
|
title=settings.PROJECT_NAME,
|
||||||
version=APP_VERSION,
|
version=APP_VERSION,
|
||||||
openapi_url=OPENAPI_V2_PATH,
|
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||||
lifespan=lifespan
|
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(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 中间件
|
# 配置 CORS 中间件
|
||||||
_app.add_middleware(
|
_app.add_middleware(
|
||||||
@@ -73,8 +305,6 @@ def create_app() -> FastAPI:
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
_app.add_middleware(V2ResponseMiddleware)
|
|
||||||
|
|
||||||
@_app.middleware("http")
|
@_app.middleware("http")
|
||||||
async def locale_context_middleware(
|
async def locale_context_middleware(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -97,6 +97,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
|
"调用工具失败": "Tool call failed",
|
||||||
"无效的媒体来源": "Invalid media source",
|
"无效的媒体来源": "Invalid media source",
|
||||||
"该媒体来源不支持此音乐接口": "This media source is not supported by this music endpoint",
|
"该媒体来源不支持此音乐接口": "This media source is not supported by this music endpoint",
|
||||||
"媒体来源和媒体 ID 必须同时提供": "Media source and media ID must be provided together",
|
"媒体来源和媒体 ID 必须同时提供": "Media source and media ID must be provided together",
|
||||||
@@ -1370,6 +1371,10 @@
|
|||||||
"source": "同步媒体服务器 - {name}",
|
"source": "同步媒体服务器 - {name}",
|
||||||
"target": "Sync Media Server - {name}"
|
"target": "Sync Media Server - {name}"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"source": "调用工具失败: {error}",
|
||||||
|
"target": "Tool call failed: {error}"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"source": "{name} 执行完成",
|
"source": "{name} 执行完成",
|
||||||
"target": "{name_i18n} completed"
|
"target": "{name_i18n} completed"
|
||||||
|
|||||||
@@ -97,6 +97,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
|
"调用工具失败": "調用工具失敗",
|
||||||
"媒体来源和媒体 ID 必须同时提供": "媒體來源和媒體 ID 必須同時提供",
|
"媒体来源和媒体 ID 必须同时提供": "媒體來源和媒體 ID 必須同時提供",
|
||||||
"media_source 和 media_id 必须同时提供": "media_source 和 media_id 必須同時提供",
|
"media_source 和 media_id 必须同时提供": "media_source 和 media_id 必須同時提供",
|
||||||
"模块不支持测试": "模組不支援測試",
|
"模块不支持测试": "模組不支援測試",
|
||||||
@@ -1366,6 +1367,10 @@
|
|||||||
"source": "同步媒体服务器 - {name}",
|
"source": "同步媒体服务器 - {name}",
|
||||||
"target": "同步媒體伺服器 - {name}"
|
"target": "同步媒體伺服器 - {name}"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"source": "调用工具失败: {error}",
|
||||||
|
"target": "調用工具失敗: {error}"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"source": "{name} 执行完成",
|
"source": "{name} 执行完成",
|
||||||
"target": "{name_i18n} 執行完成"
|
"target": "{name_i18n} 執行完成"
|
||||||
|
|||||||
@@ -1220,12 +1220,16 @@ class TransHandler:
|
|||||||
:param file_ext: 文件扩展名
|
:param file_ext: 文件扩展名
|
||||||
:param episodes_info: 当前季的全部集信息
|
:param episodes_info: 当前季的全部集信息
|
||||||
"""
|
"""
|
||||||
return TemplateHelper().builder.build(
|
naming_context = TemplateHelper().builder.build(
|
||||||
meta=meta,
|
meta=meta,
|
||||||
mediainfo=mediainfo,
|
mediainfo=mediainfo,
|
||||||
file_extension=file_ext,
|
file_extension=file_ext,
|
||||||
episodes_info=episodes_info,
|
episodes_info=episodes_info,
|
||||||
)
|
)
|
||||||
|
# 重命名格式是独立的用户配置契约,继续只暴露各数据源原有 ID 变量。
|
||||||
|
naming_context.pop("media_source", None)
|
||||||
|
naming_context.pop("media_id", None)
|
||||||
|
return naming_context
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __delete_version_files(storage_oper: StorageBase, path: Path) -> bool:
|
def __delete_version_files(storage_oper: StorageBase, path: Path) -> bool:
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
from .agent import *
|
from .agent import *
|
||||||
|
from .cache import *
|
||||||
|
from .category import *
|
||||||
|
from .common import *
|
||||||
from .context import *
|
from .context import *
|
||||||
from .dashboard import *
|
from .dashboard import *
|
||||||
from .download import *
|
from .download import *
|
||||||
@@ -6,13 +9,18 @@ from .event import *
|
|||||||
from .exception import *
|
from .exception import *
|
||||||
from .file import *
|
from .file import *
|
||||||
from .history import *
|
from .history import *
|
||||||
|
from .llm import *
|
||||||
from .mediaserver import *
|
from .mediaserver import *
|
||||||
from .message import *
|
from .message import *
|
||||||
|
from .mfa import *
|
||||||
from .music import *
|
from .music import *
|
||||||
from .monitoring import *
|
from .monitoring import *
|
||||||
|
from .notification import *
|
||||||
from .plugin import *
|
from .plugin import *
|
||||||
from .response import *
|
from .response import *
|
||||||
from .rule import *
|
from .rule import *
|
||||||
|
from .search import *
|
||||||
|
from .storage import *
|
||||||
from .openai import *
|
from .openai import *
|
||||||
from .servarr import *
|
from .servarr import *
|
||||||
from .servcookie import *
|
from .servcookie import *
|
||||||
|
|||||||
+86
-3
@@ -6,6 +6,8 @@ from typing import Any, List, Literal, Optional, Union
|
|||||||
from langchain_core.messages import BaseMessage
|
from langchain_core.messages import BaseMessage
|
||||||
from pydantic import BaseModel, Field, ConfigDict, field_serializer
|
from pydantic import BaseModel, Field, ConfigDict, field_serializer
|
||||||
|
|
||||||
|
from app.schemas.common import JsonData
|
||||||
|
|
||||||
|
|
||||||
class ConversationMemory(BaseModel):
|
class ConversationMemory(BaseModel):
|
||||||
"""对话记忆模型"""
|
"""对话记忆模型"""
|
||||||
@@ -97,7 +99,7 @@ class AgentMcpServerToolInfo(BaseModel):
|
|||||||
name: str = Field(..., description="原始 MCP 工具名称")
|
name: str = Field(..., description="原始 MCP 工具名称")
|
||||||
agent_tool_name: str = Field(..., description="注入 Agent 后的工具名称")
|
agent_tool_name: str = Field(..., description="注入 Agent 后的工具名称")
|
||||||
description: str = Field(default="", description="工具说明")
|
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):
|
class AgentMcpServerTestResult(BaseModel):
|
||||||
@@ -217,8 +219,8 @@ class AgentChatSession(BaseModel):
|
|||||||
username: Optional[str] = Field(None, description="用户名")
|
username: Optional[str] = Field(None, description="用户名")
|
||||||
original_chat_id: Optional[str] = Field(None, description="原聊天 ID")
|
original_chat_id: Optional[str] = Field(None, description="原聊天 ID")
|
||||||
message_count: int = Field(default=0, description="展示消息数量")
|
message_count: int = Field(default=0, description="展示消息数量")
|
||||||
created_at: Optional[str] = Field(None, description="创建时间")
|
created_at: Optional[datetime | str] = Field(None, description="创建时间")
|
||||||
updated_at: Optional[str] = Field(None, description="更新时间")
|
updated_at: Optional[datetime | str] = Field(None, description="更新时间")
|
||||||
messages: list[AgentChatMessage] = Field(default_factory=list, description="展示消息列表")
|
messages: list[AgentChatMessage] = Field(default_factory=list, description="展示消息列表")
|
||||||
|
|
||||||
|
|
||||||
@@ -229,3 +231,84 @@ class AgentChatDisplaySaveRequest(BaseModel):
|
|||||||
|
|
||||||
messages: list[AgentChatMessage] = Field(default_factory=list, description="展示消息列表")
|
messages: list[AgentChatMessage] = Field(default_factory=list, description="展示消息列表")
|
||||||
title: Optional[str] = Field(None, 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="是否停止了正在执行的任务")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, RootModel
|
||||||
|
|
||||||
|
|
||||||
class CategoryRule(BaseModel):
|
class CategoryRule(BaseModel):
|
||||||
@@ -29,3 +29,7 @@ class CategoryConfig(BaseModel):
|
|||||||
movie: Optional[Dict[str, Optional[CategoryRule]]] = {}
|
movie: Optional[Dict[str, Optional[CategoryRule]]] = {}
|
||||||
# 电视剧分类策略
|
# 电视剧分类策略
|
||||||
tv: Optional[Dict[str, Optional[CategoryRule]]] = {}
|
tv: Optional[Dict[str, Optional[CategoryRule]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class MediaCategoryMap(RootModel[Dict[str, list[str]]]):
|
||||||
|
"""媒体类型与自动分类名称列表的映射。"""
|
||||||
|
|||||||
@@ -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="耗时毫秒数")
|
||||||
+207
-40
@@ -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.music import MusicInfo, MusicMeta
|
||||||
from app.schemas.media import OptionalMediaIdentityMixin
|
from app.schemas.media import OptionalMediaIdentityMixin
|
||||||
from app.schemas.types import MediaSource
|
from app.schemas.types import MediaSource
|
||||||
@@ -73,6 +74,144 @@ class MetaInfo(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
media_id: Optional[str] = None
|
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):
|
class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||||
"""
|
"""
|
||||||
识别媒体信息
|
识别媒体信息
|
||||||
@@ -87,6 +226,10 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
# 英文标题
|
# 英文标题
|
||||||
en_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
|
year: Optional[str] = None
|
||||||
# 标题(年份)
|
# 标题(年份)
|
||||||
@@ -116,6 +259,8 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
backdrop_path: Optional[str] = None
|
backdrop_path: Optional[str] = None
|
||||||
# 海报图片
|
# 海报图片
|
||||||
poster_path: Optional[str] = None
|
poster_path: Optional[str] = None
|
||||||
|
# 标题 LOGO
|
||||||
|
logo_path: Optional[str] = None
|
||||||
# 评分
|
# 评分
|
||||||
vote_average: Optional[float] = 0.0
|
vote_average: Optional[float] = 0.0
|
||||||
# 描述
|
# 描述
|
||||||
@@ -123,58 +268,60 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
# 二级分类
|
# 二级分类
|
||||||
category: Optional[str] = ""
|
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
|
detail_link: Optional[str] = None
|
||||||
# 其它TMDB属性
|
# 其它TMDB属性
|
||||||
# 是否成人内容
|
# 是否成人内容
|
||||||
adult: Optional[bool] = False
|
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
|
first_air_date: Optional[str] = None
|
||||||
# 首页
|
# 首页
|
||||||
homepage: 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
|
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_episodes: Optional[int] = 0
|
||||||
# 季数
|
# 季数
|
||||||
number_of_seasons: 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
|
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
|
status: Optional[str] = None
|
||||||
# 标签
|
# 标签
|
||||||
tagline: Optional[str] = None
|
tagline: Optional[str] = None
|
||||||
# 风格ID
|
# 风格ID
|
||||||
genre_ids: Optional[list] = Field(default_factory=list)
|
genre_ids: Optional[list[int | str]] = Field(default_factory=list)
|
||||||
# 评价数量
|
# 评价数量
|
||||||
vote_count: Optional[int] = 0
|
vote_count: Optional[int] = 0
|
||||||
# 流行度
|
# 流行度
|
||||||
@@ -182,11 +329,18 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
# 时长
|
# 时长
|
||||||
runtime: Optional[int] = None
|
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
|
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):
|
class TorrentInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||||
@@ -239,7 +393,7 @@ class TorrentInfo(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
# HR
|
# HR
|
||||||
hit_and_run: Optional[bool] = False
|
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
|
pri_order: Optional[int] = 0
|
||||||
# 种子分类 电影/电视剧/音乐
|
# 种子分类 电影/电视剧/音乐
|
||||||
@@ -326,19 +480,6 @@ class Context(BaseModel):
|
|||||||
confirmed_full_coverage: Optional[bool] = False
|
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):
|
class MediaPerson(BaseModel):
|
||||||
"""
|
"""
|
||||||
媒体人物信息
|
媒体人物信息
|
||||||
@@ -346,17 +487,17 @@ class MediaPerson(BaseModel):
|
|||||||
# 来源:themoviedb、douban、bangumi、anilist
|
# 来源:themoviedb、douban、bangumi、anilist
|
||||||
source: Optional[str] = None
|
source: Optional[str] = None
|
||||||
# 公共
|
# 公共
|
||||||
id: Optional[int] = None
|
id: Optional[int | str] = None
|
||||||
type: Optional[Union[str, int]] = 1
|
type: Optional[Union[str, int]] = 1
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
character: Optional[str] = None
|
character: Optional[str] = None
|
||||||
images: Optional[dict] = Field(default_factory=dict)
|
images: Optional[MediaImageSet] = None
|
||||||
# themoviedb
|
# themoviedb
|
||||||
profile_path: Optional[str] = None
|
profile_path: Optional[str] = None
|
||||||
gender: Optional[Union[str, int]] = None
|
gender: Optional[Union[str, int]] = None
|
||||||
original_name: Optional[str] = None
|
original_name: Optional[str] = None
|
||||||
credit_id: 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
|
birthday: Optional[str] = None
|
||||||
deathday: Optional[str] = None
|
deathday: Optional[str] = None
|
||||||
imdb_id: Optional[str] = None
|
imdb_id: Optional[str] = None
|
||||||
@@ -365,11 +506,37 @@ class MediaPerson(BaseModel):
|
|||||||
popularity: Optional[float] = None
|
popularity: Optional[float] = None
|
||||||
biography: Optional[str] = None
|
biography: Optional[str] = None
|
||||||
# douban
|
# douban
|
||||||
roles: Optional[list] = Field(default_factory=list)
|
roles: Optional[list[str]] = Field(default_factory=list)
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
url: Optional[str] = None
|
url: Optional[str] = None
|
||||||
avatar: Optional[Union[str, dict]] = None
|
avatar: Optional[Union[str, MediaImageSet]] = None
|
||||||
latin_name: Optional[str] = None
|
latin_name: Optional[str] = None
|
||||||
# bangumi
|
# bangumi
|
||||||
career: Optional[list] = Field(default_factory=list)
|
career: Optional[list[str]] = Field(default_factory=list)
|
||||||
relation: Optional[str] = None
|
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]]):
|
||||||
|
"""媒体、音乐、合集与人物的统一搜索结果列表。"""
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Optional
|
|||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
from app.helper.locale import LocaleHelper
|
from app.helper.locale import LocaleHelper
|
||||||
|
from app.schemas.common import JsonData
|
||||||
|
|
||||||
|
|
||||||
class Statistic(BaseModel):
|
class Statistic(BaseModel):
|
||||||
@@ -120,7 +121,7 @@ class ScheduleProgress(BaseModel):
|
|||||||
# 多语言错误信息
|
# 多语言错误信息
|
||||||
error_i18n: Optional[str] = None
|
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")
|
@model_validator(mode="after")
|
||||||
def fill_i18n_fields(self) -> "ScheduleProgress":
|
def fill_i18n_fields(self) -> "ScheduleProgress":
|
||||||
|
|||||||
@@ -25,3 +25,15 @@ class DownloadDirectory(BaseModel):
|
|||||||
priority: Optional[int] = Field(default=0, description="目录优先级")
|
priority: Optional[int] = Field(default=0, description="目录优先级")
|
||||||
media_type: Optional[str] = Field(default=None, description="适用媒体类型")
|
media_type: Optional[str] = Field(default=None, description="适用媒体类型")
|
||||||
media_category: 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="已保存字幕文件列表")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Iterable, Optional, Dict, Any, List, Set, Callable
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
from app.schemas.common import JsonData
|
||||||
from app.schemas.message import MessageChannel
|
from app.schemas.message import MessageChannel
|
||||||
from app.schemas.file import FileItem
|
from app.schemas.file import FileItem
|
||||||
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
|
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
|
||||||
@@ -484,11 +485,11 @@ class DiscoverMediaSource(BaseModel):
|
|||||||
name: str = Field(..., description="数据源名称")
|
name: str = Field(..., description="数据源名称")
|
||||||
media_source: MediaSource = Field(..., description="媒体来源枚举")
|
media_source: MediaSource = Field(..., description="媒体来源枚举")
|
||||||
api_path: str = Field(..., description="媒体数据源API地址")
|
api_path: str = Field(..., description="媒体数据源API地址")
|
||||||
filter_params: Optional[Dict[str, Any]] = Field(
|
filter_params: Optional[Dict[str, JsonData]] = Field(
|
||||||
default=None, description="过滤参数"
|
default=None, description="过滤参数"
|
||||||
)
|
)
|
||||||
filter_ui: Optional[List[dict]] = Field(default=[], description="过滤参数UI配置")
|
filter_ui: Optional[List[Dict[str, JsonData]]] = Field(default=[], description="过滤参数UI配置")
|
||||||
depends: Optional[Dict[str, list]] = Field(
|
depends: Optional[Dict[str, list[str]]] = Field(
|
||||||
default=None, description="UI依赖关系字典"
|
default=None, description="UI依赖关系字典"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -10,6 +10,8 @@ WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:[\\/]")
|
|||||||
|
|
||||||
|
|
||||||
class FileURI(BaseModel):
|
class FileURI(BaseModel):
|
||||||
|
"""带存储类型的文件 URI。"""
|
||||||
|
|
||||||
# 文件路径
|
# 文件路径
|
||||||
path: Optional[str] = "/"
|
path: Optional[str] = "/"
|
||||||
# 存储类型
|
# 存储类型
|
||||||
@@ -45,6 +47,8 @@ class FileURI(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class FileItem(FileURI):
|
class FileItem(FileURI):
|
||||||
|
"""文件或目录条目,目录可递归包含子条目。"""
|
||||||
|
|
||||||
# 类型 dir/file
|
# 类型 dir/file
|
||||||
type: Optional[str] = None
|
type: Optional[str] = None
|
||||||
# 文件名
|
# 文件名
|
||||||
@@ -58,7 +62,7 @@ class FileItem(FileURI):
|
|||||||
# 修改时间
|
# 修改时间
|
||||||
modify_time: Optional[float] = None
|
modify_time: Optional[float] = None
|
||||||
# 子节点
|
# 子节点
|
||||||
children: Optional[list] = Field(default_factory=list)
|
children: Optional[list["FileItem"]] = Field(default_factory=list)
|
||||||
# ID
|
# ID
|
||||||
fileid: Optional[str] = None
|
fileid: Optional[str] = None
|
||||||
# 父ID
|
# 父ID
|
||||||
@@ -74,6 +78,8 @@ class FileItem(FileURI):
|
|||||||
|
|
||||||
|
|
||||||
class StorageUsage(BaseModel):
|
class StorageUsage(BaseModel):
|
||||||
|
"""存储空间使用情况。"""
|
||||||
|
|
||||||
# 总空间
|
# 总空间
|
||||||
total: float = 0.0
|
total: float = 0.0
|
||||||
# 剩余空间
|
# 剩余空间
|
||||||
@@ -81,5 +87,7 @@ class StorageUsage(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class StorageTransType(BaseModel):
|
class StorageTransType(BaseModel):
|
||||||
|
"""存储支持的传输类型及其显示名称。"""
|
||||||
|
|
||||||
# 传输类型
|
# 传输类型
|
||||||
transtype: Optional[dict] = Field(default_factory=dict)
|
transtype: Optional[dict[str, str]] = Field(default_factory=dict)
|
||||||
|
|||||||
+12
-2
@@ -1,7 +1,8 @@
|
|||||||
from typing import Optional, Any
|
from typing import List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.schemas.common import JsonData
|
||||||
from app.schemas.media import OptionalMediaIdentityMixin
|
from app.schemas.media import OptionalMediaIdentityMixin
|
||||||
from app.schemas.types import MediaSource
|
from app.schemas.types import MediaSource
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ class DownloadHistory(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
# 创建时间
|
# 创建时间
|
||||||
date: Optional[str] = None
|
date: Optional[str] = None
|
||||||
# 备注
|
# 备注
|
||||||
note: Optional[Any] = None
|
note: Optional[JsonData] = None
|
||||||
# 自定义媒体类别
|
# 自定义媒体类别
|
||||||
media_category: Optional[str] = None
|
media_category: Optional[str] = None
|
||||||
# 自定义剧集组
|
# 自定义剧集组
|
||||||
@@ -121,4 +122,13 @@ class TransferHistory(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class BatchTransferHistoryRedoRequest(BaseModel):
|
class BatchTransferHistoryRedoRequest(BaseModel):
|
||||||
|
"""批量重新整理历史请求。"""
|
||||||
|
|
||||||
history_ids: list[int] = Field(default_factory=list)
|
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="记录总数")
|
||||||
|
|||||||
@@ -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
|
||||||
+185
-8
@@ -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):
|
class ToolCallRequest(BaseModel):
|
||||||
"""工具调用请求模型"""
|
"""工具调用请求模型"""
|
||||||
tool_name: str = Field(..., description="工具名称")
|
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):
|
class McpJsonSchema(RootModel[dict[str, JsonData]]):
|
||||||
"""工具调用响应模型"""
|
"""MCP 工具的 JSON Schema。"""
|
||||||
success: bool = Field(..., description="是否成功")
|
|
||||||
result: Optional[str] = Field(None, description="工具执行结果")
|
|
||||||
error: Optional[str] = Field(None, description="错误信息")
|
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()
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Union, List, Any
|
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.media import OptionalMediaIdentityMixin
|
||||||
from app.schemas.types import MediaSource, MediaType
|
from app.schemas.types import MediaSource, MediaType
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ class ExistMediaInfo(BaseModel):
|
|||||||
# 类型 电影、电视剧、音乐
|
# 类型 电影、电视剧、音乐
|
||||||
type: Optional[MediaType] = None
|
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
|
# 媒体服务器类型:plex、jellyfin、emby、zspace、trimemedia、ugreen、navidrome
|
||||||
server_type: Optional[str] = None
|
server_type: Optional[str] = None
|
||||||
# 媒体服务器名称
|
# 媒体服务器名称
|
||||||
@@ -23,6 +24,25 @@ class ExistMediaInfo(BaseModel):
|
|||||||
itemid: Optional[Union[str, int]] = None
|
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):
|
class NotExistMediaInfo(BaseModel):
|
||||||
"""
|
"""
|
||||||
媒体服务器不存在媒体信息
|
媒体服务器不存在媒体信息
|
||||||
@@ -30,7 +50,7 @@ class NotExistMediaInfo(BaseModel):
|
|||||||
# 季
|
# 季
|
||||||
season: Optional[int] = None
|
season: Optional[int] = None
|
||||||
# 剧集列表
|
# 剧集列表
|
||||||
episodes: Optional[list] = Field(default_factory=list)
|
episodes: Optional[List[int]] = Field(default_factory=list)
|
||||||
# 总集数
|
# 总集数
|
||||||
total_episode: Optional[int] = 0
|
total_episode: Optional[int] = 0
|
||||||
# 开始集
|
# 开始集
|
||||||
@@ -70,7 +90,7 @@ class MediaServerLibrary(BaseModel):
|
|||||||
# 名称
|
# 名称
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
# 路径
|
# 路径
|
||||||
path: Optional[Union[str, list]] = None
|
path: Optional[Union[str, List[str]]] = None
|
||||||
# 类型
|
# 类型
|
||||||
type: Optional[str] = None
|
type: Optional[str] = None
|
||||||
# 媒体库内媒体数量
|
# 媒体库内媒体数量
|
||||||
@@ -88,6 +108,8 @@ class MediaServerLibrary(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MediaServerItemUserState(BaseModel):
|
class MediaServerItemUserState(BaseModel):
|
||||||
|
"""媒体服务器条目的用户播放状态。"""
|
||||||
|
|
||||||
# 已播放
|
# 已播放
|
||||||
played: Optional[bool] = None
|
played: Optional[bool] = None
|
||||||
# 继续播放
|
# 继续播放
|
||||||
@@ -128,9 +150,9 @@ class MediaServerItem(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
# 路径
|
# 路径
|
||||||
path: Optional[str] = None
|
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
|
lst_mod_date: Optional[str] = None
|
||||||
user_state: Optional[MediaServerItemUserState] = None
|
user_state: Optional[MediaServerItemUserState] = None
|
||||||
@@ -172,7 +194,7 @@ class WebhookEventInfo(BaseModel):
|
|||||||
save_reason: Optional[str] = None
|
save_reason: Optional[str] = None
|
||||||
item_isvirtual: Optional[bool] = None
|
item_isvirtual: Optional[bool] = None
|
||||||
media_type: Optional[str] = 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")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -236,7 +258,7 @@ class MediaServerPlayItem(BaseModel):
|
|||||||
image: Optional[str] = None
|
image: Optional[str] = None
|
||||||
link: Optional[str] = None
|
link: Optional[str] = None
|
||||||
percent: Optional[float] = 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
|
server_type: Optional[str] = None
|
||||||
# 飞牛的图片需要Cookies
|
# 飞牛的图片需要Cookies
|
||||||
use_cookies: Optional[bool] = None
|
use_cookies: Optional[bool] = None
|
||||||
|
|||||||
+16
-5
@@ -4,6 +4,7 @@ from typing import Optional, Union, List, Dict, Set, Any
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from app.schemas.common import JsonData
|
||||||
from app.schemas.types import ContentType, NotificationType, MessageChannel
|
from app.schemas.types import ContentType, NotificationType, MessageChannel
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ class MessageResponse(BaseModel):
|
|||||||
# 消息来源
|
# 消息来源
|
||||||
source: Optional[str] = None
|
source: Optional[str] = None
|
||||||
# 渠道自定义上下文(如飞书流式卡片 card_id/element_id/sequence)
|
# 渠道自定义上下文(如飞书流式卡片 card_id/element_id/sequence)
|
||||||
metadata: Optional[Dict[str, Any]] = None
|
metadata: Optional[Dict[str, JsonData]] = None
|
||||||
# 是否发送成功
|
# 是否发送成功
|
||||||
success: bool = False
|
success: bool = False
|
||||||
|
|
||||||
@@ -80,7 +81,17 @@ class NotificationHistoryItem(BaseModel):
|
|||||||
# 消息方向:0-接收消息,1-发送消息
|
# 消息方向:0-接收消息,1-发送消息
|
||||||
action: Optional[int] = None
|
action: Optional[int] = None
|
||||||
# 附件json
|
# 附件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):
|
class CommingMessage(BaseModel):
|
||||||
@@ -300,7 +311,7 @@ class Subscription(BaseModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
endpoint: Optional[str] = None
|
endpoint: Optional[str] = None
|
||||||
keys: Optional[dict] = Field(default_factory=dict)
|
keys: Optional[dict[str, str]] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionMessage(BaseModel):
|
class SubscriptionMessage(BaseModel):
|
||||||
@@ -312,7 +323,7 @@ class SubscriptionMessage(BaseModel):
|
|||||||
body: Optional[str] = None
|
body: Optional[str] = None
|
||||||
icon: Optional[str] = None
|
icon: Optional[str] = None
|
||||||
url: 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):
|
class AgentWebChatRequest(BaseModel):
|
||||||
@@ -345,7 +356,7 @@ class AgentWebChatRequest(BaseModel):
|
|||||||
# 文件附件列表
|
# 文件附件列表
|
||||||
files: Optional[List[AgentWebChatFile]] = Field(default_factory=list)
|
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,用于传统交互原地编辑卡片
|
# WebAgent 按钮回调关联的原消息 ID,用于传统交互原地编辑卡片
|
||||||
original_message_id: Optional[Union[str, int]] = Field(default=None)
|
original_message_id: Optional[Union[str, int]] = Field(default=None)
|
||||||
# WebAgent 按钮回调关联的原聊天 ID,用于传统交互原地编辑卡片
|
# WebAgent 按钮回调关联的原聊天 ID,用于传统交互原地编辑卡片
|
||||||
|
|||||||
@@ -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
|
||||||
+27
-4
@@ -1,7 +1,8 @@
|
|||||||
from typing import Any, Literal, Optional, Union
|
from typing import Literal, Optional, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.common import JsonData
|
||||||
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
|
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
|
||||||
from app.schemas.types import MediaSource, MusicEntityType, MusicTargetEntityType
|
from app.schemas.types import MediaSource, MusicEntityType, MusicTargetEntityType
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ class MusicInfo(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
names: list[str] = Field(default_factory=list)
|
names: list[str] = Field(default_factory=list)
|
||||||
detail_link: Optional[str] = None
|
detail_link: Optional[str] = None
|
||||||
listen_count: Optional[int] = 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
|
title_year: Optional[str] = None
|
||||||
poster_path: Optional[str] = None
|
poster_path: Optional[str] = None
|
||||||
backdrop_path: Optional[str] = None
|
backdrop_path: Optional[str] = None
|
||||||
@@ -125,7 +126,7 @@ class MusicAlbumInfo(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
detail_link: Optional[str] = None
|
detail_link: Optional[str] = None
|
||||||
tracks: list[MusicInfo] = Field(default_factory=list)
|
tracks: list[MusicInfo] = Field(default_factory=list)
|
||||||
releases: list[MusicRelease] = 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
|
title_year: Optional[str] = None
|
||||||
poster_path: Optional[str] = None
|
poster_path: Optional[str] = None
|
||||||
backdrop_path: Optional[str] = None
|
backdrop_path: Optional[str] = None
|
||||||
@@ -160,7 +161,7 @@ class MusicArtistInfo(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
detail_link: Optional[str] = None
|
detail_link: Optional[str] = None
|
||||||
external_links: dict[str, str] = Field(default_factory=dict)
|
external_links: dict[str, str] = Field(default_factory=dict)
|
||||||
album_count: Optional[int] = None
|
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
|
poster_path: Optional[str] = None
|
||||||
overview: Optional[str] = None
|
overview: Optional[str] = None
|
||||||
|
|
||||||
@@ -171,3 +172,25 @@ class MusicRecognizeRequest(RequiredMediaIdentityMixin, BaseModel):
|
|||||||
media_source: MediaSource
|
media_source: MediaSource
|
||||||
media_id: str
|
media_id: str
|
||||||
music_type: Optional[MusicTargetEntityType] = None
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
+44
-9
@@ -1,7 +1,9 @@
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.schemas.common import JsonData
|
||||||
|
|
||||||
|
|
||||||
class OpenAIModelInfo(BaseModel):
|
class OpenAIModelInfo(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
@@ -16,14 +18,18 @@ class OpenAIModelListResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class OpenAIChatMessage(BaseModel):
|
class OpenAIChatMessage(BaseModel):
|
||||||
|
"""OpenAI Chat Completions 请求中的一条消息。"""
|
||||||
|
|
||||||
role: str
|
role: str
|
||||||
content: Any
|
content: JsonData
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
class OpenAIChatCompletionsRequest(BaseModel):
|
class OpenAIChatCompletionsRequest(BaseModel):
|
||||||
|
"""OpenAI Chat Completions 兼容请求。"""
|
||||||
|
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
messages: List[OpenAIChatMessage]
|
messages: List[OpenAIChatMessage]
|
||||||
user: Optional[str] = None
|
user: Optional[str] = None
|
||||||
@@ -33,8 +39,10 @@ class OpenAIChatCompletionsRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class OpenAIResponsesRequest(BaseModel):
|
class OpenAIResponsesRequest(BaseModel):
|
||||||
|
"""OpenAI Responses API 兼容请求。"""
|
||||||
|
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
input: Any
|
input: JsonData
|
||||||
instructions: Optional[str] = None
|
instructions: Optional[str] = None
|
||||||
user: Optional[str] = None
|
user: Optional[str] = None
|
||||||
stream: bool = False
|
stream: bool = False
|
||||||
@@ -69,9 +77,30 @@ class OpenAIChatCompletionResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class OpenAIResponsesOutputText(BaseModel):
|
class OpenAIResponsesOutputText(BaseModel):
|
||||||
|
"""Responses API 输出中的文本内容块。"""
|
||||||
|
|
||||||
type: str = "output_text"
|
type: str = "output_text"
|
||||||
text: str
|
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):
|
class OpenAIResponsesOutputMessage(BaseModel):
|
||||||
@@ -83,14 +112,16 @@ class OpenAIResponsesOutputMessage(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class OpenAIResponsesResponse(BaseModel):
|
class OpenAIResponsesResponse(BaseModel):
|
||||||
|
"""OpenAI Responses API 的非流式成功响应。"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
object: str = "response"
|
object: str = "response"
|
||||||
created_at: int
|
created_at: int
|
||||||
status: str = "completed"
|
status: str = "completed"
|
||||||
model: str
|
model: str
|
||||||
output: List[OpenAIResponsesOutputMessage] = Field(default_factory=list)
|
output: List[OpenAIResponsesOutputMessage] = Field(default_factory=list)
|
||||||
error: Optional[Any] = None
|
error: Optional["OpenAIErrorDetail"] = None
|
||||||
incomplete_details: Optional[Any] = None
|
incomplete_details: Optional[OpenAIIncompleteDetails] = None
|
||||||
usage: OpenAIUsage
|
usage: OpenAIUsage
|
||||||
|
|
||||||
|
|
||||||
@@ -105,20 +136,24 @@ class OpenAIErrorResponse(BaseModel):
|
|||||||
error: OpenAIErrorDetail
|
error: OpenAIErrorDetail
|
||||||
|
|
||||||
|
|
||||||
OpenAIChatContentPart = Dict[str, Any]
|
OpenAIChatContentPart = Dict[str, JsonData]
|
||||||
|
|
||||||
|
|
||||||
class AnthropicMessage(BaseModel):
|
class AnthropicMessage(BaseModel):
|
||||||
|
"""Anthropic Messages 请求中的一条消息。"""
|
||||||
|
|
||||||
role: str
|
role: str
|
||||||
content: Any
|
content: JsonData
|
||||||
|
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
class AnthropicMessagesRequest(BaseModel):
|
class AnthropicMessagesRequest(BaseModel):
|
||||||
|
"""Anthropic Messages 兼容请求。"""
|
||||||
|
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
messages: List[AnthropicMessage]
|
messages: List[AnthropicMessage]
|
||||||
system: Optional[Any] = None
|
system: Optional[JsonData] = None
|
||||||
max_tokens: Optional[int] = 1024
|
max_tokens: Optional[int] = 1024
|
||||||
stream: bool = False
|
stream: bool = False
|
||||||
|
|
||||||
|
|||||||
+55
-7
@@ -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):
|
class Plugin(BaseModel):
|
||||||
@@ -51,7 +53,7 @@ class Plugin(BaseModel):
|
|||||||
# 安装次数
|
# 安装次数
|
||||||
install_count: Optional[int] = 0
|
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
|
add_time: Optional[int] = 0
|
||||||
# 插件公钥
|
# 插件公钥
|
||||||
@@ -70,11 +72,11 @@ class PluginDashboard(Plugin):
|
|||||||
# 演染模式
|
# 演染模式
|
||||||
render_mode: Optional[str] = Field(default="vuetify")
|
render_mode: Optional[str] = Field(default="vuetify")
|
||||||
# 全局配置
|
# 全局配置
|
||||||
attrs: Optional[dict] = Field(default_factory=dict)
|
attrs: Optional[dict[str, JsonData]] = Field(default_factory=dict)
|
||||||
# col列数
|
# 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):
|
class PluginSidebarNavItem(BaseModel):
|
||||||
@@ -115,6 +117,10 @@ class PluginRating(BaseModel):
|
|||||||
user_rating: Optional[float] = Field(default=None, description="当前安装实例评分")
|
user_rating: Optional[float] = Field(default=None, description="当前安装实例评分")
|
||||||
|
|
||||||
|
|
||||||
|
class PluginRatingMap(RootModel[Dict[str, PluginRating]]):
|
||||||
|
"""插件 ID 与评分结果的映射。"""
|
||||||
|
|
||||||
|
|
||||||
class PluginMemoryInfo(BaseModel):
|
class PluginMemoryInfo(BaseModel):
|
||||||
"""插件内存信息"""
|
"""插件内存信息"""
|
||||||
plugin_id: str = Field(description="插件ID")
|
plugin_id: str = Field(description="插件ID")
|
||||||
@@ -126,4 +132,46 @@ class PluginMemoryInfo(BaseModel):
|
|||||||
calculation_time_ms: float = Field(description="计算耗时(毫秒)")
|
calculation_time_ms: float = Field(description="计算耗时(毫秒)")
|
||||||
timestamp: float = Field(description="统计时间戳")
|
timestamp: float = Field(description="统计时间戳")
|
||||||
error: Optional[str] = Field(default=None, 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
|
||||||
|
|||||||
+36
-17
@@ -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
|
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
|
success: bool
|
||||||
# 消息文本
|
# 消息文本
|
||||||
message: Optional[str] = None
|
message: str = ""
|
||||||
# 多语言消息文本
|
|
||||||
message_i18n: Optional[str] = None
|
|
||||||
# 数据
|
# 数据
|
||||||
data: Optional[Any] = Field(default_factory=dict)
|
data: Optional[DataT] = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@field_validator("message", mode="before")
|
||||||
def fill_message_i18n(self) -> "Response":
|
@classmethod
|
||||||
"""
|
def localize_message(cls, value: Any) -> str:
|
||||||
自动补充响应消息的多语言文本。
|
"""按当前请求语言直接本地化消息文本,并将空消息归一为空字符串。"""
|
||||||
"""
|
if value is None:
|
||||||
if self.message and self.message_i18n is None:
|
return ""
|
||||||
self.message_i18n = LocaleHelper.translate_text(
|
message = str(value)
|
||||||
self.message, locale=LocaleHelper.get_current_locale()
|
if not message:
|
||||||
|
return ""
|
||||||
|
return LocaleHelper.translate_text(
|
||||||
|
message, locale=LocaleHelper.get_current_locale()
|
||||||
)
|
)
|
||||||
return self
|
|
||||||
|
|
||||||
|
class ValidationIssue(BaseModel):
|
||||||
|
"""请求参数校验失败时返回的单项错误信息。"""
|
||||||
|
|
||||||
|
# 参数位置
|
||||||
|
location: list[str | int]
|
||||||
|
# 错误说明
|
||||||
|
message: str
|
||||||
|
# 错误类型
|
||||||
|
error_type: str
|
||||||
|
|||||||
@@ -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)
|
||||||
+185
-6
@@ -1,8 +1,185 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
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):
|
class RadarrMovie(BaseModel):
|
||||||
|
"""Radarr 兼容接口的电影结构。"""
|
||||||
|
|
||||||
id: Optional[int] = None
|
id: Optional[int] = None
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
year: Optional[str | int] = None
|
year: Optional[str | int] = None
|
||||||
@@ -20,6 +197,8 @@ class RadarrMovie(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SonarrSeries(BaseModel):
|
class SonarrSeries(BaseModel):
|
||||||
|
"""Sonarr 兼容接口的剧集结构。"""
|
||||||
|
|
||||||
id: Optional[int] = None
|
id: Optional[int] = None
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
sortTitle: Optional[str] = None
|
sortTitle: Optional[str] = None
|
||||||
@@ -28,9 +207,9 @@ class SonarrSeries(BaseModel):
|
|||||||
overview: Optional[str] = None
|
overview: Optional[str] = None
|
||||||
network: Optional[str] = None
|
network: Optional[str] = None
|
||||||
airTime: Optional[str] = None
|
airTime: Optional[str] = None
|
||||||
images: list = Field(default_factory=list)
|
images: list[ServarrImage] = Field(default_factory=list)
|
||||||
remotePoster: Optional[str] = None
|
remotePoster: Optional[str] = None
|
||||||
seasons: list = Field(default_factory=list)
|
seasons: list[SonarrSeason] = Field(default_factory=list)
|
||||||
year: Optional[str | int] = None
|
year: Optional[str | int] = None
|
||||||
path: Optional[str] = None
|
path: Optional[str] = None
|
||||||
profileId: Optional[int] = None
|
profileId: Optional[int] = None
|
||||||
@@ -49,11 +228,11 @@ class SonarrSeries(BaseModel):
|
|||||||
cleanTitle: Optional[str] = None
|
cleanTitle: Optional[str] = None
|
||||||
titleSlug: Optional[str] = None
|
titleSlug: Optional[str] = None
|
||||||
certification: Optional[str] = None
|
certification: Optional[str] = None
|
||||||
genres: list = Field(default_factory=list)
|
genres: list[str] = Field(default_factory=list)
|
||||||
tags: list = Field(default_factory=list)
|
tags: list[int] = Field(default_factory=list)
|
||||||
added: Optional[str] = None
|
added: Optional[str] = None
|
||||||
ratings: Optional[dict] = None
|
ratings: Optional[SonarrRatings] = None
|
||||||
qualityProfileId: Optional[int] = None
|
qualityProfileId: Optional[int] = None
|
||||||
statistics: dict = Field(default_factory=dict)
|
statistics: SonarrStatistics = Field(default_factory=SonarrStatistics)
|
||||||
isAvailable: Optional[bool] = False
|
isAvailable: Optional[bool] = False
|
||||||
hasFile: Optional[bool] = False
|
hasFile: Optional[bool] = False
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
from fastapi import Query
|
from typing import Literal
|
||||||
from pydantic import BaseModel
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.schemas.common import JsonData
|
||||||
|
|
||||||
|
|
||||||
class CookieData(BaseModel):
|
class CookieData(BaseModel):
|
||||||
encrypted: str = Query(min_length=1, max_length=1024 * 1024 * 50)
|
"""CookieCloud 上传的加密数据。"""
|
||||||
uuid: str = Query(min_length=5, pattern="^[a-zA-Z0-9]+$")
|
|
||||||
|
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):
|
class CookiePassword(BaseModel):
|
||||||
|
"""CookieCloud 下载并解密数据所需的密码。"""
|
||||||
|
|
||||||
password: str
|
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
|
||||||
|
|||||||
+35
-6
@@ -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):
|
class Site(BaseModel):
|
||||||
|
"""站点配置及运行状态。"""
|
||||||
|
|
||||||
# ID
|
# ID
|
||||||
id: Optional[int] = None
|
id: Optional[int] = None
|
||||||
# 站点名称
|
# 站点名称
|
||||||
@@ -33,7 +44,7 @@ class Site(BaseModel):
|
|||||||
# 是否公开站点
|
# 是否公开站点
|
||||||
public: Optional[int] = 0
|
public: Optional[int] = 0
|
||||||
# 备注
|
# 备注
|
||||||
note: Optional[Any] = None
|
note: Optional[JsonData] = None
|
||||||
# 超时时间
|
# 超时时间
|
||||||
timeout: Optional[int] = 15
|
timeout: Optional[int] = 15
|
||||||
# 流控单位周期
|
# 流控单位周期
|
||||||
@@ -51,6 +62,8 @@ class Site(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SiteStatistic(BaseModel):
|
class SiteStatistic(BaseModel):
|
||||||
|
"""单个站点的访问成功率与耗时统计。"""
|
||||||
|
|
||||||
# 站点ID
|
# 站点ID
|
||||||
domain: Optional[str] = None
|
domain: Optional[str] = None
|
||||||
# 成功次数
|
# 成功次数
|
||||||
@@ -64,12 +77,14 @@ class SiteStatistic(BaseModel):
|
|||||||
# 最后修改时间
|
# 最后修改时间
|
||||||
lst_mod_date: Optional[str] = None
|
lst_mod_date: Optional[str] = None
|
||||||
# 备注
|
# 备注
|
||||||
note: Optional[Any] = None
|
note: Optional[Dict[str, int]] = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class SiteUserData(BaseModel):
|
class SiteUserData(BaseModel):
|
||||||
|
"""站点用户账户、流量、做种和未读消息数据。"""
|
||||||
|
|
||||||
# 站点域名
|
# 站点域名
|
||||||
domain: Optional[str] = None
|
domain: Optional[str] = None
|
||||||
# 用户名
|
# 用户名
|
||||||
@@ -97,11 +112,11 @@ class SiteUserData(BaseModel):
|
|||||||
# 下载体积
|
# 下载体积
|
||||||
leeching_size: Optional[int] = 0
|
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: 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
|
err_msg: Optional[str] = None
|
||||||
# 更新日期
|
# 更新日期
|
||||||
@@ -111,6 +126,8 @@ class SiteUserData(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SiteAuth(BaseModel):
|
class SiteAuth(BaseModel):
|
||||||
|
"""站点认证模块及其参数。"""
|
||||||
|
|
||||||
site: Optional[str] = None
|
site: Optional[str] = None
|
||||||
params: Optional[Dict[str, Union[int, str]]] = Field(default_factory=dict)
|
params: Optional[Dict[str, Union[int, str]]] = Field(default_factory=dict)
|
||||||
|
|
||||||
@@ -125,6 +142,18 @@ class SiteCookieUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SiteCategory(BaseModel):
|
class SiteCategory(BaseModel):
|
||||||
|
"""站点资源分类。"""
|
||||||
|
|
||||||
id: Optional[int] = None
|
id: Optional[int] = None
|
||||||
cat: Optional[str] = None
|
cat: Optional[str] = None
|
||||||
desc: Optional[str] = None
|
desc: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SiteIconData(BaseModel):
|
||||||
|
"""站点图标地址或 Base64 内容。"""
|
||||||
|
|
||||||
|
icon: str
|
||||||
|
|
||||||
|
|
||||||
|
class SiteMappingData(RootModel[dict[str, str]]):
|
||||||
|
"""站点域名到显示名称的映射。"""
|
||||||
|
|||||||
@@ -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="状态提示")
|
||||||
@@ -110,7 +110,7 @@ class Subscribe(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
# 已完成集数
|
# 已完成集数
|
||||||
completed_episode: Optional[int] = None
|
completed_episode: Optional[int] = None
|
||||||
# 附加信息
|
# 附加信息
|
||||||
note: Optional[Any] = None
|
note: Optional[List[int]] = None
|
||||||
# 状态:N-新建, R-订阅中
|
# 状态:N-新建, R-订阅中
|
||||||
state: Optional[str] = None
|
state: Optional[str] = None
|
||||||
# 最后更新时间
|
# 最后更新时间
|
||||||
@@ -197,6 +197,8 @@ class Subscribe(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SubscribeShare(OptionalMediaIdentityMixin, BaseModel):
|
class SubscribeShare(OptionalMediaIdentityMixin, BaseModel):
|
||||||
|
"""可供其他用户复用的订阅分享信息。"""
|
||||||
|
|
||||||
# 分享ID
|
# 分享ID
|
||||||
id: Optional[int] = None
|
id: Optional[int] = None
|
||||||
# 订阅ID
|
# 订阅ID
|
||||||
@@ -268,6 +270,8 @@ class SubscribeShare(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SubscribeShareStatistics(BaseModel):
|
class SubscribeShareStatistics(BaseModel):
|
||||||
|
"""单个用户的订阅分享数量与复用统计。"""
|
||||||
|
|
||||||
# 分享人
|
# 分享人
|
||||||
share_user: Optional[str] = None
|
share_user: Optional[str] = None
|
||||||
# 分享数量
|
# 分享数量
|
||||||
@@ -277,6 +281,8 @@ class SubscribeShareStatistics(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SubscribeDownloadFileInfo(BaseModel):
|
class SubscribeDownloadFileInfo(BaseModel):
|
||||||
|
"""订阅剧集关联的下载文件信息。"""
|
||||||
|
|
||||||
# 种子名称
|
# 种子名称
|
||||||
torrent_title: Optional[str] = None
|
torrent_title: Optional[str] = None
|
||||||
# 站点名称
|
# 站点名称
|
||||||
@@ -290,6 +296,8 @@ class SubscribeDownloadFileInfo(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SubscribeLibraryFileInfo(BaseModel):
|
class SubscribeLibraryFileInfo(BaseModel):
|
||||||
|
"""订阅剧集关联的媒体库文件信息。"""
|
||||||
|
|
||||||
# 存储
|
# 存储
|
||||||
storage: Optional[str] = "local"
|
storage: Optional[str] = "local"
|
||||||
# 文件路径
|
# 文件路径
|
||||||
@@ -303,6 +311,8 @@ class SubscribeLibraryFileInfo(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SubscribeEpisodeInfo(BaseModel):
|
class SubscribeEpisodeInfo(BaseModel):
|
||||||
|
"""订阅单集的元数据及关联文件。"""
|
||||||
|
|
||||||
# 标题
|
# 标题
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
# 描述
|
# 描述
|
||||||
@@ -316,6 +326,8 @@ class SubscribeEpisodeInfo(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SubscrbieInfo(BaseModel):
|
class SubscrbieInfo(BaseModel):
|
||||||
|
"""订阅详情及按集号归组的文件信息。"""
|
||||||
|
|
||||||
# 订阅信息
|
# 订阅信息
|
||||||
subscribe: Optional[Subscribe] = None
|
subscribe: Optional[Subscribe] = None
|
||||||
# 集信息 {集号: {download: 文件路径,library: 文件路径, backdrop: url, title: 标题, description: 描述}}
|
# 集信息 {集号: {download: 文件路径,library: 文件路径, backdrop: url, title: 标题, description: 描述}}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ from typing import Optional, Any
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from app.schemas.context import MediaInfo, MetaInfo, TorrentInfo
|
||||||
|
from app.schemas.rule import FilterRuleGroup
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ServiceInfo:
|
class ServiceInfo:
|
||||||
@@ -132,6 +135,61 @@ class StorageConf(BaseModel):
|
|||||||
config: Optional[dict] = Field(default_factory=dict)
|
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):
|
class TransferDirectoryConf(BaseModel):
|
||||||
"""
|
"""
|
||||||
文件整理目录配置
|
文件整理目录配置
|
||||||
|
|||||||
+53
-2
@@ -16,6 +16,34 @@ class TmdbSeason(BaseModel):
|
|||||||
vote_average: Optional[float] = None
|
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):
|
class TmdbEpisode(BaseModel):
|
||||||
"""
|
"""
|
||||||
TMDB集信息
|
TMDB集信息
|
||||||
@@ -29,5 +57,28 @@ class TmdbEpisode(BaseModel):
|
|||||||
season_number: Optional[int] = None
|
season_number: Optional[int] = None
|
||||||
still_path: Optional[str] = None
|
still_path: Optional[str] = None
|
||||||
vote_average: Optional[float] = None
|
vote_average: Optional[float] = None
|
||||||
crew: Optional[list] = Field(default_factory=list)
|
crew: Optional[list[TmdbEpisodeCrew]] = Field(default_factory=list)
|
||||||
guest_stars: Optional[list] = 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)
|
||||||
|
|||||||
+12
-1
@@ -3,7 +3,16 @@ from typing import Optional
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MfaChallenge(BaseModel):
|
||||||
|
"""密码认证通过后需要继续完成的二次验证信息。"""
|
||||||
|
|
||||||
|
# 可用的二次验证方式
|
||||||
|
mfa_methods: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
|
"""OAuth2 登录成功后返回的访问令牌。"""
|
||||||
|
|
||||||
# 令牌
|
# 令牌
|
||||||
access_token: str
|
access_token: str
|
||||||
# 令牌类型
|
# 令牌类型
|
||||||
@@ -19,12 +28,14 @@ class Token(BaseModel):
|
|||||||
# 权限级别
|
# 权限级别
|
||||||
level: int = 1
|
level: int = 1
|
||||||
# 详细权限
|
# 详细权限
|
||||||
permissions: Optional[dict] = Field(default_factory=dict)
|
permissions: Optional[dict[str, bool]] = Field(default_factory=dict)
|
||||||
# 是否显示配置向导
|
# 是否显示配置向导
|
||||||
wizard: Optional[bool] = None
|
wizard: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class TokenPayload(BaseModel):
|
class TokenPayload(BaseModel):
|
||||||
|
"""访问令牌中携带的用户身份与授权信息。"""
|
||||||
|
|
||||||
# 用户ID
|
# 用户ID
|
||||||
sub: Optional[int] = None
|
sub: Optional[int] = None
|
||||||
# 用户名
|
# 用户名
|
||||||
|
|||||||
+78
-1
@@ -41,12 +41,32 @@ class DownloaderTorrent(BaseModel):
|
|||||||
ratio_limit: Optional[float] = None
|
ratio_limit: Optional[float] = None
|
||||||
seeding_time_limit: Optional[int] = None
|
seeding_time_limit: Optional[int] = None
|
||||||
trackers: Optional[List[str]] = Field(default_factory=list)
|
trackers: Optional[List[str]] = Field(default_factory=list)
|
||||||
media: Optional[dict] = Field(default_factory=dict)
|
media: Optional["DownloadTaskMedia"] = None
|
||||||
userid: Optional[str] = None
|
userid: Optional[str] = None
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
left_time: 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):
|
class TransferTorrent(DownloaderTorrent):
|
||||||
"""
|
"""
|
||||||
待转移任务信息
|
待转移任务信息
|
||||||
@@ -281,3 +301,60 @@ class ManualTransferTargetPath(BaseModel):
|
|||||||
library_type_folder: Optional[bool] = False
|
library_type_folder: Optional[bool] = False
|
||||||
# 媒体库类别子目录
|
# 媒体库类别子目录
|
||||||
library_category_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
|
||||||
|
|||||||
+42
-6
@@ -2,9 +2,13 @@ from typing import Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
|
||||||
|
from app.schemas.common import JsonData
|
||||||
|
|
||||||
|
|
||||||
# Shared properties
|
# Shared properties
|
||||||
class UserBase(BaseModel):
|
class UserBase(BaseModel):
|
||||||
|
"""用户公共资料、权限和个性化设置。"""
|
||||||
|
|
||||||
# 用户名
|
# 用户名
|
||||||
name: str
|
name: str
|
||||||
# 邮箱,未启用
|
# 邮箱,未启用
|
||||||
@@ -18,33 +22,39 @@ class UserBase(BaseModel):
|
|||||||
# 是否开启二次验证
|
# 是否开启二次验证
|
||||||
is_otp: Optional[bool] = False
|
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)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
# Properties to receive via API on creation
|
# Properties to receive via API on creation
|
||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
|
"""创建用户时接收的资料和初始凭据。"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
settings: Optional[dict] = Field(default_factory=dict)
|
settings: Optional[dict[str, JsonData]] = Field(default_factory=dict)
|
||||||
permissions: Optional[dict] = Field(default_factory=dict)
|
permissions: Optional[dict[str, bool]] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
# Properties to receive via API on update
|
# Properties to receive via API on update
|
||||||
class UserUpdate(UserBase):
|
class UserUpdate(UserBase):
|
||||||
|
"""更新用户时接收的完整资料。"""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
settings: Optional[dict] = Field(default_factory=dict)
|
settings: Optional[dict[str, JsonData]] = Field(default_factory=dict)
|
||||||
permissions: Optional[dict] = Field(default_factory=dict)
|
permissions: Optional[dict[str, bool]] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class UserInDBBase(UserBase):
|
class UserInDBBase(UserBase):
|
||||||
|
"""包含数据库主键的用户公共记录。"""
|
||||||
|
|
||||||
id: Optional[int] = None
|
id: Optional[int] = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -52,10 +62,36 @@ class UserInDBBase(UserBase):
|
|||||||
|
|
||||||
# Additional properties to return via API
|
# Additional properties to return via API
|
||||||
class User(UserInDBBase):
|
class User(UserInDBBase):
|
||||||
|
"""对 API 调用方公开的用户资料。"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# Additional properties stored in DB
|
# Additional properties stored in DB
|
||||||
class UserInDB(UserInDBBase):
|
class UserInDB(UserInDBBase):
|
||||||
|
"""包含密码哈希的内部用户记录。"""
|
||||||
|
|
||||||
hashed_password: str
|
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
|
||||||
|
|||||||
+112
-14
@@ -2,6 +2,7 @@ from typing import Any, List, Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.schemas.common import JsonData
|
||||||
from app.schemas.context import Context, MediaInfo
|
from app.schemas.context import Context, MediaInfo
|
||||||
from app.schemas.download import DownloadTask
|
from app.schemas.download import DownloadTask
|
||||||
from app.schemas.file import FileItem
|
from app.schemas.file import FileItem
|
||||||
@@ -9,6 +10,58 @@ from app.schemas.site import Site
|
|||||||
from app.schemas.subscribe import Subscribe
|
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):
|
class Workflow(BaseModel):
|
||||||
"""
|
"""
|
||||||
工作流信息
|
工作流信息
|
||||||
@@ -19,15 +72,15 @@ class Workflow(BaseModel):
|
|||||||
timer: Optional[str] = Field(default=None, description="定时器")
|
timer: Optional[str] = Field(default=None, description="定时器")
|
||||||
trigger_type: Optional[str] = Field(default='timer', description="触发类型:timer-定时触发 event-事件触发 manual-手动触发")
|
trigger_type: Optional[str] = Field(default='timer', description="触发类型:timer-定时触发 event-事件触发 manual-手动触发")
|
||||||
event_type: Optional[str] = Field(default=None, description="事件类型(当trigger_type为event时使用)")
|
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="状态")
|
state: Optional[str] = Field(default=None, description="状态")
|
||||||
current_action: Optional[str] = Field(default=None, description="已执行动作")
|
current_action: Optional[str] = Field(default=None, description="已执行动作")
|
||||||
result: Optional[str] = Field(default=None, description="任务执行结果")
|
result: Optional[str] = Field(default=None, description="任务执行结果")
|
||||||
run_count: Optional[int] = Field(default=0, description="已执行次数")
|
run_count: Optional[int] = Field(default=0, description="已执行次数")
|
||||||
actions: Optional[list] = Field(default_factory=list, description="任务列表")
|
actions: Optional[list["Action"]] = Field(default_factory=list, description="任务列表")
|
||||||
flows: Optional[list] = Field(default_factory=list, description="任务流")
|
flows: Optional[list["ActionFlow"]] = Field(default_factory=list, description="任务流")
|
||||||
execution_config: Optional[dict] = Field(default_factory=dict, description="工作流执行配置")
|
execution_config: Optional[WorkflowExecutionConfig] = Field(default_factory=WorkflowExecutionConfig, description="工作流执行配置")
|
||||||
execution_state: Optional[dict] = Field(default_factory=dict, description="工作流结构化执行状态")
|
execution_state: Optional[WorkflowExecutionState] = Field(default_factory=WorkflowExecutionState, description="工作流结构化执行状态")
|
||||||
add_time: Optional[str] = Field(default=None, description="创建时间")
|
add_time: Optional[str] = Field(default=None, description="创建时间")
|
||||||
last_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="动作类型 (类名)")
|
type: Optional[str] = Field(default=None, description="动作类型 (类名)")
|
||||||
name: Optional[str] = Field(default=None, description="动作名称")
|
name: Optional[str] = Field(default=None, description="动作名称")
|
||||||
description: Optional[str] = Field(default=None, description="动作描述")
|
description: Optional[str] = Field(default=None, description="动作描述")
|
||||||
position: Optional[dict] = Field(default_factory=dict, description="位置")
|
position: Optional[ActionPosition] = Field(default_factory=ActionPosition, description="位置")
|
||||||
data: Optional[dict] = Field(default_factory=dict, description="参数")
|
data: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="参数")
|
||||||
inputs: Optional[List[str]] = Field(default_factory=list, 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="多上游节点汇合策略")
|
join_policy: Optional[str] = Field(default=None, description="多上游节点汇合策略")
|
||||||
fail_policy: Optional[str] = Field(default=None, description="动作失败后的工作流处理策略")
|
fail_policy: Optional[str] = Field(default=None, description="动作失败后的工作流处理策略")
|
||||||
branch_policy: Optional[str] = Field(default=None, description="多出边分支策略")
|
branch_policy: Optional[str] = Field(default=None, description="多出边分支策略")
|
||||||
concurrency_key: Optional[str] = Field(default=None, description="并发互斥键")
|
concurrency_key: Optional[str] = Field(default=None, description="并发互斥键")
|
||||||
timeout: Optional[int] = 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):
|
class ActionExecution(BaseModel):
|
||||||
@@ -82,10 +135,10 @@ class ActionContext(BaseModel):
|
|||||||
downloads: Optional[List[DownloadTask]] = Field(default_factory=list, description="下载任务列表")
|
downloads: Optional[List[DownloadTask]] = Field(default_factory=list, description="下载任务列表")
|
||||||
sites: Optional[List[Site]] = Field(default_factory=list, description="站点列表")
|
sites: Optional[List[Site]] = Field(default_factory=list, description="站点列表")
|
||||||
subscribes: Optional[List[Subscribe]] = Field(default_factory=list, description="订阅列表")
|
subscribes: Optional[List[Subscribe]] = Field(default_factory=list, description="订阅列表")
|
||||||
workflow_context: Optional[dict] = Field(default_factory=dict, description="工作流全局上下文")
|
workflow_context: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="工作流全局上下文")
|
||||||
node_outputs: Optional[dict] = Field(default_factory=dict, description="节点输出数据")
|
node_outputs: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="节点输出数据")
|
||||||
runtime_state: Optional[dict] = Field(default_factory=dict, description="运行期状态")
|
runtime_state: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="运行期状态")
|
||||||
artifacts: Optional[dict] = 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="执行历史")
|
execute_history: Optional[List[ActionExecution]] = Field(default_factory=list, description="执行历史")
|
||||||
progress: Optional[int] = Field(default=0, description="执行进度(%)")
|
progress: Optional[int] = Field(default=0, description="执行进度(%)")
|
||||||
|
|
||||||
@@ -97,6 +150,7 @@ class ActionResult(BaseModel):
|
|||||||
success: Optional[bool] = Field(default=True, description="动作是否执行成功")
|
success: Optional[bool] = Field(default=True, description="动作是否执行成功")
|
||||||
message: Optional[str] = Field(default=None, description="动作执行消息")
|
message: Optional[str] = Field(default=None, description="动作执行消息")
|
||||||
context: Optional[ActionContext] = Field(default=None, description="动作执行后的上下文")
|
context: Optional[ActionContext] = Field(default=None, description="动作执行后的上下文")
|
||||||
|
# 动作内部可暂存待序列化对象;API 输出前由工作流序列化器转为 JsonData。
|
||||||
outputs: Optional[dict[str, Any]] = Field(default_factory=dict, description="当前节点显式输出")
|
outputs: Optional[dict[str, Any]] = Field(default_factory=dict, description="当前节点显式输出")
|
||||||
next_policy: Optional[str] = Field(default=None, description="动作完成后的调度策略")
|
next_policy: Optional[str] = Field(default=None, description="动作完成后的调度策略")
|
||||||
attempts: Optional[int] = Field(default=1, description="动作实际尝试次数")
|
attempts: Optional[int] = Field(default=1, description="动作实际尝试次数")
|
||||||
@@ -110,7 +164,7 @@ class ActionFlow(BaseModel):
|
|||||||
source: Optional[str] = Field(default=None, description="源动作")
|
source: Optional[str] = Field(default=None, description="源动作")
|
||||||
target: Optional[str] = Field(default=None, description="目标动作")
|
target: Optional[str] = Field(default=None, description="目标动作")
|
||||||
animated: Optional[bool] = Field(default=True, 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="流转条件表达式")
|
condition: Optional[str] = Field(default=None, description="流转条件表达式")
|
||||||
join_policy: Optional[str] = Field(default=None, description="目标节点汇合策略")
|
join_policy: Optional[str] = Field(default=None, description="目标节点汇合策略")
|
||||||
branch_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="复用人次")
|
count: Optional[int] = Field(default=0, description="复用人次")
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
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
|
||||||
|
|||||||
@@ -8,15 +8,10 @@ def init_routers(app: FastAPI):
|
|||||||
初始化路由
|
初始化路由
|
||||||
"""
|
"""
|
||||||
from app.api.apiv1 import api_router
|
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.servarr import arr_router
|
||||||
from app.api.servcookie import cookie_router
|
from app.api.servcookie import cookie_router
|
||||||
# API路由
|
# API路由
|
||||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
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路由
|
# Radarr、Sonarr路由
|
||||||
app.include_router(arr_router, prefix="/api/v3")
|
app.include_router(arr_router, prefix="/api/v3")
|
||||||
# CookieCloud路由
|
# CookieCloud路由
|
||||||
|
|||||||
@@ -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:
|
def _raise_non_rust_panic(err: BaseException) -> None:
|
||||||
"""
|
"""
|
||||||
只吞掉 Rust 扩展 panic/异常,保留用户中断和进程退出语义。
|
只吞掉 Rust 扩展 panic/异常,保留用户中断和进程退出语义。
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import threading
|
|||||||
from time import monotonic, sleep
|
from time import monotonic, sleep
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.core.config import global_vars
|
from app.core.config import global_vars
|
||||||
from app.core.event import eventmanager, Event
|
from app.core.event import eventmanager, Event
|
||||||
from app.db.models import Workflow
|
from app.db.models import Workflow
|
||||||
@@ -212,6 +214,8 @@ class WorkFlowManager(metaclass=Singleton):
|
|||||||
|
|
||||||
def _get_retry_config(self, action: Action) -> dict:
|
def _get_retry_config(self, action: Action) -> dict:
|
||||||
retry_config = action.retry or self._get_action_data_value(action, "retry") or {}
|
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):
|
if not isinstance(retry_config, dict):
|
||||||
retry_config = {}
|
retry_config = {}
|
||||||
return {
|
return {
|
||||||
|
|||||||
+34
-30
@@ -114,21 +114,19 @@ MoviePilot 也提供普通 REST API 给前端和自动化客户端使用。所
|
|||||||
|
|
||||||
#### REST API 版本
|
#### REST API 版本
|
||||||
|
|
||||||
- `/api/v1` 默认保持原有响应结构,已有客户端无需迁移;登录壁纸接口的 URL 已统一放入 `data`。
|
- 普通 JSON REST 接口统一使用 `/api/v1`,不再提供 `/api/v2` 套壳版本。
|
||||||
- `/api/v2` 复用 `/api/v1` 的同一套路由、请求参数、鉴权依赖和业务实现,只统一普通 JSON 响应结构。
|
- 成功和失败响应都只包含 `success`、`message`、`data` 三个顶层字段;各接口只有 `data` 的模型可以变化。
|
||||||
- v1 中已经使用通用 `Response` 的接口在 v2 中保持原样;其他成功 JSON 响应转换为 `{"success": true, "message": "", "data": <原响应>}`。
|
- 成功响应为 `{"success": true, "message": "", "data": <接口数据>}`。HTTP 错误保留原状态码,返回 `{"success": false, "message": <错误原因>, "data": null}`;请求参数校验错误会在 `data` 中附带结构化错误列表。
|
||||||
- HTTP 错误保留原状态码,并统一返回 `{"success": false, "message": <错误详情>, "data": {}}`;非业务异常不做多语言翻译。
|
- 每个普通 JSON 端点都会在 OpenAPI 中声明具体的 `Response[DataModel]`,调用方可从 `/docs` 或 `/api/v1/openapi.json` 查询数据结构。
|
||||||
- SSE、文件、图片、空响应,以及 OpenAI、Anthropic、MCP 等标准协议接口保持原始响应格式,不进行通用封装。
|
- SSE、文件、图片、HTML、空响应,以及 OAuth2 登录、OpenAI、Anthropic、MCP JSON-RPC 等标准协议端点保持协议原生响应体;它们会在 OpenAPI 中显式声明对应的流、文件或协议模型。
|
||||||
|
|
||||||
因此,普通 REST 接口可将文档中的 `/api/v1/...` 路径直接替换为 `/api/v2/...`。例如 `/api/v1/download/` 对应 `/api/v2/download/`。
|
客户端可发送 `X-MoviePilot-Locale: zh-CN|zh-TW|en-US` 或 `Accept-Language`。后端会按当前请求语言直接翻译顶层 `message`;未提供语言头时使用简体中文,翻译缺失时回退原文本。SSE 和业务数据中原有的 `text_i18n`、`error_i18n` 等展示字段继续保留。
|
||||||
|
|
||||||
通用 REST 响应包含 `success`、`message`、`message_i18n`、`data` 字段。为兼容 App 和第三方客户端,`message` 继续保留原中文或原始后端文本;新版前端可发送 `X-MoviePilot-Locale: zh-CN|zh-TW|en-US` 或 `Accept-Language`,并优先展示 `message_i18n`。未提供语言头或翻译缺失时,`message_i18n` 会回退为原文本。
|
`GET /api/v1/login/wallpaper` 会将壁纸 URL 放在 `data` 字段中。`POST /api/v1/user/avatar/{user_id}` 会以 `data.filename` 返回原始文件名。上述接口的 `message` 均不承载业务数据。
|
||||||
|
|
||||||
`GET /api/v1/login/wallpaper` 及对应的 v2 路径会将壁纸 URL 放在 `data` 字段中。`POST /api/v1/user/avatar/{user_id}` 及对应的 v2 路径会以 `data.filename` 返回原始文件名。上述接口的 `message` 均不再承载业务数据。
|
FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返回顶层 `detail` / `detail_i18n`。
|
||||||
|
|
||||||
FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶层 `detail` / `detail_i18n`。
|
交互式接口文档 `/docs` 读取 `/api/v1/openapi.json`,页面版本号直接使用 `version.py` 中的后端 `APP_VERSION`。
|
||||||
|
|
||||||
交互式接口文档 `/docs` 默认读取 `/api/v2/openapi.json`,页面版本号直接使用 `version.py` 中的后端 `APP_VERSION`。旧地址 `/api/v1/openapi.json` 继续保留并返回同一份完整接口文档。
|
|
||||||
|
|
||||||
#### 媒体识别 / 整理
|
#### 媒体识别 / 整理
|
||||||
|
|
||||||
@@ -242,7 +240,7 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
|
|||||||
| GET | `/api/v1/system/setting/public/{key}` | 登录用户读取白名单内非敏感系统设置,仅支持目录、存储、站点范围、默认订阅规则、Follow 订阅者和插件市场地址等前端必需配置 |
|
| GET | `/api/v1/system/setting/public/{key}` | 登录用户读取白名单内非敏感系统设置,仅支持目录、存储、站点范围、默认订阅规则、Follow 订阅者和插件市场地址等前端必需配置 |
|
||||||
| POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | 管理员从 MoviePilot Wiki 的插件文档同步公开插件仓库清单,和本地 `PLUGIN_MARKET` 合并去重后写入配置 |
|
| POST | `/api/v1/system/setting/PLUGIN_MARKET/sync-wiki` | 管理员从 MoviePilot Wiki 的插件文档同步公开插件仓库清单,和本地 `PLUGIN_MARKET` 合并去重后写入配置 |
|
||||||
| GET | `/api/v1/system/modulelist` | 查询已加载模块,保留 `name` 原始中文字段,并提供 `name_i18n` 和 `name_key` 给多语言前端展示 |
|
| GET | `/api/v1/system/modulelist` | 查询已加载模块,保留 `name` 原始中文字段,并提供 `name_i18n` 和 `name_key` 给多语言前端展示 |
|
||||||
| GET | `/api/v1/system/moduletest/{moduleid}` | 测试指定模块可用性,保留原 `message`,并在标准响应顶层返回 `message_i18n` |
|
| GET | `/api/v1/system/moduletest/{moduleid}` | 测试指定模块可用性,标准响应的 `message` 会按请求语言直接返回翻译文本 |
|
||||||
| GET | `/api/v1/message/agent/mcp/servers` | 管理员查询 Agent 外部 MCP 服务器配置 |
|
| GET | `/api/v1/message/agent/mcp/servers` | 管理员查询 Agent 外部 MCP 服务器配置 |
|
||||||
| POST | `/api/v1/message/agent/mcp/servers` | 管理员保存 Agent 外部 MCP 服务器配置 |
|
| POST | `/api/v1/message/agent/mcp/servers` | 管理员保存 Agent 外部 MCP 服务器配置 |
|
||||||
| POST | `/api/v1/message/agent/mcp/servers/test` | 管理员测试单个 Agent 外部 MCP 服务器并读取工具列表 |
|
| POST | `/api/v1/message/agent/mcp/servers/test` | 管理员测试单个 Agent 外部 MCP 服务器并读取工具列表 |
|
||||||
@@ -341,7 +339,10 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
|
|||||||
|
|
||||||
**响应示例**:
|
**响应示例**:
|
||||||
```json
|
```json
|
||||||
[
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": [
|
||||||
{
|
{
|
||||||
"name": "add_subscribe",
|
"name": "add_subscribe",
|
||||||
"description": "Add media subscription to create automated download rules...",
|
"description": "Add media subscription to create automated download rules...",
|
||||||
@@ -351,18 +352,13 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
|
|||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The title of the media to subscribe to"
|
"description": "The title of the media to subscribe to"
|
||||||
},
|
}
|
||||||
"year": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Release year of the media"
|
|
||||||
},
|
|
||||||
...
|
|
||||||
},
|
},
|
||||||
"required": ["title", "media_type"]
|
"required": ["title", "media_type"]
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
...
|
]
|
||||||
]
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 系统诊断工具
|
#### 系统诊断工具
|
||||||
@@ -393,8 +389,10 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"result": "成功添加订阅:流浪地球 (2019)",
|
"message": "",
|
||||||
"error": null
|
"data": {
|
||||||
|
"result": "成功添加订阅:流浪地球 (2019)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -402,8 +400,8 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": false,
|
"success": false,
|
||||||
"result": null,
|
"message": "调用工具失败: 参数验证失败",
|
||||||
"error": "调用工具失败: 参数验证失败"
|
"data": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -421,6 +419,9 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
|
|||||||
**响应示例**:
|
**响应示例**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": {
|
||||||
"name": "add_subscribe",
|
"name": "add_subscribe",
|
||||||
"description": "Add media subscription to create automated download rules...",
|
"description": "Add media subscription to create automated download rules...",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
@@ -429,11 +430,11 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
|
|||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The title of the media to subscribe to"
|
"description": "The title of the media to subscribe to"
|
||||||
},
|
}
|
||||||
...
|
|
||||||
},
|
},
|
||||||
"required": ["title", "media_type"]
|
"required": ["title", "media_type"]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -451,6 +452,9 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
|
|||||||
**响应示例**:
|
**响应示例**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
@@ -460,9 +464,9 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler
|
|||||||
"year": {
|
"year": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Release year of the media"
|
"description": "Release year of the media"
|
||||||
},
|
}
|
||||||
...
|
|
||||||
},
|
},
|
||||||
"required": ["title", "year", "media_type"]
|
"required": ["title", "year", "media_type"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -52,15 +52,16 @@ Regex substitution. The left side is a regex pattern, the right side is the repl
|
|||||||
|
|
||||||
**Special replacement for direct ID specification:**
|
**Special replacement for direct ID specification:**
|
||||||
```
|
```
|
||||||
被替换词 => {[media_source=themoviedb;media_id=xxx;type=movie/tv;s=xxx;e=xxx]}
|
被替换词 => {[tmdbid=xxx;type=movie/tv;s=xxx;e=xxx]}
|
||||||
被替换词 => {[media_source=douban;media_id=xxx;type=movie/tv;s=xxx;e=xxx]}
|
被替换词 => {[doubanid=xxx;type=movie/tv;s=xxx;e=xxx]}
|
||||||
```
|
```
|
||||||
`media_source` must use a `MediaSource` enum value and `media_id` must be that
|
Use the source-specific field that matches the target metadata provider:
|
||||||
source's native ID. Where `s` (season) and `e` (episode) are optional. For TMDB
|
`tmdbid`, `doubanid`, `bangumiid`, or `anilistid`. Where `s` (season) and `e`
|
||||||
TV recognition, add `g=xxx` to specify an episode group:
|
(episode) are optional. For TMDB TV recognition, add `g=xxx` to specify an
|
||||||
|
episode group:
|
||||||
|
|
||||||
```
|
```
|
||||||
被替换词 => {[media_source=themoviedb;media_id=xxx;type=tv;g=xxx;s=xxx;e=xxx]}
|
被替换词 => {[tmdbid=xxx;type=tv;g=xxx;s=xxx;e=xxx]}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Episode Offset (集偏移)
|
### 3. Episode Offset (集偏移)
|
||||||
@@ -115,13 +116,13 @@ Bad (too broad for a global rule):
|
|||||||
```
|
```
|
||||||
REPACK
|
REPACK
|
||||||
1080p
|
1080p
|
||||||
S01E01 => {[media_source=themoviedb;media_id=12345;type=tv;s=1;e=1]}
|
S01E01 => {[tmdbid=12345;type=tv;s=1;e=1]}
|
||||||
```
|
```
|
||||||
|
|
||||||
Better (scoped to the user's sample pattern):
|
Better (scoped to the user's sample pattern):
|
||||||
```
|
```
|
||||||
(\[SubGroup\].*?My\.Show.*?2024.*?)REPACK => \1
|
(\[SubGroup\].*?My\.Show.*?2024.*?)REPACK => \1
|
||||||
Some\.Weird\.Name(?:\.2024)?(?:\.S01E\d+)? => {[media_source=themoviedb;media_id=12345;type=tv;s=1]}
|
Some\.Weird\.Name(?:\.2024)?(?:\.S01E\d+)? => {[tmdbid=12345;type=tv;s=1]}
|
||||||
\[Baha\] <> \[1080P\] >> EP-12
|
\[Baha\] <> \[1080P\] >> EP-12
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -228,7 +229,7 @@ Tell the user:
|
|||||||
**Solution**: Direct ID specification with a sample-specific alias pattern:
|
**Solution**: Direct ID specification with a sample-specific alias pattern:
|
||||||
```
|
```
|
||||||
# 仅在 Some.Weird.Name 这一命名模式下强制绑定 TMDB ID 12345
|
# 仅在 Some.Weird.Name 这一命名模式下强制绑定 TMDB ID 12345
|
||||||
Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[media_source=themoviedb;media_id=12345;type=tv;s=1]}
|
Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[tmdbid=12345;type=tv;s=1]}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Force TMDB Episode Group Recognition
|
### Force TMDB Episode Group Recognition
|
||||||
@@ -238,7 +239,7 @@ Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[media_source=themoviedb;media_i
|
|||||||
**Solution**: Direct TMDB ID specification with `g=...`:
|
**Solution**: Direct TMDB ID specification with `g=...`:
|
||||||
```
|
```
|
||||||
# 仅在 Some.Weird.Name 命名模式下绑定 TMDB ID 12345 并指定剧集组
|
# 仅在 Some.Weird.Name 命名模式下绑定 TMDB ID 12345 并指定剧集组
|
||||||
Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[media_source=themoviedb;media_id=12345;type=tv;g=5ad0ec240e0a26303f00d84d;s=1]}
|
Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[tmdbid=12345;type=tv;g=5ad0ec240e0a26303f00d84d;s=1]}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Combined Fix
|
### Combined Fix
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: moviepilot-api
|
name: moviepilot-api
|
||||||
version: 12
|
version: 13
|
||||||
description: >-
|
description: >-
|
||||||
Use this skill when you need to call MoviePilot REST API endpoints directly
|
Use this skill when you need to call MoviePilot REST API endpoints directly
|
||||||
with the bundled Python client. Covers MoviePilot HTTP endpoints across media
|
with the bundled Python client. Covers MoviePilot HTTP endpoints across media
|
||||||
@@ -75,22 +75,21 @@ python scripts/mp-api.py <METHOD> <PATH> [key=value ...] [--json '<body>']
|
|||||||
|
|
||||||
### API versions and response envelopes
|
### API versions and response envelopes
|
||||||
|
|
||||||
- `/api/v1` preserves the existing endpoint-specific response shapes by
|
- `/api/v1` is the only MoviePilot application REST API version; the former
|
||||||
default; the login wallpaper URL is now returned in `data`.
|
`/api/v2` wrapping layer is no longer available.
|
||||||
- `/api/v2` reuses the same routes, parameters, authentication dependencies,
|
- Every ordinary JSON endpoint returns exactly
|
||||||
and business handlers, but wraps ordinary JSON responses in the shared
|
`{"success":<boolean>,"message":<string>,"data":<endpoint data>}`. Only the
|
||||||
`Response` envelope.
|
`data` schema varies between endpoints, and the concrete envelope is visible
|
||||||
- A successful raw v1 payload becomes
|
in `/docs` and `/api/v1/openapi.json`.
|
||||||
`{"success":true,"message":"","data":<original payload>}` in v2.
|
- HTTP errors keep their status code and use `success=false`; validation errors
|
||||||
- Existing `Response` payloads are not wrapped again. HTTP errors on both v1
|
include their structured details in `data`.
|
||||||
and v2 keep their original status code and expose the error text in
|
- Send `X-MoviePilot-Locale: zh-CN|zh-TW|en-US` or `Accept-Language` when the
|
||||||
`message` with `data={}`. Non-business HTTP exceptions are not translated.
|
response message must match a specific language. The backend returns the
|
||||||
- SSE, files, images, empty responses, and OpenAI, Anthropic, or MCP protocol
|
translated text directly in `message` and falls back to the original text
|
||||||
endpoints keep their protocol-native response body.
|
when no translation exists.
|
||||||
|
- SSE, files, images, HTML, empty responses, OAuth2 login, and OpenAI,
|
||||||
Use `/api/v2` for app clients that require one JSON envelope. Any ordinary
|
Anthropic, or MCP JSON-RPC protocol endpoints keep their protocol-native
|
||||||
REST path listed below can switch from `/api/v1/...` to `/api/v2/...` without
|
response body and explicit OpenAPI declaration.
|
||||||
changing its method, parameters, request body, or authentication.
|
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
@@ -107,8 +106,8 @@ python scripts/mp-api.py DELETE /api/v1/subscribe/123
|
|||||||
# Endpoints that require ?token= auth
|
# Endpoints that require ?token= auth
|
||||||
python scripts/mp-api.py GET /api/v1/dashboard/statistic2 --token-param
|
python scripts/mp-api.py GET /api/v1/dashboard/statistic2 --token-param
|
||||||
|
|
||||||
# Uniform v2 JSON response envelope
|
# Uniform v1 JSON response envelope
|
||||||
python scripts/mp-api.py GET /api/v2/dashboard/cpu
|
python scripts/mp-api.py GET /api/v1/dashboard/cpu
|
||||||
```
|
```
|
||||||
|
|
||||||
## Complete API Reference
|
## Complete API Reference
|
||||||
|
|||||||
@@ -274,7 +274,8 @@ def test_anilist_module_normalizes_voice_actor_and_person_detail() -> None:
|
|||||||
assert credits[0].source == "anilist"
|
assert credits[0].source == "anilist"
|
||||||
assert credits[0].name == "種﨑敦美"
|
assert credits[0].name == "種﨑敦美"
|
||||||
assert credits[0].character == "フリーレン"
|
assert credits[0].character == "フリーレン"
|
||||||
assert credits[0].images["large"] == "https://img.example/actor.jpg"
|
assert credits[0].images is not None
|
||||||
|
assert credits[0].images.large == "https://img.example/actor.jpg"
|
||||||
assert person is not None
|
assert person is not None
|
||||||
assert person.birthday == "1990-09-27"
|
assert person.birthday == "1990-09-27"
|
||||||
assert person.career == ["Voice Actor"]
|
assert person.career == ["Voice Actor"]
|
||||||
|
|||||||
@@ -71,27 +71,9 @@ def test_extended_ids_fall_back_when_installed_rust_is_old() -> None:
|
|||||||
assert metainfo["media_id"] == "154587"
|
assert metainfo["media_id"] == "154587"
|
||||||
|
|
||||||
|
|
||||||
def test_generic_identity_falls_back_when_installed_rust_is_old() -> None:
|
|
||||||
"""旧 Rust 扩展缺少通用字段时应直接使用 Python 解析器。"""
|
|
||||||
with patch(
|
|
||||||
"app.core.metainfo.rust_accel.supports_unified_media_identity",
|
|
||||||
return_value=False,
|
|
||||||
), patch(
|
|
||||||
"app.core.metainfo.rust_accel.find_metainfo",
|
|
||||||
side_effect=AssertionError("旧 Rust 扩展不应处理通用媒体身份"),
|
|
||||||
):
|
|
||||||
_, metainfo = find_metainfo(
|
|
||||||
"Frieren {[media_source=anilist;media_id=154587]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert metainfo["media_source"] == "anilist"
|
|
||||||
assert metainfo["media_id"] == "154587"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"title",
|
"title",
|
||||||
[
|
[
|
||||||
"Movie {[media_source=themoviedb;media_id=0;type=movies]}",
|
|
||||||
"Movie {[tmdbid=0;type=movies]}",
|
"Movie {[tmdbid=0;type=movies]}",
|
||||||
"Movie [tmdbid=0]",
|
"Movie [tmdbid=0]",
|
||||||
"Anime [anilist=0]",
|
"Anime [anilist=0]",
|
||||||
|
|||||||
@@ -325,6 +325,6 @@ def test_upload_avatar_returns_filename_in_data(monkeypatch):
|
|||||||
|
|
||||||
assert response.success is True
|
assert response.success is True
|
||||||
assert response.data == {"filename": "avatar.png"}
|
assert response.data == {"filename": "avatar.png"}
|
||||||
assert response.message is None
|
assert response.message == ""
|
||||||
assert response.message_i18n is None
|
assert not hasattr(response, "message_i18n")
|
||||||
assert fake_user.values == {"avatar": "data:image/ico;base64,b'YXZhdGFy'"}
|
assert fake_user.values == {"avatar": "data:image/ico;base64,b'YXZhdGFy'"}
|
||||||
|
|||||||
@@ -0,0 +1,767 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.api.response import (
|
||||||
|
RAW_RESPONSE_OPENAPI_KEY,
|
||||||
|
ResponseAPIRoute,
|
||||||
|
ResponseAPIRouter,
|
||||||
|
)
|
||||||
|
from app.factory import (
|
||||||
|
localized_http_exception_handler,
|
||||||
|
localized_unhandled_exception_handler,
|
||||||
|
localized_validation_exception_handler,
|
||||||
|
)
|
||||||
|
from app.helper.locale import LocaleHelper
|
||||||
|
from app.schemas.common import JsonData
|
||||||
|
from app.schemas.response import Response
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.anyio
|
||||||
|
|
||||||
|
|
||||||
|
class Item(BaseModel):
|
||||||
|
"""统一响应测试使用的业务数据模型。"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def anyio_backend():
|
||||||
|
"""使用 asyncio 运行异步接口测试。"""
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def api_app() -> FastAPI:
|
||||||
|
"""构造使用统一响应路由的最小测试应用。"""
|
||||||
|
app = FastAPI()
|
||||||
|
app.router.route_class = ResponseAPIRoute
|
||||||
|
app.add_exception_handler(HTTPException, localized_http_exception_handler)
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
|
||||||
|
app.add_exception_handler(
|
||||||
|
RequestValidationError,
|
||||||
|
localized_validation_exception_handler,
|
||||||
|
)
|
||||||
|
app.add_exception_handler(Exception, localized_unhandled_exception_handler)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def locale_middleware(request, call_next):
|
||||||
|
"""在测试应用中模拟生产环境的请求语言上下文。"""
|
||||||
|
token = LocaleHelper.set_current_locale(
|
||||||
|
LocaleHelper.get_locale_from_request(request)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await call_next(request)
|
||||||
|
finally:
|
||||||
|
LocaleHelper.reset_current_locale(token)
|
||||||
|
|
||||||
|
@app.get("/items", response_model=list[Item])
|
||||||
|
async def get_items() -> list[Item]:
|
||||||
|
"""返回需要自动封装的业务数据。"""
|
||||||
|
return [Item(id=1)]
|
||||||
|
|
||||||
|
@app.get("/wrapped", response_model=Response[Item])
|
||||||
|
async def get_wrapped_response() -> Response[Item]:
|
||||||
|
"""返回已经封装的响应。"""
|
||||||
|
return Response(success=True, message="模块不支持测试", data=Item(id=2))
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/oauth-token",
|
||||||
|
response_model=Item,
|
||||||
|
openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True},
|
||||||
|
)
|
||||||
|
async def get_oauth_token() -> Item:
|
||||||
|
"""模拟必须保持顶层字段的标准协议响应。"""
|
||||||
|
return Item(id=3)
|
||||||
|
|
||||||
|
@app.get("/error")
|
||||||
|
async def get_error() -> None:
|
||||||
|
"""抛出需要统一处理的 HTTP 错误。"""
|
||||||
|
raise HTTPException(status_code=400, detail="用户名或密码错误")
|
||||||
|
|
||||||
|
@app.get("/validated/{item_id}", response_model=Item)
|
||||||
|
async def get_validated_item(item_id: int) -> Item:
|
||||||
|
"""返回带路径参数校验的业务数据。"""
|
||||||
|
return Item(id=item_id)
|
||||||
|
|
||||||
|
@app.get("/crash", response_model=Item)
|
||||||
|
async def get_crash() -> Item:
|
||||||
|
"""抛出需要隐藏内部细节的未捕获异常。"""
|
||||||
|
raise RuntimeError("private failure detail")
|
||||||
|
|
||||||
|
@app.get("/native", response_model=None)
|
||||||
|
async def get_native_response() -> dict[str, bool]:
|
||||||
|
"""返回显式旁路的原生 JSON 协议。"""
|
||||||
|
return {"native": True}
|
||||||
|
|
||||||
|
@app.get("/events", response_model=None)
|
||||||
|
async def get_events() -> StreamingResponse:
|
||||||
|
"""返回不应封装的事件流。"""
|
||||||
|
|
||||||
|
async def event_source():
|
||||||
|
"""生成一条测试事件。"""
|
||||||
|
yield "data: ok\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(event_source(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def make_client(app: FastAPI) -> httpx.AsyncClient:
|
||||||
|
"""创建不访问真实网络的 ASGI 测试客户端。"""
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=app),
|
||||||
|
base_url="http://testserver",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_route_wraps_data_and_keeps_existing_response(api_app: FastAPI):
|
||||||
|
"""普通数据应自动封装,已经封装的响应不应重复套壳。"""
|
||||||
|
async with make_client(api_app) as client:
|
||||||
|
items_response = await client.get("/items")
|
||||||
|
wrapped_response = await client.get("/wrapped")
|
||||||
|
|
||||||
|
assert items_response.json() == {
|
||||||
|
"success": True,
|
||||||
|
"message": "",
|
||||||
|
"data": [{"id": 1}],
|
||||||
|
}
|
||||||
|
assert wrapped_response.json() == {
|
||||||
|
"success": True,
|
||||||
|
"message": "模块不支持测试",
|
||||||
|
"data": {"id": 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_explicit_none_and_stream_keep_native_protocol(api_app: FastAPI):
|
||||||
|
"""显式无响应模型和流式响应应保持原生协议。"""
|
||||||
|
async with make_client(api_app) as client:
|
||||||
|
native_response = await client.get("/native")
|
||||||
|
stream_response = await client.get("/events")
|
||||||
|
|
||||||
|
assert native_response.json() == {"native": True}
|
||||||
|
assert stream_response.headers["content-type"].startswith("text/event-stream")
|
||||||
|
assert stream_response.text == "data: ok\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_accept_language_localizes_success_and_http_error(api_app: FastAPI):
|
||||||
|
"""Accept-Language 应直接决定成功与 HTTP 错误响应的 message。"""
|
||||||
|
async with make_client(api_app) as client:
|
||||||
|
wrapped_response = await client.get(
|
||||||
|
"/wrapped", headers={"Accept-Language": "en-US"}
|
||||||
|
)
|
||||||
|
error_response = await client.get(
|
||||||
|
"/error", headers={"Accept-Language": "en-US"}
|
||||||
|
)
|
||||||
|
zh_error_response = await client.get(
|
||||||
|
"/error", headers={"Accept-Language": "zh-CN"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert wrapped_response.json()["message"] == "Module does not support testing"
|
||||||
|
assert error_response.status_code == 400
|
||||||
|
assert error_response.json() == {
|
||||||
|
"success": False,
|
||||||
|
"message": "Incorrect username or password",
|
||||||
|
"data": None,
|
||||||
|
}
|
||||||
|
assert zh_error_response.json()["message"] == "用户名或密码错误"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_validation_error_uses_unified_model(api_app: FastAPI):
|
||||||
|
"""请求参数校验失败应返回统一协议和明确的错误项结构。"""
|
||||||
|
async with make_client(api_app) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/validated/not-an-integer",
|
||||||
|
headers={"Accept-Language": "en-US"},
|
||||||
|
)
|
||||||
|
zh_response = await client.get(
|
||||||
|
"/validated/not-an-integer",
|
||||||
|
headers={"Accept-Language": "zh-CN"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert payload["success"] is False
|
||||||
|
assert payload["message"] == "Request parameters are incorrect"
|
||||||
|
assert payload["data"] == [
|
||||||
|
{
|
||||||
|
"location": ["path", "item_id"],
|
||||||
|
"message": "Input should be a valid integer, unable to parse string as an integer",
|
||||||
|
"error_type": "int_parsing",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert zh_response.json()["message"] == "请求参数不正确"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unhandled_exception_uses_localized_unified_response(api_app: FastAPI):
|
||||||
|
"""未捕获异常应返回本地化统一响应且不泄露内部错误。"""
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(
|
||||||
|
app=api_app,
|
||||||
|
raise_app_exceptions=False,
|
||||||
|
),
|
||||||
|
base_url="http://testserver",
|
||||||
|
) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/crash",
|
||||||
|
headers={"Accept-Language": "en-US"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert response.json() == {
|
||||||
|
"success": False,
|
||||||
|
"message": "Unknown error",
|
||||||
|
"data": None,
|
||||||
|
}
|
||||||
|
assert "private failure detail" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_declares_generic_success_and_error_models(api_app: FastAPI):
|
||||||
|
"""OpenAPI 应展示业务数据类型及统一的 HTTP/422 错误响应结构。"""
|
||||||
|
schema = api_app.openapi()
|
||||||
|
operation = schema["paths"]["/items"]["get"]
|
||||||
|
success_ref = operation["responses"]["200"]["content"]["application/json"][
|
||||||
|
"schema"
|
||||||
|
]["$ref"]
|
||||||
|
validation_ref = operation["responses"]["422"]["content"][
|
||||||
|
"application/json"
|
||||||
|
]["schema"]["$ref"]
|
||||||
|
|
||||||
|
assert success_ref.endswith("/Response_list_Item__")
|
||||||
|
assert validation_ref.endswith("/Response_list_ValidationIssue__")
|
||||||
|
assert "HTTPValidationError" not in schema["components"]["schemas"]
|
||||||
|
success_schema = schema["components"]["schemas"][success_ref.rsplit("/", 1)[-1]]
|
||||||
|
assert success_schema["required"] == ["success", "message", "data"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openapi_marker_keeps_oauth_payload_at_top_level(api_app: FastAPI):
|
||||||
|
"""显式原生标记应保留 OAuth 等标准协议的顶层字段及模型。"""
|
||||||
|
async with make_client(api_app) as client:
|
||||||
|
response = await client.get("/oauth-token")
|
||||||
|
|
||||||
|
operation = api_app.openapi()["paths"]["/oauth-token"]["get"]
|
||||||
|
schema_ref = operation["responses"]["200"]["content"]["application/json"][
|
||||||
|
"schema"
|
||||||
|
]["$ref"]
|
||||||
|
|
||||||
|
assert response.json() == {"id": 3}
|
||||||
|
assert schema_ref.endswith("/Item")
|
||||||
|
assert operation[RAW_RESPONSE_OPENAPI_KEY] is True
|
||||||
|
validation_ref = operation["responses"]["422"]["content"][
|
||||||
|
"application/json"
|
||||||
|
]["schema"]["$ref"]
|
||||||
|
assert validation_ref.endswith("/Response_list_ValidationIssue__")
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_localizes_zh_en_and_falls_back_to_source():
|
||||||
|
"""Response 应支持中英文上下文,未知文案按原文回退。"""
|
||||||
|
zh_token = LocaleHelper.set_current_locale("zh-CN")
|
||||||
|
try:
|
||||||
|
zh_response = Response[None](success=False, message="用户名或密码错误")
|
||||||
|
finally:
|
||||||
|
LocaleHelper.reset_current_locale(zh_token)
|
||||||
|
|
||||||
|
en_token = LocaleHelper.set_current_locale("en-US")
|
||||||
|
try:
|
||||||
|
en_response = Response[None](success=False, message="用户名或密码错误")
|
||||||
|
fallback_response = Response[None](success=False, message="未登记的新错误文案")
|
||||||
|
finally:
|
||||||
|
LocaleHelper.reset_current_locale(en_token)
|
||||||
|
|
||||||
|
assert zh_response.message == "用户名或密码错误"
|
||||||
|
assert en_response.message == "Incorrect username or password"
|
||||||
|
assert fallback_response.message == "未登记的新错误文案"
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_defaults_are_serialized_despite_required_openapi_fields():
|
||||||
|
"""省略默认值构造仍应在序列化结果中完整输出三块结构。"""
|
||||||
|
response = Response[None](success=True)
|
||||||
|
|
||||||
|
assert response.model_dump() == {
|
||||||
|
"success": True,
|
||||||
|
"message": "",
|
||||||
|
"data": None,
|
||||||
|
}
|
||||||
|
assert Response[None].model_json_schema()["required"] == [
|
||||||
|
"success",
|
||||||
|
"message",
|
||||||
|
"data",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_rejects_fields_outside_unified_protocol():
|
||||||
|
"""统一响应顶层只允许 success、message、data 三个字段。"""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Response[None](success=True, message_i18n="unexpected")
|
||||||
|
|
||||||
|
|
||||||
|
def test_v1_routes_use_response_route_except_native_protocols():
|
||||||
|
"""v1 普通接口应使用统一路由,标准协议路由保持原生实现。"""
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
|
from app.api.apiv1 import api_router
|
||||||
|
|
||||||
|
api_routes = [
|
||||||
|
route for route in api_router.routes if isinstance(route, APIRoute)
|
||||||
|
]
|
||||||
|
native_paths = {
|
||||||
|
"/openai/v1/models",
|
||||||
|
"/openai/v1/chat/completions",
|
||||||
|
"/openai/v1/responses",
|
||||||
|
"/anthropic/v1/messages",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert all(
|
||||||
|
isinstance(route, ResponseAPIRoute) or route.path in native_paths
|
||||||
|
for route in api_routes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_v1_json_routes_have_concrete_data_models():
|
||||||
|
"""普通 v1 JSON 路由禁止未参数化、Any 或通用 JSON 顶层输出模型。"""
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
|
from app.api.apiv1 import api_router
|
||||||
|
|
||||||
|
weak_routes = []
|
||||||
|
for route in api_router.routes:
|
||||||
|
if not isinstance(route, APIRoute):
|
||||||
|
continue
|
||||||
|
response_model = route.response_model
|
||||||
|
try:
|
||||||
|
is_response_model = issubclass(response_model, Response)
|
||||||
|
except TypeError:
|
||||||
|
is_response_model = False
|
||||||
|
if not is_response_model:
|
||||||
|
continue
|
||||||
|
generic_args = response_model.__pydantic_generic_metadata__.get("args")
|
||||||
|
if not generic_args or generic_args in ((Any,), (JsonData,)):
|
||||||
|
weak_routes.append((route.path, route.name, generic_args))
|
||||||
|
|
||||||
|
assert weak_routes == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_v1_model_free_routes_match_audited_native_allowlist():
|
||||||
|
"""无响应模型仅允许固定的协议、流、文件、图片、HTML 与 204 路由。"""
|
||||||
|
from app.api.apiv1 import api_router
|
||||||
|
|
||||||
|
expected_routes = {
|
||||||
|
("/message/", "incoming_verify"),
|
||||||
|
("/message/agent/file/{file_id}", "download_web_agent_file"),
|
||||||
|
("/message/agent/stream", "web_agent_stream"),
|
||||||
|
("/search/media/{media_id}/stream", "search_by_id_stream"),
|
||||||
|
("/search/title/stream", "search_by_title_stream"),
|
||||||
|
("/search/subtitle/title/stream", "search_subtitle_by_title_stream"),
|
||||||
|
(
|
||||||
|
"/search/subtitle/media/{media_id}/stream",
|
||||||
|
"search_subtitle_by_id_stream",
|
||||||
|
),
|
||||||
|
("/system/img/{proxy}", "proxy_img"),
|
||||||
|
("/system/cache/image", "cache_img"),
|
||||||
|
("/system/progress/{process_type}", "get_progress"),
|
||||||
|
("/system/message", "get_message"),
|
||||||
|
("/system/logging", "get_logging"),
|
||||||
|
("/system/logging/download/{name}", "download_logging"),
|
||||||
|
(
|
||||||
|
"/llm/provider-auth/callback/{provider_id}",
|
||||||
|
"llm_provider_auth_callback",
|
||||||
|
),
|
||||||
|
("/plugin/file/{plugin_id}/{filepath:path}", "plugin_static_file"),
|
||||||
|
("/storage/download", "download"),
|
||||||
|
("/storage/image", "image"),
|
||||||
|
("/mcp", "delete_mcp_session"),
|
||||||
|
}
|
||||||
|
actual_routes = {
|
||||||
|
(route.path, route.name)
|
||||||
|
for route in api_router.routes
|
||||||
|
if isinstance(route, ResponseAPIRoute) and route.response_model is None
|
||||||
|
}
|
||||||
|
|
||||||
|
assert actual_routes == expected_routes
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_protocol_openapi_has_explicit_response_schemas():
|
||||||
|
"""OpenAI、Anthropic 与 MCP 原生协议响应必须在 OpenAPI 中明确建模。"""
|
||||||
|
from app.factory import create_app
|
||||||
|
from app.startup.routers_initializer import init_routers
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
init_routers(app)
|
||||||
|
schema = app.openapi()
|
||||||
|
operations = {
|
||||||
|
("/api/v1/openai/v1/models", "get"): "OpenAIErrorResponse",
|
||||||
|
("/api/v1/openai/v1/chat/completions", "post"): "OpenAIErrorResponse",
|
||||||
|
("/api/v1/openai/v1/responses", "post"): "OpenAIErrorResponse",
|
||||||
|
("/api/v1/anthropic/v1/messages", "post"): "AnthropicErrorResponse",
|
||||||
|
}
|
||||||
|
|
||||||
|
for (path, method), error_model in operations.items():
|
||||||
|
success_content = schema["paths"][path][method]["responses"]["200"][
|
||||||
|
"content"
|
||||||
|
]
|
||||||
|
response_schema = success_content["application/json"]["schema"]
|
||||||
|
assert response_schema
|
||||||
|
if path.endswith(("/chat/completions", "/messages")):
|
||||||
|
assert success_content["text/event-stream"]["schema"] == {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
for status_code in ("400", "401", "422", "500", "503"):
|
||||||
|
error_schema = schema["paths"][path][method]["responses"][status_code][
|
||||||
|
"content"
|
||||||
|
]["application/json"]["schema"]
|
||||||
|
assert error_schema["$ref"].endswith(f"/{error_model}")
|
||||||
|
|
||||||
|
mcp_schema = schema["paths"]["/api/v1/mcp"]["post"]["responses"]["200"][
|
||||||
|
"content"
|
||||||
|
]["application/json"]["schema"]
|
||||||
|
assert len(mcp_schema["anyOf"]) == 2
|
||||||
|
post_responses = schema["paths"]["/api/v1/mcp"]["post"]["responses"]
|
||||||
|
delete_responses = schema["paths"]["/api/v1/mcp"]["delete"]["responses"]
|
||||||
|
assert delete_responses["204"] == {"description": "MCP 会话已终止"}
|
||||||
|
for status_code in ("400", "401", "403", "404", "409", "422", "500"):
|
||||||
|
for responses in (post_responses, delete_responses):
|
||||||
|
error_ref = responses[status_code]["content"]["application/json"][
|
||||||
|
"schema"
|
||||||
|
]["$ref"]
|
||||||
|
assert error_ref.endswith("/McpJsonRpcError")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_native_protocol_validation_errors_keep_native_shapes():
|
||||||
|
"""OpenAI 与 Anthropic 的请求校验错误应保持各自协议的错误结构。"""
|
||||||
|
from app.factory import create_app
|
||||||
|
from app.startup.routers_initializer import init_routers
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
init_routers(app)
|
||||||
|
async with make_client(app) as client:
|
||||||
|
openai_response = await client.post(
|
||||||
|
"/api/v1/openai/v1/chat/completions",
|
||||||
|
json={"messages": "invalid"},
|
||||||
|
)
|
||||||
|
openai_responses_response = await client.post(
|
||||||
|
"/api/v1/openai/v1/responses",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
anthropic_response = await client.post(
|
||||||
|
"/api/v1/anthropic/v1/messages",
|
||||||
|
json={"messages": "invalid"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert openai_response.status_code == 422
|
||||||
|
assert openai_response.json() == {
|
||||||
|
"error": {
|
||||||
|
"message": "Input should be a valid list",
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"param": "messages",
|
||||||
|
"code": "invalid_request_error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert openai_responses_response.status_code == 422
|
||||||
|
assert openai_responses_response.json()["error"]["type"] == (
|
||||||
|
"invalid_request_error"
|
||||||
|
)
|
||||||
|
assert openai_responses_response.json()["error"]["param"] == "input"
|
||||||
|
assert anthropic_response.status_code == 422
|
||||||
|
assert anthropic_response.json() == {
|
||||||
|
"type": "error",
|
||||||
|
"error": {
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
"message": "messages: Input should be a valid list",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_root_auth_error_keeps_jsonrpc_shape():
|
||||||
|
"""MCP 根端点的依赖异常应保持 JSON-RPC,REST 子端点仍由统一协议处理。"""
|
||||||
|
from app.factory import create_app
|
||||||
|
from app.startup.routers_initializer import init_routers
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
init_routers(app)
|
||||||
|
async with make_client(app) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/mcp",
|
||||||
|
json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert payload["jsonrpc"] == "2.0"
|
||||||
|
assert payload["id"] is None
|
||||||
|
assert payload["error"]["code"] == -32001
|
||||||
|
assert "success" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
async def test_native_ai_http_and_unhandled_errors_keep_protocol_shapes():
|
||||||
|
"""兼容协议的依赖异常与未捕获异常都应返回原生错误体。"""
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
def request_for(path: str) -> Request:
|
||||||
|
"""构造直接调用异常处理器所需的最小请求对象。"""
|
||||||
|
return Request(
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"method": "POST",
|
||||||
|
"path": path,
|
||||||
|
"headers": [],
|
||||||
|
"query_string": b"",
|
||||||
|
"server": ("testserver", 80),
|
||||||
|
"client": ("testclient", 123),
|
||||||
|
"scheme": "http",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
openai_http = await localized_http_exception_handler(
|
||||||
|
request_for("/api/v1/openai/v1/chat/completions"),
|
||||||
|
HTTPException(status_code=401, detail="Invalid bearer token."),
|
||||||
|
)
|
||||||
|
anthropic_http = await localized_http_exception_handler(
|
||||||
|
request_for("/api/v1/anthropic/v1/messages"),
|
||||||
|
HTTPException(status_code=403, detail="invalid x-api-key"),
|
||||||
|
)
|
||||||
|
openai_crash = await localized_unhandled_exception_handler(
|
||||||
|
request_for("/api/v1/openai/v1/responses"),
|
||||||
|
RuntimeError("private openai failure"),
|
||||||
|
)
|
||||||
|
anthropic_crash = await localized_unhandled_exception_handler(
|
||||||
|
request_for("/api/v1/anthropic/v1/messages"),
|
||||||
|
RuntimeError("private anthropic failure"),
|
||||||
|
)
|
||||||
|
|
||||||
|
openai_http_payload = openai_http.body.decode()
|
||||||
|
anthropic_http_payload = anthropic_http.body.decode()
|
||||||
|
openai_crash_payload = openai_crash.body.decode()
|
||||||
|
anthropic_crash_payload = anthropic_crash.body.decode()
|
||||||
|
assert openai_http.status_code == 401
|
||||||
|
assert '"type":"authentication_error"' in openai_http_payload
|
||||||
|
assert '"success"' not in openai_http_payload
|
||||||
|
assert anthropic_http.status_code == 403
|
||||||
|
assert '"type":"authentication_error"' in anthropic_http_payload
|
||||||
|
assert '"success"' not in anthropic_http_payload
|
||||||
|
assert openai_crash.status_code == 500
|
||||||
|
assert '"type":"server_error"' in openai_crash_payload
|
||||||
|
assert "private openai failure" not in openai_crash_payload
|
||||||
|
assert anthropic_crash.status_code == 500
|
||||||
|
assert '"type":"api_error"' in anthropic_crash_payload
|
||||||
|
assert "private anthropic failure" not in anthropic_crash_payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_servarr_and_cookiecloud_openapi_has_explicit_models():
|
||||||
|
"""兼容协议成功响应必须显式建模,错误响应必须声明统一结构。"""
|
||||||
|
from app.factory import create_app
|
||||||
|
from app.startup.routers_initializer import init_routers
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
init_routers(app)
|
||||||
|
schema = app.openapi()
|
||||||
|
compatible_paths = [
|
||||||
|
path
|
||||||
|
for path in schema["paths"]
|
||||||
|
if path.startswith(("/api/v3", "/cookiecloud"))
|
||||||
|
]
|
||||||
|
|
||||||
|
assert compatible_paths
|
||||||
|
for path in compatible_paths:
|
||||||
|
for operation in schema["paths"][path].values():
|
||||||
|
success_response = operation["responses"].get("200")
|
||||||
|
if success_response:
|
||||||
|
success_schemas = [
|
||||||
|
content["schema"]
|
||||||
|
for content in success_response.get("content", {}).values()
|
||||||
|
]
|
||||||
|
assert success_schemas and all(success_schemas)
|
||||||
|
for status_code in ("400", "401", "403", "404", "409", "422", "500"):
|
||||||
|
error_response = operation["responses"][status_code]
|
||||||
|
error_schema = next(iter(error_response["content"].values()))[
|
||||||
|
"schema"
|
||||||
|
]
|
||||||
|
assert error_schema["$ref"].startswith(
|
||||||
|
"#/components/schemas/Response_"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_openapi_error_responses_use_json_schemas():
|
||||||
|
"""所有普通与原生协议错误响应都应在文档中声明 JSON 媒体类型和结构。"""
|
||||||
|
from app.factory import create_app
|
||||||
|
from app.startup.routers_initializer import init_routers
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
init_routers(app)
|
||||||
|
schema = app.openapi()
|
||||||
|
methods = {"get", "post", "put", "patch", "delete", "options", "head"}
|
||||||
|
|
||||||
|
invalid_responses = []
|
||||||
|
for path, path_item in schema["paths"].items():
|
||||||
|
for method, operation in path_item.items():
|
||||||
|
if method not in methods:
|
||||||
|
continue
|
||||||
|
for status_code, response in operation["responses"].items():
|
||||||
|
if not str(status_code).startswith(("4", "5")):
|
||||||
|
continue
|
||||||
|
json_schema = response.get("content", {}).get(
|
||||||
|
"application/json", {}
|
||||||
|
).get("schema")
|
||||||
|
if not json_schema:
|
||||||
|
invalid_responses.append((path, method, status_code))
|
||||||
|
|
||||||
|
assert invalid_responses == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_success_models_have_no_implicit_empty_nested_schemas():
|
||||||
|
"""2xx 响应可达模型不得包含裸 Any、裸数组或未声明值类型的开放映射。"""
|
||||||
|
from app.factory import create_app
|
||||||
|
from app.startup.routers_initializer import init_routers
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
init_routers(app)
|
||||||
|
schema = app.openapi()
|
||||||
|
components = schema["components"]["schemas"]
|
||||||
|
allowed_open_components = {
|
||||||
|
# 三个外部兼容协议允许规范声明之外的请求扩展字段。
|
||||||
|
"AnthropicMessage",
|
||||||
|
"AnthropicMessagesRequest",
|
||||||
|
"OpenAIChatCompletionsRequest",
|
||||||
|
"OpenAIChatMessage",
|
||||||
|
"OpenAIResponsesRequest",
|
||||||
|
# 分类规则与 CookieCloud 解密载荷按设计接受扩展键。
|
||||||
|
"CategoryRule",
|
||||||
|
"CookieDecryptedPayload",
|
||||||
|
}
|
||||||
|
allowed_empty_components = {"McpJsonRpcEmptyResult"}
|
||||||
|
violations = []
|
||||||
|
|
||||||
|
def visit(node: Any, component: str, location: str) -> None:
|
||||||
|
"""递归检查单个组件节点中的隐式弱类型。"""
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return
|
||||||
|
if node == {} and component not in allowed_empty_components:
|
||||||
|
violations.append((component, location, "empty"))
|
||||||
|
if (
|
||||||
|
node.get("type") == "array"
|
||||||
|
and "items" not in node
|
||||||
|
and "prefixItems" not in node
|
||||||
|
):
|
||||||
|
violations.append((component, location, "untyped-array"))
|
||||||
|
if (
|
||||||
|
node.get("additionalProperties") is True
|
||||||
|
and component not in allowed_open_components
|
||||||
|
):
|
||||||
|
violations.append((component, location, "open-object"))
|
||||||
|
if (
|
||||||
|
node.get("type") == "object"
|
||||||
|
and not node.get("properties")
|
||||||
|
and "additionalProperties" not in node
|
||||||
|
and component not in allowed_empty_components
|
||||||
|
):
|
||||||
|
violations.append((component, location, "untyped-object"))
|
||||||
|
for key, value in node.items():
|
||||||
|
if key in {"default", "example", "examples"}:
|
||||||
|
continue
|
||||||
|
if isinstance(value, dict):
|
||||||
|
visit(value, component, f"{location}.{key}")
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for index, item in enumerate(value):
|
||||||
|
visit(item, component, f"{location}.{key}[{index}]")
|
||||||
|
|
||||||
|
for component_name, component_schema in components.items():
|
||||||
|
visit(component_schema, component_name, component_name)
|
||||||
|
|
||||||
|
assert violations == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_routes_only_register_v1(monkeypatch):
|
||||||
|
"""插件动态路由只应注册 v1 地址并由应用统一路由类处理。"""
|
||||||
|
from app.api.endpoints import plugin as plugin_endpoint
|
||||||
|
|
||||||
|
class FakeApp:
|
||||||
|
"""记录动态注册路径的应用桩。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.routes = []
|
||||||
|
self.openapi_schema = None
|
||||||
|
|
||||||
|
def add_api_route(self, **kwargs):
|
||||||
|
"""记录新增的路由路径。"""
|
||||||
|
self.routes.append(SimpleNamespace(path=kwargs["path"]))
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
"""模拟 FastAPI 路由重建。"""
|
||||||
|
|
||||||
|
class FakePluginManager:
|
||||||
|
"""返回单个测试插件 API 的管理器桩。"""
|
||||||
|
|
||||||
|
def get_plugin_apis(self, plugin_id):
|
||||||
|
"""返回测试插件 API。"""
|
||||||
|
assert plugin_id == "DemoPlugin"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"path": "/DemoPlugin/health",
|
||||||
|
"endpoint": lambda: {"ok": True},
|
||||||
|
"methods": ["GET"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
fake_app = FakeApp()
|
||||||
|
monkeypatch.setattr(plugin_endpoint, "app", fake_app)
|
||||||
|
monkeypatch.setattr(plugin_endpoint, "PluginManager", FakePluginManager)
|
||||||
|
|
||||||
|
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="add")
|
||||||
|
assert [route.path for route in fake_app.routes] == [
|
||||||
|
"/api/v1/plugin/DemoPlugin/health"
|
||||||
|
]
|
||||||
|
|
||||||
|
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="remove")
|
||||||
|
assert fake_app.routes == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_router_uses_response_route_class():
|
||||||
|
"""统一路由器应默认创建统一响应路由。"""
|
||||||
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
@router.get("/health", response_model=bool)
|
||||||
|
def health() -> bool:
|
||||||
|
"""返回测试健康状态。"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
assert isinstance(router.routes[0], ResponseAPIRoute)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_route_without_annotation_uses_recursive_json_model():
|
||||||
|
"""动态插件未声明模型时应以 OpenAPI 可递归展示的 JSON 类型约束 data。"""
|
||||||
|
app = FastAPI()
|
||||||
|
app.router.route_class = ResponseAPIRoute
|
||||||
|
|
||||||
|
def plugin_endpoint():
|
||||||
|
"""模拟未声明返回注解的插件动态接口。"""
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
app.add_api_route("/plugin", plugin_endpoint, methods=["GET"])
|
||||||
|
route = app.routes[-1]
|
||||||
|
generic_args = route.response_model.__pydantic_generic_metadata__["args"]
|
||||||
|
|
||||||
|
assert generic_args == (JsonData,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_bare_response_uses_recursive_json_without_double_wrapping():
|
||||||
|
"""动态插件声明裸 Response 时应补齐递归 JSON 类型且不重复封装。"""
|
||||||
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
@router.get("/plugin", response_model=Response)
|
||||||
|
def plugin_endpoint() -> Response:
|
||||||
|
"""模拟返回统一响应但未参数化 data 的插件接口。"""
|
||||||
|
return Response(success=True, data={"ok": True})
|
||||||
|
|
||||||
|
route = router.routes[0]
|
||||||
|
generic_args = route.response_model.__pydantic_generic_metadata__["args"]
|
||||||
|
result = route.endpoint()
|
||||||
|
|
||||||
|
assert generic_args == (JsonData,)
|
||||||
|
assert isinstance(result, Response)
|
||||||
|
assert result.data == {"ok": True}
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
from fastapi import FastAPI, HTTPException
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
|
|
||||||
from app.api.apiv2_utils import (
|
|
||||||
OPENAPI_V2_PATH,
|
|
||||||
V2ResponseMiddleware,
|
|
||||||
configure_v2_openapi,
|
|
||||||
)
|
|
||||||
from app.schemas.response import Response
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.anyio
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def anyio_backend():
|
|
||||||
"""使用 asyncio 运行异步接口测试。"""
|
|
||||||
return "asyncio"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def api_app() -> FastAPI:
|
|
||||||
"""构造同时包含 v1 和 v2 示例接口的测试应用。"""
|
|
||||||
app = FastAPI()
|
|
||||||
app.add_middleware(V2ResponseMiddleware)
|
|
||||||
|
|
||||||
@app.get("/api/v1/items")
|
|
||||||
async def get_v1_items() -> list[dict]:
|
|
||||||
"""返回未封装的 v1 列表。"""
|
|
||||||
return [{"id": 1}]
|
|
||||||
|
|
||||||
@app.get("/api/v2/items")
|
|
||||||
async def get_v2_items() -> list[dict]:
|
|
||||||
"""返回供 v2 适配器封装的列表。"""
|
|
||||||
return [{"id": 1}]
|
|
||||||
|
|
||||||
@app.get("/api/v2/wrapped", response_model=Response)
|
|
||||||
async def get_wrapped_response() -> Response:
|
|
||||||
"""返回已经使用通用结构封装的响应。"""
|
|
||||||
return Response(success=True, message="操作成功", data={"id": 1})
|
|
||||||
|
|
||||||
@app.get("/api/v2/error")
|
|
||||||
async def get_error() -> None:
|
|
||||||
"""返回供 v2 适配器转换的 HTTP 错误。"""
|
|
||||||
raise HTTPException(status_code=400, detail="请求参数错误")
|
|
||||||
|
|
||||||
@app.get("/api/v2/validated/{item_id}")
|
|
||||||
async def get_validated_item(item_id: int) -> dict:
|
|
||||||
"""返回带路径参数校验的示例数据。"""
|
|
||||||
return {"id": item_id}
|
|
||||||
|
|
||||||
@app.get("/api/v2/openai/v1/models")
|
|
||||||
async def get_openai_models() -> dict:
|
|
||||||
"""返回需要保持原始协议结构的 OpenAI 模型列表。"""
|
|
||||||
return {"object": "list", "data": []}
|
|
||||||
|
|
||||||
@app.get("/api/v2/events")
|
|
||||||
async def get_events() -> None:
|
|
||||||
"""返回不应封装的 SSE 流。"""
|
|
||||||
async def event_source():
|
|
||||||
"""生成一条测试事件。"""
|
|
||||||
yield "data: ok\n\n"
|
|
||||||
|
|
||||||
return StreamingResponse(event_source(), media_type="text/event-stream")
|
|
||||||
|
|
||||||
@app.get(OPENAPI_V2_PATH, include_in_schema=False)
|
|
||||||
async def get_v2_openapi_schema() -> dict:
|
|
||||||
"""返回不应被 v2 中间件封装的 OpenAPI 文档。"""
|
|
||||||
return {"openapi": "3.1.0", "info": {"title": "Test", "version": "1.0.0"}}
|
|
||||||
|
|
||||||
configure_v2_openapi(app)
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
def make_client(app: FastAPI) -> httpx.AsyncClient:
|
|
||||||
"""创建不访问真实网络的 ASGI 测试客户端。"""
|
|
||||||
return httpx.AsyncClient(
|
|
||||||
transport=httpx.ASGITransport(app=app),
|
|
||||||
base_url="http://testserver",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_v2_wraps_raw_json_without_changing_v1(api_app: FastAPI):
|
|
||||||
"""v2 应封装原始 JSON 数据,同时保持 v1 返回结构不变。"""
|
|
||||||
async with make_client(api_app) as client:
|
|
||||||
v1_response = await client.get("/api/v1/items")
|
|
||||||
v2_response = await client.get("/api/v2/items")
|
|
||||||
|
|
||||||
assert v1_response.json() == [{"id": 1}]
|
|
||||||
assert v2_response.json() == {
|
|
||||||
"success": True,
|
|
||||||
"message": "",
|
|
||||||
"data": [{"id": 1}],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_v2_keeps_existing_response_payload(api_app: FastAPI):
|
|
||||||
"""已经使用 Response 的接口不应被重复封装。"""
|
|
||||||
async with make_client(api_app) as client:
|
|
||||||
response = await client.get("/api/v2/wrapped")
|
|
||||||
|
|
||||||
payload = response.json()
|
|
||||||
assert payload["success"] is True
|
|
||||||
assert payload["message"] == "操作成功"
|
|
||||||
assert payload["data"] == {"id": 1}
|
|
||||||
assert "success" not in payload["data"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_v2_moves_http_error_detail_to_message(api_app: FastAPI):
|
|
||||||
"""v2 HTTP 错误应保留状态码并把 detail 转换到 message。"""
|
|
||||||
async with make_client(api_app) as client:
|
|
||||||
response = await client.get("/api/v2/error")
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json() == {
|
|
||||||
"success": False,
|
|
||||||
"message": "请求参数错误",
|
|
||||||
"data": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_v2_moves_validation_error_to_message(api_app: FastAPI):
|
|
||||||
"""v2 参数校验错误也应返回可直接展示的 message。"""
|
|
||||||
async with make_client(api_app) as client:
|
|
||||||
response = await client.get("/api/v2/validated/not-an-integer")
|
|
||||||
|
|
||||||
payload = response.json()
|
|
||||||
assert response.status_code == 422
|
|
||||||
assert payload["success"] is False
|
|
||||||
assert payload["message"]
|
|
||||||
assert payload["data"] == {}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_v2_keeps_protocol_response_unwrapped(api_app: FastAPI):
|
|
||||||
"""OpenAI 等标准协议接口应保持原始响应结构。"""
|
|
||||||
async with make_client(api_app) as client:
|
|
||||||
response = await client.get("/api/v2/openai/v1/models")
|
|
||||||
|
|
||||||
assert response.json() == {"object": "list", "data": []}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_v2_keeps_streaming_response_unwrapped(api_app: FastAPI):
|
|
||||||
"""v2 SSE 等非 JSON 响应应保持原始内容。"""
|
|
||||||
async with make_client(api_app) as client:
|
|
||||||
response = await client.get("/api/v2/events")
|
|
||||||
|
|
||||||
assert response.headers["content-type"].startswith("text/event-stream")
|
|
||||||
assert response.text == "data: ok\n\n"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_v2_keeps_openapi_schema_unwrapped(api_app: FastAPI):
|
|
||||||
"""v2 OpenAPI 文档应保持 Swagger UI 可读取的原始结构。"""
|
|
||||||
async with make_client(api_app) as client:
|
|
||||||
response = await client.get(OPENAPI_V2_PATH)
|
|
||||||
|
|
||||||
assert response.json() == {
|
|
||||||
"openapi": "3.1.0",
|
|
||||||
"info": {"title": "Test", "version": "1.0.0"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_v2_openapi_uses_response_schema(api_app: FastAPI):
|
|
||||||
"""v2 普通 JSON 接口的 OpenAPI 应声明通用 Response 模型。"""
|
|
||||||
schema = api_app.openapi()
|
|
||||||
|
|
||||||
raw_schema = schema["paths"]["/api/v2/items"]["get"]["responses"]["200"]
|
|
||||||
protocol_schema = schema["paths"]["/api/v2/openai/v1/models"]["get"]
|
|
||||||
stream_schema = schema["paths"]["/api/v2/events"]["get"]
|
|
||||||
|
|
||||||
assert raw_schema["content"]["application/json"]["schema"] == {
|
|
||||||
"$ref": "#/components/schemas/Response"
|
|
||||||
}
|
|
||||||
assert protocol_schema["responses"]["200"]["content"]["application/json"][
|
|
||||||
"schema"
|
|
||||||
] != {"$ref": "#/components/schemas/Response"}
|
|
||||||
assert stream_schema["responses"]["200"]["content"]["application/json"].get(
|
|
||||||
"schema"
|
|
||||||
) != {"$ref": "#/components/schemas/Response"}
|
|
||||||
data_schema = schema["components"]["schemas"]["Response"]["properties"]["data"]
|
|
||||||
assert data_schema["title"] == "Data"
|
|
||||||
assert {} in data_schema["anyOf"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_response_accepts_scalar_data():
|
|
||||||
"""通用 Response 的 data 应支持标量接口数据。"""
|
|
||||||
response = Response(success=True, data=1)
|
|
||||||
|
|
||||||
assert response.data == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_v2_router_reuses_v1_route_endpoints():
|
|
||||||
"""v2 路由应直接复用 v1 的端点函数,避免复制业务实现。"""
|
|
||||||
from fastapi.routing import APIRoute
|
|
||||||
|
|
||||||
from app.api.apiv1 import api_router
|
|
||||||
from app.api.apiv2 import api_router_v2
|
|
||||||
|
|
||||||
v1_routes = [route for route in api_router.routes if isinstance(route, APIRoute)]
|
|
||||||
v2_routes = [route for route in api_router_v2.routes if isinstance(route, APIRoute)]
|
|
||||||
|
|
||||||
assert len(v2_routes) == len(v1_routes)
|
|
||||||
assert all(
|
|
||||||
v2_route.path == v1_route.path
|
|
||||||
and v2_route.methods == v1_route.methods
|
|
||||||
and v2_route.endpoint is v1_route.endpoint
|
|
||||||
for v1_route, v2_route in zip(v1_routes, v2_routes)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_plugin_routes_are_mirrored_to_v2(monkeypatch):
|
|
||||||
"""插件动态路由注册和移除时应同步维护 v2 路径。"""
|
|
||||||
from app.api.endpoints import plugin as plugin_endpoint
|
|
||||||
|
|
||||||
class FakeApp:
|
|
||||||
"""记录动态注册路径的应用桩。"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.routes = []
|
|
||||||
self.openapi_schema = None
|
|
||||||
|
|
||||||
def add_api_route(self, **kwargs):
|
|
||||||
"""记录新增的路由路径。"""
|
|
||||||
self.routes.append(SimpleNamespace(path=kwargs["path"]))
|
|
||||||
|
|
||||||
def setup(self):
|
|
||||||
"""模拟 FastAPI 路由重建。"""
|
|
||||||
|
|
||||||
class FakePluginManager:
|
|
||||||
"""返回单个测试插件 API 的管理器桩。"""
|
|
||||||
|
|
||||||
def get_plugin_apis(self, plugin_id):
|
|
||||||
"""返回测试插件 API。"""
|
|
||||||
assert plugin_id == "DemoPlugin"
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"path": "/DemoPlugin/health",
|
|
||||||
"endpoint": lambda: {"ok": True},
|
|
||||||
"methods": ["GET"],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
fake_app = FakeApp()
|
|
||||||
monkeypatch.setattr(plugin_endpoint, "app", fake_app)
|
|
||||||
monkeypatch.setattr(plugin_endpoint, "PluginManager", FakePluginManager)
|
|
||||||
|
|
||||||
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="add")
|
|
||||||
|
|
||||||
assert [route.path for route in fake_app.routes] == [
|
|
||||||
"/api/v1/plugin/DemoPlugin/health",
|
|
||||||
"/api/v2/plugin/DemoPlugin/health",
|
|
||||||
]
|
|
||||||
|
|
||||||
plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="remove")
|
|
||||||
|
|
||||||
assert fake_app.routes == []
|
|
||||||
@@ -23,7 +23,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
|
|||||||
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
|
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
|
||||||
expected_versions = {
|
expected_versions = {
|
||||||
"database-operation": "4",
|
"database-operation": "4",
|
||||||
"moviepilot-api": "12",
|
"moviepilot-api": "13",
|
||||||
"moviepilot-cli": "7",
|
"moviepilot-cli": "7",
|
||||||
"moviepilot-update": "3",
|
"moviepilot-update": "3",
|
||||||
"organize-files": "3",
|
"organize-files": "3",
|
||||||
|
|||||||
@@ -115,3 +115,22 @@ async def test_get_routes_do_not_require_auth_header(cookiecloud_app, monkeypatc
|
|||||||
assert get_response.json() == {"encrypted": "payload"}
|
assert get_response.json() == {"encrypted": "payload"}
|
||||||
assert post_response.status_code == 200
|
assert post_response.status_code == 200
|
||||||
assert post_response.json() == {"encrypted": "payload"}
|
assert post_response.json() == {"encrypted": "payload"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cookiecloud_openapi_declares_native_success_models(cookiecloud_app):
|
||||||
|
"""CookieCloud 原生兼容响应也必须在 OpenAPI 中给出明确结构。"""
|
||||||
|
schema = cookiecloud_app.openapi()
|
||||||
|
|
||||||
|
root_content = schema["paths"]["/cookiecloud/"]["get"]["responses"]["200"][
|
||||||
|
"content"
|
||||||
|
]
|
||||||
|
update_schema = schema["paths"]["/cookiecloud/update"]["post"]["responses"][
|
||||||
|
"200"
|
||||||
|
]["content"]["application/json"]["schema"]
|
||||||
|
download_schema = schema["paths"]["/cookiecloud/get/{uuid}"]["get"][
|
||||||
|
"responses"
|
||||||
|
]["200"]["content"]["application/json"]["schema"]
|
||||||
|
|
||||||
|
assert root_content["text/plain"]["schema"] == {"type": "string"}
|
||||||
|
assert update_schema["$ref"].endswith("/CookieActionResponse")
|
||||||
|
assert download_schema["$ref"].endswith("/CookieEncryptedPayload")
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""API 响应中稳定集合与动态 JSON 字段的模型契约测试。"""
|
||||||
|
|
||||||
|
from app.schemas.file import FileItem, StorageTransType
|
||||||
|
from app.schemas.mediaserver import MediaServerLibrary, MediaServerPlayItem, NotExistMediaInfo
|
||||||
|
from app.schemas.plugin import Plugin, PluginDashboard
|
||||||
|
from app.schemas.site import SiteStatistic, SiteUserData
|
||||||
|
from app.schemas.subscribe import Subscribe
|
||||||
|
from app.schemas.tmdb import TmdbEpisode
|
||||||
|
from app.schemas.token import Token
|
||||||
|
from app.schemas.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def _nonnull_branch(schema: dict) -> dict:
|
||||||
|
"""返回可空字段中非 null 的 JSON Schema 分支。"""
|
||||||
|
return next(
|
||||||
|
branch
|
||||||
|
for branch in schema.get("anyOf", [schema])
|
||||||
|
if branch.get("type") != "null"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stable_collections_serialize_without_changing_payload_shape():
|
||||||
|
"""稳定集合收窄后应继续输出原有对象、数组及字段名称。"""
|
||||||
|
child = FileItem(type="file", name="episode.mkv")
|
||||||
|
file_item = FileItem(type="dir", name="Season 1", children=[child])
|
||||||
|
episode = TmdbEpisode(
|
||||||
|
episode_number=1,
|
||||||
|
crew=[{"id": 11, "name": "Writer", "job": "Writer"}],
|
||||||
|
guest_stars=[{"id": 12, "name": "Actor", "character": "Guest"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert file_item.model_dump(mode="json")["children"][0]["name"] == "episode.mkv"
|
||||||
|
assert NotExistMediaInfo(episodes=[1, 2]).model_dump()["episodes"] == [1, 2]
|
||||||
|
assert MediaServerLibrary(path=["/movies", "/tv"]).model_dump()["path"] == [
|
||||||
|
"/movies",
|
||||||
|
"/tv",
|
||||||
|
]
|
||||||
|
assert MediaServerPlayItem(BackdropImageTags=["backdrop-tag"]).model_dump()[
|
||||||
|
"BackdropImageTags"
|
||||||
|
] == ["backdrop-tag"]
|
||||||
|
assert StorageTransType(transtype={"move": "移动"}).model_dump()["transtype"] == {
|
||||||
|
"move": "移动"
|
||||||
|
}
|
||||||
|
assert episode.model_dump()["crew"][0]["job"] == "Writer"
|
||||||
|
assert episode.model_dump()["guest_stars"][0]["character"] == "Guest"
|
||||||
|
|
||||||
|
|
||||||
|
def test_site_and_subscribe_legacy_values_remain_compatible():
|
||||||
|
"""站点消息的三项、四项协议以及订阅集号列表应继续兼容。"""
|
||||||
|
userdata = SiteUserData(
|
||||||
|
seeding_info=[[3, 1024]],
|
||||||
|
message_unread_contents=[
|
||||||
|
["标题", "日期", "正文"],
|
||||||
|
["标题", "日期", "正文", "sunnypt-message:1"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = userdata.model_dump(mode="json")
|
||||||
|
assert payload["seeding_info"] == [[3, 1024]]
|
||||||
|
assert payload["message_unread_contents"] == [
|
||||||
|
["标题", "日期", "正文"],
|
||||||
|
["标题", "日期", "正文", "sunnypt-message:1"],
|
||||||
|
]
|
||||||
|
assert SiteStatistic(note={"2026-08-12 12:00:00": 2}).model_dump()["note"] == {
|
||||||
|
"2026-08-12 12:00:00": 2
|
||||||
|
}
|
||||||
|
assert Subscribe(note=[1, 2]).model_dump()["note"] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_permissions_and_extension_json_keep_values_but_have_explicit_schemas():
|
||||||
|
"""权限映射应为布尔值,扩展数据应通过递归 JSON Schema 展示合法类型。"""
|
||||||
|
permissions = {"manage": True, "search": False}
|
||||||
|
token = Token(
|
||||||
|
access_token="token",
|
||||||
|
token_type="bearer",
|
||||||
|
super_user=False,
|
||||||
|
user_id=1,
|
||||||
|
user_name="tester",
|
||||||
|
permissions=permissions,
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
name="tester",
|
||||||
|
permissions=permissions,
|
||||||
|
settings={"nickname": "测试", "layout": {"dense": True}},
|
||||||
|
)
|
||||||
|
plugin = Plugin(history={"v1.0.0": "首次发布"})
|
||||||
|
dashboard = PluginDashboard(
|
||||||
|
attrs={"class": ["pa-2", {"active": True}]},
|
||||||
|
cols={"md": 6},
|
||||||
|
elements=[{"component": "VAlert", "text": "状态正常"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert token.model_dump()["permissions"] == permissions
|
||||||
|
assert user.model_dump()["settings"]["layout"] == {"dense": True}
|
||||||
|
assert plugin.model_dump()["history"] == {"v1.0.0": "首次发布"}
|
||||||
|
assert dashboard.model_dump()["elements"][0]["component"] == "VAlert"
|
||||||
|
|
||||||
|
user_schema = User.model_json_schema()
|
||||||
|
permission_schema = _nonnull_branch(user_schema["properties"]["permissions"])
|
||||||
|
settings_schema = _nonnull_branch(user_schema["properties"]["settings"])
|
||||||
|
assert permission_schema["additionalProperties"] == {"type": "boolean"}
|
||||||
|
assert settings_schema["additionalProperties"]["$ref"].endswith("/JsonData")
|
||||||
|
|
||||||
|
|
||||||
|
def test_collection_json_schemas_define_items_or_tuple_members():
|
||||||
|
"""集合字段在 OpenAPI 生成前就应声明元素或元组成员结构。"""
|
||||||
|
file_schema = FileItem.model_json_schema()
|
||||||
|
file_item_schema = file_schema["$defs"]["FileItem"]
|
||||||
|
children_schema = _nonnull_branch(file_item_schema["properties"]["children"])
|
||||||
|
site_schema = SiteUserData.model_json_schema()
|
||||||
|
seeding_schema = _nonnull_branch(site_schema["properties"]["seeding_info"])
|
||||||
|
messages_schema = _nonnull_branch(
|
||||||
|
site_schema["properties"]["message_unread_contents"]
|
||||||
|
)
|
||||||
|
episode_schema = TmdbEpisode.model_json_schema()
|
||||||
|
crew_schema = _nonnull_branch(episode_schema["properties"]["crew"])
|
||||||
|
|
||||||
|
assert children_schema["items"]["$ref"].endswith("/FileItem")
|
||||||
|
assert len(seeding_schema["items"]["prefixItems"]) == 2
|
||||||
|
assert all(
|
||||||
|
branch.get("prefixItems")
|
||||||
|
for branch in messages_schema["items"]["anyOf"]
|
||||||
|
)
|
||||||
|
assert crew_schema["items"]["$ref"].endswith("/TmdbEpisodeCrew")
|
||||||
+16
-12
@@ -236,25 +236,28 @@ def test_locale_helper_translates_common_backend_response_messages():
|
|||||||
assert LocaleHelper.translate_text(message, locale="en-US") == expected
|
assert LocaleHelper.translate_text(message, locale="en-US") == expected
|
||||||
|
|
||||||
|
|
||||||
def test_response_auto_fills_message_i18n_from_locale_context():
|
def test_response_localizes_message_from_locale_context():
|
||||||
"""通用 Response 应根据请求语言上下文自动补充多语言消息。"""
|
"""通用 Response 应根据请求语言上下文直接翻译消息。"""
|
||||||
token = LocaleHelper.set_current_locale("en-US")
|
token = LocaleHelper.set_current_locale("en-US")
|
||||||
try:
|
try:
|
||||||
response = Response(success=False, message="模块不支持测试")
|
response = Response(success=False, message="模块不支持测试")
|
||||||
finally:
|
finally:
|
||||||
LocaleHelper.reset_current_locale(token)
|
LocaleHelper.reset_current_locale(token)
|
||||||
|
|
||||||
assert response.message == "模块不支持测试"
|
assert response.message == "Module does not support testing"
|
||||||
assert response.message_i18n == "Module does not support testing"
|
assert not hasattr(response, "message_i18n")
|
||||||
|
|
||||||
|
|
||||||
def test_http_exception_handler_returns_untranslated_response_envelope():
|
def test_http_exception_handler_returns_localized_response_envelope():
|
||||||
"""HTTPException 响应应统一封装并保留原始错误文本。"""
|
"""HTTPException 响应应统一封装并直接翻译错误文本。"""
|
||||||
token = LocaleHelper.set_current_locale("en-US")
|
token = LocaleHelper.set_current_locale("en-US")
|
||||||
try:
|
try:
|
||||||
response = asyncio.run(
|
response = asyncio.run(
|
||||||
localized_http_exception_handler(
|
localized_http_exception_handler(
|
||||||
None,
|
SimpleNamespace(
|
||||||
|
query_params={},
|
||||||
|
headers={"accept-language": "en-US"},
|
||||||
|
),
|
||||||
HTTPException(status_code=401, detail="用户名或密码错误"),
|
HTTPException(status_code=401, detail="用户名或密码错误"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -265,18 +268,19 @@ def test_http_exception_handler_returns_untranslated_response_envelope():
|
|||||||
payload = json.loads(response.body)
|
payload = json.loads(response.body)
|
||||||
assert payload == {
|
assert payload == {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "用户名或密码错误",
|
"message": "Incorrect username or password",
|
||||||
"data": {},
|
"data": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_application_docs_use_v2_openapi_and_backend_version():
|
def test_application_docs_use_v1_openapi_and_backend_version():
|
||||||
"""默认接口文档应展示 v2 地址并使用真实后端版本号。"""
|
"""默认接口文档应展示 v1 地址并使用真实后端版本号。"""
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
assert app.openapi_url == "/api/v2/openapi.json"
|
assert app.openapi_url == "/api/v1/openapi.json"
|
||||||
assert app.version == APP_VERSION
|
assert app.version == APP_VERSION
|
||||||
assert "/api/v1/openapi.json" in {route.path for route in app.routes}
|
assert "/api/v1/openapi.json" in {route.path for route in app.routes}
|
||||||
|
assert "/api/v2/openapi.json" not in {route.path for route in app.routes}
|
||||||
|
|
||||||
|
|
||||||
def test_progress_helper_get_adds_i18n_fields_without_mutating_cache():
|
def test_progress_helper_get_adds_i18n_fields_without_mutating_cache():
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ def test_login_mfa_response_contains_methods_after_password_verification(monkeyp
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert response.headers["x-mfa-required"] == "true"
|
assert response.headers["x-mfa-required"] == "true"
|
||||||
assert json.loads(response.body) == {
|
assert json.loads(response.body) == {
|
||||||
"detail": "需要二次验证",
|
"success": False,
|
||||||
"mfa_methods": ["otp"],
|
"message": "需要二次验证",
|
||||||
|
"data": {"mfa_methods": ["otp"]},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -113,5 +114,5 @@ def test_wallpaper_returns_url_in_data(monkeypatch):
|
|||||||
|
|
||||||
assert response.success is True
|
assert response.success is True
|
||||||
assert response.data == "https://images.example/wallpaper.jpg"
|
assert response.data == "https://images.example/wallpaper.jpg"
|
||||||
assert response.message is None
|
assert response.message == ""
|
||||||
assert response.message_i18n is None
|
assert not hasattr(response, "message_i18n")
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import schemas
|
||||||
|
from app.api.response import ResponseAPIRouter
|
||||||
|
from app.core.context import MediaInfo as CoreMediaInfo
|
||||||
|
from app.schemas.types import MediaSource, MediaType
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_search_response_preserves_core_collection_fields() -> None:
|
||||||
|
"""媒体搜索响应模型应保留 Core MediaInfo 合集输出的全部兼容字段。"""
|
||||||
|
media = CoreMediaInfo(tmdb_info={
|
||||||
|
"id": 42,
|
||||||
|
"media_type": MediaType.COLLECTION,
|
||||||
|
"collection_id": 42,
|
||||||
|
"name": "示例合集",
|
||||||
|
"original_name": "Example Collection",
|
||||||
|
})
|
||||||
|
media.hk_title = "香港标题"
|
||||||
|
media.tw_title = "台灣標題"
|
||||||
|
media.sg_title = "新加坡标题"
|
||||||
|
media.logo_path = "/logo.png"
|
||||||
|
media.content_rating = "PG-13"
|
||||||
|
media.season_years = {1: "2024"}
|
||||||
|
payload = media.to_dict()
|
||||||
|
assert payload["media_source"] == MediaSource.TMDB.value
|
||||||
|
|
||||||
|
router = ResponseAPIRouter()
|
||||||
|
|
||||||
|
@router.get("/media/search", response_model=schemas.MediaSearchResults)
|
||||||
|
def search_media() -> list[dict]:
|
||||||
|
"""返回代表性的 Core MediaInfo 合集序列化结果。"""
|
||||||
|
return [payload]
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
response = TestClient(app).get("/media/search")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
result = response.json()["data"][0]
|
||||||
|
assert result["hk_title"] == "香港标题"
|
||||||
|
assert result["tw_title"] == "台灣標題"
|
||||||
|
assert result["sg_title"] == "新加坡标题"
|
||||||
|
assert result["logo_path"] == "/logo.png"
|
||||||
|
assert result["content_rating"] == "PG-13"
|
||||||
|
assert result["season_years"] == {"1": "2024"}
|
||||||
|
assert "tmdb_info" in result
|
||||||
|
assert "douban_info" in result
|
||||||
|
assert "bangumi_info" in result
|
||||||
|
assert "anilist_info" in result
|
||||||
@@ -92,7 +92,7 @@ async def test_media_search_route_accepts_comma_separated_music_sources() -> Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == []
|
assert response.json() == {"success": True, "message": "", "data": []}
|
||||||
chain.async_search_music.assert_awaited_once_with(
|
chain.async_search_music.assert_awaited_once_with(
|
||||||
query="周杰伦",
|
query="周杰伦",
|
||||||
limit=30,
|
limit=30,
|
||||||
|
|||||||
+13
-3
@@ -564,10 +564,10 @@ def test_emby_tmdbid_overrides_braced_metainfo_tmdbid():
|
|||||||
assert "[tmdbid=222]" not in title
|
assert "[tmdbid=222]" not in title
|
||||||
|
|
||||||
|
|
||||||
def test_generic_media_identity_tag_is_the_only_output_contract():
|
def test_custom_identifier_uses_source_specific_id_and_returns_unified_identity():
|
||||||
"""通用标签应产生枚举来源和字符串ID,且不暴露来源专用字段。"""
|
"""自定义识别词使用专用ID语法,解析结果仍转换为统一身份。"""
|
||||||
title, metainfo = find_metainfo(
|
title, metainfo = find_metainfo(
|
||||||
"Movie {[media_source=themoviedb;media_id=550;type=movies]}"
|
"Movie {[tmdbid=550;type=movies]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert title.strip() == "Movie"
|
assert title.strip() == "Movie"
|
||||||
@@ -576,6 +576,16 @@ def test_generic_media_identity_tag_is_the_only_output_contract():
|
|||||||
assert {"tmdbid", "doubanid", "bangumiid", "anilistid"}.isdisjoint(metainfo)
|
assert {"tmdbid", "doubanid", "bangumiid", "anilistid"}.isdisjoint(metainfo)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_media_identity_is_not_custom_identifier_syntax():
|
||||||
|
"""通用身份字段不得被自定义识别词解析器接收。"""
|
||||||
|
_, metainfo = find_metainfo(
|
||||||
|
"Movie {[media_source=themoviedb;media_id=550;type=movies]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert metainfo["media_source"] is None
|
||||||
|
assert metainfo["media_id"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_metainfopath_auxiliary_chinese_stem_uses_parent_title():
|
def test_metainfopath_auxiliary_chinese_stem_uses_parent_title():
|
||||||
"""测试辅助文件名合并父目录标题与年份。"""
|
"""测试辅助文件名合并父目录标题与年份。"""
|
||||||
path = Path(
|
path = Path(
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.api.servarr import arr_router
|
||||||
|
|
||||||
|
|
||||||
|
def test_servarr_openapi_declares_native_success_models() -> None:
|
||||||
|
"""Servarr 原生兼容端点必须在 OpenAPI 中暴露具体成功响应模型。"""
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(arr_router, prefix="/api/v3")
|
||||||
|
schema = app.openapi()
|
||||||
|
|
||||||
|
expected_models = {
|
||||||
|
("/api/v3/system/status", "get"): "ServarrSystemStatus",
|
||||||
|
("/api/v3/qualityProfile", "get"): "ServarrQualityProfile",
|
||||||
|
("/api/v3/rootfolder", "get"): "ServarrRootFolder",
|
||||||
|
("/api/v3/tag", "get"): "ServarrTag",
|
||||||
|
("/api/v3/languageprofile", "get"): "ServarrLanguageProfile",
|
||||||
|
("/api/v3/movie", "post"): "ServarrIdResponse",
|
||||||
|
("/api/v3/series/lookup", "get"): "SonarrSeries",
|
||||||
|
("/api/v3/series", "put"): "ServarrIdResponse",
|
||||||
|
}
|
||||||
|
for (path, method), model_name in expected_models.items():
|
||||||
|
response_schema = schema["paths"][path][method]["responses"]["200"][
|
||||||
|
"content"
|
||||||
|
]["application/json"]["schema"]
|
||||||
|
serialized_schema = str(response_schema)
|
||||||
|
assert model_name in serialized_schema, (path, method, response_schema)
|
||||||
@@ -1,26 +1,7 @@
|
|||||||
from types import ModuleType
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.api.endpoints import system as system_endpoint
|
||||||
from app.helper.locale import LocaleHelper
|
from app.helper.locale import LocaleHelper
|
||||||
from app.testing import stub_modules
|
|
||||||
|
|
||||||
|
|
||||||
def _stub(name: str, **attrs) -> tuple:
|
|
||||||
"""构造带指定属性的占位模块,返回给 stub_modules 使用。"""
|
|
||||||
module = ModuleType(name)
|
|
||||||
for key, value in attrs.items():
|
|
||||||
setattr(module, key, value)
|
|
||||||
return name, module
|
|
||||||
|
|
||||||
|
|
||||||
class _Dummy:
|
|
||||||
"""隔离 system endpoint 导入期重依赖的占位对象。"""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def __getattr__(self, _name):
|
|
||||||
return lambda *args, **kwargs: None
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeDoubanModule:
|
class _FakeDoubanModule:
|
||||||
@@ -44,43 +25,6 @@ class _FakeModuleManager:
|
|||||||
return False, "模块不支持测试"
|
return False, "模块不支持测试"
|
||||||
|
|
||||||
|
|
||||||
_STUB_MODULES = dict([
|
|
||||||
_stub("pillow_avif"),
|
|
||||||
_stub("aiofiles"),
|
|
||||||
_stub("psutil"),
|
|
||||||
_stub("app.helper.sites", SitesHelper=_Dummy),
|
|
||||||
_stub("app.chain.media", MediaChain=_Dummy),
|
|
||||||
_stub("app.chain.mediaserver", MediaServerChain=_Dummy),
|
|
||||||
_stub("app.chain.search", SearchChain=_Dummy),
|
|
||||||
_stub("app.chain.system", SystemChain=_Dummy),
|
|
||||||
_stub("app.core.event", eventmanager=_Dummy(), Event=_Dummy, EventManager=_Dummy),
|
|
||||||
_stub("app.core.metainfo", MetaInfo=_Dummy),
|
|
||||||
_stub("app.core.module", ModuleManager=_Dummy),
|
|
||||||
_stub("app.core.security", verify_apitoken=_Dummy, verify_resource_token=_Dummy, verify_token=_Dummy),
|
|
||||||
_stub("app.db.models", User=_Dummy),
|
|
||||||
_stub("app.db.systemconfig_oper", SystemConfigOper=_Dummy),
|
|
||||||
_stub("app.db.user_oper", get_current_active_superuser=_Dummy,
|
|
||||||
get_current_active_superuser_async=_Dummy, get_current_active_user_async=_Dummy),
|
|
||||||
_stub("app.helper.image", ImageHelper=_Dummy),
|
|
||||||
_stub("app.helper.mediaserver", MediaServerHelper=_Dummy),
|
|
||||||
_stub("app.helper.message", MessageHelper=_Dummy),
|
|
||||||
_stub("app.helper.progress", ProgressHelper=_Dummy),
|
|
||||||
_stub("app.helper.rule", RuleHelper=_Dummy),
|
|
||||||
_stub("app.helper.server", MoviePilotServerHelper=_Dummy),
|
|
||||||
_stub("app.helper.system", SystemHelper=_Dummy),
|
|
||||||
_stub("app.log", logger=_Dummy(), log_settings=_Dummy(),
|
|
||||||
LogConfigModel=type("LogConfigModel", (), {})),
|
|
||||||
_stub("app.scheduler", Scheduler=_Dummy),
|
|
||||||
_stub("app.utils.crypto", HashUtils=_Dummy),
|
|
||||||
_stub("app.utils.http", RequestUtils=_Dummy, AsyncRequestUtils=_Dummy),
|
|
||||||
_stub("version", APP_VERSION="test"),
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
with stub_modules(_STUB_MODULES):
|
|
||||||
from app.api.endpoints import system as system_endpoint
|
|
||||||
|
|
||||||
|
|
||||||
def test_system_modulelist_keeps_chinese_name_and_adds_i18n_name():
|
def test_system_modulelist_keeps_chinese_name_and_adds_i18n_name():
|
||||||
"""模块列表接口应保留旧中文字段,并提供前端可用的多语言字段。"""
|
"""模块列表接口应保留旧中文字段,并提供前端可用的多语言字段。"""
|
||||||
token = LocaleHelper.set_current_locale("en-US")
|
token = LocaleHelper.set_current_locale("en-US")
|
||||||
@@ -97,8 +41,8 @@ def test_system_modulelist_keeps_chinese_name_and_adds_i18n_name():
|
|||||||
assert module["name_key"] == "system.modules.DoubanModule.name"
|
assert module["name_key"] == "system.modules.DoubanModule.name"
|
||||||
|
|
||||||
|
|
||||||
def test_system_moduletest_keeps_chinese_message_and_adds_i18n_message():
|
def test_system_moduletest_localizes_message():
|
||||||
"""模块测试接口应保留旧中文 message,并在顶层提供多语言 message_i18n。"""
|
"""模块测试接口应按当前请求语言直接返回翻译后的 message。"""
|
||||||
token = LocaleHelper.set_current_locale("en-US")
|
token = LocaleHelper.set_current_locale("en-US")
|
||||||
with patch.object(system_endpoint, "ModuleManager", return_value=_FakeModuleManager()):
|
with patch.object(system_endpoint, "ModuleManager", return_value=_FakeModuleManager()):
|
||||||
try:
|
try:
|
||||||
@@ -107,5 +51,5 @@ def test_system_moduletest_keeps_chinese_message_and_adds_i18n_message():
|
|||||||
LocaleHelper.reset_current_locale(token)
|
LocaleHelper.reset_current_locale(token)
|
||||||
|
|
||||||
assert response.success is False
|
assert response.success is False
|
||||||
assert response.message == "模块不支持测试"
|
assert response.message == "Module does not support testing"
|
||||||
assert response.message_i18n == "Module does not support testing"
|
assert not hasattr(response, "message_i18n")
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from app.core.context import MediaInfo
|
|||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
from app.core.meta import MetaMusic
|
from app.core.meta import MetaMusic
|
||||||
from app.helper.message import TemplateContextBuilder
|
from app.helper.message import TemplateContextBuilder
|
||||||
from app.schemas.types import MediaType
|
from app.modules.filemanager.transhandler import TransHandler
|
||||||
|
from app.schemas.types import MediaSource, MediaType
|
||||||
from app.schemas.tmdb import TmdbEpisode
|
from app.schemas.tmdb import TmdbEpisode
|
||||||
|
|
||||||
|
|
||||||
@@ -141,6 +142,34 @@ def test_build_preserves_special_season_context() -> None:
|
|||||||
assert context["season_year"] == "2024"
|
assert context["season_year"] == "2024"
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_rename_context_keeps_source_specific_id_variables() -> None:
|
||||||
|
"""文件重命名沿用原专用ID变量,不暴露统一身份字段。"""
|
||||||
|
mediainfo = MediaInfo(
|
||||||
|
media_source=MediaSource.AniList,
|
||||||
|
media_id="170942",
|
||||||
|
type=MediaType.TV,
|
||||||
|
title="测试动画",
|
||||||
|
tmdb_id=24680,
|
||||||
|
imdb_id="tt1234567",
|
||||||
|
douban_id="35000000",
|
||||||
|
bangumi_id=499390,
|
||||||
|
anilist_id=170942,
|
||||||
|
)
|
||||||
|
|
||||||
|
context = TransHandler.get_naming_dict(
|
||||||
|
meta=MetaInfo("Test.Show.S01E01"),
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context["tmdbid"] == 24680
|
||||||
|
assert context["imdbid"] == "tt1234567"
|
||||||
|
assert context["doubanid"] == "35000000"
|
||||||
|
assert context["bangumiid"] == 499390
|
||||||
|
assert context["anilistid"] == 170942
|
||||||
|
assert "media_source" not in context
|
||||||
|
assert "media_id" not in context
|
||||||
|
|
||||||
|
|
||||||
def test_build_exposes_music_audio_specs_for_notifications() -> None:
|
def test_build_exposes_music_audio_specs_for_notifications() -> None:
|
||||||
"""下载和整理通知上下文应包含格式化音质及可独立引用的技术参数。"""
|
"""下载和整理通知上下文应包含格式化音质及可独立引用的技术参数。"""
|
||||||
meta = MetaMusic(
|
meta = MetaMusic(
|
||||||
|
|||||||
Reference in New Issue
Block a user