mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-22 08:43:37 +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'; "
|
||||
"Lines starting with '#' are comments. "
|
||||
"The replacement target supports: "
|
||||
"{[media_source=themoviedb;media_id=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]}; "
|
||||
"{[tmdbid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]}; "
|
||||
"tmdbid may be replaced with doubanid, bangumiid, or anilistid; "
|
||||
"g is an optional episode group ID for TV recognition."
|
||||
)
|
||||
require_admin: bool = True
|
||||
|
||||
@@ -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
|
||||
@@ -13,12 +13,13 @@ from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any, AsyncIterator, Callable, Optional, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
|
||||
from fastapi import Depends, File, Form, HTTPException, Request, UploadFile, status
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.agent import MoviePilotAgent, ReplyMode, StreamingHandler, agent_manager
|
||||
from app.agent.llm.capability import AgentCapabilityManager
|
||||
from app.agent.mcp import agent_mcp_manager
|
||||
@@ -40,7 +41,7 @@ from app.helper.locale import LocaleHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import EventType, MessageChannel
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
WEB_AGENT_SESSION_PREFIX = "web-agent:"
|
||||
WEB_AGENT_SOURCE = "web-agent"
|
||||
@@ -169,7 +170,11 @@ def _ensure_superuser(user: User) -> None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
|
||||
|
||||
@router.get("/mcp/servers", summary="查询 Agent MCP 服务器配置", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/mcp/servers",
|
||||
summary="查询 Agent MCP 服务器配置",
|
||||
response_model=schemas.Response[schemas.AgentMcpServerListData],
|
||||
)
|
||||
async def list_agent_mcp_servers(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
) -> schemas.Response:
|
||||
@@ -189,7 +194,11 @@ async def list_agent_mcp_servers(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/mcp/servers", summary="保存 Agent MCP 服务器配置", response_model=schemas.Response)
|
||||
@router.post(
|
||||
"/mcp/servers",
|
||||
summary="保存 Agent MCP 服务器配置",
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
async def save_agent_mcp_servers(
|
||||
request: schemas.AgentMcpServersSaveRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
@@ -205,7 +214,11 @@ async def save_agent_mcp_servers(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/mcp/servers/test", summary="测试 Agent MCP 服务器", response_model=schemas.Response)
|
||||
@router.post(
|
||||
"/mcp/servers/test",
|
||||
summary="测试 Agent MCP 服务器",
|
||||
response_model=schemas.Response[schemas.AgentMcpServerTestResult],
|
||||
)
|
||||
async def test_agent_mcp_server(
|
||||
request: schemas.AgentMcpServerTestRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
@@ -1574,7 +1587,22 @@ def _split_web_agent_output(text: str) -> list[dict]:
|
||||
return events
|
||||
|
||||
|
||||
@router.get("/file/{file_id}", summary="下载 Web 智能助手附件")
|
||||
@router.get(
|
||||
"/file/{file_id}",
|
||||
summary="下载 Web 智能助手附件",
|
||||
response_model=None,
|
||||
response_class=FileResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Agent 附件文件",
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {"type": "string", "format": "binary"}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def download_web_agent_file(file_id: str) -> FileResponse:
|
||||
"""
|
||||
下载 Web 智能助手本轮生成的临时附件。
|
||||
@@ -1599,7 +1627,11 @@ async def download_web_agent_file(file_id: str) -> FileResponse:
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", summary="上传 Web 智能助手附件", response_model=schemas.Response)
|
||||
@router.post(
|
||||
"/upload",
|
||||
summary="上传 Web 智能助手附件",
|
||||
response_model=schemas.Response[schemas.AgentChatUploadAttachment],
|
||||
)
|
||||
async def upload_web_agent_file(
|
||||
file: UploadFile = File(...),
|
||||
session_id: Optional[str] = Form(None),
|
||||
@@ -1639,7 +1671,11 @@ async def upload_web_agent_file(
|
||||
return schemas.Response(success=True, data=attachment)
|
||||
|
||||
|
||||
@router.post("/callback", summary="Web 智能助手按钮回调", response_model=schemas.Response)
|
||||
@router.post(
|
||||
"/callback",
|
||||
summary="Web 智能助手按钮回调",
|
||||
response_model=schemas.Response[schemas.AgentWebCallbackData],
|
||||
)
|
||||
async def web_agent_callback(
|
||||
payload: schemas.AgentWebChoiceRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
@@ -1673,7 +1709,11 @@ async def web_agent_callback(
|
||||
return schemas.Response(success=True, data=result)
|
||||
|
||||
|
||||
@router.get("/commands", summary="获取 Web 智能助手可用命令", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/commands",
|
||||
summary="获取 Web 智能助手可用命令",
|
||||
response_model=schemas.Response[list[schemas.AgentWebCommandInfo]],
|
||||
)
|
||||
async def list_web_agent_commands(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
) -> schemas.Response:
|
||||
@@ -1689,7 +1729,11 @@ async def list_web_agent_commands(
|
||||
return schemas.Response(success=True, data=_build_web_agent_command_items())
|
||||
|
||||
|
||||
@router.get("/sessions", summary="获取 Agent 历史会话", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/sessions",
|
||||
summary="获取 Agent 历史会话",
|
||||
response_model=schemas.Response[list[schemas.AgentChatSessionSummary]],
|
||||
)
|
||||
async def list_agent_chat_sessions(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -1719,7 +1763,11 @@ async def list_agent_chat_sessions(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", summary="获取 Agent 历史会话详情", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/sessions/{session_id}",
|
||||
summary="获取 Agent 历史会话详情",
|
||||
response_model=schemas.Response[schemas.AgentChatSessionDetail],
|
||||
)
|
||||
async def get_agent_chat_session(
|
||||
session_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
@@ -1757,7 +1805,11 @@ async def get_agent_chat_session(
|
||||
return schemas.Response(success=True, data=data)
|
||||
|
||||
|
||||
@router.put("/sessions/{session_id}/display", summary="保存 Agent 展示会话", response_model=schemas.Response)
|
||||
@router.put(
|
||||
"/sessions/{session_id}/display",
|
||||
summary="保存 Agent 展示会话",
|
||||
response_model=schemas.Response[schemas.AgentChatSessionSummary],
|
||||
)
|
||||
async def save_agent_chat_display(
|
||||
session_id: str,
|
||||
payload: schemas.AgentChatDisplaySaveRequest,
|
||||
@@ -1795,7 +1847,11 @@ async def save_agent_chat_display(
|
||||
return schemas.Response(success=True, data=AgentChatOper.to_summary(chat))
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", summary="删除 Agent 历史会话", response_model=schemas.Response)
|
||||
@router.delete(
|
||||
"/sessions/{session_id}",
|
||||
summary="删除 Agent 历史会话",
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
async def delete_agent_chat_session(
|
||||
session_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
@@ -1817,7 +1873,11 @@ async def delete_agent_chat_session(
|
||||
return schemas.Response(success=deleted, message="删除成功" if deleted else "删除失败")
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/stop", summary="停止 Web 智能助手当前任务", response_model=schemas.Response)
|
||||
@router.post(
|
||||
"/sessions/{session_id}/stop",
|
||||
summary="停止 Web 智能助手当前任务",
|
||||
response_model=schemas.Response[schemas.AgentSessionStopData],
|
||||
)
|
||||
async def stop_web_agent_session_task(
|
||||
session_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
@@ -1848,7 +1908,18 @@ async def stop_web_agent_session_task(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stream", summary="Web智能助手流式对话")
|
||||
@router.post(
|
||||
"/stream",
|
||||
summary="Web智能助手流式对话",
|
||||
response_model=None,
|
||||
response_class=StreamingResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Agent SSE 事件流",
|
||||
"content": {"text/event-stream": {"schema": {"type": "string"}}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def web_agent_stream(
|
||||
payload: schemas.AgentWebChatRequest,
|
||||
request: Request,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import Depends, Query
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.anilist import AniListChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
PageParam = Annotated[int, Query(ge=1)]
|
||||
CountParam = Annotated[int, Query(ge=1, le=50)]
|
||||
|
||||
@@ -20,7 +20,15 @@ from app.core.config import settings
|
||||
from app.core.security import anthropic_api_key_header
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
router = APIRouter()
|
||||
ANTHROPIC_ERROR_RESPONSES = {
|
||||
400: {"model": schemas.AnthropicErrorResponse, "description": "请求格式错误"},
|
||||
401: {"model": schemas.AnthropicErrorResponse, "description": "认证失败"},
|
||||
422: {"model": schemas.AnthropicErrorResponse, "description": "请求参数校验失败"},
|
||||
500: {"model": schemas.AnthropicErrorResponse, "description": "服务内部错误"},
|
||||
503: {"model": schemas.AnthropicErrorResponse, "description": "AI Agent 不可用"},
|
||||
}
|
||||
|
||||
router = APIRouter(responses=ANTHROPIC_ERROR_RESPONSES)
|
||||
|
||||
SESSION_PREFIX = "anthropic:"
|
||||
|
||||
@@ -100,6 +108,14 @@ async def _stream_anthropic_response(
|
||||
"/messages",
|
||||
summary="Anthropic compatible messages",
|
||||
response_model=schemas.AnthropicMessagesResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "Anthropic message 或 SSE 数据流",
|
||||
"content": {
|
||||
"text/event-stream": {"schema": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def messages(
|
||||
payload: schemas.AnthropicMessagesRequest,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
|
||||
from app.core.auth import build_token_response, consume_plugin_auth_ticket
|
||||
from app.core.plugin import PluginManager
|
||||
from app.db.models.passkey import PassKey
|
||||
from app.db.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
class AuthExchangeRequest(BaseModel):
|
||||
@@ -39,7 +40,11 @@ def _system_auth_providers() -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
@router.get("/providers", summary="查询登录认证提供方", response_model=list[dict])
|
||||
@router.get(
|
||||
"/providers",
|
||||
summary="查询登录认证提供方",
|
||||
response_model=list[schemas.AuthProviderInfo],
|
||||
)
|
||||
def auth_providers() -> list[dict[str, Any]]:
|
||||
"""
|
||||
查询系统和插件提供的登录认证入口。
|
||||
@@ -51,7 +56,12 @@ def auth_providers() -> list[dict[str, Any]]:
|
||||
return [provider for provider in providers if provider.get("enabled", True)]
|
||||
|
||||
|
||||
@router.post("/exchange", summary="兑换插件认证登录票据", response_model=schemas.Token)
|
||||
@router.post(
|
||||
"/exchange",
|
||||
summary="兑换插件认证登录票据",
|
||||
response_model=schemas.Token,
|
||||
openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True},
|
||||
)
|
||||
def auth_exchange(body: AuthExchangeRequest) -> schemas.Token:
|
||||
"""
|
||||
将插件认证成功后生成的一次性票据兑换为系统 Token。
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from typing import List, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import Depends
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.dashboard import DashboardChain
|
||||
from app.chain.storage import StorageChain
|
||||
from app.core.config import settings
|
||||
@@ -16,7 +17,7 @@ from app.helper.directory import DirectoryHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
def _build_statistic(db: Session, name: Optional[str] = None) -> schemas.Statistic:
|
||||
@@ -186,7 +187,7 @@ async def schedule(_: Any = Depends(get_current_active_superuser)) -> Any:
|
||||
@router.get(
|
||||
"/schedule/{job_id}/progress",
|
||||
summary="后台服务进度",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.ScheduleProgress],
|
||||
)
|
||||
async def schedule_progress(
|
||||
job_id: str, _: Any = Depends(get_current_active_superuser)
|
||||
@@ -215,7 +216,7 @@ async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
@router.get(
|
||||
"/schedule2/{job_id}/progress",
|
||||
summary="后台服务进度(API_TOKEN)",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.ScheduleProgress],
|
||||
)
|
||||
async def schedule_progress2(
|
||||
job_id: str, _: Annotated[str, Depends(verify_apitoken)]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import Depends
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
@@ -11,7 +12,7 @@ from app.core.security import verify_token
|
||||
from app.schemas import DiscoverSourceEventData
|
||||
from app.schemas.types import ChainEventType, MediaType
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import Depends
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.schemas import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from typing import Any, List, Annotated, Optional, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from fastapi import Depends, Body
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
|
||||
@@ -24,7 +25,7 @@ from app.schemas.types import (
|
||||
from app.utils.media import is_music_media_source, normalize_music_type
|
||||
from app.utils.security import SecurityUtils
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
|
||||
@@ -62,7 +63,11 @@ def current(
|
||||
return DownloadChain().downloading(name)
|
||||
|
||||
|
||||
@router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response)
|
||||
@router.post(
|
||||
"/",
|
||||
summary="添加下载(含媒体信息)",
|
||||
response_model=schemas.Response[schemas.DownloadAddedData],
|
||||
)
|
||||
def download(
|
||||
media_in: Union[schemas.MusicInfo, schemas.MediaInfo],
|
||||
torrent_in: schemas.TorrentInfo,
|
||||
@@ -102,7 +107,9 @@ def download(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/add", summary="添加下载(不含媒体信息)", response_model=schemas.Response
|
||||
"/add",
|
||||
summary="添加下载(不含媒体信息)",
|
||||
response_model=schemas.Response[schemas.DownloadAddedData],
|
||||
)
|
||||
def add(
|
||||
torrent_in: schemas.TorrentInfo,
|
||||
@@ -185,7 +192,11 @@ def add(
|
||||
return schemas.Response(success=True, data={"download_id": did})
|
||||
|
||||
|
||||
@router.post("/subtitle", summary="下载字幕", response_model=schemas.Response)
|
||||
@router.post(
|
||||
"/subtitle",
|
||||
summary="下载字幕",
|
||||
response_model=schemas.Response[schemas.SubtitleDownloadData],
|
||||
)
|
||||
def download_subtitle(
|
||||
subtitle_in: schemas.SubtitleInfo,
|
||||
media_source: Annotated[MediaSource, Body()],
|
||||
@@ -216,7 +227,7 @@ def download_subtitle(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response)
|
||||
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response[None])
|
||||
def start(
|
||||
hashString: str,
|
||||
name: Optional[str] = None,
|
||||
@@ -229,7 +240,7 @@ def start(
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
|
||||
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response)
|
||||
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response[None])
|
||||
def stop(
|
||||
hashString: str,
|
||||
name: Optional[str] = None,
|
||||
@@ -242,7 +253,11 @@ def stop(
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
|
||||
@router.get("/clients", summary="查询可用下载器", response_model=List[dict])
|
||||
@router.get(
|
||||
"/clients",
|
||||
summary="查询可用下载器",
|
||||
response_model=List[schemas.ServiceClientInfo],
|
||||
)
|
||||
async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询可用下载器
|
||||
@@ -282,7 +297,7 @@ def paths(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
|
||||
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response[None])
|
||||
def delete(
|
||||
hashString: str,
|
||||
name: Optional[str] = None,
|
||||
|
||||
@@ -3,11 +3,12 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import List, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.agent import ReplyMode, agent_manager
|
||||
from app.agent.prompt.transfer_redo import (
|
||||
build_batch_manual_redo_prompt,
|
||||
@@ -30,7 +31,7 @@ from app.helper.progress import ProgressHelper
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.jieba import cut as jieba_cut
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
def normalize_history_ids(history_ids: list[int]) -> list[int]:
|
||||
@@ -145,7 +146,11 @@ async def download_history(
|
||||
return await DownloadHistory.async_list_by_page(db, page, count)
|
||||
|
||||
|
||||
@router.delete("/download", summary="删除下载历史记录", response_model=schemas.Response)
|
||||
@router.delete(
|
||||
"/download",
|
||||
summary="删除下载历史记录",
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
async def delete_download_history(
|
||||
history_in: schemas.DownloadHistory,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -166,7 +171,11 @@ def _glob_to_like(pattern: str) -> str:
|
||||
return result.replace("*", "%").replace("?", "_")
|
||||
|
||||
|
||||
@router.get("/transfer", summary="查询整理记录", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/transfer",
|
||||
summary="查询整理记录",
|
||||
response_model=schemas.Response[schemas.TransferHistoryPage],
|
||||
)
|
||||
async def transfer_history(
|
||||
title: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
@@ -218,7 +227,7 @@ async def transfer_history(
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/transfer", summary="删除整理记录", response_model=schemas.Response)
|
||||
@router.delete("/transfer", summary="删除整理记录", response_model=schemas.Response[None])
|
||||
def delete_transfer_history(
|
||||
history_in: schemas.TransferHistory,
|
||||
deletesrc: Optional[bool] = False,
|
||||
@@ -260,7 +269,7 @@ def delete_transfer_history(
|
||||
@router.post(
|
||||
"/transfer/{history_id}/ai-redo",
|
||||
summary="智能助手重新整理",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.ProgressKeyData],
|
||||
)
|
||||
def ai_redo_transfer_history(
|
||||
history_id: int,
|
||||
@@ -289,7 +298,9 @@ def ai_redo_transfer_history(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/transfer/ai-redo", summary="智能助手批量重新整理", response_model=schemas.Response
|
||||
"/transfer/ai-redo",
|
||||
summary="智能助手批量重新整理",
|
||||
response_model=schemas.Response[schemas.BatchProgressKeyData],
|
||||
)
|
||||
def batch_ai_redo_transfer_history(
|
||||
payload: schemas.BatchTransferHistoryRedoRequest,
|
||||
@@ -336,7 +347,11 @@ def batch_ai_redo_transfer_history(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/empty/transfer", summary="清空整理记录", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/empty/transfer",
|
||||
summary="清空整理记录",
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
async def empty_transfer_history(
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import re
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Request
|
||||
from fastapi import Body, Depends, Request, Response
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.agent.llm import (
|
||||
LLMHelper,
|
||||
LLMProviderManager,
|
||||
@@ -20,7 +21,7 @@ from app.db.user_oper import (
|
||||
)
|
||||
from app.log import logger
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
class LlmTestRequest(BaseModel):
|
||||
@@ -85,7 +86,11 @@ def _sanitize_llm_error(message: str, api_key: Optional[str] = None) -> str:
|
||||
return sanitized
|
||||
|
||||
|
||||
@router.get("/models", summary="获取LLM模型列表", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/models",
|
||||
summary="获取LLM模型列表",
|
||||
response_model=schemas.Response[schemas.LLMModelCatalogData],
|
||||
)
|
||||
async def get_llm_models(
|
||||
provider: str,
|
||||
api_key: Optional[str] = None,
|
||||
@@ -125,7 +130,11 @@ async def get_llm_models(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/providers", summary="获取LLM提供商目录", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/providers",
|
||||
summary="获取LLM提供商目录",
|
||||
response_model=schemas.Response[list[schemas.LLMProviderInfo]],
|
||||
)
|
||||
async def get_llm_providers(
|
||||
_: User = Depends(get_current_active_user_async),
|
||||
):
|
||||
@@ -142,7 +151,7 @@ async def get_llm_providers(
|
||||
@router.post(
|
||||
"/provider-auth/start",
|
||||
summary="启动LLM提供商授权",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.LLMProviderAuthSession],
|
||||
)
|
||||
async def start_llm_provider_auth(
|
||||
payload: LlmProviderAuthStartRequest,
|
||||
@@ -173,7 +182,7 @@ async def start_llm_provider_auth(
|
||||
@router.get(
|
||||
"/provider-auth/{session_id}",
|
||||
summary="获取LLM提供商授权会话状态",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.LLMProviderAuthSession],
|
||||
)
|
||||
async def get_llm_provider_auth_session(
|
||||
session_id: str,
|
||||
@@ -192,7 +201,7 @@ async def get_llm_provider_auth_session(
|
||||
@router.post(
|
||||
"/provider-auth/{session_id}/poll",
|
||||
summary="轮询LLM提供商授权会话",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.LLMProviderAuthSession],
|
||||
)
|
||||
async def poll_llm_provider_auth_session(
|
||||
session_id: str,
|
||||
@@ -211,7 +220,7 @@ async def poll_llm_provider_auth_session(
|
||||
@router.delete(
|
||||
"/provider-auth/{provider_id}",
|
||||
summary="断开LLM提供商授权",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
async def delete_llm_provider_auth(
|
||||
provider_id: str,
|
||||
@@ -230,8 +239,15 @@ async def delete_llm_provider_auth(
|
||||
@router.get(
|
||||
"/provider-auth/callback/{provider_id}",
|
||||
summary="LLM提供商OAuth回调",
|
||||
response_class=HTMLResponse,
|
||||
response_class=Response,
|
||||
name="llm_provider_auth_callback",
|
||||
response_model=None,
|
||||
responses={
|
||||
200: {
|
||||
"description": "OAuth 授权结果页面",
|
||||
"content": {"text/html": {"schema": {"type": "string"}}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def llm_provider_auth_callback(
|
||||
provider_id: str,
|
||||
@@ -253,7 +269,11 @@ async def llm_provider_auth_callback(
|
||||
return HTMLResponse(content=render_auth_result_html(success, message))
|
||||
|
||||
|
||||
@router.post("/test", summary="测试LLM调用", response_model=schemas.Response)
|
||||
@router.post(
|
||||
"/test",
|
||||
summary="测试LLM调用",
|
||||
response_model=schemas.Response[schemas.LLMTestResult],
|
||||
)
|
||||
async def llm_test(
|
||||
payload: Annotated[Optional[LlmTestRequest], Body()] = None,
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any, List, Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
|
||||
from fastapi import Depends, Form, HTTPException, Request, Response
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
|
||||
from app.chain.user import MfaRequired, UserChain
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
@@ -14,10 +15,21 @@ from app.helper.sites import SitesHelper # noqa
|
||||
from app.helper.image import WallpaperHelper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
@router.post("/access-token", summary="获取token", response_model=schemas.Token)
|
||||
@router.post(
|
||||
"/access-token",
|
||||
summary="获取token",
|
||||
response_model=schemas.Token,
|
||||
responses={
|
||||
401: {
|
||||
"model": schemas.Response[schemas.MfaChallenge],
|
||||
"description": "需要二次验证或认证失败",
|
||||
}
|
||||
},
|
||||
openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True},
|
||||
)
|
||||
def login_access_token(
|
||||
request: Request,
|
||||
response: Response,
|
||||
@@ -34,12 +46,16 @@ def login_access_token(
|
||||
if not success:
|
||||
# 只有密码已经验证通过时才返回 MFA 方法,避免泄露账号安全配置。
|
||||
if isinstance(user_or_message, MfaRequired):
|
||||
challenge = schemas.Response[schemas.MfaChallenge](
|
||||
success=False,
|
||||
message="需要二次验证",
|
||||
data=schemas.MfaChallenge(
|
||||
mfa_methods=list(user_or_message.methods)
|
||||
),
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"detail": "需要二次验证",
|
||||
"mfa_methods": list(user_or_message.methods),
|
||||
},
|
||||
content=challenge.model_dump(mode="json"),
|
||||
headers={"X-MFA-Required": "true"},
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
@@ -83,7 +99,11 @@ def login_access_token(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/wallpaper", summary="登录页面电影海报", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/wallpaper",
|
||||
summary="登录页面电影海报",
|
||||
response_model=schemas.Response[str],
|
||||
)
|
||||
def wallpaper() -> Any:
|
||||
"""
|
||||
获取登录页面电影海报
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from typing import List, Any, Dict, Annotated, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
|
||||
from app.agent.tools.manager import moviepilot_tool_manager
|
||||
from app.core.security import verify_apikey
|
||||
from app.log import logger
|
||||
@@ -14,7 +15,7 @@ try:
|
||||
except ImportError:
|
||||
APP_VERSION = "unknown"
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
# MCP 协议版本
|
||||
MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"]
|
||||
@@ -27,6 +28,15 @@ MCP_HIDDEN_TOOLS = {
|
||||
"write_file",
|
||||
"read_file",
|
||||
}
|
||||
MCP_JSONRPC_ERROR_RESPONSES = {
|
||||
400: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 请求错误"},
|
||||
401: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 认证失败"},
|
||||
403: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 访问被拒绝"},
|
||||
404: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 方法不存在"},
|
||||
409: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 请求冲突"},
|
||||
422: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 参数校验失败"},
|
||||
500: {"model": schemas.McpJsonRpcError, "description": "JSON-RPC 内部错误"},
|
||||
}
|
||||
|
||||
|
||||
def list_exposed_tools():
|
||||
@@ -66,7 +76,24 @@ def create_jsonrpc_error(
|
||||
return error
|
||||
|
||||
|
||||
@router.post("", summary="MCP JSON-RPC 端点", response_model=None)
|
||||
@router.post(
|
||||
"",
|
||||
summary="MCP JSON-RPC 端点",
|
||||
response_model=schemas.McpJsonRpcResponse,
|
||||
openapi_extra={
|
||||
RAW_RESPONSE_OPENAPI_KEY: True,
|
||||
"requestBody": {
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {"schema": schemas.MCP_JSONRPC_REQUEST_SCHEMA}
|
||||
},
|
||||
},
|
||||
},
|
||||
responses={
|
||||
**MCP_JSONRPC_ERROR_RESPONSES,
|
||||
204: {"description": "JSON-RPC 通知已接收"},
|
||||
},
|
||||
)
|
||||
async def mcp_jsonrpc(
|
||||
request: Request, _: Annotated[str, Depends(verify_apikey)] = None
|
||||
) -> Union[JSONResponse, Response]:
|
||||
@@ -111,7 +138,9 @@ async def mcp_jsonrpc(
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "initialized must be a notification"},
|
||||
content=create_jsonrpc_error(
|
||||
request_id, -32600, "initialized must be a notification"
|
||||
),
|
||||
)
|
||||
|
||||
# 处理工具列表请求
|
||||
@@ -234,7 +263,17 @@ async def handle_tools_call(params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@router.delete("", summary="终止 MCP 会话", response_model=None)
|
||||
@router.delete(
|
||||
"",
|
||||
summary="终止 MCP 会话",
|
||||
status_code=204,
|
||||
response_class=Response,
|
||||
response_model=None,
|
||||
responses={
|
||||
**MCP_JSONRPC_ERROR_RESPONSES,
|
||||
204: {"description": "MCP 会话已终止"},
|
||||
},
|
||||
)
|
||||
async def delete_mcp_session(
|
||||
_: Annotated[str, Depends(verify_apikey)] = None,
|
||||
) -> Union[JSONResponse, Response]:
|
||||
@@ -247,7 +286,11 @@ async def delete_mcp_session(
|
||||
# ==================== 兼容的 RESTful API 端点 ====================
|
||||
|
||||
|
||||
@router.get("/tools", summary="列出所有可用工具", response_model=List[Dict[str, Any]])
|
||||
@router.get(
|
||||
"/tools",
|
||||
summary="列出所有可用工具",
|
||||
response_model=List[schemas.McpToolInfo],
|
||||
)
|
||||
async def list_tools(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"""
|
||||
获取所有可用的工具列表
|
||||
@@ -274,7 +317,11 @@ async def list_tools(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
raise HTTPException(status_code=500, detail=f"获取工具列表失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/tools/call", summary="调用工具", response_model=schemas.ToolCallResponse)
|
||||
@router.post(
|
||||
"/tools/call",
|
||||
summary="调用工具",
|
||||
response_model=schemas.Response[schemas.ToolCallData],
|
||||
)
|
||||
async def call_tool(
|
||||
request: schemas.ToolCallRequest, _: Annotated[str, Depends(verify_apikey)] = None
|
||||
) -> Any:
|
||||
@@ -292,13 +339,20 @@ async def call_tool(
|
||||
request.tool_name, request.arguments
|
||||
)
|
||||
|
||||
return schemas.ToolCallResponse(success=True, result=result_text)
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
data=schemas.ToolCallData(result=result_text),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"调用工具 {request.tool_name} 失败: {e}", exc_info=True)
|
||||
return schemas.ToolCallResponse(success=False, error=f"调用工具失败: {str(e)}")
|
||||
return schemas.Response(success=False, message="调用工具失败")
|
||||
|
||||
|
||||
@router.get("/tools/{tool_name}", summary="获取工具详情", response_model=Dict[str, Any])
|
||||
@router.get(
|
||||
"/tools/{tool_name}",
|
||||
summary="获取工具详情",
|
||||
response_model=schemas.McpToolInfo,
|
||||
)
|
||||
async def get_tool_info(
|
||||
tool_name: str, _: Annotated[str, Depends(verify_apikey)]
|
||||
) -> Any:
|
||||
@@ -332,7 +386,7 @@ async def get_tool_info(
|
||||
@router.get(
|
||||
"/tools/{tool_name}/schema",
|
||||
summary="获取工具参数Schema",
|
||||
response_model=Dict[str, Any],
|
||||
response_model=schemas.McpJsonSchema,
|
||||
)
|
||||
async def get_tool_schema(
|
||||
tool_name: str, _: Annotated[str, Depends(verify_apikey)]
|
||||
|
||||
@@ -2,10 +2,11 @@ from pathlib import Path
|
||||
from typing import Annotated, Any, List, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import Depends, Query
|
||||
from pydantic import BeforeValidator
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.scraping import ScrapingChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
@@ -27,7 +28,7 @@ from app.utils.media import (
|
||||
resolve_media_identity,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
def _split_media_source_query(value: object) -> tuple[str, ...]:
|
||||
@@ -235,7 +236,11 @@ async def recognize_file2(
|
||||
return await recognize_file(path, media_source)
|
||||
|
||||
|
||||
@router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict])
|
||||
@router.get(
|
||||
"/search",
|
||||
summary="搜索媒体/人物信息",
|
||||
response_model=schemas.MediaSearchResults,
|
||||
)
|
||||
async def search(
|
||||
title: str,
|
||||
type: Optional[str] = "media",
|
||||
@@ -316,7 +321,7 @@ async def search(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response
|
||||
"/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response[None]
|
||||
)
|
||||
def scrape(
|
||||
fileitem: schemas.FileItem,
|
||||
@@ -426,7 +431,9 @@ def scrape(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/category/config", summary="获取分类策略配置", response_model=schemas.Response
|
||||
"/category/config",
|
||||
summary="获取分类策略配置",
|
||||
response_model=schemas.Response[schemas.CategoryConfig],
|
||||
)
|
||||
def get_category_config(_: User = Depends(get_current_active_user)):
|
||||
"""
|
||||
@@ -437,7 +444,7 @@ def get_category_config(_: User = Depends(get_current_active_user)):
|
||||
|
||||
|
||||
@router.post(
|
||||
"/category/config", summary="保存分类策略配置", response_model=schemas.Response
|
||||
"/category/config", summary="保存分类策略配置", response_model=schemas.Response[None]
|
||||
)
|
||||
def save_category_config(
|
||||
config: CategoryConfig, _: User = Depends(get_current_active_superuser)
|
||||
@@ -451,7 +458,11 @@ def save_category_config(
|
||||
return schemas.Response(success=False, message="保存失败")
|
||||
|
||||
|
||||
@router.get("/category", summary="查询自动分类配置", response_model=dict)
|
||||
@router.get(
|
||||
"/category",
|
||||
summary="查询自动分类配置",
|
||||
response_model=schemas.MediaCategoryMap,
|
||||
)
|
||||
async def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询自动分类配置
|
||||
@@ -479,7 +490,11 @@ async def group_seasons(
|
||||
return await TmdbChain().async_tmdb_group_seasons(group_id=normalized_group_id)
|
||||
|
||||
|
||||
@router.get("/groups/{tmdbid}", summary="查询媒体剧集组", response_model=List[dict])
|
||||
@router.get(
|
||||
"/groups/{tmdbid}",
|
||||
summary="查询媒体剧集组",
|
||||
response_model=List[schemas.MediaEpisodeGroup],
|
||||
)
|
||||
async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询媒体剧集组列表(themoviedb)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from typing import Any, List, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.core.context import MediaInfo
|
||||
@@ -18,7 +19,7 @@ from app.schemas import MediaType, NotExistMediaInfo
|
||||
from app.schemas.types import MediaSource, SystemConfigKey
|
||||
from app.utils.media import build_media_key, resolve_media_identity
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
def _require_mediaserver_result(result: Optional[List[Any]]) -> List[Any]:
|
||||
@@ -33,7 +34,11 @@ def _require_mediaserver_result(result: Optional[List[Any]]) -> List[Any]:
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/play/{itemid:path}", summary="在线播放")
|
||||
@router.get(
|
||||
"/play/{itemid:path}",
|
||||
summary="在线播放",
|
||||
response_model=schemas.Response[schemas.MediaServerPlayData],
|
||||
)
|
||||
def play_item(
|
||||
itemid: str, _: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> schemas.Response:
|
||||
@@ -64,7 +69,9 @@ def play_item(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/exists", summary="查询本地是否存在(数据库)", response_model=schemas.Response
|
||||
"/exists",
|
||||
summary="查询本地是否存在(数据库)",
|
||||
response_model=schemas.Response[schemas.MediaServerExistsData],
|
||||
)
|
||||
async def exists_local(
|
||||
title: Optional[str] = None,
|
||||
@@ -106,7 +113,7 @@ async def exists_local(
|
||||
@router.post(
|
||||
"/exists_remote",
|
||||
summary="查询已存在的剧集信息(媒体服务器)",
|
||||
response_model=Dict[int, list],
|
||||
response_model=schemas.MediaServerExistingEpisodes,
|
||||
)
|
||||
def exists(
|
||||
media_in: schemas.MediaInfo, _: schemas.TokenPayload = Depends(verify_token)
|
||||
@@ -225,7 +232,11 @@ def library(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/clients", summary="查询可用媒体服务器", response_model=List[dict])
|
||||
@router.get(
|
||||
"/clients",
|
||||
summary="查询可用媒体服务器",
|
||||
response_model=List[schemas.ServiceClientInfo],
|
||||
)
|
||||
async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询可用媒体服务器
|
||||
|
||||
@@ -2,12 +2,13 @@ import json
|
||||
import time
|
||||
from typing import Union, Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||
from fastapi import BackgroundTasks, Depends, Request
|
||||
from pywebpush import WebPushException, webpush
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.message import MessageChain
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.security import verify_token, verify_apitoken
|
||||
@@ -22,7 +23,7 @@ from app.log import logger
|
||||
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
|
||||
from app.schemas.types import MessageChannel, SystemConfigKey
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
def _normalize_notification_clear_timestamp(value: Any) -> int:
|
||||
@@ -69,7 +70,7 @@ def start_message_chain(body: Any, form: Any, args: Any):
|
||||
MessageChain().process(body=body, form=form, args=args)
|
||||
|
||||
|
||||
@router.post("/", summary="接收用户消息", response_model=schemas.Response)
|
||||
@router.post("/", summary="接收用户消息", response_model=schemas.Response[None])
|
||||
async def user_message(
|
||||
background_tasks: BackgroundTasks,
|
||||
request: Request,
|
||||
@@ -109,7 +110,7 @@ async def user_message(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post("/web", summary="接收WEB消息", response_model=schemas.Response)
|
||||
@router.post("/web", summary="接收WEB消息", response_model=schemas.Response[None])
|
||||
async def web_message(
|
||||
request: Request,
|
||||
text: Optional[str] = None,
|
||||
@@ -148,7 +149,7 @@ async def web_message(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/web", summary="获取WEB消息", response_model=List[dict])
|
||||
@router.get("/web", summary="获取WEB消息", response_model=List[schemas.WebMessageItem])
|
||||
async def get_web_message(
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -190,7 +191,11 @@ async def get_notification_message(
|
||||
return [schemas.NotificationHistoryItem(**message.to_dict()) for message in messages]
|
||||
|
||||
|
||||
@router.delete("/notification", summary="清理通知消息", response_model=schemas.Response)
|
||||
@router.delete(
|
||||
"/notification",
|
||||
summary="清理通知消息",
|
||||
response_model=schemas.Response[schemas.NotificationClearData],
|
||||
)
|
||||
async def clear_notification_message(
|
||||
scope: schemas.NotificationClearScope = schemas.NotificationClearScope.All,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
@@ -260,7 +265,25 @@ def vocechat_verify() -> Any:
|
||||
return {"status": "OK"}
|
||||
|
||||
|
||||
@router.get("/", summary="回调请求验证")
|
||||
@router.get(
|
||||
"/",
|
||||
summary="回调请求验证",
|
||||
response_model=None,
|
||||
responses={
|
||||
200: {
|
||||
"description": "消息平台原生验证响应",
|
||||
"content": {
|
||||
"text/plain": {"schema": {"type": "string"}},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"status": {"type": "string"}},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
def incoming_verify(
|
||||
token: Optional[str] = None,
|
||||
echostr: Optional[str] = None,
|
||||
@@ -285,7 +308,7 @@ def incoming_verify(
|
||||
@router.post(
|
||||
"/webpush/subscribe",
|
||||
summary="客户端webpush通知订阅",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
async def subscribe(
|
||||
subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)
|
||||
@@ -300,7 +323,7 @@ async def subscribe(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webpush/send", summary="发送webpush通知", response_model=schemas.Response
|
||||
"/webpush/send", summary="发送webpush通知", response_model=schemas.Response[None]
|
||||
)
|
||||
def send_notification(
|
||||
payload: schemas.SubscriptionMessage,
|
||||
|
||||
@@ -7,10 +7,11 @@ from datetime import timedelta
|
||||
from typing import Any, Annotated, Optional
|
||||
|
||||
from app.helper.sites import SitesHelper
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body, Request, Response
|
||||
from fastapi import Depends, HTTPException, Body, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.db import get_async_db
|
||||
@@ -28,7 +29,7 @@ from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.otp import OtpUtils
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
# ==================== 辅助函数 ====================
|
||||
|
||||
@@ -117,7 +118,7 @@ class PassKeyDeleteRequest(schemas.BaseModel):
|
||||
@router.get(
|
||||
"/status/{username}",
|
||||
summary="判断用户是否开启二次验证",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.MfaStatusData],
|
||||
)
|
||||
async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> Any:
|
||||
"""
|
||||
@@ -125,19 +126,21 @@ async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) ->
|
||||
"""
|
||||
user: User = await User.async_get_by_name(db, username)
|
||||
if not user:
|
||||
return schemas.Response(success=False)
|
||||
return schemas.Response(success=False, message="用户不存在")
|
||||
|
||||
# 检查是否启用了OTP
|
||||
has_otp = user.is_otp
|
||||
|
||||
return schemas.Response(success=has_otp)
|
||||
return schemas.Response(success=True, data={"enabled": bool(has_otp)})
|
||||
|
||||
|
||||
# ==================== OTP 相关接口 ====================
|
||||
|
||||
|
||||
@router.post(
|
||||
"/otp/generate", summary="生成 OTP 验证 URI", response_model=schemas.Response
|
||||
"/otp/generate",
|
||||
summary="生成 OTP 验证 URI",
|
||||
response_model=schemas.Response[schemas.OtpGenerateData],
|
||||
)
|
||||
def otp_generate(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
@@ -147,7 +150,7 @@ def otp_generate(
|
||||
return schemas.Response(success=secret != "", data={"secret": secret, "uri": uri})
|
||||
|
||||
|
||||
@router.post("/otp/verify", summary="绑定并验证 OTP", response_model=schemas.Response)
|
||||
@router.post("/otp/verify", summary="绑定并验证 OTP", response_model=schemas.Response[None])
|
||||
async def otp_verify(
|
||||
data: OtpVerifyRequest,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -163,7 +166,9 @@ async def otp_verify(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/otp/disable", summary="关闭当前用户的 OTP 验证", response_model=schemas.Response
|
||||
"/otp/disable",
|
||||
summary="关闭当前用户的 OTP 验证",
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
async def otp_disable(
|
||||
data: OtpDisableRequest,
|
||||
@@ -190,7 +195,7 @@ class PassKeyRegistrationStart(schemas.BaseModel):
|
||||
class PassKeyRegistrationFinish(schemas.BaseModel):
|
||||
"""PassKey注册完成请求"""
|
||||
|
||||
credential: dict
|
||||
credential: dict[str, schemas.JsonData]
|
||||
transaction_token: str
|
||||
name: str = "通行密钥"
|
||||
|
||||
@@ -204,14 +209,14 @@ class PassKeyAuthenticationStart(schemas.BaseModel):
|
||||
class PassKeyAuthenticationFinish(schemas.BaseModel):
|
||||
"""PassKey认证完成请求"""
|
||||
|
||||
credential: dict
|
||||
credential: dict[str, schemas.JsonData]
|
||||
transaction_token: str
|
||||
|
||||
|
||||
@router.post(
|
||||
"/passkey/register/start",
|
||||
summary="开始注册 PassKey",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.PasskeyStartData],
|
||||
)
|
||||
def passkey_register_start(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
@@ -251,7 +256,7 @@ def passkey_register_start(
|
||||
@router.post(
|
||||
"/passkey/register/finish",
|
||||
summary="完成注册 PassKey",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
def passkey_register_finish(
|
||||
passkey_req: PassKeyRegistrationFinish,
|
||||
@@ -318,7 +323,7 @@ def passkey_register_finish(
|
||||
@router.post(
|
||||
"/passkey/authenticate/start",
|
||||
summary="开始 PassKey 认证",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.PasskeyStartData],
|
||||
)
|
||||
def passkey_authenticate_start(
|
||||
passkey_req: PassKeyAuthenticationStart = Body(...),
|
||||
@@ -364,6 +369,7 @@ def passkey_authenticate_start(
|
||||
"/passkey/authenticate/finish",
|
||||
summary="完成 PassKey 认证",
|
||||
response_model=schemas.Token,
|
||||
openapi_extra={RAW_RESPONSE_OPENAPI_KEY: True},
|
||||
)
|
||||
def passkey_authenticate_finish(
|
||||
request: Request, response: Response, passkey_req: PassKeyAuthenticationFinish
|
||||
@@ -453,7 +459,7 @@ def passkey_authenticate_finish(
|
||||
@router.get(
|
||||
"/passkey/list",
|
||||
summary="获取当前用户的 PassKey 列表",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[list[schemas.PasskeyInfo]],
|
||||
)
|
||||
def passkey_list(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
@@ -486,7 +492,11 @@ def passkey_list(
|
||||
return schemas.Response(success=False, message=f"获取列表失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/passkey/delete", summary="删除 PassKey", response_model=schemas.Response)
|
||||
@router.post(
|
||||
"/passkey/delete",
|
||||
summary="删除 PassKey",
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
async def passkey_delete(
|
||||
data: PassKeyDeleteRequest,
|
||||
current_user: User = Depends(get_current_active_user_async),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
@@ -17,7 +18,7 @@ from app.modules.listenbrainz import (
|
||||
)
|
||||
from app.modules.musicbrainz.music_cache import MusicBrainzCache
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
CountParam = Annotated[int, Query(ge=1, le=100)]
|
||||
PageParam = Annotated[int, Query(ge=1)]
|
||||
@@ -107,7 +108,9 @@ async def recognize_music(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/cache", summary="查询音乐识别缓存", response_model=schemas.Response
|
||||
"/cache",
|
||||
summary="查询音乐识别缓存",
|
||||
response_model=schemas.Response[schemas.MusicRecognitionCacheData],
|
||||
)
|
||||
async def music_recognition_cache(
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
@@ -129,7 +132,7 @@ async def music_recognition_cache(
|
||||
@router.delete(
|
||||
"/cache/{cache_key:path}",
|
||||
summary="删除指定音乐识别缓存",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
async def delete_music_recognition_cache(
|
||||
cache_key: str,
|
||||
@@ -143,7 +146,7 @@ async def delete_music_recognition_cache(
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/cache", summary="清空音乐识别缓存", response_model=schemas.Response
|
||||
"/cache", summary="清空音乐识别缓存", response_model=schemas.Response[None]
|
||||
)
|
||||
async def clear_music_recognition_cache(
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import Depends
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.core.module import ModuleManager
|
||||
from app.db.models import User
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.modules.wechatclawbot.wechatclawbot import WechatClawBot
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
def _build_wechatclawbot_temp_client(
|
||||
@@ -84,7 +85,7 @@ def _get_wechatclawbot_client(
|
||||
@router.get(
|
||||
"/wechatclawbot/status",
|
||||
summary="查询微信 ClawBot 登录状态",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.WechatClawBotData],
|
||||
)
|
||||
def wechatclawbot_status(
|
||||
source: Optional[str] = None,
|
||||
@@ -121,7 +122,7 @@ def wechatclawbot_status(
|
||||
@router.post(
|
||||
"/wechatclawbot/refresh",
|
||||
summary="刷新微信 ClawBot 二维码",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.WechatClawBotData],
|
||||
)
|
||||
def refresh_wechatclawbot_qrcode(
|
||||
source: Optional[str] = None,
|
||||
@@ -155,7 +156,7 @@ def refresh_wechatclawbot_qrcode(
|
||||
@router.post(
|
||||
"/wechatclawbot/logout",
|
||||
summary="退出微信 ClawBot 登录",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.WechatClawBotData],
|
||||
)
|
||||
def logout_wechatclawbot(
|
||||
source: Optional[str] = None,
|
||||
@@ -189,7 +190,7 @@ def logout_wechatclawbot(
|
||||
@router.get(
|
||||
"/wechatclawbot/test",
|
||||
summary="测试微信 ClawBot 连通性",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
def test_wechatclawbot(
|
||||
source: Optional[str] = None,
|
||||
@@ -219,7 +220,7 @@ def test_wechatclawbot(
|
||||
@router.post(
|
||||
"/wechatclawbot/migrate",
|
||||
summary="迁移微信 ClawBot 登录缓存",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
def migrate_wechatclawbot_cache(
|
||||
old_source: str,
|
||||
|
||||
@@ -20,7 +20,15 @@ from app.core.config import settings
|
||||
from app.core.security import openai_bearer_scheme
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
router = APIRouter()
|
||||
OPENAI_ERROR_RESPONSES = {
|
||||
400: {"model": schemas.OpenAIErrorResponse, "description": "请求格式错误"},
|
||||
401: {"model": schemas.OpenAIErrorResponse, "description": "认证失败"},
|
||||
422: {"model": schemas.OpenAIErrorResponse, "description": "请求参数校验失败"},
|
||||
500: {"model": schemas.OpenAIErrorResponse, "description": "服务内部错误"},
|
||||
503: {"model": schemas.OpenAIErrorResponse, "description": "AI Agent 不可用"},
|
||||
}
|
||||
|
||||
router = APIRouter(responses=OPENAI_ERROR_RESPONSES)
|
||||
|
||||
MODEL_ID = "moviepilot-agent"
|
||||
SESSION_PREFIX = "openai:"
|
||||
@@ -274,6 +282,14 @@ async def list_models(
|
||||
"/chat/completions",
|
||||
summary="OpenAI compatible chat completions",
|
||||
response_model=schemas.OpenAIChatCompletionResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "OpenAI chat completion 或 SSE 数据流",
|
||||
"content": {
|
||||
"text/event-stream": {"schema": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def chat_completions(
|
||||
payload: schemas.OpenAIChatCompletionsRequest,
|
||||
|
||||
@@ -5,13 +5,13 @@ from typing import Annotated, Any, Dict, List, Optional
|
||||
|
||||
import aiofiles
|
||||
from anyio import Path as AsyncPath
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Security
|
||||
from fastapi import Depends, Header, HTTPException, Security
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from starlette import status
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from app import schemas
|
||||
from app.api.apiv2_utils import API_V2_STR, OPENAPI_V2_PATH
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.command import Command
|
||||
from app.core.cache import async_fresh
|
||||
from app.core.config import settings
|
||||
@@ -39,15 +39,13 @@ from app.schemas.types import ChainEventType, SystemConfigKey
|
||||
|
||||
PROTECTED_ROUTES = {
|
||||
"/api/v1/openapi.json",
|
||||
OPENAPI_V2_PATH,
|
||||
"/docs",
|
||||
"/docs/oauth2-redirect",
|
||||
"/redoc",
|
||||
}
|
||||
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
||||
PLUGIN_V2_PREFIX = f"{API_V2_STR}/plugin"
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
_plugin_release_refresh_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
@@ -166,11 +164,8 @@ def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
|
||||
elif Depends(verify_apikey) not in dependencies:
|
||||
dependencies.append(Depends(verify_apikey))
|
||||
app.add_api_route(**api, tags=["plugin"])
|
||||
v2_api = api.copy()
|
||||
v2_api["path"] = api_path.replace(PLUGIN_PREFIX, PLUGIN_V2_PREFIX, 1)
|
||||
app.add_api_route(**v2_api, tags=["plugin"])
|
||||
is_modified = True
|
||||
logger.debug(f"Added plugin routes: {api_path}, {v2_api['path']}")
|
||||
logger.debug(f"Added plugin route: {api_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding plugin route {api_path}: {str(e)}")
|
||||
|
||||
@@ -188,12 +183,9 @@ def _remove_routes(plugin_id: str) -> bool:
|
||||
"""
|
||||
if not plugin_id:
|
||||
return False
|
||||
prefixes = {
|
||||
f"{PLUGIN_PREFIX}/{plugin_id}/",
|
||||
f"{PLUGIN_V2_PREFIX}/{plugin_id}/",
|
||||
}
|
||||
prefix = f"{PLUGIN_PREFIX}/{plugin_id}/"
|
||||
routes_to_remove = [
|
||||
route for route in app.routes if any(route.path.startswith(prefix) for prefix in prefixes)
|
||||
route for route in app.routes if route.path.startswith(prefix)
|
||||
]
|
||||
removed = False
|
||||
for route in routes_to_remove:
|
||||
@@ -425,7 +417,11 @@ async def plugin_history(
|
||||
return plugin
|
||||
|
||||
|
||||
@router.get("/releases/{plugin_id}", summary="获取插件Release版本", response_model=dict)
|
||||
@router.get(
|
||||
"/releases/{plugin_id}",
|
||||
summary="获取插件Release版本",
|
||||
response_model=schemas.PluginReleaseData,
|
||||
)
|
||||
async def plugin_releases(
|
||||
plugin_id: str,
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
@@ -484,7 +480,11 @@ async def plugin_releases(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/statistic", summary="插件安装统计", response_model=dict)
|
||||
@router.get(
|
||||
"/statistic",
|
||||
summary="插件安装统计",
|
||||
response_model=schemas.JsonObject,
|
||||
)
|
||||
async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
插件安装统计
|
||||
@@ -495,7 +495,7 @@ async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
@router.get(
|
||||
"/rating",
|
||||
summary="批量查询插件评分",
|
||||
response_model=Dict[str, schemas.PluginRating],
|
||||
response_model=schemas.PluginRatingMap,
|
||||
)
|
||||
async def plugin_ratings(
|
||||
plugin_ids: Optional[str] = None,
|
||||
@@ -531,7 +531,7 @@ async def plugin_rating(
|
||||
@router.post(
|
||||
"/rating/{plugin_id}",
|
||||
summary="提交插件评分",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.PluginRating],
|
||||
)
|
||||
async def rate_plugin(
|
||||
plugin_id: str,
|
||||
@@ -558,7 +558,7 @@ async def rate_plugin(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response
|
||||
"/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response[None]
|
||||
)
|
||||
def reload_plugin(
|
||||
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
||||
@@ -573,7 +573,7 @@ def reload_plugin(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
|
||||
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response[None])
|
||||
async def install(
|
||||
plugin_id: str,
|
||||
repo_url: Optional[str] = "",
|
||||
@@ -623,7 +623,11 @@ async def install(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/remotes", summary="获取插件联邦组件列表", response_model=List[dict])
|
||||
@router.get(
|
||||
"/remotes",
|
||||
summary="获取插件联邦组件列表",
|
||||
response_model=List[schemas.PluginRemoteInfo],
|
||||
)
|
||||
async def remotes(token: str) -> Any:
|
||||
"""
|
||||
获取插件联邦组件列表
|
||||
@@ -645,7 +649,11 @@ def plugin_sidebar_nav(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
return PluginManager().get_plugin_sidebar_nav()
|
||||
|
||||
|
||||
@router.get("/form/{plugin_id}", summary="获取插件表单页面")
|
||||
@router.get(
|
||||
"/form/{plugin_id}",
|
||||
summary="获取插件表单页面",
|
||||
response_model=schemas.JsonObject,
|
||||
)
|
||||
def plugin_form(
|
||||
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
||||
) -> dict:
|
||||
@@ -677,7 +685,11 @@ def plugin_form(
|
||||
return {}
|
||||
|
||||
|
||||
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
|
||||
@router.get(
|
||||
"/page/{plugin_id}",
|
||||
summary="获取插件数据页面",
|
||||
response_model=schemas.JsonObject,
|
||||
)
|
||||
def plugin_page(
|
||||
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
||||
) -> dict:
|
||||
@@ -701,7 +713,11 @@ def plugin_page(
|
||||
return {}
|
||||
|
||||
|
||||
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
|
||||
@router.get(
|
||||
"/dashboard/meta",
|
||||
summary="获取所有插件仪表板元信息",
|
||||
response_model=List[schemas.PluginDashboardMetaItem],
|
||||
)
|
||||
def plugin_dashboard_meta(
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
) -> List[dict]:
|
||||
@@ -737,7 +753,7 @@ def plugin_dashboard(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response
|
||||
"/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response[None]
|
||||
)
|
||||
def reset_plugin(
|
||||
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
||||
@@ -761,7 +777,24 @@ def reset_plugin(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件")
|
||||
@router.get(
|
||||
"/file/{plugin_id}/{filepath:path}",
|
||||
summary="获取插件静态文件",
|
||||
response_model=None,
|
||||
response_class=StreamingResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "插件静态资源",
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {"type": "string", "format": "binary"}
|
||||
},
|
||||
"application/javascript": {"schema": {"type": "string"}},
|
||||
"text/css": {"schema": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def plugin_static_file(
|
||||
plugin_id: str,
|
||||
filepath: str,
|
||||
@@ -839,7 +872,11 @@ async def plugin_static_file(
|
||||
raise HTTPException(status_code=500, detail="Internal Server Error")
|
||||
|
||||
|
||||
@router.get("/folders", summary="获取插件文件夹配置", response_model=dict)
|
||||
@router.get(
|
||||
"/folders",
|
||||
summary="获取插件文件夹配置",
|
||||
response_model=schemas.PluginFoldersData,
|
||||
)
|
||||
async def get_plugin_folders(
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
) -> dict:
|
||||
@@ -854,7 +891,7 @@ async def get_plugin_folders(
|
||||
return {}
|
||||
|
||||
|
||||
@router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response)
|
||||
@router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response[None])
|
||||
async def save_plugin_folders(
|
||||
folders: dict, _: User = Depends(get_current_active_superuser_async)
|
||||
) -> Any:
|
||||
@@ -870,7 +907,7 @@ async def save_plugin_folders(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response
|
||||
"/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response[None]
|
||||
)
|
||||
async def create_plugin_folder(
|
||||
folder_name: str, _: User = Depends(get_current_active_superuser_async)
|
||||
@@ -890,7 +927,7 @@ async def create_plugin_folder(
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response
|
||||
"/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response[None]
|
||||
)
|
||||
async def delete_plugin_folder(
|
||||
folder_name: str, _: User = Depends(get_current_active_superuser_async)
|
||||
@@ -912,7 +949,7 @@ async def delete_plugin_folder(
|
||||
@router.put(
|
||||
"/folders/{folder_name}/plugins",
|
||||
summary="更新文件夹中的插件",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
async def update_folder_plugins(
|
||||
folder_name: str,
|
||||
@@ -931,7 +968,7 @@ async def update_folder_plugins(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response
|
||||
"/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response[None]
|
||||
)
|
||||
def clone_plugin(
|
||||
plugin_id: str, clone_data: dict, _: User = Depends(get_current_active_superuser)
|
||||
@@ -962,7 +999,11 @@ def clone_plugin(
|
||||
return schemas.Response(success=False, message=f"创建插件分身失败:{str(e)}")
|
||||
|
||||
|
||||
@router.get("/{plugin_id}", summary="获取插件配置")
|
||||
@router.get(
|
||||
"/{plugin_id}",
|
||||
summary="获取插件配置",
|
||||
response_model=schemas.JsonObject,
|
||||
)
|
||||
async def plugin_config(
|
||||
plugin_id: str, _: User = Depends(get_current_active_superuser_async)
|
||||
) -> dict:
|
||||
@@ -972,7 +1013,7 @@ async def plugin_config(
|
||||
return PluginManager().get_plugin_config(plugin_id)
|
||||
|
||||
|
||||
@router.put("/{plugin_id}", summary="更新插件配置", response_model=schemas.Response)
|
||||
@router.put("/{plugin_id}", summary="更新插件配置", response_model=schemas.Response[None])
|
||||
def set_plugin_config(
|
||||
plugin_id: str, conf: dict, _: User = Depends(get_current_active_superuser)
|
||||
) -> Any:
|
||||
@@ -989,7 +1030,7 @@ def set_plugin_config(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response)
|
||||
@router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response[None])
|
||||
def uninstall_plugin(
|
||||
plugin_id: str, _: User = Depends(get_current_active_superuser)
|
||||
) -> Any:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from typing import Any, Awaitable, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, status
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
@@ -10,7 +11,7 @@ from app.modules.themoviedb.tmdbv3api.exceptions import TMDbException
|
||||
from app.schemas import RecommendSourceEventData
|
||||
from app.schemas.types import ChainEventType
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
async def _require_tmdb_result(operation: Awaitable[List[Any]]) -> List[Any]:
|
||||
|
||||
@@ -4,10 +4,11 @@ import time
|
||||
from typing import Any, AsyncIterator, Iterator, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, Body, Request
|
||||
from fastapi import Depends, Body, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.search import SearchChain
|
||||
from app.core.security import verify_resource_token, verify_token
|
||||
from app.helper.locale import LocaleHelper
|
||||
@@ -16,7 +17,7 @@ from app.schemas.types import MediaSource, MediaType
|
||||
from app.utils.media import normalize_music_type, resolve_media_identity
|
||||
from app.utils.security import SecurityUtils
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
_SSE_APPEND_FLUSH_INTERVAL = 1
|
||||
_SSE_APPEND_MAX_ITEMS = 48
|
||||
@@ -330,7 +331,11 @@ async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
return [torrent.to_dict() for torrent in torrents]
|
||||
|
||||
|
||||
@router.get("/last/context", summary="查询上次搜索上下文", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/last/context",
|
||||
summary="查询上次搜索上下文",
|
||||
response_model=schemas.Response[schemas.SearchLastContextData],
|
||||
)
|
||||
async def search_latest_context(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询上次搜索结果及其对应的搜索参数。
|
||||
@@ -352,7 +357,18 @@ async def search_latest_context(_: schemas.TokenPayload = Depends(verify_token))
|
||||
)
|
||||
|
||||
|
||||
@router.get("/media/{media_id}/stream", summary="渐进式精确搜索资源")
|
||||
@router.get(
|
||||
"/media/{media_id}/stream",
|
||||
summary="渐进式精确搜索资源",
|
||||
response_model=None,
|
||||
response_class=StreamingResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "资源搜索 SSE 事件流",
|
||||
"content": {"text/event-stream": {"schema": {"type": "string"}}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def search_by_id_stream(
|
||||
request: Request,
|
||||
media_id: str,
|
||||
@@ -401,7 +417,11 @@ async def search_by_id_stream(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/media/{media_id}", summary="精确搜索资源", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/media/{media_id}",
|
||||
summary="精确搜索资源",
|
||||
response_model=schemas.Response[list[schemas.TorrentInfo]],
|
||||
)
|
||||
async def search_by_id(
|
||||
media_id: str,
|
||||
media_source: MediaSource,
|
||||
@@ -440,7 +460,18 @@ async def search_by_id(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/title/stream", summary="渐进式模糊搜索资源")
|
||||
@router.get(
|
||||
"/title/stream",
|
||||
summary="渐进式模糊搜索资源",
|
||||
response_model=None,
|
||||
response_class=StreamingResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "资源搜索 SSE 事件流",
|
||||
"content": {"text/event-stream": {"schema": {"type": "string"}}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def search_by_title_stream(
|
||||
request: Request,
|
||||
keyword: Optional[str] = None,
|
||||
@@ -467,7 +498,11 @@ async def search_by_title_stream(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/title", summary="模糊搜索资源", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/title",
|
||||
summary="模糊搜索资源",
|
||||
response_model=schemas.Response[list[schemas.TorrentInfo]],
|
||||
)
|
||||
async def search_by_title(
|
||||
keyword: Optional[str] = None,
|
||||
mtype: Optional[str] = None,
|
||||
@@ -492,7 +527,18 @@ async def search_by_title(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/subtitle/title/stream", summary="渐进式模糊搜索字幕")
|
||||
@router.get(
|
||||
"/subtitle/title/stream",
|
||||
summary="渐进式模糊搜索字幕",
|
||||
response_model=None,
|
||||
response_class=StreamingResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "字幕搜索 SSE 事件流",
|
||||
"content": {"text/event-stream": {"schema": {"type": "string"}}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def search_subtitle_by_title_stream(
|
||||
request: Request,
|
||||
keyword: Optional[str] = None,
|
||||
@@ -517,7 +563,11 @@ async def search_subtitle_by_title_stream(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/subtitle/title", summary="模糊搜索字幕", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/subtitle/title",
|
||||
summary="模糊搜索字幕",
|
||||
response_model=schemas.Response[list[schemas.SubtitleInfo]],
|
||||
)
|
||||
async def search_subtitle_by_title(
|
||||
keyword: Optional[str] = None,
|
||||
page: Optional[int] = 0,
|
||||
@@ -581,7 +631,18 @@ async def _build_subtitle_search_source(
|
||||
return call_search(**search_params), ""
|
||||
|
||||
|
||||
@router.get("/subtitle/media/{media_id}/stream", summary="渐进式精确搜索字幕")
|
||||
@router.get(
|
||||
"/subtitle/media/{media_id}/stream",
|
||||
summary="渐进式精确搜索字幕",
|
||||
response_model=None,
|
||||
response_class=StreamingResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "字幕搜索 SSE 事件流",
|
||||
"content": {"text/event-stream": {"schema": {"type": "string"}}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def search_subtitle_by_id_stream(
|
||||
request: Request,
|
||||
media_id: str,
|
||||
@@ -625,7 +686,11 @@ async def search_subtitle_by_id_stream(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/subtitle/media/{media_id}", summary="精确搜索字幕", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/subtitle/media/{media_id}",
|
||||
summary="精确搜索字幕",
|
||||
response_model=schemas.Response[list[schemas.SubtitleInfo]],
|
||||
)
|
||||
async def search_subtitle_by_id(
|
||||
media_id: str,
|
||||
media_source: MediaSource,
|
||||
@@ -657,7 +722,11 @@ async def search_subtitle_by_id(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/recommend", summary="AI推荐资源", response_model=schemas.Response)
|
||||
@router.post(
|
||||
"/recommend",
|
||||
summary="AI推荐资源",
|
||||
response_model=schemas.Response[schemas.SearchRecommendStatusData],
|
||||
)
|
||||
async def recommend_search_results(
|
||||
filtered_indices: Optional[List[int]] = Body(
|
||||
None, embed=True, description="筛选后的索引列表"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from typing import List, Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.background import BackgroundTasks
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.api.endpoints.plugin import register_plugin_api
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
@@ -33,7 +34,7 @@ from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey, EventType, MediaType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
def _indexer_supports_media_type(indexer: dict, media_type: MediaType) -> bool:
|
||||
@@ -129,7 +130,7 @@ async def read_sites_by_media_type(
|
||||
]
|
||||
|
||||
|
||||
@router.post("/", summary="新增站点", response_model=schemas.Response)
|
||||
@router.post("/", summary="新增站点", response_model=schemas.Response[None])
|
||||
async def add_site(
|
||||
*,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -168,7 +169,7 @@ async def add_site(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.put("/", summary="更新站点", response_model=schemas.Response)
|
||||
@router.put("/", summary="更新站点", response_model=schemas.Response[None])
|
||||
async def update_site(
|
||||
*,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -199,7 +200,7 @@ async def update_site(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response)
|
||||
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response[None])
|
||||
async def cookie_cloud_sync(
|
||||
background_tasks: BackgroundTasks,
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
@@ -211,7 +212,7 @@ async def cookie_cloud_sync(
|
||||
return schemas.Response(success=True, message="CookieCloud同步任务已启动!")
|
||||
|
||||
|
||||
@router.get("/reset", summary="重置站点", response_model=schemas.Response)
|
||||
@router.get("/reset", summary="重置站点", response_model=schemas.Response[None])
|
||||
def reset(
|
||||
db: AsyncSession = Depends(get_db), _: User = Depends(get_current_active_superuser)
|
||||
) -> Any:
|
||||
@@ -229,7 +230,7 @@ def reset(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/priorities", summary="批量更新站点优先级", response_model=schemas.Response
|
||||
"/priorities", summary="批量更新站点优先级", response_model=schemas.Response[None]
|
||||
)
|
||||
async def update_sites_priority(
|
||||
priorities: List[dict],
|
||||
@@ -281,7 +282,7 @@ def _update_site_cookie(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response
|
||||
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response[None]
|
||||
)
|
||||
def update_cookie_by_body(
|
||||
site_id: int,
|
||||
@@ -302,7 +303,7 @@ def update_cookie_by_body(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response
|
||||
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response[None]
|
||||
)
|
||||
def update_cookie(
|
||||
site_id: int,
|
||||
@@ -325,7 +326,9 @@ def update_cookie(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/userdata/{site_id}", summary="更新站点用户数据", response_model=schemas.Response
|
||||
"/userdata/{site_id}",
|
||||
summary="更新站点用户数据",
|
||||
response_model=schemas.Response[schemas.SiteUserData],
|
||||
)
|
||||
def refresh_userdata(
|
||||
site_id: int,
|
||||
@@ -369,7 +372,9 @@ async def read_userdata_latest(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/userdata/{site_id}", summary="查询某站点用户数据", response_model=schemas.Response
|
||||
"/userdata/{site_id}",
|
||||
summary="查询某站点用户数据",
|
||||
response_model=schemas.Response[list[schemas.SiteUserData]],
|
||||
)
|
||||
async def read_userdata(
|
||||
site_id: int,
|
||||
@@ -394,7 +399,7 @@ async def read_userdata(
|
||||
return schemas.Response(success=True, data=[data.to_dict() for data in user_datas])
|
||||
|
||||
|
||||
@router.get("/test/{site_id}", summary="连接测试", response_model=schemas.Response)
|
||||
@router.get("/test/{site_id}", summary="连接测试", response_model=schemas.Response[None])
|
||||
def test_site(
|
||||
site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -413,7 +418,11 @@ def test_site(
|
||||
return schemas.Response(success=status, message=message)
|
||||
|
||||
|
||||
@router.get("/icon/{site_id}", summary="站点图标", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/icon/{site_id}",
|
||||
summary="站点图标",
|
||||
response_model=schemas.Response[schemas.SiteIconData],
|
||||
)
|
||||
async def site_icon(
|
||||
site_id: int,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -576,7 +585,7 @@ async def read_rss_sites(
|
||||
return rss_sites
|
||||
|
||||
|
||||
@router.get("/auth", summary="查询认证站点", response_model=dict)
|
||||
@router.get("/auth", summary="查询认证站点", response_model=schemas.JsonObject)
|
||||
async def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> dict:
|
||||
"""
|
||||
获取可认证站点列表
|
||||
@@ -584,7 +593,7 @@ async def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> di
|
||||
return SitesHelper().get_authsites()
|
||||
|
||||
|
||||
@router.post("/auth", summary="用户站点认证", response_model=schemas.Response)
|
||||
@router.post("/auth", summary="用户站点认证", response_model=schemas.Response[None])
|
||||
def auth_site(
|
||||
auth_info: schemas.SiteAuth, _: User = Depends(get_current_active_superuser)
|
||||
) -> Any:
|
||||
@@ -604,7 +613,9 @@ def auth_site(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/mapping", summary="获取站点域名到名称的映射", response_model=schemas.Response
|
||||
"/mapping",
|
||||
summary="获取站点域名到名称的映射",
|
||||
response_model=schemas.Response[schemas.SiteMappingData],
|
||||
)
|
||||
async def site_mapping(_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
@@ -620,7 +631,11 @@ async def site_mapping(_: User = Depends(get_current_active_superuser_async)):
|
||||
return schemas.Response(success=False, message=f"获取映射失败:{str(e)}")
|
||||
|
||||
|
||||
@router.get("/supporting", summary="获取支持的站点列表", response_model=dict)
|
||||
@router.get(
|
||||
"/supporting",
|
||||
summary="获取支持的站点列表",
|
||||
response_model=schemas.JsonObject,
|
||||
)
|
||||
async def support_sites(_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
获取支持的站点列表
|
||||
@@ -646,7 +661,7 @@ async def read_site(
|
||||
return site
|
||||
|
||||
|
||||
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response)
|
||||
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response[None])
|
||||
async def delete_site(
|
||||
site_id: int,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
|
||||
@@ -4,10 +4,11 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import Depends, HTTPException
|
||||
from starlette.responses import FileResponse, Response
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.storage import StorageChain
|
||||
from app.chain.transfer import TransferChain
|
||||
@@ -23,10 +24,14 @@ from app.helper.progress import ProgressHelper
|
||||
from app.schemas.types import ProgressKey
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
@router.get("/qrcode/{name}", summary="生成二维码内容", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/qrcode/{name}",
|
||||
summary="生成二维码内容",
|
||||
response_model=schemas.Response[schemas.StorageQrCodeData],
|
||||
)
|
||||
def qrcode(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
生成二维码
|
||||
@@ -38,7 +43,9 @@ def qrcode(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get(
|
||||
"/auth_url/{name}", summary="获取 OAuth2 授权 URL", response_model=schemas.Response
|
||||
"/auth_url/{name}",
|
||||
summary="获取 OAuth2 授权 URL",
|
||||
response_model=schemas.Response[schemas.StorageAuthUrlData],
|
||||
)
|
||||
def auth_url(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -50,7 +57,11 @@ def auth_url(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
return schemas.Response(success=False, message=errmsg)
|
||||
|
||||
|
||||
@router.get("/check/{name}", summary="二维码登录确认", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/check/{name}",
|
||||
summary="二维码登录确认",
|
||||
response_model=schemas.Response[schemas.StorageLoginStatusData],
|
||||
)
|
||||
def check(
|
||||
name: str,
|
||||
ck: Optional[str] = None,
|
||||
@@ -69,7 +80,7 @@ def check(
|
||||
return schemas.Response(success=False, message=errmsg)
|
||||
|
||||
|
||||
@router.post("/save/{name}", summary="保存存储配置", response_model=schemas.Response)
|
||||
@router.post("/save/{name}", summary="保存存储配置", response_model=schemas.Response[None])
|
||||
def save(name: str, conf: dict, _: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
保存存储配置
|
||||
@@ -78,7 +89,7 @@ def save(name: str, conf: dict, _: User = Depends(get_current_active_superuser))
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/reset/{name}", summary="重置存储配置", response_model=schemas.Response)
|
||||
@router.get("/reset/{name}", summary="重置存储配置", response_model=schemas.Response[None])
|
||||
def reset(name: str, _: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
重置存储配置
|
||||
@@ -114,7 +125,7 @@ def list_files(
|
||||
return file_list
|
||||
|
||||
|
||||
@router.post("/mkdir", summary="创建目录", response_model=schemas.Response)
|
||||
@router.post("/mkdir", summary="创建目录", response_model=schemas.Response[None])
|
||||
def mkdir(
|
||||
fileitem: schemas.FileItem,
|
||||
name: str,
|
||||
@@ -134,7 +145,7 @@ def mkdir(
|
||||
return schemas.Response(success=False)
|
||||
|
||||
|
||||
@router.post("/delete", summary="删除文件或目录", response_model=schemas.Response)
|
||||
@router.post("/delete", summary="删除文件或目录", response_model=schemas.Response[None])
|
||||
def delete(
|
||||
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
|
||||
) -> Any:
|
||||
@@ -149,7 +160,23 @@ def delete(
|
||||
return schemas.Response(success=False)
|
||||
|
||||
|
||||
@router.post("/download", summary="下载文件")
|
||||
@router.post(
|
||||
"/download",
|
||||
summary="下载文件",
|
||||
response_model=None,
|
||||
response_class=FileResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "文件内容",
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {"type": "string", "format": "binary"}
|
||||
}
|
||||
},
|
||||
},
|
||||
404: {"model": schemas.Response[None], "description": "文件下载失败"},
|
||||
},
|
||||
)
|
||||
def download(
|
||||
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
|
||||
) -> Any:
|
||||
@@ -165,7 +192,20 @@ def download(
|
||||
return schemas.Response(success=False)
|
||||
|
||||
|
||||
@router.post("/image", summary="预览图片")
|
||||
@router.post(
|
||||
"/image",
|
||||
summary="预览图片",
|
||||
response_model=None,
|
||||
response_class=Response,
|
||||
responses={
|
||||
200: {
|
||||
"description": "图片内容",
|
||||
"content": {
|
||||
"image/jpeg": {"schema": {"type": "string", "format": "binary"}}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
def image(
|
||||
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
|
||||
) -> Any:
|
||||
@@ -181,7 +221,7 @@ def image(
|
||||
return Response(content=tmp_file.read_bytes(), media_type="image/jpeg")
|
||||
|
||||
|
||||
@router.post("/rename", summary="重命名文件或目录", response_model=schemas.Response)
|
||||
@router.post("/rename", summary="重命名文件或目录", response_model=schemas.Response[None])
|
||||
def rename(
|
||||
fileitem: schemas.FileItem,
|
||||
new_name: str,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from typing import List, Any, Annotated, Optional
|
||||
|
||||
import cn2an
|
||||
from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header
|
||||
from fastapi import Request, BackgroundTasks, Depends, HTTPException, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
@@ -32,7 +33,7 @@ from app.schemas.types import (
|
||||
)
|
||||
from app.utils.media import normalize_media_source, resolve_media_identity
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
def start_subscribe_add(
|
||||
@@ -181,7 +182,11 @@ async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
return await Subscribe.async_list()
|
||||
|
||||
|
||||
@router.post("/", summary="新增订阅", response_model=schemas.Response)
|
||||
@router.post(
|
||||
"/",
|
||||
summary="新增订阅",
|
||||
response_model=schemas.Response[schemas.IdData],
|
||||
)
|
||||
async def create_subscribe(
|
||||
*,
|
||||
subscribe_in: schemas.Subscribe,
|
||||
@@ -242,7 +247,7 @@ async def create_subscribe(
|
||||
return schemas.Response(success=bool(sid), message=message, data={"id": sid})
|
||||
|
||||
|
||||
@router.put("/", summary="更新订阅", response_model=schemas.Response)
|
||||
@router.put("/", summary="更新订阅", response_model=schemas.Response[None])
|
||||
async def update_subscribe(
|
||||
*,
|
||||
subscribe_in: schemas.Subscribe,
|
||||
@@ -314,7 +319,7 @@ async def update_subscribe(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.put("/status/{subid}", summary="更新订阅状态", response_model=schemas.Response)
|
||||
@router.put("/status/{subid}", summary="更新订阅状态", response_model=schemas.Response[None])
|
||||
async def update_subscribe_status(
|
||||
subid: int,
|
||||
state: str,
|
||||
@@ -367,7 +372,7 @@ async def subscribe_media_identity(
|
||||
return result if result else Subscribe()
|
||||
|
||||
|
||||
@router.get("/refresh", summary="刷新订阅", response_model=schemas.Response)
|
||||
@router.get("/refresh", summary="刷新订阅", response_model=schemas.Response[None])
|
||||
def refresh_subscribes(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
) -> Any:
|
||||
@@ -380,7 +385,7 @@ def refresh_subscribes(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/reset/{subid}", summary="重置订阅", response_model=schemas.Response)
|
||||
@router.get("/reset/{subid}", summary="重置订阅", response_model=schemas.Response[None])
|
||||
async def reset_subscribes(
|
||||
subid: int,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -428,7 +433,7 @@ async def reset_subscribes(
|
||||
return schemas.Response(success=False, message="订阅不存在")
|
||||
|
||||
|
||||
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response)
|
||||
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response[None])
|
||||
def check_subscribes(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
) -> Any:
|
||||
@@ -441,7 +446,7 @@ def check_subscribes(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response)
|
||||
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response[None])
|
||||
async def search_subscribes(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -470,7 +475,7 @@ async def search_subscribes(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response
|
||||
"/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response[None]
|
||||
)
|
||||
async def search_subscribe(
|
||||
subscribe_id: int,
|
||||
@@ -492,7 +497,7 @@ async def search_subscribe(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.delete("/media/{media_id}", summary="删除订阅", response_model=schemas.Response)
|
||||
@router.delete("/media/{media_id}", summary="删除订阅", response_model=schemas.Response[None])
|
||||
async def delete_subscribe_by_media_identity(
|
||||
media_id: str,
|
||||
media_source: MediaSource,
|
||||
@@ -536,7 +541,7 @@ async def delete_subscribe_by_media_identity(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response
|
||||
"/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response[None]
|
||||
)
|
||||
async def seerr_subscribe(
|
||||
request: Request,
|
||||
@@ -640,7 +645,7 @@ async def subscribe_history(
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response
|
||||
"/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response[None]
|
||||
)
|
||||
async def delete_subscribe_history(
|
||||
history_id: int,
|
||||
@@ -750,7 +755,7 @@ def subscribe_files(
|
||||
return schemas.SubscrbieInfo()
|
||||
|
||||
|
||||
@router.post("/share", summary="分享订阅", response_model=schemas.Response)
|
||||
@router.post("/share", summary="分享订阅", response_model=schemas.Response[None])
|
||||
async def subscribe_share(
|
||||
sub: schemas.SubscribeShare,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -771,7 +776,7 @@ async def subscribe_share(
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response)
|
||||
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response[None])
|
||||
async def subscribe_share_delete(
|
||||
share_id: int, _: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
@@ -782,7 +787,7 @@ async def subscribe_share_delete(
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.post("/fork", summary="复用订阅", response_model=schemas.Response)
|
||||
@router.post("/fork", summary="复用订阅", response_model=schemas.Response[None])
|
||||
async def subscribe_fork(
|
||||
sub: schemas.SubscribeShare,
|
||||
current_user: User = Depends(get_current_active_user_async),
|
||||
@@ -811,7 +816,7 @@ async def followed_subscribers(_: schemas.TokenPayload = Depends(verify_token))
|
||||
return SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
|
||||
|
||||
|
||||
@router.post("/follow", summary="Follow订阅分享人", response_model=schemas.Response)
|
||||
@router.post("/follow", summary="Follow订阅分享人", response_model=schemas.Response[None])
|
||||
async def follow_subscriber(
|
||||
share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
@@ -828,7 +833,7 @@ async def follow_subscriber(
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/follow", summary="取消Follow订阅分享人", response_model=schemas.Response
|
||||
"/follow", summary="取消Follow订阅分享人", response_model=schemas.Response[None]
|
||||
)
|
||||
async def unfollow_subscriber(
|
||||
share_uid: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
|
||||
@@ -902,7 +907,7 @@ async def read_subscribe(
|
||||
return subscribe if subscribe else Subscribe()
|
||||
|
||||
|
||||
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
|
||||
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response[None])
|
||||
async def delete_subscribe(
|
||||
subscribe_id: int,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
|
||||
@@ -14,10 +14,11 @@ import anyio
|
||||
import pillow_avif # noqa 用于自动注册AVIF支持
|
||||
from anyio import Path as AsyncPath
|
||||
from app.helper.sites import SitesHelper # noqa # noqa
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Header, Request, Response
|
||||
from fastapi import Body, Depends, HTTPException, Header, Request, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.search import SearchChain
|
||||
@@ -58,7 +59,7 @@ from app.utils.security import SecurityUtils
|
||||
from app.utils.url import UrlUtils
|
||||
from version import APP_VERSION
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
_NETTEST_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
|
||||
_PUBLIC_SYSTEM_CONFIG_KEYS = {
|
||||
@@ -581,7 +582,23 @@ async def fetch_image(
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/img/{proxy}", summary="图片代理")
|
||||
@router.get(
|
||||
"/img/{proxy}",
|
||||
summary="图片代理",
|
||||
response_model=None,
|
||||
response_class=Response,
|
||||
responses={
|
||||
200: {
|
||||
"description": "代理图片内容",
|
||||
"content": {
|
||||
"image/jpeg": {"schema": {"type": "string", "format": "binary"}},
|
||||
"image/png": {"schema": {"type": "string", "format": "binary"}},
|
||||
"image/webp": {"schema": {"type": "string", "format": "binary"}},
|
||||
},
|
||||
},
|
||||
304: {"description": "图片缓存未修改"},
|
||||
},
|
||||
)
|
||||
async def proxy_img(
|
||||
imgurl: str,
|
||||
proxy: bool = False,
|
||||
@@ -609,7 +626,23 @@ async def proxy_img(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cache/image", summary="图片缓存")
|
||||
@router.get(
|
||||
"/cache/image",
|
||||
summary="图片缓存",
|
||||
response_model=None,
|
||||
response_class=Response,
|
||||
responses={
|
||||
200: {
|
||||
"description": "缓存图片内容",
|
||||
"content": {
|
||||
"image/jpeg": {"schema": {"type": "string", "format": "binary"}},
|
||||
"image/png": {"schema": {"type": "string", "format": "binary"}},
|
||||
"image/webp": {"schema": {"type": "string", "format": "binary"}},
|
||||
},
|
||||
},
|
||||
304: {"description": "图片缓存未修改"},
|
||||
},
|
||||
)
|
||||
async def cache_img(
|
||||
url: str,
|
||||
if_none_match: Annotated[str | None, Header()] = None,
|
||||
@@ -624,7 +657,11 @@ async def cache_img(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/global", summary="查询非敏感系统设置", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/global",
|
||||
summary="查询非敏感系统设置",
|
||||
response_model=schemas.Response[schemas.JsonObject],
|
||||
)
|
||||
def get_global_setting(token: str):
|
||||
"""
|
||||
查询非敏感系统设置(默认鉴权)
|
||||
@@ -655,7 +692,9 @@ def get_global_setting(token: str):
|
||||
|
||||
|
||||
@router.get(
|
||||
"/global/user", summary="查询用户相关系统设置", response_model=schemas.Response
|
||||
"/global/user",
|
||||
summary="查询用户相关系统设置",
|
||||
response_model=schemas.Response[schemas.JsonObject],
|
||||
)
|
||||
async def get_user_global_setting(_: User = Depends(get_current_active_user_async)):
|
||||
"""
|
||||
@@ -692,7 +731,11 @@ async def get_user_global_setting(_: User = Depends(get_current_active_user_asyn
|
||||
return schemas.Response(success=True, data=info)
|
||||
|
||||
|
||||
@router.get("/env", summary="查询系统配置", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/env",
|
||||
summary="查询系统配置",
|
||||
response_model=schemas.Response[schemas.JsonObject],
|
||||
)
|
||||
async def get_env_setting(
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
) -> schemas.Response:
|
||||
@@ -713,7 +756,11 @@ async def get_env_setting(
|
||||
return schemas.Response(success=True, data=info)
|
||||
|
||||
|
||||
@router.get("/usage/statistic", summary="查询安装版本统计报表", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/usage/statistic",
|
||||
summary="查询安装版本统计报表",
|
||||
response_model=schemas.Response[schemas.JsonObject],
|
||||
)
|
||||
async def usage_statistic(_: User = Depends(get_current_active_user_async)):
|
||||
"""
|
||||
查询安装版本统计报表
|
||||
@@ -721,7 +768,7 @@ async def usage_statistic(_: User = Depends(get_current_active_user_async)):
|
||||
return schemas.Response(success=True, data=await MoviePilotServerHelper.async_get_usage_statistic())
|
||||
|
||||
|
||||
@router.get("/ping", summary="服务存活检测", response_model=schemas.Response)
|
||||
@router.get("/ping", summary="服务存活检测", response_model=schemas.Response[None])
|
||||
async def ping(_: User = Depends(get_current_active_user_async)) -> schemas.Response:
|
||||
"""
|
||||
检测服务是否可用
|
||||
@@ -729,7 +776,11 @@ async def ping(_: User = Depends(get_current_active_user_async)) -> schemas.Resp
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post("/env", summary="更新系统配置", response_model=schemas.Response)
|
||||
@router.post(
|
||||
"/env",
|
||||
summary="更新系统配置",
|
||||
response_model=schemas.Response[schemas.SystemEnvironmentUpdateData],
|
||||
)
|
||||
async def set_env_setting(
|
||||
env: dict, _: User = Depends(get_current_active_superuser_async)
|
||||
):
|
||||
@@ -768,7 +819,18 @@ async def set_env_setting(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/progress/{process_type}", summary="实时进度")
|
||||
@router.get(
|
||||
"/progress/{process_type}",
|
||||
summary="实时进度",
|
||||
response_model=None,
|
||||
response_class=StreamingResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "处理进度 SSE 事件流",
|
||||
"content": {"text/event-stream": {"schema": {"type": "string"}}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def get_progress(
|
||||
request: Request,
|
||||
process_type: str,
|
||||
@@ -794,7 +856,11 @@ async def get_progress(
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/setting/public/{key}", summary="查询公开系统设置", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/setting/public/{key}",
|
||||
summary="查询公开系统设置",
|
||||
response_model=schemas.Response[schemas.ValueData],
|
||||
)
|
||||
async def get_public_setting(
|
||||
key: str, _: User = Depends(get_current_active_user_async)
|
||||
) -> schemas.Response:
|
||||
@@ -812,7 +878,7 @@ async def get_public_setting(
|
||||
@router.post(
|
||||
"/setting/PLUGIN_MARKET/sync-wiki",
|
||||
summary="从Wiki同步插件市场仓库",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.PluginMarketSyncData],
|
||||
)
|
||||
async def sync_plugin_market_from_wiki(
|
||||
request: Optional[schemas.PluginMarketSyncRequest] = Body(default=None),
|
||||
@@ -876,7 +942,11 @@ async def sync_plugin_market_from_wiki(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/setting/{key}",
|
||||
summary="查询系统设置",
|
||||
response_model=schemas.Response[schemas.ValueData],
|
||||
)
|
||||
async def get_setting(
|
||||
key: str, _: User = Depends(get_current_active_superuser_async)
|
||||
) -> schemas.Response:
|
||||
@@ -890,7 +960,7 @@ async def get_setting(
|
||||
return schemas.Response(success=True, data={"value": value})
|
||||
|
||||
|
||||
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
|
||||
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response[None])
|
||||
async def set_setting(
|
||||
key: str,
|
||||
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
|
||||
@@ -926,7 +996,18 @@ async def set_setting(
|
||||
return schemas.Response(success=False, message=f"配置项 '{key}' 不存在")
|
||||
|
||||
|
||||
@router.get("/message", summary="实时消息")
|
||||
@router.get(
|
||||
"/message",
|
||||
summary="实时消息",
|
||||
response_model=None,
|
||||
response_class=StreamingResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "系统消息 SSE 事件流",
|
||||
"content": {"text/event-stream": {"schema": {"type": "string"}}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def get_message(
|
||||
request: Request,
|
||||
role: Optional[str] = "system",
|
||||
@@ -951,7 +1032,21 @@ async def get_message(
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/logging", summary="实时日志")
|
||||
@router.get(
|
||||
"/logging",
|
||||
summary="实时日志",
|
||||
response_model=None,
|
||||
response_class=StreamingResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "实时日志流或完整日志文本",
|
||||
"content": {
|
||||
"text/event-stream": {"schema": {"type": "string"}},
|
||||
"text/plain": {"schema": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def get_logging(
|
||||
request: Request,
|
||||
length: Optional[int] = 50,
|
||||
@@ -1065,7 +1160,22 @@ async def get_logging(
|
||||
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/logging/download/{name}", summary="下载日志")
|
||||
@router.get(
|
||||
"/logging/download/{name}",
|
||||
summary="下载日志",
|
||||
response_model=None,
|
||||
response_class=StreamingResponse,
|
||||
responses={
|
||||
200: {
|
||||
"description": "日志 ZIP 文件",
|
||||
"content": {
|
||||
"application/zip": {
|
||||
"schema": {"type": "string", "format": "binary"}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def download_logging(
|
||||
name: str,
|
||||
_: schemas.TokenPayload = Depends(_verify_log_resource_superuser),
|
||||
@@ -1077,7 +1187,9 @@ async def download_logging(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/versions", summary="查询Github所有Release版本", response_model=schemas.Response
|
||||
"/versions",
|
||||
summary="查询Github所有Release版本",
|
||||
response_model=schemas.Response[schemas.JsonObjectList],
|
||||
)
|
||||
async def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
@@ -1093,7 +1205,11 @@ async def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
return schemas.Response(success=False)
|
||||
|
||||
|
||||
@router.get("/ruletest", summary="过滤规则测试", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/ruletest",
|
||||
summary="过滤规则测试",
|
||||
response_model=schemas.Response[schemas.RuleTestData],
|
||||
)
|
||||
def ruletest(
|
||||
title: str,
|
||||
rulegroup_name: str,
|
||||
@@ -1165,7 +1281,9 @@ def ruletest(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/nettest/targets", summary="获取网络测试目标", response_model=schemas.Response
|
||||
"/nettest/targets",
|
||||
summary="获取网络测试目标",
|
||||
response_model=schemas.Response[list[schemas.NetTestTarget]],
|
||||
)
|
||||
async def nettest_targets(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
@@ -1187,7 +1305,11 @@ async def nettest_targets(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/nettest", summary="测试网络连通性")
|
||||
@router.get(
|
||||
"/nettest",
|
||||
summary="测试网络连通性",
|
||||
response_model=schemas.Response[schemas.TimeData],
|
||||
)
|
||||
async def nettest(
|
||||
target_id: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
@@ -1278,7 +1400,9 @@ async def nettest(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response
|
||||
"/modulelist",
|
||||
summary="查询已加载的模块ID列表",
|
||||
response_model=schemas.Response[schemas.SystemModuleListData],
|
||||
)
|
||||
def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
@@ -1302,7 +1426,7 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
|
||||
|
||||
@router.get(
|
||||
"/moduletest/{moduleid}", summary="模块可用性测试", response_model=schemas.Response
|
||||
"/moduletest/{moduleid}", summary="模块可用性测试", response_model=schemas.Response[None]
|
||||
)
|
||||
def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
@@ -1312,7 +1436,7 @@ def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.get("/restart", summary="重启系统", response_model=schemas.Response)
|
||||
@router.get("/restart", summary="重启系统", response_model=schemas.Response[None])
|
||||
def restart_system(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
重启系统(仅管理员)
|
||||
@@ -1323,7 +1447,7 @@ def restart_system(_: User = Depends(get_current_active_superuser)):
|
||||
return schemas.Response(success=ret, message=msg)
|
||||
|
||||
|
||||
@router.post("/upgrade", summary="升级并重启系统", response_model=schemas.Response)
|
||||
@router.post("/upgrade", summary="升级并重启系统", response_model=schemas.Response[None])
|
||||
def upgrade_system(
|
||||
mode: Annotated[str | None, Body()] = None,
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
@@ -1341,7 +1465,7 @@ def upgrade_system(
|
||||
return schemas.Response(success=ret, message=msg)
|
||||
|
||||
|
||||
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
|
||||
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response[None])
|
||||
def run_scheduler(jobid: str, _: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
执行命令(仅管理员)
|
||||
@@ -1356,7 +1480,7 @@ def run_scheduler(jobid: str, _: User = Depends(get_current_active_superuser)):
|
||||
|
||||
|
||||
@router.get(
|
||||
"/runscheduler2", summary="运行服务(API_TOKEN)", response_model=schemas.Response
|
||||
"/runscheduler2", summary="运行服务(API_TOKEN)", response_model=schemas.Response[None]
|
||||
)
|
||||
def run_scheduler2(jobid: str, _: Annotated[str, Depends(verify_apitoken)]):
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from typing import List, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import Depends
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.config import settings
|
||||
from app.core.security import verify_token
|
||||
@@ -12,11 +13,13 @@ from app.db.user_oper import get_current_active_superuser_async
|
||||
from app.modules.themoviedb.tmdb_cache import TmdbCache
|
||||
from app.schemas.types import MediaType, SystemConfigKey
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/cache", summary="查询 TheMovieDb 识别缓存", response_model=schemas.Response
|
||||
"/cache",
|
||||
summary="查询 TheMovieDb 识别缓存",
|
||||
response_model=schemas.Response[schemas.TmdbRecognitionCacheData],
|
||||
)
|
||||
async def tmdb_recognition_cache(
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
@@ -42,7 +45,7 @@ async def tmdb_recognition_cache(
|
||||
@router.delete(
|
||||
"/cache/{cache_key:path}",
|
||||
summary="删除指定 TheMovieDb 识别缓存",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
async def delete_tmdb_recognition_cache(
|
||||
cache_key: str,
|
||||
@@ -56,7 +59,7 @@ async def delete_tmdb_recognition_cache(
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/cache", summary="清空 TheMovieDb 识别缓存", response_model=schemas.Response
|
||||
"/cache", summary="清空 TheMovieDb 识别缓存", response_model=schemas.Response[None]
|
||||
)
|
||||
async def clear_tmdb_recognition_cache(
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import Depends
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.core.config import settings
|
||||
@@ -27,10 +28,14 @@ from app.utils.media import (
|
||||
resolve_media_identity,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
@router.get("/cache", summary="获取种子缓存", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/cache",
|
||||
summary="获取种子缓存",
|
||||
response_model=schemas.Response[schemas.TorrentCacheData],
|
||||
)
|
||||
async def torrents_cache(_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
获取当前种子缓存数据
|
||||
@@ -97,7 +102,7 @@ async def torrents_cache(_: User = Depends(get_current_active_superuser_async)):
|
||||
@router.delete(
|
||||
"/cache/{domain}/{torrent_hash}",
|
||||
summary="删除指定种子缓存",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[None],
|
||||
)
|
||||
async def delete_cache(
|
||||
domain: str,
|
||||
@@ -145,7 +150,7 @@ async def delete_cache(
|
||||
return schemas.Response(success=False, message=f"删除失败:{str(e)}")
|
||||
|
||||
|
||||
@router.delete("/cache", summary="清理种子缓存", response_model=schemas.Response)
|
||||
@router.delete("/cache", summary="清理种子缓存", response_model=schemas.Response[None])
|
||||
async def clear_cache(_: User = Depends(get_current_active_superuser_async)):
|
||||
"""
|
||||
清理所有种子缓存
|
||||
@@ -159,7 +164,7 @@ async def clear_cache(_: User = Depends(get_current_active_superuser_async)):
|
||||
return schemas.Response(success=False, message=f"清理失败:{str(e)}")
|
||||
|
||||
|
||||
@router.post("/cache/refresh", summary="刷新种子缓存", response_model=schemas.Response)
|
||||
@router.post("/cache/refresh", summary="刷新种子缓存", response_model=schemas.Response[None])
|
||||
def refresh_cache(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
刷新种子缓存
|
||||
@@ -186,7 +191,7 @@ def refresh_cache(_: User = Depends(get_current_active_superuser)):
|
||||
@router.post(
|
||||
"/cache/reidentify/{domain}/{torrent_hash}",
|
||||
summary="重新识别种子",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.TorrentReidentifyData],
|
||||
)
|
||||
async def reidentify_cache(
|
||||
domain: str,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings, global_vars
|
||||
@@ -25,10 +26,14 @@ from app.schemas import (
|
||||
EpisodeFormatRecommendItem,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
@router.get("/name", summary="查询整理后的名称", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/name",
|
||||
summary="查询整理后的名称",
|
||||
response_model=schemas.Response[schemas.NameData],
|
||||
)
|
||||
def query_name(
|
||||
path: str, filetype: str, _: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
@@ -79,7 +84,7 @@ async def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/queue", summary="从整理队列中删除任务", response_model=schemas.Response
|
||||
"/queue", summary="从整理队列中删除任务", response_model=schemas.Response[None]
|
||||
)
|
||||
async def remove_queue(
|
||||
fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)
|
||||
@@ -181,7 +186,7 @@ def _get_manual_transfer_target_key(
|
||||
@router.post(
|
||||
"/manual/target-path",
|
||||
summary="匹配手动转移目的路径",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.ManualTransferTargetPath],
|
||||
)
|
||||
def match_manual_transfer_target_path(
|
||||
transer_item: ManualTransferItem,
|
||||
@@ -244,7 +249,7 @@ def match_manual_transfer_target_path(
|
||||
@router.post(
|
||||
"/manual/history",
|
||||
summary="查询手动转移成功历史",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.ManualTransferHistoryInfo],
|
||||
)
|
||||
def query_manual_transfer_history(
|
||||
transer_item: ManualTransferItem,
|
||||
@@ -275,7 +280,11 @@ def query_manual_transfer_history(
|
||||
return schemas.Response(success=True, data=history_info.model_dump())
|
||||
|
||||
|
||||
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
|
||||
@router.post(
|
||||
"/manual",
|
||||
summary="手动转移",
|
||||
response_model=schemas.Response[schemas.ManualTransferResultData],
|
||||
)
|
||||
def manual_transfer(
|
||||
transer_item: ManualTransferItem,
|
||||
background: Optional[bool] = False,
|
||||
@@ -574,7 +583,7 @@ def manual_transfer(
|
||||
@router.post(
|
||||
"/episode-format/recommend",
|
||||
summary="推荐集数定位模板",
|
||||
response_model=schemas.Response,
|
||||
response_model=schemas.Response[schemas.EpisodeFormatRecommendData],
|
||||
)
|
||||
def recommend_episode_format(
|
||||
recommend_item: EpisodeFormatRecommendItem,
|
||||
@@ -600,7 +609,7 @@ def recommend_episode_format(
|
||||
return schemas.Response(success=True, data=data)
|
||||
|
||||
|
||||
@router.get("/now", summary="立即执行下载器文件整理", response_model=schemas.Response)
|
||||
@router.get("/now", summary="立即执行下载器文件整理", response_model=schemas.Response[None])
|
||||
def now(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
|
||||
"""
|
||||
立即执行下载器文件整理 API_TOKEN认证(?token=xxx)
|
||||
|
||||
@@ -2,10 +2,11 @@ import base64
|
||||
import re
|
||||
from typing import Annotated, Any, List, Union
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, UploadFile, File
|
||||
from fastapi import Body, Depends, HTTPException, UploadFile, File
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.core.security import get_password_hash
|
||||
from app.db import get_async_db
|
||||
from app.db.models.user import User
|
||||
@@ -16,7 +17,7 @@ from app.db.user_oper import (
|
||||
)
|
||||
from app.db.userconfig_oper import UserConfigOper
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
@router.get("/", summary="所有用户", response_model=List[schemas.User])
|
||||
@@ -30,7 +31,7 @@ async def list_users(
|
||||
return await current_user.async_list(db)
|
||||
|
||||
|
||||
@router.post("/", summary="新增用户", response_model=schemas.Response)
|
||||
@router.post("/", summary="新增用户", response_model=schemas.Response[None])
|
||||
async def create_user(
|
||||
*,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -51,7 +52,7 @@ async def create_user(
|
||||
return schemas.Response(success=True if user else False)
|
||||
|
||||
|
||||
@router.put("/", summary="更新用户", response_model=schemas.Response)
|
||||
@router.put("/", summary="更新用户", response_model=schemas.Response[None])
|
||||
async def update_user(
|
||||
*,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -98,7 +99,9 @@ async def read_current_user(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/avatar/{user_id}", summary="上传用户头像", response_model=schemas.Response
|
||||
"/avatar/{user_id}",
|
||||
summary="上传用户头像",
|
||||
response_model=schemas.Response[schemas.FileNameData],
|
||||
)
|
||||
async def upload_avatar(
|
||||
user_id: int,
|
||||
@@ -122,7 +125,11 @@ async def upload_avatar(
|
||||
return schemas.Response(success=True, data={"filename": file.filename})
|
||||
|
||||
|
||||
@router.get("/config/{key}", summary="查询用户配置", response_model=schemas.Response)
|
||||
@router.get(
|
||||
"/config/{key}",
|
||||
summary="查询用户配置",
|
||||
response_model=schemas.Response[schemas.ValueData],
|
||||
)
|
||||
def get_config(key: str, current_user: User = Depends(get_current_active_user)):
|
||||
"""
|
||||
查询用户配置
|
||||
@@ -131,7 +138,7 @@ def get_config(key: str, current_user: User = Depends(get_current_active_user)):
|
||||
return schemas.Response(success=True, data={"value": value})
|
||||
|
||||
|
||||
@router.post("/config/{key}", summary="更新用户配置", response_model=schemas.Response)
|
||||
@router.post("/config/{key}", summary="更新用户配置", response_model=schemas.Response[None])
|
||||
def set_config(
|
||||
key: str,
|
||||
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
|
||||
@@ -144,7 +151,7 @@ def set_config(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.delete("/id/{user_id}", summary="删除用户", response_model=schemas.Response)
|
||||
@router.delete("/id/{user_id}", summary="删除用户", response_model=schemas.Response[None])
|
||||
async def delete_user_by_id(
|
||||
*,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -161,7 +168,7 @@ async def delete_user_by_id(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.delete("/name/{user_name}", summary="删除用户", response_model=schemas.Response)
|
||||
@router.delete("/name/{user_name}", summary="删除用户", response_model=schemas.Response[None])
|
||||
async def delete_user_by_name(
|
||||
*,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from typing import Any, Annotated
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Request, Depends
|
||||
from fastapi import BackgroundTasks, Request, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.webhook import WebhookChain
|
||||
from app.core.security import verify_apitoken
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
def start_webhook_chain(body: Any, form: Any, args: Any):
|
||||
@@ -16,7 +17,7 @@ def start_webhook_chain(body: Any, form: Any, args: Any):
|
||||
WebhookChain().message(body=body, form=form, args=args)
|
||||
|
||||
|
||||
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response)
|
||||
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response[None])
|
||||
async def webhook_message(
|
||||
background_tasks: BackgroundTasks,
|
||||
request: Request,
|
||||
@@ -32,7 +33,7 @@ async def webhook_message(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response)
|
||||
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response[None])
|
||||
async def webhook_message_get(
|
||||
background_tasks: BackgroundTasks,
|
||||
request: Request,
|
||||
|
||||
@@ -2,11 +2,12 @@ import json
|
||||
from datetime import datetime
|
||||
from typing import List, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.chain.workflow import WorkflowChain
|
||||
from app.core.config import global_vars
|
||||
from app.core.plugin import PluginManager
|
||||
@@ -23,7 +24,7 @@ from app.helper.server import MoviePilotServerHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import EventType, EVENT_TYPE_NAMES
|
||||
|
||||
router = APIRouter()
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
WORKFLOW_TRIGGER_TIMER = "timer"
|
||||
WORKFLOW_TRIGGER_EVENT = "event"
|
||||
@@ -41,7 +42,7 @@ async def list_workflows(
|
||||
return await WorkflowOper(db).async_list()
|
||||
|
||||
|
||||
@router.post("/", summary="创建工作流", response_model=schemas.Response)
|
||||
@router.post("/", summary="创建工作流", response_model=schemas.Response[None])
|
||||
async def create_workflow(
|
||||
workflow: schemas.Workflow,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -63,7 +64,11 @@ async def create_workflow(
|
||||
return schemas.Response(success=True, message="创建工作流成功")
|
||||
|
||||
|
||||
@router.get("/plugin/actions", summary="查询插件动作", response_model=List[dict])
|
||||
@router.get(
|
||||
"/plugin/actions",
|
||||
summary="查询插件动作",
|
||||
response_model=List[schemas.PluginWorkflowActionGroup],
|
||||
)
|
||||
def list_plugin_actions(
|
||||
plugin_id: str = None, _: User = Depends(get_current_active_manage_user)
|
||||
) -> Any:
|
||||
@@ -73,7 +78,11 @@ def list_plugin_actions(
|
||||
return PluginManager().get_plugin_actions(plugin_id)
|
||||
|
||||
|
||||
@router.get("/actions", summary="所有动作", response_model=List[dict])
|
||||
@router.get(
|
||||
"/actions",
|
||||
summary="所有动作",
|
||||
response_model=List[schemas.WorkflowActionDefinition],
|
||||
)
|
||||
async def list_actions(_: User = Depends(get_current_active_manage_user_async)) -> Any:
|
||||
"""
|
||||
获取所有动作
|
||||
@@ -81,7 +90,11 @@ async def list_actions(_: User = Depends(get_current_active_manage_user_async))
|
||||
return WorkFlowManager().list_actions()
|
||||
|
||||
|
||||
@router.get("/event_types", summary="获取所有事件类型", response_model=List[dict])
|
||||
@router.get(
|
||||
"/event_types",
|
||||
summary="获取所有事件类型",
|
||||
response_model=List[schemas.NameValueOption],
|
||||
)
|
||||
async def get_event_types(_: User = Depends(get_current_active_manage_user_async)) -> Any:
|
||||
"""
|
||||
获取所有事件类型
|
||||
@@ -95,7 +108,7 @@ async def get_event_types(_: User = Depends(get_current_active_manage_user_async
|
||||
]
|
||||
|
||||
|
||||
@router.post("/share", summary="分享工作流", response_model=schemas.Response)
|
||||
@router.post("/share", summary="分享工作流", response_model=schemas.Response[None])
|
||||
async def workflow_share(
|
||||
workflow: schemas.WorkflowShare, _: User = Depends(get_current_active_manage_user_async)
|
||||
) -> Any:
|
||||
@@ -116,7 +129,7 @@ async def workflow_share(
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response)
|
||||
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response[None])
|
||||
async def workflow_share_delete(
|
||||
share_id: int, _: User = Depends(get_current_active_manage_user_async)
|
||||
) -> Any:
|
||||
@@ -127,7 +140,7 @@ async def workflow_share_delete(
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.post("/fork", summary="复用工作流", response_model=schemas.Response)
|
||||
@router.post("/fork", summary="复用工作流", response_model=schemas.Response[None])
|
||||
async def workflow_fork(
|
||||
workflow: schemas.WorkflowShare,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -206,7 +219,7 @@ async def workflow_shares(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{workflow_id}/run", summary="执行工作流", response_model=schemas.Response
|
||||
"/{workflow_id}/run", summary="执行工作流", response_model=schemas.Response[None]
|
||||
)
|
||||
def run_workflow(
|
||||
workflow_id: int,
|
||||
@@ -223,7 +236,7 @@ def run_workflow(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{workflow_id}/start", summary="启用工作流", response_model=schemas.Response
|
||||
"/{workflow_id}/start", summary="启用工作流", response_model=schemas.Response[None]
|
||||
)
|
||||
def start_workflow(
|
||||
workflow_id: int,
|
||||
@@ -257,7 +270,7 @@ def start_workflow(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{workflow_id}/pause", summary="停用工作流", response_model=schemas.Response
|
||||
"/{workflow_id}/pause", summary="停用工作流", response_model=schemas.Response[None]
|
||||
)
|
||||
def pause_workflow(
|
||||
workflow_id: int,
|
||||
@@ -285,7 +298,7 @@ def pause_workflow(
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{workflow_id}/reset", summary="重置工作流", response_model=schemas.Response
|
||||
"/{workflow_id}/reset", summary="重置工作流", response_model=schemas.Response[None]
|
||||
)
|
||||
async def reset_workflow(
|
||||
workflow_id: int,
|
||||
@@ -319,7 +332,7 @@ async def get_workflow(
|
||||
return await WorkflowOper(db).async_get(workflow_id)
|
||||
|
||||
|
||||
@router.put("/{workflow_id}", summary="更新工作流", response_model=schemas.Response)
|
||||
@router.put("/{workflow_id}", summary="更新工作流", response_model=schemas.Response[None])
|
||||
def update_workflow(
|
||||
workflow: schemas.Workflow,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -349,7 +362,7 @@ def update_workflow(
|
||||
return schemas.Response(success=True, message="更新成功")
|
||||
|
||||
|
||||
@router.delete("/{workflow_id}", summary="删除工作流", response_model=schemas.Response)
|
||||
@router.delete("/{workflow_id}", summary="删除工作流", response_model=schemas.Response[None])
|
||||
def delete_workflow(
|
||||
workflow_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
158
app/api/response.py
Normal file
158
app/api/response.py
Normal file
@@ -0,0 +1,158 @@
|
||||
import inspect
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.datastructures import DefaultPlaceholder
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.routing import APIRoute, get_typed_return_annotation
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.response import Response, ValidationIssue
|
||||
|
||||
|
||||
ERROR_RESPONSES: dict[int, dict[str, Any]] = {
|
||||
400: {"model": Response[None], "description": "请求错误"},
|
||||
401: {"model": Response[None], "description": "未认证"},
|
||||
403: {"model": Response[None], "description": "无权限"},
|
||||
404: {"model": Response[None], "description": "资源不存在"},
|
||||
409: {"model": Response[None], "description": "资源冲突"},
|
||||
422: {
|
||||
"model": Response[list[ValidationIssue]],
|
||||
"description": "请求参数校验失败",
|
||||
},
|
||||
500: {"model": Response[None], "description": "服务器内部错误"},
|
||||
}
|
||||
RAW_RESPONSE_OPENAPI_KEY = "x-moviepilot-raw-response"
|
||||
|
||||
|
||||
class ResponseAPIRoute(APIRoute):
|
||||
"""为普通 JSON 接口统一声明并生成 ``Response[T]`` 响应。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
endpoint: Callable[..., Any],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""根据原始响应模型决定是否包装接口定义及运行时返回值。"""
|
||||
response_model = kwargs.get("response_model")
|
||||
response_class = kwargs.get("response_class", JSONResponse)
|
||||
status_code = kwargs.get("status_code")
|
||||
openapi_extra = kwargs.get("openapi_extra") or {}
|
||||
force_raw = bool(openapi_extra.get(RAW_RESPONSE_OPENAPI_KEY))
|
||||
|
||||
if isinstance(response_model, DefaultPlaceholder):
|
||||
inferred_model = get_typed_return_annotation(endpoint)
|
||||
if self._is_native_response_model(inferred_model):
|
||||
response_model = None
|
||||
else:
|
||||
response_model = inferred_model or JsonData
|
||||
if response_model is Any:
|
||||
response_model = JsonData
|
||||
if response_model is Response:
|
||||
response_model = Response[JsonData]
|
||||
kwargs["response_model"] = response_model
|
||||
|
||||
should_wrap = self._should_wrap_response(
|
||||
response_model=response_model,
|
||||
response_class=response_class,
|
||||
status_code=status_code,
|
||||
force_raw=force_raw,
|
||||
)
|
||||
if should_wrap:
|
||||
kwargs["response_model"] = Response[response_model]
|
||||
endpoint = self._wrap_endpoint(endpoint)
|
||||
|
||||
kwargs["responses"] = self._merge_error_responses(
|
||||
kwargs.get("responses")
|
||||
)
|
||||
|
||||
super().__init__(path=path, endpoint=endpoint, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _should_wrap_response(
|
||||
response_model: Any,
|
||||
response_class: Any,
|
||||
status_code: int | None,
|
||||
force_raw: bool,
|
||||
) -> bool:
|
||||
"""判断当前路由是否属于需要统一封装的普通 JSON 接口。"""
|
||||
if force_raw or response_model is None or status_code in {204, 304}:
|
||||
return False
|
||||
|
||||
resolved_response_class = (
|
||||
response_class.value
|
||||
if isinstance(response_class, DefaultPlaceholder)
|
||||
else response_class
|
||||
)
|
||||
try:
|
||||
if not issubclass(resolved_response_class, JSONResponse):
|
||||
return False
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
return not ResponseAPIRoute._is_response_model(response_model)
|
||||
|
||||
@staticmethod
|
||||
def _is_response_model(response_model: Any) -> bool:
|
||||
"""判断声明模型是否已经是统一响应模型。"""
|
||||
try:
|
||||
return issubclass(response_model, Response)
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_native_response_model(response_model: Any) -> bool:
|
||||
"""判断返回注解是否声明为 Starlette 原生响应。"""
|
||||
try:
|
||||
return issubclass(response_model, StarletteResponse)
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _merge_error_responses(
|
||||
responses: dict[int | str, dict[str, Any]] | None,
|
||||
) -> dict[int | str, dict[str, Any]]:
|
||||
"""补齐统一错误模型,并保留端点已经显式声明的响应。"""
|
||||
merged_responses: dict[int | str, dict[str, Any]] = dict(ERROR_RESPONSES)
|
||||
merged_responses.update(responses or {})
|
||||
return merged_responses
|
||||
|
||||
@staticmethod
|
||||
def _wrap_endpoint(endpoint: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""包装端点返回值,同时保持原函数签名供 FastAPI 注入依赖。"""
|
||||
if inspect.iscoroutinefunction(endpoint):
|
||||
|
||||
@wraps(endpoint)
|
||||
async def async_endpoint(*args: Any, **kwargs: Any) -> Any:
|
||||
"""异步调用端点并封装普通业务数据。"""
|
||||
result = await endpoint(*args, **kwargs)
|
||||
return ResponseAPIRoute._wrap_result(result)
|
||||
|
||||
return async_endpoint
|
||||
|
||||
@wraps(endpoint)
|
||||
def sync_endpoint(*args: Any, **kwargs: Any) -> Any:
|
||||
"""同步调用端点并封装普通业务数据。"""
|
||||
result = endpoint(*args, **kwargs)
|
||||
return ResponseAPIRoute._wrap_result(result)
|
||||
|
||||
return sync_endpoint
|
||||
|
||||
@staticmethod
|
||||
def _wrap_result(result: Any) -> Any:
|
||||
"""保留已封装或原生响应,其余结果写入统一响应的数据区域。"""
|
||||
if isinstance(result, (Response, StarletteResponse)):
|
||||
return result
|
||||
return Response(success=True, data=result)
|
||||
|
||||
|
||||
class ResponseAPIRouter(APIRouter):
|
||||
"""默认使用统一响应路由类的 API 路由器。"""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
"""初始化路由器并允许调用方显式覆盖路由类。"""
|
||||
kwargs.setdefault("route_class", ResponseAPIRoute)
|
||||
super().__init__(**kwargs)
|
||||
@@ -1,10 +1,11 @@
|
||||
from typing import Any, List, Annotated
|
||||
from typing import List, Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ERROR_RESPONSES
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.tvdb import TvdbChain
|
||||
@@ -16,7 +17,7 @@ from app.schemas import RadarrMovie, SonarrSeries
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from version import APP_VERSION
|
||||
|
||||
arr_router = APIRouter(tags=["servarr"])
|
||||
arr_router = APIRouter(tags=["servarr"], responses=ERROR_RESPONSES)
|
||||
|
||||
|
||||
def _subscribe_tmdb_id(subscribe: Subscribe) -> int | None:
|
||||
@@ -30,12 +31,18 @@ def _subscribe_tmdb_id(subscribe: Subscribe) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
@arr_router.get("/system/status", summary="系统状态")
|
||||
async def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
@arr_router.get(
|
||||
"/system/status",
|
||||
summary="系统状态",
|
||||
response_model=schemas.ServarrSystemStatus,
|
||||
)
|
||||
async def arr_system_status(
|
||||
_: Annotated[str, Depends(verify_apikey)],
|
||||
) -> schemas.ServarrSystemStatus:
|
||||
"""
|
||||
模拟Radarr、Sonarr系统状态
|
||||
"""
|
||||
return {
|
||||
return schemas.ServarrSystemStatus.model_validate({
|
||||
"appName": "MoviePilot",
|
||||
"instanceName": "moviepilot",
|
||||
"version": APP_VERSION,
|
||||
@@ -81,16 +88,22 @@ async def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"packageAuthor": "jxxghp",
|
||||
"packageUpdateMechanism": "builtIn",
|
||||
"packageUpdateMechanismMessage": "",
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@arr_router.get("/qualityProfile", summary="质量配置")
|
||||
async def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
@arr_router.get(
|
||||
"/qualityProfile",
|
||||
summary="质量配置",
|
||||
response_model=List[schemas.ServarrQualityProfile],
|
||||
)
|
||||
async def arr_qualityProfile(
|
||||
_: Annotated[str, Depends(verify_apikey)],
|
||||
) -> List[schemas.ServarrQualityProfile]:
|
||||
"""
|
||||
模拟Radarr、Sonarr质量配置
|
||||
"""
|
||||
return [
|
||||
{
|
||||
schemas.ServarrQualityProfile.model_validate({
|
||||
"id": 1,
|
||||
"name": "默认",
|
||||
"upgradeAllowed": True,
|
||||
@@ -112,41 +125,55 @@ async def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"minFormatScore": 0,
|
||||
"cutoffFormatScore": 0,
|
||||
"formatItems": [{"id": 0, "format": 0, "name": "默认", "score": 0}],
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
|
||||
@arr_router.get("/rootfolder", summary="根目录")
|
||||
async def arr_rootfolder(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
@arr_router.get(
|
||||
"/rootfolder",
|
||||
summary="根目录",
|
||||
response_model=List[schemas.ServarrRootFolder],
|
||||
)
|
||||
async def arr_rootfolder(
|
||||
_: Annotated[str, Depends(verify_apikey)],
|
||||
) -> List[schemas.ServarrRootFolder]:
|
||||
"""
|
||||
模拟Radarr、Sonarr根目录
|
||||
"""
|
||||
return [
|
||||
{
|
||||
schemas.ServarrRootFolder.model_validate({
|
||||
"id": 1,
|
||||
"path": "/",
|
||||
"accessible": True,
|
||||
"freeSpace": 0,
|
||||
"unmappedFolders": [],
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
|
||||
@arr_router.get("/tag", summary="标签")
|
||||
async def arr_tag(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
@arr_router.get("/tag", summary="标签", response_model=List[schemas.ServarrTag])
|
||||
async def arr_tag(
|
||||
_: Annotated[str, Depends(verify_apikey)],
|
||||
) -> List[schemas.ServarrTag]:
|
||||
"""
|
||||
模拟Radarr、Sonarr标签
|
||||
"""
|
||||
return [{"id": 1, "label": "默认"}]
|
||||
return [schemas.ServarrTag(id=1, label="默认")]
|
||||
|
||||
|
||||
@arr_router.get("/languageprofile", summary="语言")
|
||||
async def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
@arr_router.get(
|
||||
"/languageprofile",
|
||||
summary="语言",
|
||||
response_model=List[schemas.ServarrLanguageProfile],
|
||||
)
|
||||
async def arr_languageprofile(
|
||||
_: Annotated[str, Depends(verify_apikey)],
|
||||
) -> List[schemas.ServarrLanguageProfile]:
|
||||
"""
|
||||
模拟Radarr、Sonarr语言
|
||||
"""
|
||||
return [
|
||||
{
|
||||
schemas.ServarrLanguageProfile.model_validate({
|
||||
"id": 1,
|
||||
"name": "默认",
|
||||
"upgradeAllowed": True,
|
||||
@@ -154,7 +181,7 @@ async def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
"languages": [
|
||||
{"id": 1, "language": {"id": 1, "name": "默认"}, "allowed": True}
|
||||
],
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
|
||||
@@ -163,7 +190,7 @@ async def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
|
||||
)
|
||||
async def arr_movies(
|
||||
_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)
|
||||
) -> Any:
|
||||
) -> List[schemas.RadarrMovie]:
|
||||
"""
|
||||
查询Rardar电影
|
||||
"""
|
||||
@@ -259,7 +286,7 @@ async def arr_movies(
|
||||
)
|
||||
def arr_movie_lookup(
|
||||
term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)
|
||||
) -> Any:
|
||||
) -> List[schemas.RadarrMovie]:
|
||||
"""
|
||||
查询Rardar电影 term: `tmdb:${id}`
|
||||
存在和不存在均不能返回错误
|
||||
@@ -319,7 +346,7 @@ async def arr_movie(
|
||||
mid: int,
|
||||
_: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
) -> Any:
|
||||
) -> schemas.RadarrMovie:
|
||||
"""
|
||||
查询Rardar电影订阅
|
||||
"""
|
||||
@@ -340,12 +367,14 @@ async def arr_movie(
|
||||
raise HTTPException(status_code=404, detail="未找到该电影!")
|
||||
|
||||
|
||||
@arr_router.post("/movie", summary="新增电影订阅")
|
||||
@arr_router.post(
|
||||
"/movie", summary="新增电影订阅", response_model=schemas.ServarrIdResponse
|
||||
)
|
||||
async def arr_add_movie(
|
||||
_: Annotated[str, Depends(verify_apikey)],
|
||||
movie: RadarrMovie,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
) -> Any:
|
||||
) -> schemas.ServarrIdResponse:
|
||||
"""
|
||||
新增Rardar电影订阅
|
||||
"""
|
||||
@@ -354,7 +383,7 @@ async def arr_add_movie(
|
||||
db, MediaSource.TMDB.value, str(movie.tmdbId)
|
||||
)
|
||||
if subscribes:
|
||||
return {"id": subscribes[0].id}
|
||||
return schemas.ServarrIdResponse(id=subscribes[0].id)
|
||||
# 添加订阅
|
||||
sid, message = await SubscribeChain().async_add(
|
||||
title=movie.title,
|
||||
@@ -365,19 +394,19 @@ async def arr_add_movie(
|
||||
username="Seerr",
|
||||
)
|
||||
if sid:
|
||||
return {"id": sid}
|
||||
return schemas.ServarrIdResponse(id=sid)
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}")
|
||||
|
||||
|
||||
@arr_router.delete(
|
||||
"/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response
|
||||
"/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response[None]
|
||||
)
|
||||
async def arr_remove_movie(
|
||||
mid: int,
|
||||
_: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
) -> Any:
|
||||
) -> schemas.Response[None]:
|
||||
"""
|
||||
删除Rardar电影订阅
|
||||
"""
|
||||
@@ -394,7 +423,7 @@ async def arr_remove_movie(
|
||||
)
|
||||
async def arr_series(
|
||||
_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)
|
||||
) -> Any:
|
||||
) -> List[schemas.SonarrSeries]:
|
||||
"""
|
||||
查询Sonarr剧集
|
||||
"""
|
||||
@@ -531,10 +560,14 @@ async def arr_series(
|
||||
return result
|
||||
|
||||
|
||||
@arr_router.get("/series/lookup", summary="查询剧集")
|
||||
@arr_router.get(
|
||||
"/series/lookup",
|
||||
summary="查询剧集",
|
||||
response_model=List[schemas.SonarrSeries],
|
||||
)
|
||||
def arr_series_lookup(
|
||||
term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)
|
||||
) -> Any:
|
||||
) -> List[schemas.SonarrSeries]:
|
||||
"""
|
||||
查询Sonarr剧集 term: `tvdb:${id}` title
|
||||
"""
|
||||
@@ -641,12 +674,14 @@ def arr_series_lookup(
|
||||
return sonarr_series_list if sonarr_series_list else [SonarrSeries()]
|
||||
|
||||
|
||||
@arr_router.get("/series/{tid}", summary="剧集详情")
|
||||
@arr_router.get(
|
||||
"/series/{tid}", summary="剧集详情", response_model=schemas.SonarrSeries
|
||||
)
|
||||
async def arr_serie(
|
||||
tid: int,
|
||||
_: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
) -> Any:
|
||||
) -> schemas.SonarrSeries:
|
||||
"""
|
||||
查询Sonarr剧集
|
||||
"""
|
||||
@@ -676,12 +711,14 @@ async def arr_serie(
|
||||
raise HTTPException(status_code=404, detail="未找到该电视剧!")
|
||||
|
||||
|
||||
@arr_router.post("/series", summary="新增剧集订阅")
|
||||
@arr_router.post(
|
||||
"/series", summary="新增剧集订阅", response_model=schemas.ServarrIdResponse
|
||||
)
|
||||
async def arr_add_series(
|
||||
tv: schemas.SonarrSeries,
|
||||
_: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
) -> Any:
|
||||
) -> schemas.ServarrIdResponse:
|
||||
"""
|
||||
新增Sonarr剧集订阅
|
||||
"""
|
||||
@@ -692,24 +729,24 @@ async def arr_add_series(
|
||||
db,
|
||||
media_source=MediaSource.TMDB.value,
|
||||
media_id=str(tv.tmdbId),
|
||||
season=season.get("seasonNumber"),
|
||||
season=season.seasonNumber,
|
||||
)
|
||||
if subscribe:
|
||||
continue
|
||||
left_seasons.append(season)
|
||||
# 全部已存在订阅
|
||||
if not left_seasons:
|
||||
return {"id": 1}
|
||||
return schemas.ServarrIdResponse(id=1)
|
||||
# 剩下的添加订阅
|
||||
sid = 0
|
||||
message = ""
|
||||
for season in left_seasons:
|
||||
if not season.get("monitored"):
|
||||
if not season.monitored:
|
||||
continue
|
||||
sid, message = await SubscribeChain().async_add(
|
||||
title=tv.title,
|
||||
year=tv.year,
|
||||
season=season.get("seasonNumber"),
|
||||
season=season.seasonNumber,
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id=str(tv.tmdbId),
|
||||
mtype=MediaType.TV,
|
||||
@@ -717,27 +754,33 @@ async def arr_add_series(
|
||||
)
|
||||
|
||||
if sid:
|
||||
return {"id": sid}
|
||||
return schemas.ServarrIdResponse(id=sid)
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}")
|
||||
|
||||
|
||||
@arr_router.put("/series", summary="更新剧集订阅")
|
||||
@arr_router.put(
|
||||
"/series", summary="更新剧集订阅", response_model=schemas.ServarrIdResponse
|
||||
)
|
||||
async def arr_update_series(
|
||||
tv: schemas.SonarrSeries, _: Annotated[str, Depends(verify_apikey)]
|
||||
) -> Any:
|
||||
tv: schemas.SonarrSeries,
|
||||
_: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
) -> schemas.ServarrIdResponse:
|
||||
"""
|
||||
更新Sonarr剧集订阅
|
||||
"""
|
||||
return await arr_add_series(tv)
|
||||
return await arr_add_series(tv=tv, _=_, db=db)
|
||||
|
||||
|
||||
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
|
||||
@arr_router.delete(
|
||||
"/series/{tid}", summary="删除剧集订阅", response_model=schemas.Response[None]
|
||||
)
|
||||
async def arr_remove_series(
|
||||
tid: int,
|
||||
_: Annotated[str, Depends(verify_apikey)],
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
) -> Any:
|
||||
) -> schemas.Response[None]:
|
||||
"""
|
||||
删除Sonarr剧集订阅
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import gzip
|
||||
import hmac
|
||||
import json
|
||||
from typing import Annotated, Callable, Any, Dict, Optional
|
||||
from typing import Annotated, Callable, Optional
|
||||
|
||||
import aiofiles
|
||||
from anyio import Path as AsyncPath
|
||||
@@ -10,13 +10,17 @@ from fastapi.responses import PlainTextResponse
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from app import schemas
|
||||
from app.api.response import ERROR_RESPONSES
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.utils.crypto import CryptoJsUtils, HashUtils
|
||||
|
||||
|
||||
class GzipRequest(Request):
|
||||
"""按请求头透明解压 gzip 请求体。"""
|
||||
|
||||
async def body(self) -> bytes:
|
||||
"""读取请求体,并在需要时完成 gzip 解压。"""
|
||||
if not hasattr(self, "_body"):
|
||||
body = await super().body()
|
||||
if "gzip" in self.headers.getlist("Content-Encoding"):
|
||||
@@ -26,17 +30,21 @@ class GzipRequest(Request):
|
||||
|
||||
|
||||
class GzipRoute(APIRoute):
|
||||
"""为 CookieCloud 路由注入 gzip 请求对象。"""
|
||||
|
||||
def get_route_handler(self) -> Callable:
|
||||
"""返回支持 gzip 请求体的路由处理器。"""
|
||||
original_route_handler = super().get_route_handler()
|
||||
|
||||
async def custom_route_handler(request: Request) -> Response:
|
||||
"""将原始请求替换为可解压的请求对象后继续处理。"""
|
||||
request = GzipRequest(request.scope, request.receive)
|
||||
return await original_route_handler(request)
|
||||
|
||||
return custom_route_handler
|
||||
|
||||
|
||||
async def verify_server_enabled():
|
||||
async def verify_server_enabled() -> bool:
|
||||
"""
|
||||
校验CookieCloud服务路由是否打开
|
||||
"""
|
||||
@@ -49,7 +57,7 @@ async def verify_update_auth(
|
||||
x_cookiecloud_auth: Annotated[
|
||||
Optional[str], Header(alias="X-CookieCloud-Auth")
|
||||
] = None,
|
||||
):
|
||||
) -> bool:
|
||||
"""
|
||||
校验CookieCloud上传接口的可选共享认证头。
|
||||
"""
|
||||
@@ -67,21 +75,48 @@ cookie_router = APIRouter(
|
||||
route_class=GzipRoute,
|
||||
tags=["servcookie"],
|
||||
dependencies=[Depends(verify_server_enabled)],
|
||||
responses=ERROR_RESPONSES,
|
||||
)
|
||||
|
||||
|
||||
@cookie_router.get("/", response_class=PlainTextResponse)
|
||||
async def get_root():
|
||||
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
|
||||
@cookie_router.get(
|
||||
"/",
|
||||
response_model=None,
|
||||
response_class=Response,
|
||||
responses={
|
||||
200: {
|
||||
"description": "CookieCloud 服务说明",
|
||||
"content": {"text/plain": {"schema": {"type": "string"}}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def get_root() -> PlainTextResponse:
|
||||
"""返回 CookieCloud 兼容服务的根路径说明。"""
|
||||
return PlainTextResponse("Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud")
|
||||
|
||||
|
||||
@cookie_router.post("/", response_class=PlainTextResponse)
|
||||
async def post_root():
|
||||
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
|
||||
@cookie_router.post(
|
||||
"/",
|
||||
response_model=None,
|
||||
response_class=Response,
|
||||
responses={
|
||||
200: {
|
||||
"description": "CookieCloud 服务说明",
|
||||
"content": {"text/plain": {"schema": {"type": "string"}}},
|
||||
}
|
||||
},
|
||||
)
|
||||
async def post_root() -> PlainTextResponse:
|
||||
"""通过 POST 返回 CookieCloud 兼容服务的根路径说明。"""
|
||||
return PlainTextResponse("Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud")
|
||||
|
||||
|
||||
@cookie_router.post("/update", dependencies=[Depends(verify_update_auth)])
|
||||
async def update_cookie(req: schemas.CookieData):
|
||||
@cookie_router.post(
|
||||
"/update",
|
||||
dependencies=[Depends(verify_update_auth)],
|
||||
response_model=schemas.CookieActionResponse,
|
||||
)
|
||||
async def update_cookie(req: schemas.CookieData) -> schemas.CookieActionResponse:
|
||||
"""
|
||||
上传Cookie数据
|
||||
"""
|
||||
@@ -92,31 +127,31 @@ async def update_cookie(req: schemas.CookieData):
|
||||
async with aiofiles.open(file_path, encoding="utf-8", errors="replace", mode="r") as file:
|
||||
read_content = await file.read()
|
||||
if read_content == content:
|
||||
return {"action": "done"}
|
||||
return schemas.CookieActionResponse(action="done")
|
||||
else:
|
||||
return {"action": "error"}
|
||||
return schemas.CookieActionResponse(action="error")
|
||||
|
||||
|
||||
async def load_encrypt_data(uuid: str) -> Dict[str, Any]:
|
||||
async def load_encrypt_data(uuid: str) -> schemas.CookieEncryptedPayload:
|
||||
"""
|
||||
加载本地加密原始数据
|
||||
"""
|
||||
file_path = AsyncPath(settings.COOKIE_PATH) / f"{uuid}.json"
|
||||
|
||||
# 检查文件是否存在
|
||||
if not file_path.exists():
|
||||
if not await file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
# 读取文件
|
||||
async with aiofiles.open(file_path, encoding="utf-8", errors="replace", mode="r") as file:
|
||||
read_content = await file.read()
|
||||
data = json.loads(read_content.encode("utf-8"))
|
||||
return data
|
||||
return schemas.CookieEncryptedPayload.model_validate(data)
|
||||
|
||||
|
||||
def get_decrypted_cookie_data(
|
||||
uuid: str, password: str, encrypted: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
) -> Optional[schemas.CookieDecryptedPayload]:
|
||||
"""
|
||||
加载本地加密数据并解密为Cookie
|
||||
"""
|
||||
@@ -128,7 +163,7 @@ def get_decrypted_cookie_data(
|
||||
decrypted_data = CryptoJsUtils.decrypt(encrypted, aes_key).decode("utf-8")
|
||||
decrypted_data = json.loads(decrypted_data)
|
||||
if "cookie_data" in decrypted_data:
|
||||
return decrypted_data
|
||||
return schemas.CookieDecryptedPayload.model_validate(decrypted_data)
|
||||
except Exception as e:
|
||||
logger.error(f"解密Cookie数据失败:{str(e)}")
|
||||
return None
|
||||
@@ -136,26 +171,33 @@ def get_decrypted_cookie_data(
|
||||
return None
|
||||
|
||||
|
||||
@cookie_router.get("/get/{uuid}")
|
||||
@cookie_router.get("/get/{uuid}", response_model=schemas.CookieEncryptedPayload)
|
||||
async def get_cookie(
|
||||
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")],
|
||||
):
|
||||
) -> schemas.CookieEncryptedPayload:
|
||||
"""
|
||||
GET 下载加密数据
|
||||
"""
|
||||
return await load_encrypt_data(uuid)
|
||||
return schemas.CookieEncryptedPayload.model_validate(
|
||||
await load_encrypt_data(uuid)
|
||||
)
|
||||
|
||||
|
||||
@cookie_router.post("/get/{uuid}")
|
||||
@cookie_router.post(
|
||||
"/get/{uuid}",
|
||||
response_model=schemas.CookieEncryptedPayload | schemas.CookieDecryptedPayload | None,
|
||||
)
|
||||
async def post_cookie(
|
||||
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")],
|
||||
request: Optional[schemas.CookiePassword] = Body(None),
|
||||
):
|
||||
) -> schemas.CookieEncryptedPayload | schemas.CookieDecryptedPayload | None:
|
||||
"""
|
||||
POST 下载加密数据
|
||||
"""
|
||||
data = await load_encrypt_data(uuid)
|
||||
data = schemas.CookieEncryptedPayload.model_validate(
|
||||
await load_encrypt_data(uuid)
|
||||
)
|
||||
if request is not None:
|
||||
return get_decrypted_cookie_data(uuid, request.password, data["encrypted"])
|
||||
return get_decrypted_cookie_data(uuid, request.password, data.encrypted)
|
||||
else:
|
||||
return data
|
||||
|
||||
@@ -122,8 +122,18 @@ class WorkflowExecutor:
|
||||
)
|
||||
self.actions = {action['id']: Action(**action) for action in workflow.actions}
|
||||
self.flows = [ActionFlow(**flow) for flow in workflow.flows]
|
||||
self.execution_config = getattr(workflow, "execution_config", None) or {}
|
||||
self.restored_execution_state = getattr(workflow, "execution_state", None) or {}
|
||||
execution_config = getattr(workflow, "execution_config", None) or {}
|
||||
execution_state = getattr(workflow, "execution_state", None) or {}
|
||||
self.execution_config = (
|
||||
execution_config.model_dump(exclude_none=True)
|
||||
if isinstance(execution_config, BaseModel)
|
||||
else execution_config
|
||||
)
|
||||
self.restored_execution_state = (
|
||||
execution_state.model_dump(exclude_none=True)
|
||||
if isinstance(execution_state, BaseModel)
|
||||
else execution_state
|
||||
)
|
||||
self.total_actions = len(self.actions)
|
||||
self.success = True
|
||||
self.has_failure = False
|
||||
|
||||
@@ -28,8 +28,6 @@ _VIDEO_SEASON_EPISODE_RE = re.compile(
|
||||
_ANIME_SQUARE_BRACKET_RE = re.compile(r'\[[+0-9XVPI-]+]\s*\[', re.IGNORECASE)
|
||||
|
||||
_BRACED_METAINFO_RE = re.compile(r'(?<={\[)[\W\w]+(?=]})')
|
||||
_BRACED_MEDIA_SOURCE_RE = re.compile(r'(?:^|;)media_source=([^;]+)(?=;|$)', re.IGNORECASE)
|
||||
_BRACED_MEDIA_ID_RE = re.compile(r'(?:^|;)media_id=([^;]+)(?=;|$)', re.IGNORECASE)
|
||||
_BRACED_TMDBID_RE = re.compile(r'(?<=tmdbid=)\d+')
|
||||
_BRACED_DOUBANID_RE = re.compile(r'(?<=doubanid=)\d+')
|
||||
_BRACED_BANGUMIID_RE = re.compile(r'(?<=bangumiid=)\d+')
|
||||
@@ -64,7 +62,6 @@ _EXTENDED_MEDIA_ID_TAG_RE = re.compile(
|
||||
r'(?:bangumi(?:id)?|anilist(?:id)?)[=\-]\d+',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_GENERIC_MEDIA_ID_TAG_RE = re.compile(r'(?:^|[;\[])media_(?:source|id)=', re.IGNORECASE)
|
||||
_RUST_PARSE_OPTIONS_CACHE_KEY = "_cache_key"
|
||||
|
||||
_LEGACY_BRACED_ID_PATTERNS = (
|
||||
@@ -161,20 +158,12 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
|
||||
"""
|
||||
metainfo = _empty_metainfo()
|
||||
legacy_identities = {}
|
||||
generic_identity = (None, None)
|
||||
if not title:
|
||||
return title, metainfo
|
||||
# 当前格式为 {[media_source=...;media_id=...]},历史专用标签仅在此处兼容读取。
|
||||
# 自定义识别词是面向用户的独立语法,继续使用各数据源专用 ID 字段。
|
||||
results = _BRACED_METAINFO_RE.findall(title)
|
||||
if results:
|
||||
for result in results:
|
||||
source_match = _BRACED_MEDIA_SOURCE_RE.search(result)
|
||||
media_id_match = _BRACED_MEDIA_ID_RE.search(result)
|
||||
if source_match and media_id_match:
|
||||
generic_identity = resolve_media_identity(
|
||||
media_source=source_match.group(1).strip(),
|
||||
media_id=media_id_match.group(1).strip(),
|
||||
)
|
||||
legacy_matches = []
|
||||
for source, pattern in _LEGACY_BRACED_ID_PATTERNS:
|
||||
legacy_match = pattern.search(result)
|
||||
@@ -211,9 +200,7 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
|
||||
metainfo['end_episode'] = int(end_episode.group(0))
|
||||
# 去除title中该部分
|
||||
if (
|
||||
source_match
|
||||
or media_id_match
|
||||
or legacy_matches
|
||||
legacy_matches
|
||||
or mtype
|
||||
or episode_group
|
||||
or begin_season
|
||||
@@ -252,12 +239,11 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]:
|
||||
title = media_id_re.sub('', title).strip()
|
||||
break
|
||||
|
||||
media_source, media_id = generic_identity
|
||||
if not media_source:
|
||||
for source, _ in _LEGACY_ID_KEYS:
|
||||
if legacy_identities.get(source):
|
||||
media_source, media_id = source, legacy_identities[source]
|
||||
break
|
||||
media_source, media_id = None, None
|
||||
for source, _ in _LEGACY_ID_KEYS:
|
||||
if legacy_identities.get(source):
|
||||
media_source, media_id = source, legacy_identities[source]
|
||||
break
|
||||
metainfo['media_source'] = media_source
|
||||
metainfo['media_id'] = media_id
|
||||
|
||||
@@ -432,18 +418,13 @@ def _requires_python_metainfo(
|
||||
custom_words: Optional[List[str]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
判断标题或临时识别词是否包含当前 Rust 扩展尚未支持的媒体身份标签。
|
||||
判断标题或临时识别词是否包含当前 Rust 扩展尚未支持的数据源专用 ID 标签。
|
||||
|
||||
:param title: 原始标题
|
||||
:param custom_words: 临时识别词
|
||||
:return: 是否必须使用Python解析器
|
||||
"""
|
||||
candidates = [title or "", *(custom_words or [])]
|
||||
contains_generic_id = any(
|
||||
_GENERIC_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates
|
||||
)
|
||||
if contains_generic_id and not rust_accel.supports_unified_media_identity():
|
||||
return True
|
||||
contains_extended_id = any(
|
||||
_EXTENDED_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates
|
||||
)
|
||||
|
||||
266
app/factory.py
266
app/factory.py
@@ -1,13 +1,24 @@
|
||||
import json
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from app.api.apiv2_utils import OPENAPI_V2_PATH, V2ResponseMiddleware
|
||||
from app.api.response import ResponseAPIRoute
|
||||
from app.core.config import settings
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.log import logger
|
||||
from app.schemas.openai import (
|
||||
AnthropicErrorDetail,
|
||||
AnthropicErrorResponse,
|
||||
OpenAIErrorDetail,
|
||||
OpenAIErrorResponse,
|
||||
)
|
||||
from app.schemas.mcp import McpJsonRpcError, McpJsonRpcErrorDetail
|
||||
from app.schemas.response import Response as ApiResponse, ValidationIssue
|
||||
from app.startup.lifecycle import lifespan
|
||||
from version import APP_VERSION
|
||||
|
||||
@@ -24,29 +35,248 @@ def _get_http_exception_message(detail: Any) -> str:
|
||||
return str(detail)
|
||||
|
||||
|
||||
def _localize_exception_message(request: Request, message: str) -> str:
|
||||
"""直接按异常所属请求的语言翻译消息,避免中间件上下文已被恢复。"""
|
||||
return LocaleHelper.translate_text(
|
||||
message,
|
||||
locale=LocaleHelper.get_locale_from_request(request),
|
||||
)
|
||||
|
||||
|
||||
def _is_mcp_jsonrpc_request(request: Request) -> bool:
|
||||
"""判断请求是否指向保持原生响应的 MCP JSON-RPC 根端点。"""
|
||||
request_path = getattr(getattr(request, "url", None), "path", "")
|
||||
return request_path.rstrip("/") == f"{settings.API_V1_STR}/mcp"
|
||||
|
||||
|
||||
def _get_native_ai_protocol(request: Request) -> str | None:
|
||||
"""识别需要保持原生错误体的 OpenAI 或 Anthropic 兼容请求。"""
|
||||
request_path = getattr(getattr(request, "url", None), "path", "")
|
||||
if request_path.startswith(f"{settings.API_V1_STR}/openai/v1/"):
|
||||
return "openai"
|
||||
if request_path.startswith(f"{settings.API_V1_STR}/anthropic/v1/"):
|
||||
return "anthropic"
|
||||
return None
|
||||
|
||||
|
||||
def _native_ai_error_response(
|
||||
protocol: str,
|
||||
status_code: int,
|
||||
message: str,
|
||||
) -> JSONResponse:
|
||||
"""按 OpenAI 或 Anthropic 兼容协议构造原生错误响应。"""
|
||||
if protocol == "openai":
|
||||
error_type = (
|
||||
"authentication_error"
|
||||
if status_code in {401, 403}
|
||||
else "server_error"
|
||||
if status_code >= 500
|
||||
else "invalid_request_error"
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=OpenAIErrorResponse(
|
||||
error=OpenAIErrorDetail(
|
||||
message=message,
|
||||
type=error_type,
|
||||
code=error_type,
|
||||
)
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
error_type = (
|
||||
"authentication_error"
|
||||
if status_code in {401, 403}
|
||||
else "api_error"
|
||||
if status_code >= 500
|
||||
else "invalid_request_error"
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=AnthropicErrorResponse(
|
||||
error=AnthropicErrorDetail(type=error_type, message=message)
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
|
||||
def _mcp_jsonrpc_error_response(
|
||||
status_code: int,
|
||||
code: int,
|
||||
message: str,
|
||||
) -> JSONResponse:
|
||||
"""构造带 HTTP 状态码的 MCP JSON-RPC 原生错误响应。"""
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=McpJsonRpcError(
|
||||
jsonrpc="2.0",
|
||||
id=None,
|
||||
error=McpJsonRpcErrorDetail(code=code, message=message),
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
|
||||
def _protocol_validation_error_response(
|
||||
request: Request,
|
||||
exc: RequestValidationError,
|
||||
) -> JSONResponse | None:
|
||||
"""为 OpenAI 与 Anthropic 兼容端点生成协议原生的参数错误响应。"""
|
||||
errors = exc.errors()
|
||||
first_error = errors[0] if errors else {}
|
||||
location = ".".join(
|
||||
str(item)
|
||||
for item in first_error.get("loc", ())
|
||||
if item not in {"body"}
|
||||
)
|
||||
message = str(first_error.get("msg") or "Invalid request parameters.")
|
||||
native_ai_protocol = _get_native_ai_protocol(request)
|
||||
|
||||
if native_ai_protocol == "openai":
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=OpenAIErrorResponse(
|
||||
error=OpenAIErrorDetail(
|
||||
message=message,
|
||||
type="invalid_request_error",
|
||||
param=location or None,
|
||||
code="invalid_request_error",
|
||||
)
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
if native_ai_protocol == "anthropic":
|
||||
if location:
|
||||
message = f"{location}: {message}"
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=AnthropicErrorResponse(
|
||||
error=AnthropicErrorDetail(
|
||||
type="invalid_request_error",
|
||||
message=message,
|
||||
)
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
if _is_mcp_jsonrpc_request(request):
|
||||
if location:
|
||||
message = f"{location}: {message}"
|
||||
return _mcp_jsonrpc_error_response(
|
||||
status_code=422,
|
||||
code=-32602,
|
||||
message=message,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def localized_http_exception_handler(
|
||||
_request: Request,
|
||||
request: Request,
|
||||
exc: HTTPException,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
将 HTTPException 响应统一封装为 Response 结构并保留原始错误消息。
|
||||
|
||||
:param _request: 当前 HTTP 请求
|
||||
:param request: 当前 HTTP 请求
|
||||
:param exc: FastAPI HTTP 异常
|
||||
:return: 统一 JSON 错误响应
|
||||
"""
|
||||
message = _get_http_exception_message(exc.detail)
|
||||
message = _localize_exception_message(
|
||||
request,
|
||||
_get_http_exception_message(exc.detail),
|
||||
)
|
||||
native_ai_protocol = _get_native_ai_protocol(request)
|
||||
if native_ai_protocol:
|
||||
return _native_ai_error_response(
|
||||
protocol=native_ai_protocol,
|
||||
status_code=exc.status_code,
|
||||
message=message,
|
||||
)
|
||||
if _is_mcp_jsonrpc_request(request):
|
||||
error_codes = {
|
||||
400: -32600,
|
||||
401: -32001,
|
||||
403: -32001,
|
||||
404: -32601,
|
||||
409: -32009,
|
||||
}
|
||||
return _mcp_jsonrpc_error_response(
|
||||
status_code=exc.status_code,
|
||||
code=error_codes.get(exc.status_code, -32000),
|
||||
message=message,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"success": False,
|
||||
"message": message,
|
||||
"data": {},
|
||||
},
|
||||
content=ApiResponse[None](success=False, message=message).model_dump(mode="json"),
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
|
||||
async def localized_validation_exception_handler(
|
||||
request: Request,
|
||||
exc: RequestValidationError,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
将请求参数校验错误转换为统一响应并保留结构化错误数据。
|
||||
|
||||
:param request: 当前 HTTP 请求
|
||||
:param exc: FastAPI 请求参数校验异常
|
||||
:return: 统一 JSON 错误响应
|
||||
"""
|
||||
protocol_response = _protocol_validation_error_response(request, exc)
|
||||
if protocol_response is not None:
|
||||
return protocol_response
|
||||
|
||||
errors = [
|
||||
ValidationIssue(
|
||||
location=list(error.get("loc", ())),
|
||||
message=str(error.get("msg") or "请求参数错误"),
|
||||
error_type=str(error.get("type") or "validation_error"),
|
||||
)
|
||||
for error in exc.errors()
|
||||
]
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=ApiResponse[list[ValidationIssue]](
|
||||
success=False,
|
||||
message=_localize_exception_message(request, "请求参数不正确"),
|
||||
data=errors,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
|
||||
async def localized_unhandled_exception_handler(
|
||||
request: Request,
|
||||
exc: Exception,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
将未捕获异常隐藏为统一的服务器错误响应,避免泄露内部细节。
|
||||
|
||||
:param request: 当前 HTTP 请求
|
||||
:param exc: 未捕获异常
|
||||
:return: 统一 JSON 错误响应
|
||||
"""
|
||||
logger.error(
|
||||
f"API 请求发生未捕获异常: {exc}",
|
||||
exc_info=(type(exc), exc, exc.__traceback__),
|
||||
)
|
||||
native_ai_protocol = _get_native_ai_protocol(request)
|
||||
if native_ai_protocol:
|
||||
return _native_ai_error_response(
|
||||
protocol=native_ai_protocol,
|
||||
status_code=500,
|
||||
message="Internal server error.",
|
||||
)
|
||||
if _is_mcp_jsonrpc_request(request):
|
||||
return _mcp_jsonrpc_error_response(
|
||||
status_code=500,
|
||||
code=-32603,
|
||||
message="Internal error",
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ApiResponse[None](
|
||||
success=False,
|
||||
message=_localize_exception_message(request, "未知错误"),
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""
|
||||
创建并配置 FastAPI 应用实例。
|
||||
@@ -54,16 +284,18 @@ def create_app() -> FastAPI:
|
||||
_app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=APP_VERSION,
|
||||
openapi_url=OPENAPI_V2_PATH,
|
||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
@_app.get(f"{settings.API_V1_STR}/openapi.json", include_in_schema=False)
|
||||
def get_v1_openapi_schema() -> dict[str, Any]:
|
||||
"""保留旧版 OpenAPI 地址并返回当前完整接口文档。"""
|
||||
return _app.openapi()
|
||||
|
||||
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
|
||||
_app.add_exception_handler(
|
||||
RequestValidationError,
|
||||
localized_validation_exception_handler,
|
||||
)
|
||||
_app.add_exception_handler(Exception, localized_unhandled_exception_handler)
|
||||
# 动态注册的插件接口也必须使用统一响应路由类。
|
||||
_app.router.route_class = ResponseAPIRoute
|
||||
|
||||
# 配置 CORS 中间件
|
||||
_app.add_middleware(
|
||||
@@ -73,8 +305,6 @@ def create_app() -> FastAPI:
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
_app.add_middleware(V2ResponseMiddleware)
|
||||
|
||||
@_app.middleware("http")
|
||||
async def locale_context_middleware(
|
||||
request: Request,
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"调用工具失败": "Tool call failed",
|
||||
"无效的媒体来源": "Invalid media source",
|
||||
"该媒体来源不支持此音乐接口": "This media source is not supported by this music endpoint",
|
||||
"媒体来源和媒体 ID 必须同时提供": "Media source and media ID must be provided together",
|
||||
@@ -1370,6 +1371,10 @@
|
||||
"source": "同步媒体服务器 - {name}",
|
||||
"target": "Sync Media Server - {name}"
|
||||
},
|
||||
{
|
||||
"source": "调用工具失败: {error}",
|
||||
"target": "Tool call failed: {error}"
|
||||
},
|
||||
{
|
||||
"source": "{name} 执行完成",
|
||||
"target": "{name_i18n} completed"
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"调用工具失败": "調用工具失敗",
|
||||
"媒体来源和媒体 ID 必须同时提供": "媒體來源和媒體 ID 必須同時提供",
|
||||
"media_source 和 media_id 必须同时提供": "media_source 和 media_id 必須同時提供",
|
||||
"模块不支持测试": "模組不支援測試",
|
||||
@@ -1366,6 +1367,10 @@
|
||||
"source": "同步媒体服务器 - {name}",
|
||||
"target": "同步媒體伺服器 - {name}"
|
||||
},
|
||||
{
|
||||
"source": "调用工具失败: {error}",
|
||||
"target": "調用工具失敗: {error}"
|
||||
},
|
||||
{
|
||||
"source": "{name} 执行完成",
|
||||
"target": "{name_i18n} 執行完成"
|
||||
|
||||
@@ -1220,12 +1220,16 @@ class TransHandler:
|
||||
:param file_ext: 文件扩展名
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
"""
|
||||
return TemplateHelper().builder.build(
|
||||
naming_context = TemplateHelper().builder.build(
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
file_extension=file_ext,
|
||||
episodes_info=episodes_info,
|
||||
)
|
||||
# 重命名格式是独立的用户配置契约,继续只暴露各数据源原有 ID 变量。
|
||||
naming_context.pop("media_source", None)
|
||||
naming_context.pop("media_id", None)
|
||||
return naming_context
|
||||
|
||||
@staticmethod
|
||||
def __delete_version_files(storage_oper: StorageBase, path: Path) -> bool:
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from .agent import *
|
||||
from .cache import *
|
||||
from .category import *
|
||||
from .common import *
|
||||
from .context import *
|
||||
from .dashboard import *
|
||||
from .download import *
|
||||
@@ -6,13 +9,18 @@ from .event import *
|
||||
from .exception import *
|
||||
from .file import *
|
||||
from .history import *
|
||||
from .llm import *
|
||||
from .mediaserver import *
|
||||
from .message import *
|
||||
from .mfa import *
|
||||
from .music import *
|
||||
from .monitoring import *
|
||||
from .notification import *
|
||||
from .plugin import *
|
||||
from .response import *
|
||||
from .rule import *
|
||||
from .search import *
|
||||
from .storage import *
|
||||
from .openai import *
|
||||
from .servarr import *
|
||||
from .servcookie import *
|
||||
|
||||
@@ -6,6 +6,8 @@ from typing import Any, List, Literal, Optional, Union
|
||||
from langchain_core.messages import BaseMessage
|
||||
from pydantic import BaseModel, Field, ConfigDict, field_serializer
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
|
||||
|
||||
class ConversationMemory(BaseModel):
|
||||
"""对话记忆模型"""
|
||||
@@ -97,7 +99,7 @@ class AgentMcpServerToolInfo(BaseModel):
|
||||
name: str = Field(..., description="原始 MCP 工具名称")
|
||||
agent_tool_name: str = Field(..., description="注入 Agent 后的工具名称")
|
||||
description: str = Field(default="", description="工具说明")
|
||||
input_schema: dict[str, Any] = Field(default_factory=dict, description="工具参数 Schema")
|
||||
input_schema: dict[str, JsonData] = Field(default_factory=dict, description="工具参数 Schema")
|
||||
|
||||
|
||||
class AgentMcpServerTestResult(BaseModel):
|
||||
@@ -217,8 +219,8 @@ class AgentChatSession(BaseModel):
|
||||
username: Optional[str] = Field(None, description="用户名")
|
||||
original_chat_id: Optional[str] = Field(None, description="原聊天 ID")
|
||||
message_count: int = Field(default=0, description="展示消息数量")
|
||||
created_at: Optional[str] = Field(None, description="创建时间")
|
||||
updated_at: Optional[str] = Field(None, description="更新时间")
|
||||
created_at: Optional[datetime | str] = Field(None, description="创建时间")
|
||||
updated_at: Optional[datetime | str] = Field(None, description="更新时间")
|
||||
messages: list[AgentChatMessage] = Field(default_factory=list, description="展示消息列表")
|
||||
|
||||
|
||||
@@ -229,3 +231,84 @@ class AgentChatDisplaySaveRequest(BaseModel):
|
||||
|
||||
messages: list[AgentChatMessage] = Field(default_factory=list, description="展示消息列表")
|
||||
title: Optional[str] = Field(None, description="会话标题")
|
||||
|
||||
|
||||
class AgentMcpServerListData(BaseModel):
|
||||
"""Agent MCP 服务器列表与启用统计。"""
|
||||
|
||||
servers: list[AgentMcpServerConfig] = Field(default_factory=list, description="服务器列表")
|
||||
enabled_count: int = Field(default=0, description="已启用服务器数量")
|
||||
total_count: int = Field(default=0, description="服务器总数")
|
||||
|
||||
|
||||
class AgentChatUploadAttachment(AgentChatAttachment):
|
||||
"""Web Agent 上传完成后的附件描述。"""
|
||||
|
||||
ref: str = Field(description="供 Agent 消费的附件引用")
|
||||
status: str = Field(default="ready", description="附件处理状态")
|
||||
|
||||
|
||||
class AgentWebChoiceFeedback(BaseModel):
|
||||
"""Web Agent 选择回调的反馈快照。"""
|
||||
|
||||
request_id: str = Field(description="选择请求 ID")
|
||||
title: Optional[str] = Field(default=None, description="选择标题")
|
||||
prompt: str = Field(default="", description="选择提示")
|
||||
selected_label: str = Field(description="已选择文案")
|
||||
selected_value: str = Field(description="已选择值")
|
||||
selected_description: Optional[str] = Field(default=None, description="已选择说明")
|
||||
buttons: list[AgentChatChoiceButton] = Field(default_factory=list, description="按钮列表")
|
||||
button_rows: list[list[AgentChatChoiceButton]] = Field(default_factory=list, description="按钮行")
|
||||
|
||||
|
||||
class AgentWebCallbackData(BaseModel):
|
||||
"""Web Agent 按钮回调后供前端继续发送的数据。"""
|
||||
|
||||
message: str = Field(description="下一条用户消息")
|
||||
display_message: str = Field(default="", description="前端展示消息")
|
||||
session_id: Optional[str] = Field(default=None, description="Agent 会话 ID")
|
||||
traditional: bool = Field(default=False, description="是否为传统消息链回调")
|
||||
original_message_id: Optional[str | int] = Field(default=None, description="原消息 ID")
|
||||
original_chat_id: Optional[str | int] = Field(default=None, description="原聊天 ID")
|
||||
choice_selection: Optional[AgentChatChoiceSelection] = Field(default=None, description="选择结果快照")
|
||||
feedback: Optional[AgentWebChoiceFeedback] = Field(default=None, description="选择反馈")
|
||||
|
||||
|
||||
class AgentWebCommandInfo(BaseModel):
|
||||
"""Web Agent 可用斜杠命令摘要。"""
|
||||
|
||||
command: str = Field(description="命令")
|
||||
description: str = Field(default="", description="命令说明")
|
||||
category: str = Field(default="其他", description="命令分类")
|
||||
type: str = Field(default="", description="命令类型")
|
||||
pid: Optional[str | int] = Field(default=None, description="插件 ID")
|
||||
|
||||
|
||||
class AgentChatSessionSummary(BaseModel):
|
||||
"""Agent 历史会话摘要。"""
|
||||
|
||||
id: Optional[int] = Field(default=None, description="数据库 ID")
|
||||
session_id: str = Field(description="Agent 内部会话 ID")
|
||||
client_session_id: Optional[str] = Field(default=None, description="客户端会话 ID")
|
||||
title: Optional[str] = Field(default=None, description="会话标题")
|
||||
channel: Optional[str] = Field(default=None, description="消息渠道")
|
||||
source: Optional[str] = Field(default=None, description="渠道来源")
|
||||
user_id: Optional[str] = Field(default=None, description="用户 ID")
|
||||
username: Optional[str] = Field(default=None, description="用户名")
|
||||
original_chat_id: Optional[str] = Field(default=None, description="原聊天 ID")
|
||||
message_count: int = Field(default=0, description="展示消息数量")
|
||||
created_at: Optional[datetime | str] = Field(default=None, description="创建时间")
|
||||
updated_at: Optional[datetime | str] = Field(default=None, description="更新时间")
|
||||
|
||||
|
||||
class AgentChatSessionDetail(AgentChatSessionSummary):
|
||||
"""Agent 历史会话详情。"""
|
||||
|
||||
messages: list[AgentChatMessage] = Field(default_factory=list, description="展示消息列表")
|
||||
is_processing: bool = Field(default=False, description="会话是否正在处理")
|
||||
|
||||
|
||||
class AgentSessionStopData(BaseModel):
|
||||
"""Agent 会话停止结果。"""
|
||||
|
||||
stopped: bool = Field(description="是否停止了正在执行的任务")
|
||||
|
||||
50
app/schemas/cache.py
Normal file
50
app/schemas/cache.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""种子缓存 API 输出模型。"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
class TorrentCacheItem(BaseModel):
|
||||
"""单条站点种子缓存。"""
|
||||
|
||||
hash: str
|
||||
domain: str
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
size: Optional[int] = None
|
||||
pubdate: Optional[str] = None
|
||||
site_name: Optional[str] = None
|
||||
media_name: Optional[str] = None
|
||||
media_year: Optional[str | int] = None
|
||||
media_type: Optional[str] = None
|
||||
media_source: Optional[MediaSource] = None
|
||||
media_id: Optional[str] = None
|
||||
music_type: Optional[str] = None
|
||||
season_episode: Optional[str] = None
|
||||
resource_term: Optional[str] = None
|
||||
enclosure: Optional[str] = None
|
||||
page_url: Optional[str] = None
|
||||
poster_path: Optional[str] = None
|
||||
backdrop_path: Optional[str] = None
|
||||
|
||||
|
||||
class TorrentCacheData(BaseModel):
|
||||
"""种子缓存统计及明细。"""
|
||||
|
||||
count: int = 0
|
||||
sites: int = 0
|
||||
data: list[TorrentCacheItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TorrentReidentifyData(BaseModel):
|
||||
"""种子重新识别后的媒体身份。"""
|
||||
|
||||
media_name: Optional[str] = None
|
||||
media_year: Optional[str | int] = None
|
||||
media_type: Optional[str] = None
|
||||
media_source: Optional[MediaSource] = None
|
||||
media_id: Optional[str] = None
|
||||
music_type: Optional[str] = None
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, RootModel
|
||||
|
||||
|
||||
class CategoryRule(BaseModel):
|
||||
@@ -29,3 +29,7 @@ class CategoryConfig(BaseModel):
|
||||
movie: Optional[Dict[str, Optional[CategoryRule]]] = {}
|
||||
# 电视剧分类策略
|
||||
tv: Optional[Dict[str, Optional[CategoryRule]]] = {}
|
||||
|
||||
|
||||
class MediaCategoryMap(RootModel[Dict[str, list[str]]]):
|
||||
"""媒体类型与自动分类名称列表的映射。"""
|
||||
|
||||
78
app/schemas/common.py
Normal file
78
app/schemas/common.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""API 端点共享的小型业务数据模型。"""
|
||||
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from typing_extensions import TypeAliasType
|
||||
|
||||
|
||||
JsonData = TypeAliasType(
|
||||
"JsonData",
|
||||
Union[
|
||||
dict[str, "JsonData"],
|
||||
list["JsonData"],
|
||||
str,
|
||||
int,
|
||||
float,
|
||||
bool,
|
||||
None,
|
||||
],
|
||||
)
|
||||
"""可递归序列化的 JSON 数据;OpenAPI 会展示每一种合法 JSON 结构。"""
|
||||
|
||||
|
||||
class JsonObject(RootModel[dict[str, JsonData]]):
|
||||
"""字段由运行时扩展点决定的 JSON 对象。"""
|
||||
|
||||
|
||||
class JsonObjectList(RootModel[list[JsonObject]]):
|
||||
"""字段由运行时扩展点决定的 JSON 对象列表。"""
|
||||
|
||||
|
||||
class IdData(BaseModel):
|
||||
"""创建资源后返回的资源 ID。"""
|
||||
|
||||
id: Optional[int | str] = Field(default=None, description="资源 ID")
|
||||
|
||||
|
||||
class ValueData(BaseModel):
|
||||
"""单个动态配置值。"""
|
||||
|
||||
value: JsonData = Field(default=None, description="配置值")
|
||||
|
||||
|
||||
class FileNameData(BaseModel):
|
||||
"""文件操作结果中的文件名。"""
|
||||
|
||||
filename: Optional[str] = Field(default=None, description="文件名")
|
||||
|
||||
|
||||
class NameData(BaseModel):
|
||||
"""名称计算结果。"""
|
||||
|
||||
name: Optional[str] = Field(default=None, description="名称")
|
||||
|
||||
|
||||
class ServiceClientInfo(BaseModel):
|
||||
"""可选择的下载器或媒体服务器摘要。"""
|
||||
|
||||
name: Optional[str] = Field(default=None, description="实例名称")
|
||||
type: Optional[str] = Field(default=None, description="服务类型")
|
||||
|
||||
|
||||
class ProgressKeyData(BaseModel):
|
||||
"""异步任务进度查询标识。"""
|
||||
|
||||
progress_key: str = Field(description="进度查询标识")
|
||||
|
||||
|
||||
class BatchProgressKeyData(ProgressKeyData):
|
||||
"""批量异步任务进度查询标识。"""
|
||||
|
||||
history_ids: list[int] = Field(default_factory=list, description="历史记录 ID 列表")
|
||||
|
||||
|
||||
class TimeData(BaseModel):
|
||||
"""网络请求耗时。"""
|
||||
|
||||
time: int | float = Field(description="耗时毫秒数")
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import Optional, Dict, List, Union, Any
|
||||
from typing import Annotated, Optional, Dict, List, Union, Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Discriminator, Field, RootModel, Tag
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.music import MusicInfo, MusicMeta
|
||||
from app.schemas.media import OptionalMediaIdentityMixin
|
||||
from app.schemas.types import MediaSource
|
||||
@@ -73,6 +74,144 @@ class MetaInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
media_id: Optional[str] = None
|
||||
|
||||
|
||||
class MediaImageSet(BaseModel):
|
||||
"""跨媒体源兼容的人物图片尺寸集合。"""
|
||||
|
||||
large: Optional[str] = None
|
||||
common: Optional[str] = None
|
||||
medium: Optional[str] = None
|
||||
normal: Optional[str] = None
|
||||
small: Optional[str] = None
|
||||
grid: Optional[str] = None
|
||||
|
||||
|
||||
class MediaCredit(BaseModel):
|
||||
"""影视条目中的演职员摘要。"""
|
||||
|
||||
id: Optional[int | str] = None
|
||||
name: Optional[str] = None
|
||||
original_name: Optional[str] = None
|
||||
character: Optional[str] = None
|
||||
type: Optional[str | int] = None
|
||||
gender: Optional[str | int] = None
|
||||
adult: Optional[bool] = None
|
||||
known_for_department: Optional[str] = None
|
||||
profile_path: Optional[str] = None
|
||||
credit_id: Optional[str] = None
|
||||
cast_id: Optional[int] = None
|
||||
order: Optional[int] = None
|
||||
department: Optional[str] = None
|
||||
job: Optional[str] = None
|
||||
popularity: Optional[float] = None
|
||||
roles: list[str] = Field(default_factory=list)
|
||||
title: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
uri: Optional[str] = None
|
||||
sharing_url: Optional[str] = None
|
||||
avatar: Optional[str | MediaImageSet] = None
|
||||
images: Optional[MediaImageSet] = None
|
||||
latin_name: Optional[str] = None
|
||||
career: list[str] = Field(default_factory=list)
|
||||
relation: Optional[str] = None
|
||||
user: Optional[JsonData] = None
|
||||
|
||||
|
||||
class MediaGenre(BaseModel):
|
||||
"""影视风格摘要。"""
|
||||
|
||||
id: Optional[int | str] = None
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class MediaCompany(BaseModel):
|
||||
"""电视网或制作公司的标准摘要。"""
|
||||
|
||||
id: Optional[int | str] = None
|
||||
name: Optional[str] = None
|
||||
logo_path: Optional[str] = None
|
||||
origin_country: Optional[str] = None
|
||||
|
||||
|
||||
class MediaCountry(BaseModel):
|
||||
"""影视制作国家或地区。"""
|
||||
|
||||
id: Optional[int | str] = None
|
||||
iso_3166_1: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class MediaLanguage(BaseModel):
|
||||
"""影视内容使用的语言。"""
|
||||
|
||||
english_name: Optional[str] = None
|
||||
iso_639_1: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class MediaReleaseDate(BaseModel):
|
||||
"""电影在单个地区的一次发行记录。"""
|
||||
|
||||
date: str
|
||||
iso_code: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
type: Optional[int] = None
|
||||
|
||||
|
||||
class MediaEpisode(BaseModel):
|
||||
"""电视剧即将播出的单集摘要。"""
|
||||
|
||||
id: Optional[int] = None
|
||||
air_date: Optional[str] = None
|
||||
episode_number: Optional[int] = None
|
||||
episode_type: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
overview: Optional[str] = None
|
||||
production_code: Optional[str] = None
|
||||
runtime: Optional[int] = None
|
||||
season_number: Optional[int] = None
|
||||
show_id: Optional[int] = None
|
||||
still_path: Optional[str] = None
|
||||
vote_average: Optional[float] = None
|
||||
vote_count: Optional[int] = None
|
||||
|
||||
|
||||
class MediaSeason(BaseModel):
|
||||
"""标准季信息以及剧集组季信息。"""
|
||||
|
||||
id: Optional[int | str] = None
|
||||
air_date: Optional[str] = None
|
||||
episode_count: Optional[int] = None
|
||||
name: Optional[str] = None
|
||||
overview: Optional[str] = None
|
||||
poster_path: Optional[str] = None
|
||||
season_number: Optional[int] = None
|
||||
vote_average: Optional[float] = None
|
||||
order: Optional[int] = None
|
||||
locked: Optional[bool] = None
|
||||
episodes: list[MediaEpisode] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MediaEpisodeGroupNetwork(BaseModel):
|
||||
"""TMDB 剧集组所属电视网信息。"""
|
||||
|
||||
id: Optional[int] = None
|
||||
name: Optional[str] = None
|
||||
logo_path: Optional[str] = None
|
||||
origin_country: Optional[str] = None
|
||||
|
||||
|
||||
class MediaEpisodeGroup(BaseModel):
|
||||
"""TMDB 电视剧的剧集分组摘要。"""
|
||||
|
||||
description: str = ""
|
||||
episode_count: int = 0
|
||||
group_count: int = 0
|
||||
id: str
|
||||
name: str
|
||||
network: Optional[MediaEpisodeGroupNetwork] = None
|
||||
type: int
|
||||
|
||||
|
||||
class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""
|
||||
识别媒体信息
|
||||
@@ -87,6 +226,10 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
title: Optional[str] = None
|
||||
# 英文标题
|
||||
en_title: Optional[str] = None
|
||||
# 香港、台湾、新加坡地区标题
|
||||
hk_title: Optional[str] = None
|
||||
tw_title: Optional[str] = None
|
||||
sg_title: Optional[str] = None
|
||||
# 年份
|
||||
year: Optional[str] = None
|
||||
# 标题(年份)
|
||||
@@ -116,6 +259,8 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
backdrop_path: Optional[str] = None
|
||||
# 海报图片
|
||||
poster_path: Optional[str] = None
|
||||
# 标题 LOGO
|
||||
logo_path: Optional[str] = None
|
||||
# 评分
|
||||
vote_average: Optional[float] = 0.0
|
||||
# 描述
|
||||
@@ -123,58 +268,60 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
# 二级分类
|
||||
category: Optional[str] = ""
|
||||
# 季季集清单
|
||||
seasons: Optional[Dict[int, list]] = Field(default_factory=dict)
|
||||
seasons: Optional[Dict[int, list[int]]] = Field(default_factory=dict)
|
||||
# 季详情
|
||||
season_info: Optional[List[dict]] = Field(default_factory=list)
|
||||
season_info: Optional[List[MediaSeason]] = Field(default_factory=list)
|
||||
# 各季首播年份
|
||||
season_years: Optional[Dict[int, str]] = Field(default_factory=dict)
|
||||
# 别名和译名
|
||||
names: Optional[list] = Field(default_factory=list)
|
||||
names: Optional[list[str]] = Field(default_factory=list)
|
||||
# 演员
|
||||
actors: Optional[list] = Field(default_factory=list)
|
||||
actors: Optional[list[MediaCredit]] = Field(default_factory=list)
|
||||
# 导演
|
||||
directors: Optional[list] = Field(default_factory=list)
|
||||
directors: Optional[list[MediaCredit]] = Field(default_factory=list)
|
||||
# 详情链接
|
||||
detail_link: Optional[str] = None
|
||||
# 其它TMDB属性
|
||||
# 是否成人内容
|
||||
adult: Optional[bool] = False
|
||||
# 创建人
|
||||
created_by: Optional[list] = Field(default_factory=list)
|
||||
created_by: Optional[list[MediaCredit]] = Field(default_factory=list)
|
||||
# 集时长
|
||||
episode_run_time: Optional[list] = Field(default_factory=list)
|
||||
episode_run_time: Optional[list[int]] = Field(default_factory=list)
|
||||
# 风格
|
||||
genres: Optional[List[dict]] = Field(default_factory=list)
|
||||
genres: Optional[List[MediaGenre]] = Field(default_factory=list)
|
||||
# 首播日期
|
||||
first_air_date: Optional[str] = None
|
||||
# 首页
|
||||
homepage: Optional[str] = None
|
||||
# 语种
|
||||
languages: Optional[list] = Field(default_factory=list)
|
||||
languages: Optional[list[str]] = Field(default_factory=list)
|
||||
# 最后上映日期
|
||||
last_air_date: Optional[str] = None
|
||||
# 流媒体平台
|
||||
networks: Optional[list] = Field(default_factory=list)
|
||||
networks: Optional[list[MediaCompany]] = Field(default_factory=list)
|
||||
# 集数
|
||||
number_of_episodes: Optional[int] = 0
|
||||
# 季数
|
||||
number_of_seasons: Optional[int] = 0
|
||||
# 原产国
|
||||
origin_country: Optional[list] = Field(default_factory=list)
|
||||
origin_country: Optional[list[str]] = Field(default_factory=list)
|
||||
# 原名
|
||||
original_name: Optional[str] = None
|
||||
# 出品公司
|
||||
production_companies: Optional[list] = Field(default_factory=list)
|
||||
production_companies: Optional[list[MediaCompany]] = Field(default_factory=list)
|
||||
# 出品国
|
||||
production_countries: Optional[list] = Field(default_factory=list)
|
||||
production_countries: Optional[list[MediaCountry]] = Field(default_factory=list)
|
||||
# 语种
|
||||
spoken_languages: Optional[list] = Field(default_factory=list)
|
||||
spoken_languages: Optional[list[MediaLanguage]] = Field(default_factory=list)
|
||||
# 所有发行日期
|
||||
release_dates: list = Field(default_factory=list)
|
||||
release_dates: list[MediaReleaseDate] = Field(default_factory=list)
|
||||
# 状态
|
||||
status: Optional[str] = None
|
||||
# 标签
|
||||
tagline: Optional[str] = None
|
||||
# 风格ID
|
||||
genre_ids: Optional[list] = Field(default_factory=list)
|
||||
genre_ids: Optional[list[int | str]] = Field(default_factory=list)
|
||||
# 评价数量
|
||||
vote_count: Optional[int] = 0
|
||||
# 流行度
|
||||
@@ -182,11 +329,18 @@ class MediaInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
# 时长
|
||||
runtime: Optional[int] = None
|
||||
# 下一集
|
||||
next_episode_to_air: Optional[dict] = Field(default_factory=dict)
|
||||
next_episode_to_air: Optional[MediaEpisode] = None
|
||||
# 内容分级
|
||||
content_rating: Optional[str] = None
|
||||
# 全部剧集组
|
||||
episode_groups: Optional[list] = Field(default_factory=list)
|
||||
episode_groups: Optional[list[MediaEpisodeGroup | MediaSeason]] = Field(default_factory=list)
|
||||
# 剧集组
|
||||
episode_group: Optional[str] = None
|
||||
# 各数据源原始信息;Core MediaInfo.to_dict() 保留这些键供兼容调用方使用。
|
||||
tmdb_info: Optional[dict[str, JsonData]] = None
|
||||
douban_info: Optional[dict[str, JsonData]] = None
|
||||
bangumi_info: Optional[dict[str, JsonData]] = None
|
||||
anilist_info: Optional[dict[str, JsonData]] = None
|
||||
|
||||
|
||||
class TorrentInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
@@ -239,7 +393,7 @@ class TorrentInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
# HR
|
||||
hit_and_run: Optional[bool] = False
|
||||
# 种子标签
|
||||
labels: Optional[list] = Field(default_factory=list)
|
||||
labels: Optional[list[str]] = Field(default_factory=list)
|
||||
# 种子优先级
|
||||
pri_order: Optional[int] = 0
|
||||
# 种子分类 电影/电视剧/音乐
|
||||
@@ -326,19 +480,6 @@ class Context(BaseModel):
|
||||
confirmed_full_coverage: Optional[bool] = False
|
||||
|
||||
|
||||
class MediaSeason(BaseModel):
|
||||
"""
|
||||
季信息
|
||||
"""
|
||||
air_date: Optional[str] = None
|
||||
episode_count: Optional[int] = None
|
||||
name: Optional[str] = None
|
||||
overview: Optional[str] = None
|
||||
poster_path: Optional[str] = None
|
||||
season_number: Optional[int] = None
|
||||
vote_average: Optional[float] = None
|
||||
|
||||
|
||||
class MediaPerson(BaseModel):
|
||||
"""
|
||||
媒体人物信息
|
||||
@@ -346,17 +487,17 @@ class MediaPerson(BaseModel):
|
||||
# 来源:themoviedb、douban、bangumi、anilist
|
||||
source: Optional[str] = None
|
||||
# 公共
|
||||
id: Optional[int] = None
|
||||
id: Optional[int | str] = None
|
||||
type: Optional[Union[str, int]] = 1
|
||||
name: Optional[str] = None
|
||||
character: Optional[str] = None
|
||||
images: Optional[dict] = Field(default_factory=dict)
|
||||
images: Optional[MediaImageSet] = None
|
||||
# themoviedb
|
||||
profile_path: Optional[str] = None
|
||||
gender: Optional[Union[str, int]] = None
|
||||
original_name: Optional[str] = None
|
||||
credit_id: Optional[str] = None
|
||||
also_known_as: Optional[list] = Field(default_factory=list)
|
||||
also_known_as: Optional[list[str]] = Field(default_factory=list)
|
||||
birthday: Optional[str] = None
|
||||
deathday: Optional[str] = None
|
||||
imdb_id: Optional[str] = None
|
||||
@@ -365,11 +506,37 @@ class MediaPerson(BaseModel):
|
||||
popularity: Optional[float] = None
|
||||
biography: Optional[str] = None
|
||||
# douban
|
||||
roles: Optional[list] = Field(default_factory=list)
|
||||
roles: Optional[list[str]] = Field(default_factory=list)
|
||||
title: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
avatar: Optional[Union[str, dict]] = None
|
||||
avatar: Optional[Union[str, MediaImageSet]] = None
|
||||
latin_name: Optional[str] = None
|
||||
# bangumi
|
||||
career: Optional[list] = Field(default_factory=list)
|
||||
career: Optional[list[str]] = Field(default_factory=list)
|
||||
relation: Optional[str] = None
|
||||
|
||||
|
||||
def _media_search_result_kind(value: Any) -> str:
|
||||
"""按稳定字段区分音乐、人物与影视/合集搜索结果。"""
|
||||
if isinstance(value, BaseModel):
|
||||
value = value.model_dump()
|
||||
if isinstance(value, dict):
|
||||
if value.get("type") == "音乐" or "music_type" in value:
|
||||
return "music"
|
||||
if "source" in value and "media_source" not in value:
|
||||
return "person"
|
||||
return "media"
|
||||
|
||||
|
||||
MediaSearchResult = Annotated[
|
||||
Union[
|
||||
Annotated[MusicInfo, Tag("music")],
|
||||
Annotated[MediaPerson, Tag("person")],
|
||||
Annotated[MediaInfo, Tag("media")],
|
||||
],
|
||||
Discriminator(_media_search_result_kind),
|
||||
]
|
||||
|
||||
|
||||
class MediaSearchResults(RootModel[List[MediaSearchResult]]):
|
||||
"""媒体、音乐、合集与人物的统一搜索结果列表。"""
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Optional
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.schemas.common import JsonData
|
||||
|
||||
|
||||
class Statistic(BaseModel):
|
||||
@@ -120,7 +121,7 @@ class ScheduleProgress(BaseModel):
|
||||
# 多语言错误信息
|
||||
error_i18n: Optional[str] = None
|
||||
# 扩展数据
|
||||
data: Optional[dict] = Field(default_factory=dict)
|
||||
data: Optional[dict[str, JsonData]] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def fill_i18n_fields(self) -> "ScheduleProgress":
|
||||
|
||||
@@ -25,3 +25,15 @@ class DownloadDirectory(BaseModel):
|
||||
priority: Optional[int] = Field(default=0, description="目录优先级")
|
||||
media_type: Optional[str] = Field(default=None, description="适用媒体类型")
|
||||
media_category: Optional[str] = Field(default=None, description="适用媒体分类")
|
||||
|
||||
|
||||
class DownloadAddedData(BaseModel):
|
||||
"""下载任务添加结果。"""
|
||||
|
||||
download_id: Optional[str] = Field(default=None, description="下载任务 ID")
|
||||
|
||||
|
||||
class SubtitleDownloadData(BaseModel):
|
||||
"""字幕下载结果。"""
|
||||
|
||||
files: list[str] = Field(default_factory=list, description="已保存字幕文件列表")
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Iterable, Optional, Dict, Any, List, Set, Callable
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.message import MessageChannel
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
|
||||
@@ -484,11 +485,11 @@ class DiscoverMediaSource(BaseModel):
|
||||
name: str = Field(..., description="数据源名称")
|
||||
media_source: MediaSource = Field(..., description="媒体来源枚举")
|
||||
api_path: str = Field(..., description="媒体数据源API地址")
|
||||
filter_params: Optional[Dict[str, Any]] = Field(
|
||||
filter_params: Optional[Dict[str, JsonData]] = Field(
|
||||
default=None, description="过滤参数"
|
||||
)
|
||||
filter_ui: Optional[List[dict]] = Field(default=[], description="过滤参数UI配置")
|
||||
depends: Optional[Dict[str, list]] = Field(
|
||||
filter_ui: Optional[List[Dict[str, JsonData]]] = Field(default=[], description="过滤参数UI配置")
|
||||
depends: Optional[Dict[str, list[str]]] = Field(
|
||||
default=None, description="UI依赖关系字典"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
|
||||
|
||||
class FileURI(BaseModel):
|
||||
"""带存储类型的文件 URI。"""
|
||||
|
||||
# 文件路径
|
||||
path: Optional[str] = "/"
|
||||
# 存储类型
|
||||
@@ -45,6 +47,8 @@ class FileURI(BaseModel):
|
||||
|
||||
|
||||
class FileItem(FileURI):
|
||||
"""文件或目录条目,目录可递归包含子条目。"""
|
||||
|
||||
# 类型 dir/file
|
||||
type: Optional[str] = None
|
||||
# 文件名
|
||||
@@ -58,7 +62,7 @@ class FileItem(FileURI):
|
||||
# 修改时间
|
||||
modify_time: Optional[float] = None
|
||||
# 子节点
|
||||
children: Optional[list] = Field(default_factory=list)
|
||||
children: Optional[list["FileItem"]] = Field(default_factory=list)
|
||||
# ID
|
||||
fileid: Optional[str] = None
|
||||
# 父ID
|
||||
@@ -74,6 +78,8 @@ class FileItem(FileURI):
|
||||
|
||||
|
||||
class StorageUsage(BaseModel):
|
||||
"""存储空间使用情况。"""
|
||||
|
||||
# 总空间
|
||||
total: float = 0.0
|
||||
# 剩余空间
|
||||
@@ -81,5 +87,7 @@ class StorageUsage(BaseModel):
|
||||
|
||||
|
||||
class StorageTransType(BaseModel):
|
||||
"""存储支持的传输类型及其显示名称。"""
|
||||
|
||||
# 传输类型
|
||||
transtype: Optional[dict] = Field(default_factory=dict)
|
||||
transtype: Optional[dict[str, str]] = Field(default_factory=dict)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import Optional, Any
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.media import OptionalMediaIdentityMixin
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
@@ -52,7 +53,7 @@ class DownloadHistory(OptionalMediaIdentityMixin, BaseModel):
|
||||
# 创建时间
|
||||
date: Optional[str] = None
|
||||
# 备注
|
||||
note: Optional[Any] = None
|
||||
note: Optional[JsonData] = None
|
||||
# 自定义媒体类别
|
||||
media_category: Optional[str] = None
|
||||
# 自定义剧集组
|
||||
@@ -121,4 +122,13 @@ class TransferHistory(OptionalMediaIdentityMixin, BaseModel):
|
||||
|
||||
|
||||
class BatchTransferHistoryRedoRequest(BaseModel):
|
||||
"""批量重新整理历史请求。"""
|
||||
|
||||
history_ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TransferHistoryPage(BaseModel):
|
||||
"""整理历史分页数据。"""
|
||||
|
||||
list: List[TransferHistory] = Field(default_factory=list, description="整理历史列表")
|
||||
total: int = Field(default=0, description="记录总数")
|
||||
|
||||
115
app/schemas/llm.py
Normal file
115
app/schemas/llm.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""LLM 配置、目录和测试 API 输出模型。"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LLMAuthStatus(BaseModel):
|
||||
"""LLM 提供商授权状态摘要。"""
|
||||
|
||||
connected: bool = False
|
||||
type: Optional[str] = None
|
||||
label: Optional[str] = None
|
||||
expires_at: Optional[int | float | str] = None
|
||||
updated_at: Optional[int | float | str] = None
|
||||
|
||||
|
||||
class LLMServerToolCapability(BaseModel):
|
||||
"""模型支持的服务端工具能力。"""
|
||||
|
||||
id: str
|
||||
required_api_protocol: Optional[str] = None
|
||||
client_adapter: Optional[str] = None
|
||||
|
||||
|
||||
class LLMModelInfo(BaseModel):
|
||||
"""标准化 LLM 模型目录项。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
family: Optional[str] = None
|
||||
context_tokens: Optional[int] = None
|
||||
input_tokens: Optional[int] = None
|
||||
output_tokens: Optional[int] = None
|
||||
context_tokens_k: Optional[int] = None
|
||||
supports_reasoning: bool = False
|
||||
supports_tools: bool = False
|
||||
supports_image_input: bool = False
|
||||
supports_audio_input: bool = False
|
||||
transport: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
release_date: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
server_tools: list[LLMServerToolCapability] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LLMModelCatalogData(BaseModel):
|
||||
"""指定提供商的模型目录。"""
|
||||
|
||||
provider: str
|
||||
models: list[LLMModelInfo] = Field(default_factory=list)
|
||||
auth_status: LLMAuthStatus
|
||||
|
||||
|
||||
class LLMProviderAuthMethod(BaseModel):
|
||||
"""LLM 提供商可用的交互授权方式。"""
|
||||
|
||||
id: str
|
||||
type: str
|
||||
label: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class LLMProviderBaseUrlPreset(BaseModel):
|
||||
"""LLM 提供商预设基础地址。"""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
value: str
|
||||
runtime: Optional[str] = None
|
||||
model_list_strategy: Optional[str] = None
|
||||
|
||||
|
||||
class LLMProviderInfo(BaseModel):
|
||||
"""前端可配置的 LLM 提供商定义。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
runtime: str
|
||||
default_base_url: str = ""
|
||||
base_url_presets: list[LLMProviderBaseUrlPreset] = Field(default_factory=list)
|
||||
base_url_editable: bool = True
|
||||
requires_base_url: bool = False
|
||||
supports_api_key: bool = True
|
||||
api_key_label: Optional[str] = None
|
||||
api_key_hint: Optional[str] = None
|
||||
supports_model_refresh: bool = True
|
||||
oauth_methods: list[LLMProviderAuthMethod] = Field(default_factory=list)
|
||||
description: Optional[str] = None
|
||||
auth_status: LLMAuthStatus
|
||||
|
||||
|
||||
class LLMProviderAuthSession(BaseModel):
|
||||
"""LLM 提供商交互授权会话。"""
|
||||
|
||||
session_id: str
|
||||
provider_id: Optional[str] = None
|
||||
flow_type: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
authorize_url: Optional[str] = None
|
||||
verification_url: Optional[str] = None
|
||||
user_code: Optional[str] = None
|
||||
instructions: Optional[str] = None
|
||||
interval_seconds: Optional[int] = None
|
||||
expires_at: Optional[int | float] = None
|
||||
|
||||
|
||||
class LLMTestResult(BaseModel):
|
||||
"""LLM 连通性测试结果。"""
|
||||
|
||||
provider: str
|
||||
model: str
|
||||
duration_ms: Optional[int] = None
|
||||
reply_preview: Optional[str] = None
|
||||
@@ -1,16 +1,193 @@
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Dict, Literal, Optional, TypeAlias, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, RootModel, TypeAdapter
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
|
||||
|
||||
class ToolCallRequest(BaseModel):
|
||||
"""工具调用请求模型"""
|
||||
tool_name: str = Field(..., description="工具名称")
|
||||
arguments: Dict[str, Any] = Field(default_factory=dict, description="工具参数")
|
||||
arguments: Dict[str, JsonData] = Field(default_factory=dict, description="工具参数")
|
||||
|
||||
|
||||
class ToolCallResponse(BaseModel):
|
||||
"""工具调用响应模型"""
|
||||
success: bool = Field(..., description="是否成功")
|
||||
result: Optional[str] = Field(None, description="工具执行结果")
|
||||
error: Optional[str] = Field(None, description="错误信息")
|
||||
class McpJsonSchema(RootModel[dict[str, JsonData]]):
|
||||
"""MCP 工具的 JSON Schema。"""
|
||||
|
||||
|
||||
class McpToolInfo(BaseModel):
|
||||
"""MCP REST 工具摘要。"""
|
||||
|
||||
name: str = Field(description="工具名称")
|
||||
description: str = Field(default="", description="工具说明")
|
||||
inputSchema: McpJsonSchema = Field(description="工具参数 JSON Schema")
|
||||
|
||||
|
||||
class ToolCallData(BaseModel):
|
||||
"""MCP REST 工具调用成功后的业务数据。"""
|
||||
|
||||
result: str = Field(description="工具执行结果")
|
||||
|
||||
|
||||
class McpJsonRpcClientInfo(BaseModel):
|
||||
"""MCP JSON-RPC 客户端信息。"""
|
||||
|
||||
name: str
|
||||
version: Optional[str] = None
|
||||
|
||||
|
||||
class McpJsonRpcInitializeParams(BaseModel):
|
||||
"""MCP initialize 请求参数。"""
|
||||
|
||||
protocolVersion: str
|
||||
capabilities: dict[str, JsonData] = Field(default_factory=dict)
|
||||
clientInfo: Optional[McpJsonRpcClientInfo] = None
|
||||
|
||||
|
||||
class McpJsonRpcToolCallParams(BaseModel):
|
||||
"""MCP tools/call 请求参数。"""
|
||||
|
||||
name: str
|
||||
arguments: dict[str, JsonData] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class McpJsonRpcInitializeRequest(BaseModel):
|
||||
"""MCP initialize JSON-RPC 请求。"""
|
||||
|
||||
jsonrpc: Literal["2.0"]
|
||||
id: str | int
|
||||
method: Literal["initialize"]
|
||||
params: McpJsonRpcInitializeParams
|
||||
|
||||
|
||||
class McpJsonRpcInitializedNotification(BaseModel):
|
||||
"""MCP initialized JSON-RPC 通知。"""
|
||||
|
||||
jsonrpc: Literal["2.0"]
|
||||
method: Literal["notifications/initialized"]
|
||||
params: dict[str, JsonData] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class McpJsonRpcToolsListRequest(BaseModel):
|
||||
"""MCP tools/list JSON-RPC 请求。"""
|
||||
|
||||
jsonrpc: Literal["2.0"]
|
||||
id: str | int
|
||||
method: Literal["tools/list"]
|
||||
params: dict[str, JsonData] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class McpJsonRpcToolsCallRequest(BaseModel):
|
||||
"""MCP tools/call JSON-RPC 请求。"""
|
||||
|
||||
jsonrpc: Literal["2.0"]
|
||||
id: str | int
|
||||
method: Literal["tools/call"]
|
||||
params: McpJsonRpcToolCallParams
|
||||
|
||||
|
||||
class McpJsonRpcPingRequest(BaseModel):
|
||||
"""MCP ping JSON-RPC 请求。"""
|
||||
|
||||
jsonrpc: Literal["2.0"]
|
||||
id: str | int
|
||||
method: Literal["ping"]
|
||||
params: dict[str, JsonData] = Field(default_factory=dict)
|
||||
|
||||
|
||||
McpJsonRpcRequest: TypeAlias = Union[
|
||||
McpJsonRpcInitializeRequest,
|
||||
McpJsonRpcInitializedNotification,
|
||||
McpJsonRpcToolsListRequest,
|
||||
McpJsonRpcToolsCallRequest,
|
||||
McpJsonRpcPingRequest,
|
||||
]
|
||||
|
||||
|
||||
class McpJsonRpcServerInfo(BaseModel):
|
||||
"""MCP 服务器信息。"""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class McpJsonRpcToolsCapability(BaseModel):
|
||||
"""MCP 工具能力声明。"""
|
||||
|
||||
listChanged: bool = False
|
||||
|
||||
|
||||
class McpJsonRpcCapabilities(BaseModel):
|
||||
"""MCP 服务器能力声明。"""
|
||||
|
||||
tools: McpJsonRpcToolsCapability
|
||||
logging: dict[str, JsonData] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class McpJsonRpcInitializeResult(BaseModel):
|
||||
"""MCP initialize 响应结果。"""
|
||||
|
||||
protocolVersion: str
|
||||
capabilities: McpJsonRpcCapabilities
|
||||
serverInfo: McpJsonRpcServerInfo
|
||||
instructions: str
|
||||
|
||||
|
||||
class McpJsonRpcToolsListResult(BaseModel):
|
||||
"""MCP tools/list 响应结果。"""
|
||||
|
||||
tools: list[McpToolInfo] = Field(default_factory=list)
|
||||
|
||||
|
||||
class McpJsonRpcTextContent(BaseModel):
|
||||
"""MCP 工具调用文本内容块。"""
|
||||
|
||||
type: Literal["text"] = "text"
|
||||
text: str
|
||||
|
||||
|
||||
class McpJsonRpcToolCallResult(BaseModel):
|
||||
"""MCP tools/call 响应结果。"""
|
||||
|
||||
content: list[McpJsonRpcTextContent] = Field(default_factory=list)
|
||||
isError: bool = False
|
||||
|
||||
|
||||
class McpJsonRpcEmptyResult(BaseModel):
|
||||
"""MCP ping 的空结果。"""
|
||||
|
||||
|
||||
class McpJsonRpcSuccess(BaseModel):
|
||||
"""MCP JSON-RPC 成功响应。"""
|
||||
|
||||
jsonrpc: Literal["2.0"] = "2.0"
|
||||
id: Optional[str | int] = None
|
||||
result: Union[
|
||||
McpJsonRpcInitializeResult,
|
||||
McpJsonRpcToolsListResult,
|
||||
McpJsonRpcToolCallResult,
|
||||
McpJsonRpcEmptyResult,
|
||||
]
|
||||
|
||||
|
||||
class McpJsonRpcErrorDetail(BaseModel):
|
||||
"""MCP JSON-RPC 错误详情。"""
|
||||
|
||||
code: int
|
||||
message: str
|
||||
data: Optional[JsonData] = None
|
||||
|
||||
|
||||
class McpJsonRpcError(BaseModel):
|
||||
"""MCP JSON-RPC 错误响应。"""
|
||||
|
||||
jsonrpc: Literal["2.0"] = "2.0"
|
||||
id: Optional[str | int] = None
|
||||
error: McpJsonRpcErrorDetail
|
||||
|
||||
|
||||
McpJsonRpcResponse: TypeAlias = Union[McpJsonRpcSuccess, McpJsonRpcError]
|
||||
|
||||
# 保持协议端点自行解析并返回 JSON-RPC 错误,同时从同一组 Pydantic 模型生成请求文档。
|
||||
MCP_JSONRPC_REQUEST_SCHEMA = TypeAdapter(McpJsonRpcRequest).json_schema()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Union, List, Any
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
||||
from pydantic import BaseModel, Field, ConfigDict, RootModel, model_validator
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.media import OptionalMediaIdentityMixin
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
@@ -14,7 +15,7 @@ class ExistMediaInfo(BaseModel):
|
||||
# 类型 电影、电视剧、音乐
|
||||
type: Optional[MediaType] = None
|
||||
# 季
|
||||
seasons: Optional[Dict[int, list]] = Field(default_factory=dict)
|
||||
seasons: Optional[Dict[int, List[int]]] = Field(default_factory=dict)
|
||||
# 媒体服务器类型:plex、jellyfin、emby、zspace、trimemedia、ugreen、navidrome
|
||||
server_type: Optional[str] = None
|
||||
# 媒体服务器名称
|
||||
@@ -23,6 +24,25 @@ class ExistMediaInfo(BaseModel):
|
||||
itemid: Optional[Union[str, int]] = None
|
||||
|
||||
|
||||
class MediaServerPlayData(BaseModel):
|
||||
"""媒体服务器在线播放地址。"""
|
||||
|
||||
url: str = Field(description="播放地址")
|
||||
item_id: Optional[str] = Field(default=None, description="媒体项目 ID")
|
||||
server_id: Optional[str] = Field(default=None, description="媒体服务器 ID")
|
||||
server_type: Optional[str] = Field(default=None, description="媒体服务器类型")
|
||||
|
||||
|
||||
class MediaServerExistsData(BaseModel):
|
||||
"""本地媒体存在性查询结果。"""
|
||||
|
||||
item: Dict[str, str] = Field(default_factory=dict, description="命中的媒体项目")
|
||||
|
||||
|
||||
class MediaServerExistingEpisodes(RootModel[Dict[int, List[int]]]):
|
||||
"""媒体服务器中按季号归组的已存在集号。"""
|
||||
|
||||
|
||||
class NotExistMediaInfo(BaseModel):
|
||||
"""
|
||||
媒体服务器不存在媒体信息
|
||||
@@ -30,7 +50,7 @@ class NotExistMediaInfo(BaseModel):
|
||||
# 季
|
||||
season: Optional[int] = None
|
||||
# 剧集列表
|
||||
episodes: Optional[list] = Field(default_factory=list)
|
||||
episodes: Optional[List[int]] = Field(default_factory=list)
|
||||
# 总集数
|
||||
total_episode: Optional[int] = 0
|
||||
# 开始集
|
||||
@@ -70,7 +90,7 @@ class MediaServerLibrary(BaseModel):
|
||||
# 名称
|
||||
name: Optional[str] = None
|
||||
# 路径
|
||||
path: Optional[Union[str, list]] = None
|
||||
path: Optional[Union[str, List[str]]] = None
|
||||
# 类型
|
||||
type: Optional[str] = None
|
||||
# 媒体库内媒体数量
|
||||
@@ -88,6 +108,8 @@ class MediaServerLibrary(BaseModel):
|
||||
|
||||
|
||||
class MediaServerItemUserState(BaseModel):
|
||||
"""媒体服务器条目的用户播放状态。"""
|
||||
|
||||
# 已播放
|
||||
played: Optional[bool] = None
|
||||
# 继续播放
|
||||
@@ -128,9 +150,9 @@ class MediaServerItem(OptionalMediaIdentityMixin, BaseModel):
|
||||
# 路径
|
||||
path: Optional[str] = None
|
||||
# 季集
|
||||
seasoninfo: Optional[Dict[int, list]] = None
|
||||
seasoninfo: Optional[Dict[int, List[int]]] = None
|
||||
# 备注
|
||||
note: Optional[Any] = None
|
||||
note: Optional[JsonData] = None
|
||||
# 同步时间
|
||||
lst_mod_date: Optional[str] = None
|
||||
user_state: Optional[MediaServerItemUserState] = None
|
||||
@@ -172,7 +194,7 @@ class WebhookEventInfo(BaseModel):
|
||||
save_reason: Optional[str] = None
|
||||
item_isvirtual: Optional[bool] = None
|
||||
media_type: Optional[str] = None
|
||||
json_object: Optional[dict] = Field(default_factory=dict)
|
||||
json_object: Optional[dict[str, JsonData]] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
@@ -236,7 +258,7 @@ class MediaServerPlayItem(BaseModel):
|
||||
image: Optional[str] = None
|
||||
link: Optional[str] = None
|
||||
percent: Optional[float] = None
|
||||
BackdropImageTags: Optional[list] = Field(default_factory=list)
|
||||
BackdropImageTags: Optional[List[str]] = Field(default_factory=list)
|
||||
server_type: Optional[str] = None
|
||||
# 飞牛的图片需要Cookies
|
||||
use_cookies: Optional[bool] = None
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Optional, Union, List, Dict, Set, Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.types import ContentType, NotificationType, MessageChannel
|
||||
|
||||
|
||||
@@ -47,7 +48,7 @@ class MessageResponse(BaseModel):
|
||||
# 消息来源
|
||||
source: Optional[str] = None
|
||||
# 渠道自定义上下文(如飞书流式卡片 card_id/element_id/sequence)
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
metadata: Optional[Dict[str, JsonData]] = None
|
||||
# 是否发送成功
|
||||
success: bool = False
|
||||
|
||||
@@ -80,7 +81,17 @@ class NotificationHistoryItem(BaseModel):
|
||||
# 消息方向:0-接收消息,1-发送消息
|
||||
action: Optional[int] = None
|
||||
# 附件json
|
||||
note: Optional[Union[list, dict]] = None
|
||||
note: Optional[JsonData] = None
|
||||
|
||||
|
||||
class WebMessageItem(NotificationHistoryItem):
|
||||
"""Web 消息历史记录。"""
|
||||
|
||||
|
||||
class NotificationClearData(BaseModel):
|
||||
"""通知中心各范围的清理时间。"""
|
||||
|
||||
clear_before: NotificationClearBefore = Field(description="各范围清理时间")
|
||||
|
||||
|
||||
class CommingMessage(BaseModel):
|
||||
@@ -300,7 +311,7 @@ class Subscription(BaseModel):
|
||||
"""
|
||||
|
||||
endpoint: Optional[str] = None
|
||||
keys: Optional[dict] = Field(default_factory=dict)
|
||||
keys: Optional[dict[str, str]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SubscriptionMessage(BaseModel):
|
||||
@@ -312,7 +323,7 @@ class SubscriptionMessage(BaseModel):
|
||||
body: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
data: Optional[dict] = Field(default_factory=dict)
|
||||
data: Optional[dict[str, JsonData]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentWebChatRequest(BaseModel):
|
||||
@@ -345,7 +356,7 @@ class AgentWebChatRequest(BaseModel):
|
||||
# 文件附件列表
|
||||
files: Optional[List[AgentWebChatFile]] = Field(default_factory=list)
|
||||
# 用户通过按钮选择时的完整选择快照
|
||||
choice_selection: Optional[Dict[str, Any]] = Field(default=None)
|
||||
choice_selection: Optional[Dict[str, JsonData]] = Field(default=None)
|
||||
# WebAgent 按钮回调关联的原消息 ID,用于传统交互原地编辑卡片
|
||||
original_message_id: Optional[Union[str, int]] = Field(default=None)
|
||||
# WebAgent 按钮回调关联的原聊天 ID,用于传统交互原地编辑卡片
|
||||
|
||||
42
app/schemas/mfa.py
Normal file
42
app/schemas/mfa.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""多因素认证 API 输出模型。"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
|
||||
|
||||
class PasskeyOptions(RootModel[dict[str, JsonData]]):
|
||||
"""浏览器 WebAuthn API 使用的动态选项。"""
|
||||
|
||||
|
||||
class OtpGenerateData(BaseModel):
|
||||
"""OTP 绑定密钥和验证 URI。"""
|
||||
|
||||
secret: str = Field(description="OTP 密钥")
|
||||
uri: str = Field(description="OTP 验证 URI")
|
||||
|
||||
|
||||
class MfaStatusData(BaseModel):
|
||||
"""用户是否启用多因素认证。"""
|
||||
|
||||
enabled: bool = Field(description="是否启用多因素认证")
|
||||
|
||||
|
||||
class PasskeyStartData(BaseModel):
|
||||
"""PassKey 注册或认证的启动数据。"""
|
||||
|
||||
options: PasskeyOptions = Field(description="WebAuthn 选项")
|
||||
transaction_token: str = Field(description="一次性事务令牌")
|
||||
|
||||
|
||||
class PasskeyInfo(BaseModel):
|
||||
"""当前用户绑定的 PassKey 摘要。"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
created_at: Optional[str] = None
|
||||
last_used_at: Optional[str] = None
|
||||
aaguid: Optional[str] = None
|
||||
transports: Optional[str] = None
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import Any, Literal, Optional, Union
|
||||
from typing import Literal, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin
|
||||
from app.schemas.types import MediaSource, MusicEntityType, MusicTargetEntityType
|
||||
|
||||
@@ -75,7 +76,7 @@ class MusicInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
names: list[str] = Field(default_factory=list)
|
||||
detail_link: Optional[str] = None
|
||||
listen_count: Optional[int] = None
|
||||
raw_data: dict[str, Any] = Field(default_factory=dict)
|
||||
raw_data: dict[str, JsonData] = Field(default_factory=dict)
|
||||
title_year: Optional[str] = None
|
||||
poster_path: Optional[str] = None
|
||||
backdrop_path: Optional[str] = None
|
||||
@@ -125,7 +126,7 @@ class MusicAlbumInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
detail_link: Optional[str] = None
|
||||
tracks: list[MusicInfo] = Field(default_factory=list)
|
||||
releases: list[MusicRelease] = Field(default_factory=list)
|
||||
raw_data: dict[str, Any] = Field(default_factory=dict)
|
||||
raw_data: dict[str, JsonData] = Field(default_factory=dict)
|
||||
title_year: Optional[str] = None
|
||||
poster_path: Optional[str] = None
|
||||
backdrop_path: Optional[str] = None
|
||||
@@ -160,7 +161,7 @@ class MusicArtistInfo(OptionalMediaIdentityMixin, BaseModel):
|
||||
detail_link: Optional[str] = None
|
||||
external_links: dict[str, str] = Field(default_factory=dict)
|
||||
album_count: Optional[int] = None
|
||||
raw_data: dict[str, Any] = Field(default_factory=dict)
|
||||
raw_data: dict[str, JsonData] = Field(default_factory=dict)
|
||||
poster_path: Optional[str] = None
|
||||
overview: Optional[str] = None
|
||||
|
||||
@@ -171,3 +172,25 @@ class MusicRecognizeRequest(RequiredMediaIdentityMixin, BaseModel):
|
||||
media_source: MediaSource
|
||||
media_id: str
|
||||
music_type: Optional[MusicTargetEntityType] = None
|
||||
|
||||
|
||||
class MusicRecognitionCacheItem(BaseModel):
|
||||
"""单条 MusicBrainz 识别缓存。"""
|
||||
|
||||
key: str
|
||||
media_id: str = ""
|
||||
title: str = ""
|
||||
artists: list[str] = Field(default_factory=list)
|
||||
album: str = ""
|
||||
year: str | int = ""
|
||||
music_type: str = "recording"
|
||||
cover_url: str = ""
|
||||
|
||||
|
||||
class MusicRecognitionCacheData(BaseModel):
|
||||
"""MusicBrainz 识别缓存统计及明细。"""
|
||||
|
||||
count: int = 0
|
||||
recognized: int = 0
|
||||
unrecognized: int = 0
|
||||
data: list[MusicRecognitionCacheItem] = Field(default_factory=list)
|
||||
|
||||
29
app/schemas/notification.py
Normal file
29
app/schemas/notification.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""通知渠道 API 输出模型。"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WechatClawBotKnownTarget(BaseModel):
|
||||
"""微信 ClawBot 已知消息目标。"""
|
||||
|
||||
userid: str
|
||||
username: str
|
||||
last_active: Optional[int | float] = None
|
||||
|
||||
|
||||
class WechatClawBotData(BaseModel):
|
||||
"""微信 ClawBot 登录状态或操作结果。"""
|
||||
|
||||
success: bool
|
||||
message: Optional[str] = None
|
||||
connected: Optional[bool] = None
|
||||
account_id: Optional[str] = None
|
||||
qrcode: Optional[str] = None
|
||||
qrcode_url: Optional[str] = None
|
||||
qrcode_status: Optional[str] = None
|
||||
qrcode_updated_at: Optional[int | float] = None
|
||||
known_targets: list[WechatClawBotKnownTarget] = Field(default_factory=list)
|
||||
default_target: Optional[str] = None
|
||||
base_url: Optional[str] = None
|
||||
@@ -1,7 +1,9 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
|
||||
|
||||
class OpenAIModelInfo(BaseModel):
|
||||
id: str
|
||||
@@ -16,14 +18,18 @@ class OpenAIModelListResponse(BaseModel):
|
||||
|
||||
|
||||
class OpenAIChatMessage(BaseModel):
|
||||
"""OpenAI Chat Completions 请求中的一条消息。"""
|
||||
|
||||
role: str
|
||||
content: Any
|
||||
content: JsonData
|
||||
name: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class OpenAIChatCompletionsRequest(BaseModel):
|
||||
"""OpenAI Chat Completions 兼容请求。"""
|
||||
|
||||
model: Optional[str] = None
|
||||
messages: List[OpenAIChatMessage]
|
||||
user: Optional[str] = None
|
||||
@@ -33,8 +39,10 @@ class OpenAIChatCompletionsRequest(BaseModel):
|
||||
|
||||
|
||||
class OpenAIResponsesRequest(BaseModel):
|
||||
"""OpenAI Responses API 兼容请求。"""
|
||||
|
||||
model: Optional[str] = None
|
||||
input: Any
|
||||
input: JsonData
|
||||
instructions: Optional[str] = None
|
||||
user: Optional[str] = None
|
||||
stream: bool = False
|
||||
@@ -69,9 +77,30 @@ class OpenAIChatCompletionResponse(BaseModel):
|
||||
|
||||
|
||||
class OpenAIResponsesOutputText(BaseModel):
|
||||
"""Responses API 输出中的文本内容块。"""
|
||||
|
||||
type: str = "output_text"
|
||||
text: str
|
||||
annotations: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
annotations: List["OpenAIResponseAnnotation"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OpenAIResponseAnnotation(BaseModel):
|
||||
"""Responses API 文本内容关联的引用或文件注解。"""
|
||||
|
||||
type: str
|
||||
index: Optional[int] = None
|
||||
start_index: Optional[int] = None
|
||||
end_index: Optional[int] = None
|
||||
url: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
file_id: Optional[str] = None
|
||||
filename: Optional[str] = None
|
||||
|
||||
|
||||
class OpenAIIncompleteDetails(BaseModel):
|
||||
"""Responses API 未完整结束时的原因。"""
|
||||
|
||||
reason: str
|
||||
|
||||
|
||||
class OpenAIResponsesOutputMessage(BaseModel):
|
||||
@@ -83,14 +112,16 @@ class OpenAIResponsesOutputMessage(BaseModel):
|
||||
|
||||
|
||||
class OpenAIResponsesResponse(BaseModel):
|
||||
"""OpenAI Responses API 的非流式成功响应。"""
|
||||
|
||||
id: str
|
||||
object: str = "response"
|
||||
created_at: int
|
||||
status: str = "completed"
|
||||
model: str
|
||||
output: List[OpenAIResponsesOutputMessage] = Field(default_factory=list)
|
||||
error: Optional[Any] = None
|
||||
incomplete_details: Optional[Any] = None
|
||||
error: Optional["OpenAIErrorDetail"] = None
|
||||
incomplete_details: Optional[OpenAIIncompleteDetails] = None
|
||||
usage: OpenAIUsage
|
||||
|
||||
|
||||
@@ -105,20 +136,24 @@ class OpenAIErrorResponse(BaseModel):
|
||||
error: OpenAIErrorDetail
|
||||
|
||||
|
||||
OpenAIChatContentPart = Dict[str, Any]
|
||||
OpenAIChatContentPart = Dict[str, JsonData]
|
||||
|
||||
|
||||
class AnthropicMessage(BaseModel):
|
||||
"""Anthropic Messages 请求中的一条消息。"""
|
||||
|
||||
role: str
|
||||
content: Any
|
||||
content: JsonData
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class AnthropicMessagesRequest(BaseModel):
|
||||
"""Anthropic Messages 兼容请求。"""
|
||||
|
||||
model: Optional[str] = None
|
||||
messages: List[AnthropicMessage]
|
||||
system: Optional[Any] = None
|
||||
system: Optional[JsonData] = None
|
||||
max_tokens: Optional[int] = 1024
|
||||
stream: bool = False
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
|
||||
|
||||
class Plugin(BaseModel):
|
||||
@@ -51,7 +53,7 @@ class Plugin(BaseModel):
|
||||
# 安装次数
|
||||
install_count: Optional[int] = 0
|
||||
# 更新记录
|
||||
history: Optional[dict] = Field(default_factory=dict)
|
||||
history: Optional[dict[str, str]] = Field(default_factory=dict)
|
||||
# 添加时间,值越小表示越靠后发布
|
||||
add_time: Optional[int] = 0
|
||||
# 插件公钥
|
||||
@@ -70,11 +72,11 @@ class PluginDashboard(Plugin):
|
||||
# 演染模式
|
||||
render_mode: Optional[str] = Field(default="vuetify")
|
||||
# 全局配置
|
||||
attrs: Optional[dict] = Field(default_factory=dict)
|
||||
attrs: Optional[dict[str, JsonData]] = Field(default_factory=dict)
|
||||
# col列数
|
||||
cols: Optional[dict] = Field(default_factory=dict)
|
||||
cols: Optional[dict[str, JsonData]] = Field(default_factory=dict)
|
||||
# 页面元素
|
||||
elements: Optional[List[dict]] = Field(default_factory=list)
|
||||
elements: Optional[List[dict[str, JsonData]]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PluginSidebarNavItem(BaseModel):
|
||||
@@ -115,6 +117,10 @@ class PluginRating(BaseModel):
|
||||
user_rating: Optional[float] = Field(default=None, description="当前安装实例评分")
|
||||
|
||||
|
||||
class PluginRatingMap(RootModel[Dict[str, PluginRating]]):
|
||||
"""插件 ID 与评分结果的映射。"""
|
||||
|
||||
|
||||
class PluginMemoryInfo(BaseModel):
|
||||
"""插件内存信息"""
|
||||
plugin_id: str = Field(description="插件ID")
|
||||
@@ -126,4 +132,46 @@ class PluginMemoryInfo(BaseModel):
|
||||
calculation_time_ms: float = Field(description="计算耗时(毫秒)")
|
||||
timestamp: float = Field(description="统计时间戳")
|
||||
error: Optional[str] = Field(default=None, description="错误信息")
|
||||
object_details: Optional[List[Dict[str, Any]]] = Field(default=None, description="大对象详情")
|
||||
object_details: Optional[List[Dict[str, JsonData]]] = Field(default=None, description="大对象详情")
|
||||
|
||||
|
||||
class PluginRemoteInfo(BaseModel):
|
||||
"""插件模块联邦远程入口。"""
|
||||
|
||||
id: str
|
||||
url: str
|
||||
name: str
|
||||
|
||||
|
||||
class PluginReleaseItem(BaseModel):
|
||||
"""可安装的插件 Release 版本。"""
|
||||
|
||||
version: str
|
||||
tag_name: str
|
||||
name: str
|
||||
published_at: Optional[str] = None
|
||||
body: str = ""
|
||||
asset_name: str
|
||||
is_latest: bool = False
|
||||
is_current: bool = False
|
||||
|
||||
|
||||
class PluginReleaseData(BaseModel):
|
||||
"""插件 Release 能力与版本列表。"""
|
||||
|
||||
release_supported: bool = False
|
||||
latest_version: Optional[str] = None
|
||||
current_version: Optional[str] = None
|
||||
items: List[PluginReleaseItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PluginFoldersData(RootModel[Dict[str, List[str]]]):
|
||||
"""插件文件夹与插件 ID 列表映射。"""
|
||||
|
||||
|
||||
class PluginDashboardMetaItem(BaseModel):
|
||||
"""插件仪表板入口摘要。"""
|
||||
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
key: Optional[str] = None
|
||||
|
||||
@@ -1,29 +1,48 @@
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Generic, Optional, TypeVar
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from app.helper.locale import LocaleHelper
|
||||
|
||||
|
||||
class Response(BaseModel):
|
||||
"""通用接口响应结构"""
|
||||
DataT = TypeVar("DataT")
|
||||
|
||||
|
||||
class Response(BaseModel, Generic[DataT]):
|
||||
"""统一接口响应结构,仅允许业务数据类型随接口变化。"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="forbid",
|
||||
json_schema_extra={"required": ["success", "message", "data"]}
|
||||
)
|
||||
|
||||
# 状态
|
||||
success: bool
|
||||
# 消息文本
|
||||
message: Optional[str] = None
|
||||
# 多语言消息文本
|
||||
message_i18n: Optional[str] = None
|
||||
message: str = ""
|
||||
# 数据
|
||||
data: Optional[Any] = Field(default_factory=dict)
|
||||
data: Optional[DataT] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def fill_message_i18n(self) -> "Response":
|
||||
"""
|
||||
自动补充响应消息的多语言文本。
|
||||
"""
|
||||
if self.message and self.message_i18n is None:
|
||||
self.message_i18n = LocaleHelper.translate_text(
|
||||
self.message, locale=LocaleHelper.get_current_locale()
|
||||
)
|
||||
return self
|
||||
@field_validator("message", mode="before")
|
||||
@classmethod
|
||||
def localize_message(cls, value: Any) -> str:
|
||||
"""按当前请求语言直接本地化消息文本,并将空消息归一为空字符串。"""
|
||||
if value is None:
|
||||
return ""
|
||||
message = str(value)
|
||||
if not message:
|
||||
return ""
|
||||
return LocaleHelper.translate_text(
|
||||
message, locale=LocaleHelper.get_current_locale()
|
||||
)
|
||||
|
||||
|
||||
class ValidationIssue(BaseModel):
|
||||
"""请求参数校验失败时返回的单项错误信息。"""
|
||||
|
||||
# 参数位置
|
||||
location: list[str | int]
|
||||
# 错误说明
|
||||
message: str
|
||||
# 错误类型
|
||||
error_type: str
|
||||
|
||||
22
app/schemas/search.py
Normal file
22
app/schemas/search.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""搜索 API 输出模型。"""
|
||||
|
||||
from typing import Literal, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.context import SubtitleInfo, TorrentInfo
|
||||
|
||||
|
||||
class SearchLastContextData(BaseModel):
|
||||
"""上一次搜索的请求参数与结果。"""
|
||||
|
||||
params: dict[str, JsonData] = Field(default_factory=dict)
|
||||
results: list[Union[TorrentInfo, SubtitleInfo]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SearchRecommendStatusData(BaseModel):
|
||||
"""AI 搜索结果推荐任务状态。"""
|
||||
|
||||
status: Literal["disabled", "idle", "running", "completed", "error"]
|
||||
results: list[int] = Field(default_factory=list)
|
||||
@@ -1,8 +1,185 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ServarrVersion(BaseModel):
|
||||
"""Servarr 兼容接口使用的版本号结构。"""
|
||||
|
||||
major: int = 0
|
||||
minor: int = 0
|
||||
build: int = 0
|
||||
revision: int = 0
|
||||
majorRevision: int = 0
|
||||
minorRevision: int = 0
|
||||
|
||||
|
||||
class ServarrSystemStatus(BaseModel):
|
||||
"""Servarr 系统状态响应。"""
|
||||
|
||||
appName: str
|
||||
instanceName: str
|
||||
version: str
|
||||
buildTime: str
|
||||
isDebug: bool
|
||||
isProduction: bool
|
||||
isAdmin: bool
|
||||
isUserInteractive: bool
|
||||
startupPath: str
|
||||
appData: str
|
||||
osName: str
|
||||
osVersion: str
|
||||
isNetCore: bool
|
||||
isLinux: bool
|
||||
isOsx: bool
|
||||
isWindows: bool
|
||||
isDocker: bool
|
||||
mode: str
|
||||
branch: str
|
||||
databaseType: str
|
||||
databaseVersion: ServarrVersion
|
||||
authentication: str
|
||||
migrationVersion: int
|
||||
urlBase: str
|
||||
runtimeVersion: ServarrVersion
|
||||
runtimeName: str
|
||||
startTime: str
|
||||
packageVersion: str
|
||||
packageAuthor: str
|
||||
packageUpdateMechanism: str
|
||||
packageUpdateMechanismMessage: str
|
||||
|
||||
|
||||
class ServarrQuality(BaseModel):
|
||||
"""Servarr 质量定义。"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
source: str
|
||||
resolution: int
|
||||
|
||||
|
||||
class ServarrQualityProfileItem(BaseModel):
|
||||
"""Servarr 质量配置中的可选质量项。"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
quality: ServarrQuality
|
||||
items: list[str] = Field(default_factory=list)
|
||||
allowed: bool
|
||||
|
||||
|
||||
class ServarrFormatItem(BaseModel):
|
||||
"""Servarr 自定义格式评分项。"""
|
||||
|
||||
id: int
|
||||
format: int
|
||||
name: str
|
||||
score: int
|
||||
|
||||
|
||||
class ServarrQualityProfile(BaseModel):
|
||||
"""Servarr 质量配置响应项。"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
upgradeAllowed: bool
|
||||
cutoff: int
|
||||
items: list[ServarrQualityProfileItem] = Field(default_factory=list)
|
||||
minFormatScore: int
|
||||
cutoffFormatScore: int
|
||||
formatItems: list[ServarrFormatItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ServarrRootFolder(BaseModel):
|
||||
"""Servarr 根目录响应项。"""
|
||||
|
||||
id: int
|
||||
path: str
|
||||
accessible: bool
|
||||
freeSpace: int
|
||||
unmappedFolders: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ServarrTag(BaseModel):
|
||||
"""Servarr 标签响应项。"""
|
||||
|
||||
id: int
|
||||
label: str
|
||||
|
||||
|
||||
class ServarrLanguage(BaseModel):
|
||||
"""Servarr 语言定义。"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class ServarrLanguageProfileItem(BaseModel):
|
||||
"""Servarr 语言配置中的可选语言项。"""
|
||||
|
||||
id: int
|
||||
language: ServarrLanguage
|
||||
allowed: bool
|
||||
|
||||
|
||||
class ServarrLanguageProfile(BaseModel):
|
||||
"""Servarr 语言配置响应项。"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
upgradeAllowed: bool
|
||||
cutoff: ServarrLanguage
|
||||
languages: list[ServarrLanguageProfileItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ServarrIdResponse(BaseModel):
|
||||
"""Servarr 新增资源后返回的资源标识。"""
|
||||
|
||||
id: int
|
||||
|
||||
|
||||
class ServarrImage(BaseModel):
|
||||
"""Servarr 媒体图片。"""
|
||||
|
||||
coverType: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
remoteUrl: Optional[str] = None
|
||||
|
||||
|
||||
class SonarrStatistics(BaseModel):
|
||||
"""Sonarr 剧集或季度统计信息。"""
|
||||
|
||||
seasonCount: Optional[int] = None
|
||||
episodeFileCount: Optional[int] = None
|
||||
episodeCount: Optional[int] = None
|
||||
totalEpisodeCount: Optional[int] = None
|
||||
sizeOnDisk: Optional[int] = None
|
||||
releaseGroups: list[str] = Field(default_factory=list)
|
||||
percentOfEpisodes: Optional[float] = None
|
||||
nextAiring: Optional[str] = None
|
||||
previousAiring: Optional[str] = None
|
||||
|
||||
|
||||
class SonarrSeason(BaseModel):
|
||||
"""Sonarr 季度监控信息。"""
|
||||
|
||||
seasonNumber: Optional[int] = None
|
||||
monitored: bool = False
|
||||
statistics: Optional[SonarrStatistics] = None
|
||||
images: list[ServarrImage] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SonarrRatings(BaseModel):
|
||||
"""Sonarr 剧集评分信息。"""
|
||||
|
||||
votes: Optional[int] = None
|
||||
value: Optional[float] = None
|
||||
|
||||
|
||||
class RadarrMovie(BaseModel):
|
||||
"""Radarr 兼容接口的电影结构。"""
|
||||
|
||||
id: Optional[int] = None
|
||||
title: Optional[str] = None
|
||||
year: Optional[str | int] = None
|
||||
@@ -20,6 +197,8 @@ class RadarrMovie(BaseModel):
|
||||
|
||||
|
||||
class SonarrSeries(BaseModel):
|
||||
"""Sonarr 兼容接口的剧集结构。"""
|
||||
|
||||
id: Optional[int] = None
|
||||
title: Optional[str] = None
|
||||
sortTitle: Optional[str] = None
|
||||
@@ -28,9 +207,9 @@ class SonarrSeries(BaseModel):
|
||||
overview: Optional[str] = None
|
||||
network: Optional[str] = None
|
||||
airTime: Optional[str] = None
|
||||
images: list = Field(default_factory=list)
|
||||
images: list[ServarrImage] = Field(default_factory=list)
|
||||
remotePoster: Optional[str] = None
|
||||
seasons: list = Field(default_factory=list)
|
||||
seasons: list[SonarrSeason] = Field(default_factory=list)
|
||||
year: Optional[str | int] = None
|
||||
path: Optional[str] = None
|
||||
profileId: Optional[int] = None
|
||||
@@ -49,11 +228,11 @@ class SonarrSeries(BaseModel):
|
||||
cleanTitle: Optional[str] = None
|
||||
titleSlug: Optional[str] = None
|
||||
certification: Optional[str] = None
|
||||
genres: list = Field(default_factory=list)
|
||||
tags: list = Field(default_factory=list)
|
||||
genres: list[str] = Field(default_factory=list)
|
||||
tags: list[int] = Field(default_factory=list)
|
||||
added: Optional[str] = None
|
||||
ratings: Optional[dict] = None
|
||||
ratings: Optional[SonarrRatings] = None
|
||||
qualityProfileId: Optional[int] = None
|
||||
statistics: dict = Field(default_factory=dict)
|
||||
statistics: SonarrStatistics = Field(default_factory=SonarrStatistics)
|
||||
isAvailable: Optional[bool] = False
|
||||
hasFile: Optional[bool] = False
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
|
||||
|
||||
class CookieData(BaseModel):
|
||||
encrypted: str = Query(min_length=1, max_length=1024 * 1024 * 50)
|
||||
uuid: str = Query(min_length=5, pattern="^[a-zA-Z0-9]+$")
|
||||
"""CookieCloud 上传的加密数据。"""
|
||||
|
||||
encrypted: str = Field(min_length=1, max_length=1024 * 1024 * 50)
|
||||
uuid: str = Field(min_length=5, pattern="^[a-zA-Z0-9]+$")
|
||||
|
||||
|
||||
class CookiePassword(BaseModel):
|
||||
"""CookieCloud 下载并解密数据所需的密码。"""
|
||||
|
||||
password: str
|
||||
|
||||
|
||||
class CookieActionResponse(BaseModel):
|
||||
"""CookieCloud 上传操作结果。"""
|
||||
|
||||
action: Literal["done", "error"]
|
||||
|
||||
|
||||
class CookieEncryptedPayload(BaseModel):
|
||||
"""CookieCloud 保存和下载的加密载荷。"""
|
||||
|
||||
encrypted: str
|
||||
|
||||
|
||||
class CookieDecryptedPayload(BaseModel):
|
||||
"""CookieCloud 解密后的 Cookie 数据载荷。"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
cookie_data: JsonData
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
from typing import Optional, Any, Union, Dict
|
||||
from typing import Optional, Union, Dict
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from pydantic import BaseModel, Field, ConfigDict, RootModel
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
|
||||
|
||||
SiteUnreadMessage = Union[
|
||||
tuple[Optional[str], Optional[str], Optional[str]],
|
||||
tuple[Optional[str], Optional[str], Optional[str], Optional[str]],
|
||||
]
|
||||
"""站点未读消息,第四项为部分站点提供的持久化去重来源。"""
|
||||
|
||||
|
||||
class Site(BaseModel):
|
||||
"""站点配置及运行状态。"""
|
||||
|
||||
# ID
|
||||
id: Optional[int] = None
|
||||
# 站点名称
|
||||
@@ -33,7 +44,7 @@ class Site(BaseModel):
|
||||
# 是否公开站点
|
||||
public: Optional[int] = 0
|
||||
# 备注
|
||||
note: Optional[Any] = None
|
||||
note: Optional[JsonData] = None
|
||||
# 超时时间
|
||||
timeout: Optional[int] = 15
|
||||
# 流控单位周期
|
||||
@@ -51,6 +62,8 @@ class Site(BaseModel):
|
||||
|
||||
|
||||
class SiteStatistic(BaseModel):
|
||||
"""单个站点的访问成功率与耗时统计。"""
|
||||
|
||||
# 站点ID
|
||||
domain: Optional[str] = None
|
||||
# 成功次数
|
||||
@@ -64,12 +77,14 @@ class SiteStatistic(BaseModel):
|
||||
# 最后修改时间
|
||||
lst_mod_date: Optional[str] = None
|
||||
# 备注
|
||||
note: Optional[Any] = None
|
||||
note: Optional[Dict[str, int]] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SiteUserData(BaseModel):
|
||||
"""站点用户账户、流量、做种和未读消息数据。"""
|
||||
|
||||
# 站点域名
|
||||
domain: Optional[str] = None
|
||||
# 用户名
|
||||
@@ -97,11 +112,11 @@ class SiteUserData(BaseModel):
|
||||
# 下载体积
|
||||
leeching_size: Optional[int] = 0
|
||||
# 做种人数, 种子大小
|
||||
seeding_info: Optional[list] = Field(default_factory=list)
|
||||
seeding_info: Optional[list[tuple[int, int]]] = Field(default_factory=list)
|
||||
# 未读消息
|
||||
message_unread: Optional[int] = 0
|
||||
# 未读消息内容
|
||||
message_unread_contents: Optional[list] = Field(default_factory=list)
|
||||
message_unread_contents: Optional[list[SiteUnreadMessage]] = Field(default_factory=list)
|
||||
# 错误信息
|
||||
err_msg: Optional[str] = None
|
||||
# 更新日期
|
||||
@@ -111,6 +126,8 @@ class SiteUserData(BaseModel):
|
||||
|
||||
|
||||
class SiteAuth(BaseModel):
|
||||
"""站点认证模块及其参数。"""
|
||||
|
||||
site: Optional[str] = None
|
||||
params: Optional[Dict[str, Union[int, str]]] = Field(default_factory=dict)
|
||||
|
||||
@@ -125,6 +142,18 @@ class SiteCookieUpdate(BaseModel):
|
||||
|
||||
|
||||
class SiteCategory(BaseModel):
|
||||
"""站点资源分类。"""
|
||||
|
||||
id: Optional[int] = None
|
||||
cat: Optional[str] = None
|
||||
desc: Optional[str] = None
|
||||
|
||||
|
||||
class SiteIconData(BaseModel):
|
||||
"""站点图标地址或 Base64 内容。"""
|
||||
|
||||
icon: str
|
||||
|
||||
|
||||
class SiteMappingData(RootModel[dict[str, str]]):
|
||||
"""站点域名到显示名称的映射。"""
|
||||
|
||||
26
app/schemas/storage.py
Normal file
26
app/schemas/storage.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""存储授权 API 输出模型。"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class StorageQrCodeData(BaseModel):
|
||||
"""云存储扫码授权二维码。"""
|
||||
|
||||
codeContent: Optional[str] = Field(default=None, description="二维码原始内容")
|
||||
codeUrl: Optional[str] = Field(default=None, description="二维码图片地址")
|
||||
|
||||
|
||||
class StorageAuthUrlData(BaseModel):
|
||||
"""云存储 OAuth 授权入口。"""
|
||||
|
||||
authUrl: str = Field(description="授权地址")
|
||||
state: str = Field(description="授权状态校验值")
|
||||
|
||||
|
||||
class StorageLoginStatusData(BaseModel):
|
||||
"""云存储扫码或 OAuth 登录状态。"""
|
||||
|
||||
status: int | str = Field(description="授权状态")
|
||||
tip: str = Field(description="状态提示")
|
||||
@@ -110,7 +110,7 @@ class Subscribe(OptionalMediaIdentityMixin, BaseModel):
|
||||
# 已完成集数
|
||||
completed_episode: Optional[int] = None
|
||||
# 附加信息
|
||||
note: Optional[Any] = None
|
||||
note: Optional[List[int]] = None
|
||||
# 状态:N-新建, R-订阅中
|
||||
state: Optional[str] = None
|
||||
# 最后更新时间
|
||||
@@ -197,6 +197,8 @@ class Subscribe(OptionalMediaIdentityMixin, BaseModel):
|
||||
|
||||
|
||||
class SubscribeShare(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""可供其他用户复用的订阅分享信息。"""
|
||||
|
||||
# 分享ID
|
||||
id: Optional[int] = None
|
||||
# 订阅ID
|
||||
@@ -268,6 +270,8 @@ class SubscribeShare(OptionalMediaIdentityMixin, BaseModel):
|
||||
|
||||
|
||||
class SubscribeShareStatistics(BaseModel):
|
||||
"""单个用户的订阅分享数量与复用统计。"""
|
||||
|
||||
# 分享人
|
||||
share_user: Optional[str] = None
|
||||
# 分享数量
|
||||
@@ -277,6 +281,8 @@ class SubscribeShareStatistics(BaseModel):
|
||||
|
||||
|
||||
class SubscribeDownloadFileInfo(BaseModel):
|
||||
"""订阅剧集关联的下载文件信息。"""
|
||||
|
||||
# 种子名称
|
||||
torrent_title: Optional[str] = None
|
||||
# 站点名称
|
||||
@@ -290,6 +296,8 @@ class SubscribeDownloadFileInfo(BaseModel):
|
||||
|
||||
|
||||
class SubscribeLibraryFileInfo(BaseModel):
|
||||
"""订阅剧集关联的媒体库文件信息。"""
|
||||
|
||||
# 存储
|
||||
storage: Optional[str] = "local"
|
||||
# 文件路径
|
||||
@@ -303,6 +311,8 @@ class SubscribeLibraryFileInfo(BaseModel):
|
||||
|
||||
|
||||
class SubscribeEpisodeInfo(BaseModel):
|
||||
"""订阅单集的元数据及关联文件。"""
|
||||
|
||||
# 标题
|
||||
title: Optional[str] = None
|
||||
# 描述
|
||||
@@ -316,6 +326,8 @@ class SubscribeEpisodeInfo(BaseModel):
|
||||
|
||||
|
||||
class SubscrbieInfo(BaseModel):
|
||||
"""订阅详情及按集号归组的文件信息。"""
|
||||
|
||||
# 订阅信息
|
||||
subscribe: Optional[Subscribe] = None
|
||||
# 集信息 {集号: {download: 文件路径,library: 文件路径, backdrop: url, title: 标题, description: 描述}}
|
||||
|
||||
@@ -3,6 +3,9 @@ from typing import Optional, Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.schemas.context import MediaInfo, MetaInfo, TorrentInfo
|
||||
from app.schemas.rule import FilterRuleGroup
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceInfo:
|
||||
@@ -132,6 +135,61 @@ class StorageConf(BaseModel):
|
||||
config: Optional[dict] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SystemEnvironmentUpdateData(BaseModel):
|
||||
"""环境配置更新的成功项和失败项。"""
|
||||
|
||||
success_updates: dict[str, tuple[Optional[bool], str]] = Field(default_factory=dict)
|
||||
failed_updates: dict[str, tuple[Optional[bool], str]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PluginMarketSyncData(BaseModel):
|
||||
"""Wiki 插件市场仓库同步结果。"""
|
||||
|
||||
value: str
|
||||
repos: list[str] = Field(default_factory=list)
|
||||
wiki_repos: list[str] = Field(default_factory=list)
|
||||
added_count: int = 0
|
||||
total_count: int = 0
|
||||
source_url: str
|
||||
|
||||
|
||||
class RuleTestData(BaseModel):
|
||||
"""过滤规则测试的输入、识别和匹配明细。"""
|
||||
|
||||
title: str
|
||||
subtitle: Optional[str] = None
|
||||
rulegroup_name: str
|
||||
rulegroup: Optional[FilterRuleGroup] = None
|
||||
meta_info: MetaInfo
|
||||
media_info: Optional[MediaInfo] = None
|
||||
torrent_info: TorrentInfo
|
||||
priority: Optional[int] = None
|
||||
matched: bool = False
|
||||
|
||||
|
||||
class NetTestTarget(BaseModel):
|
||||
"""前端可选择的网络测试目标。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
icon: str
|
||||
|
||||
|
||||
class SystemModuleInfo(BaseModel):
|
||||
"""已加载系统模块摘要。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
name_i18n: str
|
||||
name_key: str
|
||||
|
||||
|
||||
class SystemModuleListData(BaseModel):
|
||||
"""已加载系统模块列表。"""
|
||||
|
||||
modules: list[SystemModuleInfo] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TransferDirectoryConf(BaseModel):
|
||||
"""
|
||||
文件整理目录配置
|
||||
|
||||
@@ -16,6 +16,34 @@ class TmdbSeason(BaseModel):
|
||||
vote_average: Optional[float] = None
|
||||
|
||||
|
||||
class TmdbEpisodeCredit(BaseModel):
|
||||
"""TMDB 剧集演职人员的公共信息。"""
|
||||
|
||||
adult: Optional[bool] = None
|
||||
gender: Optional[int] = None
|
||||
id: Optional[int] = None
|
||||
known_for_department: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
original_name: Optional[str] = None
|
||||
popularity: Optional[float] = None
|
||||
profile_path: Optional[str] = None
|
||||
credit_id: Optional[str] = None
|
||||
|
||||
|
||||
class TmdbEpisodeCrew(TmdbEpisodeCredit):
|
||||
"""TMDB 剧集幕后人员信息。"""
|
||||
|
||||
department: Optional[str] = None
|
||||
job: Optional[str] = None
|
||||
|
||||
|
||||
class TmdbEpisodeGuestStar(TmdbEpisodeCredit):
|
||||
"""TMDB 剧集客串演员信息。"""
|
||||
|
||||
character: Optional[str] = None
|
||||
order: Optional[int] = None
|
||||
|
||||
|
||||
class TmdbEpisode(BaseModel):
|
||||
"""
|
||||
TMDB集信息
|
||||
@@ -29,5 +57,28 @@ class TmdbEpisode(BaseModel):
|
||||
season_number: Optional[int] = None
|
||||
still_path: Optional[str] = None
|
||||
vote_average: Optional[float] = None
|
||||
crew: Optional[list] = Field(default_factory=list)
|
||||
guest_stars: Optional[list] = Field(default_factory=list)
|
||||
crew: Optional[list[TmdbEpisodeCrew]] = Field(default_factory=list)
|
||||
guest_stars: Optional[list[TmdbEpisodeGuestStar]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TmdbRecognitionCacheItem(BaseModel):
|
||||
"""单条 TMDB 识别缓存。"""
|
||||
|
||||
key: str
|
||||
tmdb_id: int = 0
|
||||
title: str = ""
|
||||
year: str | int = ""
|
||||
media_type: str = "unknown"
|
||||
poster_path: str = ""
|
||||
backdrop_path: str = ""
|
||||
|
||||
|
||||
class TmdbRecognitionCacheData(BaseModel):
|
||||
"""TMDB 识别缓存统计及明细。"""
|
||||
|
||||
count: int = 0
|
||||
recognized: int = 0
|
||||
unrecognized: int = 0
|
||||
shared_recognized: int = 0
|
||||
shared_recognize_enabled: bool = False
|
||||
data: list[TmdbRecognitionCacheItem] = Field(default_factory=list)
|
||||
|
||||
@@ -3,7 +3,16 @@ from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MfaChallenge(BaseModel):
|
||||
"""密码认证通过后需要继续完成的二次验证信息。"""
|
||||
|
||||
# 可用的二次验证方式
|
||||
mfa_methods: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""OAuth2 登录成功后返回的访问令牌。"""
|
||||
|
||||
# 令牌
|
||||
access_token: str
|
||||
# 令牌类型
|
||||
@@ -19,12 +28,14 @@ class Token(BaseModel):
|
||||
# 权限级别
|
||||
level: int = 1
|
||||
# 详细权限
|
||||
permissions: Optional[dict] = Field(default_factory=dict)
|
||||
permissions: Optional[dict[str, bool]] = Field(default_factory=dict)
|
||||
# 是否显示配置向导
|
||||
wizard: Optional[bool] = None
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
"""访问令牌中携带的用户身份与授权信息。"""
|
||||
|
||||
# 用户ID
|
||||
sub: Optional[int] = None
|
||||
# 用户名
|
||||
|
||||
@@ -41,12 +41,32 @@ class DownloaderTorrent(BaseModel):
|
||||
ratio_limit: Optional[float] = None
|
||||
seeding_time_limit: Optional[int] = None
|
||||
trackers: Optional[List[str]] = Field(default_factory=list)
|
||||
media: Optional[dict] = Field(default_factory=dict)
|
||||
media: Optional["DownloadTaskMedia"] = None
|
||||
userid: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
left_time: Optional[str] = None
|
||||
|
||||
|
||||
class DownloadTaskMedia(OptionalMediaIdentityMixin, BaseModel):
|
||||
"""下载任务关联的影视或音乐媒体摘要。"""
|
||||
|
||||
type: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
season: Optional[list[int] | int | str] = None
|
||||
episode: Optional[list[int] | int | str] = None
|
||||
image: Optional[str] = None
|
||||
poster: Optional[str] = None
|
||||
backdrop: Optional[str] = None
|
||||
media_source: Optional[MediaSource] = None
|
||||
media_id: Optional[str] = None
|
||||
music_type: Optional[str] = None
|
||||
artists: list[str] = Field(default_factory=list)
|
||||
album: Optional[str] = None
|
||||
album_id: Optional[str] = None
|
||||
total_tracks: Optional[int] = None
|
||||
track_number: Optional[int] = None
|
||||
|
||||
|
||||
class TransferTorrent(DownloaderTorrent):
|
||||
"""
|
||||
待转移任务信息
|
||||
@@ -281,3 +301,60 @@ class ManualTransferTargetPath(BaseModel):
|
||||
library_type_folder: Optional[bool] = False
|
||||
# 媒体库类别子目录
|
||||
library_category_folder: Optional[bool] = False
|
||||
|
||||
|
||||
class ManualTransferPreviewSummary(BaseModel):
|
||||
"""手动整理预览数量统计。"""
|
||||
|
||||
total: int = 0
|
||||
success: int = 0
|
||||
failed: int = 0
|
||||
|
||||
|
||||
class ManualTransferPreviewItem(BaseModel):
|
||||
"""单个文件的手动整理预览。"""
|
||||
|
||||
source: Optional[str] = None
|
||||
target: Optional[str] = None
|
||||
target_dir: Optional[str] = None
|
||||
success: bool = False
|
||||
message: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
season: Optional[int] = None
|
||||
episode: Optional[int] = None
|
||||
episode_end: Optional[int] = None
|
||||
part: Optional[int | str] = None
|
||||
org_string: Optional[str] = None
|
||||
apply_words: list[str] = Field(default_factory=list)
|
||||
resource_team: Optional[str] = None
|
||||
customization: Optional[str] = None
|
||||
|
||||
|
||||
class ManualTransferResultData(BaseModel):
|
||||
"""手动整理预览或执行结果数据。"""
|
||||
|
||||
summary: Optional[ManualTransferPreviewSummary] = None
|
||||
items: list[ManualTransferPreviewItem] = Field(default_factory=list)
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class EpisodeFormatRecommendData(BaseModel):
|
||||
"""集数定位模板推荐结果。"""
|
||||
|
||||
rule_name: str
|
||||
episode_format: str
|
||||
sample_file: str
|
||||
pattern: Optional[str] = None
|
||||
rule_index: Optional[int] = None
|
||||
min_file_size_mb: Optional[int] = None
|
||||
sample_count: Optional[int] = None
|
||||
majority_count: Optional[int] = None
|
||||
confidence: Optional[str] = None
|
||||
size_filter_relaxed: Optional[bool] = None
|
||||
native_verified_count: Optional[int] = None
|
||||
native_fallback_count: Optional[int] = None
|
||||
native_conflict_count: Optional[int] = None
|
||||
reason: Optional[str] = None
|
||||
reasons: list[str] = Field(default_factory=list)
|
||||
message: Optional[str] = None
|
||||
|
||||
@@ -2,9 +2,13 @@ from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
|
||||
|
||||
# Shared properties
|
||||
class UserBase(BaseModel):
|
||||
"""用户公共资料、权限和个性化设置。"""
|
||||
|
||||
# 用户名
|
||||
name: str
|
||||
# 邮箱,未启用
|
||||
@@ -18,33 +22,39 @@ class UserBase(BaseModel):
|
||||
# 是否开启二次验证
|
||||
is_otp: Optional[bool] = False
|
||||
# 权限
|
||||
permissions: Optional[dict] = Field(default_factory=dict)
|
||||
permissions: Optional[dict[str, bool]] = Field(default_factory=dict)
|
||||
# 个性化设置
|
||||
settings: Optional[dict] = Field(default_factory=dict)
|
||||
settings: Optional[dict[str, JsonData]] = Field(default_factory=dict)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# Properties to receive via API on creation
|
||||
class UserCreate(UserBase):
|
||||
"""创建用户时接收的资料和初始凭据。"""
|
||||
|
||||
name: str
|
||||
email: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
settings: Optional[dict] = Field(default_factory=dict)
|
||||
permissions: Optional[dict] = Field(default_factory=dict)
|
||||
settings: Optional[dict[str, JsonData]] = Field(default_factory=dict)
|
||||
permissions: Optional[dict[str, bool]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# Properties to receive via API on update
|
||||
class UserUpdate(UserBase):
|
||||
"""更新用户时接收的完整资料。"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
email: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
settings: Optional[dict] = Field(default_factory=dict)
|
||||
permissions: Optional[dict] = Field(default_factory=dict)
|
||||
settings: Optional[dict[str, JsonData]] = Field(default_factory=dict)
|
||||
permissions: Optional[dict[str, bool]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserInDBBase(UserBase):
|
||||
"""包含数据库主键的用户公共记录。"""
|
||||
|
||||
id: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -52,10 +62,36 @@ class UserInDBBase(UserBase):
|
||||
|
||||
# Additional properties to return via API
|
||||
class User(UserInDBBase):
|
||||
"""对 API 调用方公开的用户资料。"""
|
||||
|
||||
name: str
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
# Additional properties stored in DB
|
||||
class UserInDB(UserInDBBase):
|
||||
"""包含密码哈希的内部用户记录。"""
|
||||
|
||||
hashed_password: str
|
||||
|
||||
|
||||
class AuthProviderRemote(BaseModel):
|
||||
"""插件认证提供方的远程组件信息。"""
|
||||
|
||||
id: str
|
||||
url: str
|
||||
name: str
|
||||
|
||||
|
||||
class AuthProviderInfo(BaseModel):
|
||||
"""匿名登录页可展示的认证提供方摘要。"""
|
||||
|
||||
id: str
|
||||
type: str
|
||||
name: str
|
||||
enabled: bool = True
|
||||
method: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
component: Optional[str] = None
|
||||
plugin_id: Optional[str] = None
|
||||
remote: Optional[AuthProviderRemote] = None
|
||||
|
||||
@@ -2,6 +2,7 @@ from typing import Any, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.context import Context, MediaInfo
|
||||
from app.schemas.download import DownloadTask
|
||||
from app.schemas.file import FileItem
|
||||
@@ -9,6 +10,58 @@ from app.schemas.site import Site
|
||||
from app.schemas.subscribe import Subscribe
|
||||
|
||||
|
||||
class WorkflowExecutionConfig(BaseModel):
|
||||
"""工作流调度器的执行参数。"""
|
||||
|
||||
max_workers: Optional[int] = Field(default=None, ge=1, description="最大并发动作数")
|
||||
|
||||
|
||||
class WorkflowNodeState(BaseModel):
|
||||
"""工作流单个动作的持久化执行状态。"""
|
||||
|
||||
state: Optional[str] = None
|
||||
attempt: int = 0
|
||||
started_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class WorkflowRuntimeState(BaseModel):
|
||||
"""工作流调度器的实时进度摘要。"""
|
||||
|
||||
progress: int = 0
|
||||
finished_actions: int = 0
|
||||
running_tasks: int = 0
|
||||
errors: dict[str, str] = Field(default_factory=dict)
|
||||
node_states: dict[str, str] = Field(default_factory=dict)
|
||||
attempts: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WorkflowExecutionState(BaseModel):
|
||||
"""可恢复的工作流结构化执行快照。"""
|
||||
|
||||
version: int = 1
|
||||
nodes: dict[str, WorkflowNodeState] = Field(default_factory=dict)
|
||||
outputs: dict[str, JsonData] = Field(default_factory=dict)
|
||||
errors: dict[str, str] = Field(default_factory=dict)
|
||||
runtime: WorkflowRuntimeState = Field(default_factory=WorkflowRuntimeState)
|
||||
|
||||
|
||||
class ActionPosition(BaseModel):
|
||||
"""工作流画布中的动作坐标。"""
|
||||
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
|
||||
|
||||
class ActionRetry(BaseModel):
|
||||
"""动作失败后的重试策略。"""
|
||||
|
||||
max_attempts: int = Field(default=1, ge=1)
|
||||
interval: float = Field(default=0, ge=0)
|
||||
backoff: float = Field(default=1, ge=1)
|
||||
|
||||
|
||||
class Workflow(BaseModel):
|
||||
"""
|
||||
工作流信息
|
||||
@@ -19,15 +72,15 @@ class Workflow(BaseModel):
|
||||
timer: Optional[str] = Field(default=None, description="定时器")
|
||||
trigger_type: Optional[str] = Field(default='timer', description="触发类型:timer-定时触发 event-事件触发 manual-手动触发")
|
||||
event_type: Optional[str] = Field(default=None, description="事件类型(当trigger_type为event时使用)")
|
||||
event_conditions: Optional[dict] = Field(default_factory=dict, description="事件条件(JSON格式,用于过滤事件)")
|
||||
event_conditions: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="事件条件(JSON格式,用于过滤事件)")
|
||||
state: Optional[str] = Field(default=None, description="状态")
|
||||
current_action: Optional[str] = Field(default=None, description="已执行动作")
|
||||
result: Optional[str] = Field(default=None, description="任务执行结果")
|
||||
run_count: Optional[int] = Field(default=0, description="已执行次数")
|
||||
actions: Optional[list] = Field(default_factory=list, description="任务列表")
|
||||
flows: Optional[list] = Field(default_factory=list, description="任务流")
|
||||
execution_config: Optional[dict] = Field(default_factory=dict, description="工作流执行配置")
|
||||
execution_state: Optional[dict] = Field(default_factory=dict, description="工作流结构化执行状态")
|
||||
actions: Optional[list["Action"]] = Field(default_factory=list, description="任务列表")
|
||||
flows: Optional[list["ActionFlow"]] = Field(default_factory=list, description="任务流")
|
||||
execution_config: Optional[WorkflowExecutionConfig] = Field(default_factory=WorkflowExecutionConfig, description="工作流执行配置")
|
||||
execution_state: Optional[WorkflowExecutionState] = Field(default_factory=WorkflowExecutionState, description="工作流结构化执行状态")
|
||||
add_time: Optional[str] = Field(default=None, description="创建时间")
|
||||
last_time: Optional[str] = Field(default=None, description="最后执行时间")
|
||||
|
||||
@@ -50,16 +103,16 @@ class Action(BaseModel):
|
||||
type: Optional[str] = Field(default=None, description="动作类型 (类名)")
|
||||
name: Optional[str] = Field(default=None, description="动作名称")
|
||||
description: Optional[str] = Field(default=None, description="动作描述")
|
||||
position: Optional[dict] = Field(default_factory=dict, description="位置")
|
||||
data: Optional[dict] = Field(default_factory=dict, description="参数")
|
||||
position: Optional[ActionPosition] = Field(default_factory=ActionPosition, description="位置")
|
||||
data: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="参数")
|
||||
inputs: Optional[List[str]] = Field(default_factory=list, description="动作输入声明")
|
||||
outputs: Optional[dict] = Field(default_factory=dict, description="动作输出声明")
|
||||
outputs: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="动作输出声明")
|
||||
join_policy: Optional[str] = Field(default=None, description="多上游节点汇合策略")
|
||||
fail_policy: Optional[str] = Field(default=None, description="动作失败后的工作流处理策略")
|
||||
branch_policy: Optional[str] = Field(default=None, description="多出边分支策略")
|
||||
concurrency_key: Optional[str] = Field(default=None, description="并发互斥键")
|
||||
timeout: Optional[int] = Field(default=None, description="动作执行超时时间(秒)")
|
||||
retry: Optional[dict] = Field(default_factory=dict, description="动作重试策略")
|
||||
retry: Optional[ActionRetry] = Field(default=None, description="动作重试策略")
|
||||
|
||||
|
||||
class ActionExecution(BaseModel):
|
||||
@@ -82,10 +135,10 @@ class ActionContext(BaseModel):
|
||||
downloads: Optional[List[DownloadTask]] = Field(default_factory=list, description="下载任务列表")
|
||||
sites: Optional[List[Site]] = Field(default_factory=list, description="站点列表")
|
||||
subscribes: Optional[List[Subscribe]] = Field(default_factory=list, description="订阅列表")
|
||||
workflow_context: Optional[dict] = Field(default_factory=dict, description="工作流全局上下文")
|
||||
node_outputs: Optional[dict] = Field(default_factory=dict, description="节点输出数据")
|
||||
runtime_state: Optional[dict] = Field(default_factory=dict, description="运行期状态")
|
||||
artifacts: Optional[dict] = Field(default_factory=dict, description="大对象引用与产物数据")
|
||||
workflow_context: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="工作流全局上下文")
|
||||
node_outputs: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="节点输出数据")
|
||||
runtime_state: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="运行期状态")
|
||||
artifacts: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="大对象引用与产物数据")
|
||||
execute_history: Optional[List[ActionExecution]] = Field(default_factory=list, description="执行历史")
|
||||
progress: Optional[int] = Field(default=0, description="执行进度(%)")
|
||||
|
||||
@@ -97,6 +150,7 @@ class ActionResult(BaseModel):
|
||||
success: Optional[bool] = Field(default=True, description="动作是否执行成功")
|
||||
message: Optional[str] = Field(default=None, description="动作执行消息")
|
||||
context: Optional[ActionContext] = Field(default=None, description="动作执行后的上下文")
|
||||
# 动作内部可暂存待序列化对象;API 输出前由工作流序列化器转为 JsonData。
|
||||
outputs: Optional[dict[str, Any]] = Field(default_factory=dict, description="当前节点显式输出")
|
||||
next_policy: Optional[str] = Field(default=None, description="动作完成后的调度策略")
|
||||
attempts: Optional[int] = Field(default=1, description="动作实际尝试次数")
|
||||
@@ -110,7 +164,7 @@ class ActionFlow(BaseModel):
|
||||
source: Optional[str] = Field(default=None, description="源动作")
|
||||
target: Optional[str] = Field(default=None, description="目标动作")
|
||||
animated: Optional[bool] = Field(default=True, description="是否动画流程")
|
||||
data: Optional[dict] = Field(default_factory=dict, description="流程扩展配置")
|
||||
data: Optional[dict[str, JsonData]] = Field(default_factory=dict, description="流程扩展配置")
|
||||
condition: Optional[str] = Field(default=None, description="流转条件表达式")
|
||||
join_policy: Optional[str] = Field(default=None, description="目标节点汇合策略")
|
||||
branch_policy: Optional[str] = Field(default=None, description="源节点分支策略")
|
||||
@@ -138,3 +192,47 @@ class WorkflowShare(BaseModel):
|
||||
count: Optional[int] = Field(default=0, description="复用人次")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PluginWorkflowActionGroup(BaseModel):
|
||||
"""单个插件声明的工作流动作组。"""
|
||||
|
||||
plugin_id: str
|
||||
plugin_name: str
|
||||
actions: list[dict[str, JsonData]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ActionContractField(BaseModel):
|
||||
"""动作契约中的单个输入或输出字段。"""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
kind: str = "scalar"
|
||||
merge: Optional[str] = None
|
||||
identity: Optional[str] = None
|
||||
|
||||
|
||||
class ActionContract(BaseModel):
|
||||
"""工作流动作对编辑器公开的输入输出契约。"""
|
||||
|
||||
inputs: list[ActionContractField] = Field(default_factory=list)
|
||||
outputs: list[ActionContractField] = Field(default_factory=list)
|
||||
condition_fields: list[ActionContractField] = Field(default_factory=list)
|
||||
concurrency_key: Optional[str] = None
|
||||
|
||||
|
||||
class WorkflowActionDefinition(BaseModel):
|
||||
"""可用于工作流编辑器的动作定义。"""
|
||||
|
||||
type: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
contract: ActionContract = Field(default_factory=ActionContract)
|
||||
data: dict[str, JsonData] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class NameValueOption(BaseModel):
|
||||
"""前端选项的显示文本和值。"""
|
||||
|
||||
title: str
|
||||
value: str
|
||||
|
||||
@@ -8,15 +8,10 @@ def init_routers(app: FastAPI):
|
||||
初始化路由
|
||||
"""
|
||||
from app.api.apiv1 import api_router
|
||||
from app.api.apiv2 import api_router_v2
|
||||
from app.api.apiv2_utils import API_V2_STR, configure_v2_openapi
|
||||
from app.api.servarr import arr_router
|
||||
from app.api.servcookie import cookie_router
|
||||
# API路由
|
||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
||||
# v2 API复用v1路由,仅在响应出口统一封装
|
||||
app.include_router(api_router_v2, prefix=API_V2_STR)
|
||||
configure_v2_openapi(app)
|
||||
# Radarr、Sonarr路由
|
||||
app.include_router(arr_router, prefix="/api/v3")
|
||||
# CookieCloud路由
|
||||
|
||||
@@ -264,27 +264,6 @@ def supports_extended_media_ids() -> bool:
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def supports_unified_media_identity() -> bool:
|
||||
"""判断当前 Rust 扩展是否支持固定来源的通用媒体身份标签。"""
|
||||
if not is_enabled():
|
||||
return False
|
||||
try:
|
||||
result = _moviepilot_rust.find_metainfo_fast(
|
||||
"test {[media_source=musicbrainz;media_id=recording-1]}"
|
||||
)
|
||||
except BaseException as err:
|
||||
_raise_non_rust_panic(err)
|
||||
logger.debug(f"检测 Rust 通用媒体身份能力失败:{err}")
|
||||
return False
|
||||
metainfo = result.get("metainfo") if isinstance(result, dict) else None
|
||||
return bool(
|
||||
metainfo
|
||||
and metainfo.get("media_source") == "musicbrainz"
|
||||
and metainfo.get("media_id") == "recording-1"
|
||||
)
|
||||
|
||||
|
||||
def _raise_non_rust_panic(err: BaseException) -> None:
|
||||
"""
|
||||
只吞掉 Rust 扩展 panic/异常,保留用户中断和进程退出语义。
|
||||
|
||||
@@ -2,6 +2,8 @@ import threading
|
||||
from time import monotonic, sleep
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import global_vars
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.db.models import Workflow
|
||||
@@ -212,6 +214,8 @@ class WorkFlowManager(metaclass=Singleton):
|
||||
|
||||
def _get_retry_config(self, action: Action) -> dict:
|
||||
retry_config = action.retry or self._get_action_data_value(action, "retry") or {}
|
||||
if isinstance(retry_config, BaseModel):
|
||||
retry_config = retry_config.model_dump(exclude_none=True)
|
||||
if not isinstance(retry_config, dict):
|
||||
retry_config = {}
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user