refactor: expand runtime contracts and debt ratchets

This commit is contained in:
jxxghp
2026-08-22 08:25:36 +08:00
parent 820f7e8850
commit 5d9c6c8b19
37 changed files with 883 additions and 339 deletions
+24 -7
View File
@@ -16,7 +16,8 @@ from app.schemas.response import Response as _SchemaResponse
from app.api.response import ResponseAPIRouter
from app.chain.dashboard import DashboardChain
from app.chain.storage import StorageChain
from app.runtime.config import settings
from app.api.context import get_api_runtime_config, resolve_api_runtime_config
from app.application.configuration import ApiRuntimeConfig
from app.adapters.web.security.access import verify_apitoken
from app.api.dependencies.auth import get_current_active_superuser
from app.api.dependencies.history import get_dashboard_query_service
@@ -53,7 +54,11 @@ def _build_storage() -> _SchemaStorage:
return _SchemaStorage(total_storage=total, used_storage=total - available)
def _build_downloader(name: Optional[str] = None) -> _SchemaDownloaderInfo:
def _build_downloader(
name: Optional[str] = None,
*,
btrfs_fsid_dedup: bool = False,
) -> _SchemaDownloaderInfo:
"""
构建下载器统计信息。
"""
@@ -61,7 +66,7 @@ def _build_downloader(name: Optional[str] = None) -> _SchemaDownloaderInfo:
download_dirs = DirectoryHelper().get_local_download_dirs()
_, free_space = SystemUtils.space_usage(
[Path(d.download_path) for d in download_dirs],
btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP,
btrfs_fsid_dedup=btrfs_fsid_dedup,
)
# 下载器信息
downloader_info = _SchemaDownloaderInfo()
@@ -137,12 +142,18 @@ def system_info(_: Any = Depends(get_current_active_superuser)) -> Any:
@router.get("/downloader", summary="下载器信息", response_model=_SchemaDownloaderInfo)
def downloader(
name: Optional[str] = None, _: Any = Depends(get_current_active_superuser)
name: Optional[str] = None,
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
_: Any = Depends(get_current_active_superuser),
) -> Any:
"""
查询下载器信息
"""
return _build_downloader(name)
runtime_config = resolve_api_runtime_config(runtime_config)
return _build_downloader(
name,
btrfs_fsid_dedup=runtime_config.btrfs_fsid_dedup,
)
@router.get(
@@ -150,11 +161,17 @@ def downloader(
summary="下载器信息(API_TOKEN",
response_model=_SchemaDownloaderInfo,
)
def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
def downloader2(
_: Annotated[str, Depends(verify_apitoken)],
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
) -> Any:
"""
查询下载器信息 API_TOKEN认证(?token=xxx
"""
return _build_downloader()
runtime_config = resolve_api_runtime_config(runtime_config)
return _build_downloader(
btrfs_fsid_dedup=runtime_config.btrfs_fsid_dedup,
)
@router.get("/schedule", summary="后台服务", response_model=List[_SchemaScheduleInfo])
+78 -59
View File
@@ -111,6 +111,74 @@ def _build_unrecognized_media_info(
)
def _resolve_add_media(
torrent_in: _SchemaTorrentInfo,
media_source: MediaSource | None,
media_id: str | None,
music_type: MusicTargetEntityType | None,
allow_unrecognized: bool,
) -> tuple[MetaBase | None, MediaInfo | MusicInfo | None, _SchemaResponse | None]:
"""校验媒体身份并为无媒体信息下载构建识别上下文。"""
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
if music_type is not None and not normalized_music_type:
return None, None, _SchemaResponse(
success=False,
message="音乐实体类型无效,仅支持 recording 或 album",
)
if (media_source is None) != (media_id is None):
return None, None, _SchemaResponse(
success=False,
message="媒体来源和媒体 ID 必须同时提供",
)
is_music = (
torrent_in.category in (MediaType.MUSIC, MediaType.MUSIC.value, "music")
or is_music_media_source(media_source)
or normalized_music_type is not None
)
if is_music and media_source and not is_music_media_source(media_source):
return None, None, _SchemaResponse(
success=False,
message="音乐下载只能使用音乐元数据源",
)
if is_music and not normalized_music_type:
normalized_music_type = MUSIC_ENTITY_RECORDING
metainfo = (
MetaMusic.parse_query(torrent_in.title)
if is_music
else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
)
if media_source and media_id:
mediainfo = MediaChain().recognize_media(
meta=metainfo,
media_source=media_source,
media_id=media_id,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
else:
mediainfo = MediaChain().recognize_by_meta(
metainfo,
media_source=media_source,
obtain_images=False,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
if mediainfo:
return metainfo, mediainfo, None
if not allow_unrecognized:
return metainfo, None, _SchemaResponse(
success=False,
message="无法识别媒体信息",
data=_SchemaDownloadAddedData(requires_confirmation=True),
)
return metainfo, _build_unrecognized_media_info(
torrent_in,
metainfo,
is_music=is_music,
music_type=normalized_music_type,
), None
@router.get("/", summary="正在下载", response_model=List[_SchemaDownloaderTorrent])
def current(
name: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token)
@@ -183,66 +251,17 @@ def add(
"""
添加下载任务(不含媒体信息)
"""
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
if music_type is not None and not normalized_music_type:
return _SchemaResponse(
success=False,
message="音乐实体类型无效,仅支持 recording 或 album",
)
if (media_source is None) != (media_id is None):
return _SchemaResponse(
success=False,
message="媒体来源和媒体 ID 必须同时提供",
)
is_music = (
torrent_in.category in (MediaType.MUSIC, MediaType.MUSIC.value, "music")
or is_music_media_source(media_source)
or normalized_music_type is not None
metainfo, mediainfo, error = _resolve_add_media(
torrent_in,
media_source,
media_id,
music_type,
allow_unrecognized,
)
if is_music and media_source and not is_music_media_source(media_source):
return _SchemaResponse(
success=False,
message="音乐下载只能使用音乐元数据源",
)
if is_music and not normalized_music_type:
normalized_music_type = MUSIC_ENTITY_RECORDING
# 元数据
metainfo = (
MetaMusic.parse_query(torrent_in.title)
if is_music
else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
)
# 媒体信息
if media_source and media_id:
mediainfo = MediaChain().recognize_media(
meta=metainfo,
media_source=media_source,
media_id=media_id,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
else:
mediainfo = MediaChain().recognize_by_meta(
metainfo,
media_source=media_source,
obtain_images=False,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
if not mediainfo:
if not allow_unrecognized:
return _SchemaResponse(
success=False,
message="无法识别媒体信息",
data=_SchemaDownloadAddedData(requires_confirmation=True),
)
# 用户已确认:影视与音乐统一按种子元信息构造最小上下文继续下载
mediainfo = _build_unrecognized_media_info(
torrent_in,
metainfo,
is_music=is_music,
music_type=normalized_music_type,
)
if error:
return error
if metainfo is None or mediainfo is None:
return _SchemaResponse(success=False, message="无法识别媒体信息")
# 种子信息
torrentinfo = TorrentInfo()
torrentinfo.from_dict(torrent_in.model_dump())
+9 -3
View File
@@ -19,7 +19,9 @@ from app.agent.prompt.transfer_redo import (
build_batch_manual_redo_prompt,
build_manual_redo_prompt,
)
from app.runtime.config import settings, global_vars
from app.runtime.config import global_vars
from app.api.context import get_api_runtime_config, resolve_api_runtime_config
from app.application.configuration import ApiRuntimeConfig
from app.adapters.web.security.access import verify_token
from app.api.dependencies.auth import (
get_current_active_manage_user,
@@ -244,12 +246,14 @@ def delete_transfer_history(
async def ai_redo_transfer_history(
history_id: int,
query: HistoryQueryService = Depends(get_history_query_service),
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
_: object = Depends(get_current_active_manage_user),
) -> Any:
"""
手动触发单条历史记录的 AI 重新整理,并返回进度键。
"""
if not settings.AI_AGENT_ENABLE:
runtime_config = resolve_api_runtime_config(runtime_config)
if not runtime_config.ai_agent_enable:
return _SchemaResponse(success=False, message="MoviePilot智能助手未启用")
history = await query.get_transfer(history_id)
@@ -275,12 +279,14 @@ async def ai_redo_transfer_history(
async def batch_ai_redo_transfer_history(
payload: _SchemaBatchTransferHistoryRedoRequest,
query: HistoryQueryService = Depends(get_history_query_service),
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
_: object = Depends(get_current_active_manage_user),
) -> Any:
"""
手动触发多条历史记录的 AI 批量重新整理,并返回进度键。
"""
if not settings.AI_AGENT_ENABLE:
runtime_config = resolve_api_runtime_config(runtime_config)
if not runtime_config.ai_agent_enable:
return _SchemaResponse(success=False, message="MoviePilot智能助手未启用")
history_ids = normalize_history_ids(payload.history_ids)
+6 -4
View File
@@ -13,8 +13,8 @@ from app.api.response import RAW_RESPONSE_OPENAPI_KEY, ResponseAPIRouter
from app.chain.user import MfaRequired, UserChain
from app.adapters.web.security.access import set_or_refresh_resource_token_cookie
from app.application.security.token import create_access_token
from app.runtime.config import settings
from app.application.configuration import get_configured_system_config
from app.api.context import get_api_runtime_config, resolve_api_runtime_config
from app.application.configuration import ApiRuntimeConfig, get_configured_system_config
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
from app.application.image import WallpaperHelper
from app.schemas.types import SystemConfigKey
@@ -39,10 +39,12 @@ def login_access_token(
response: Response,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
otp_password: Annotated[str | None, Form()] = None,
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
) -> Any:
"""
获取认证Token
"""
runtime_config = resolve_api_runtime_config(runtime_config)
success, user_or_message = UserChain().user_authenticate(
username=form_data.username, password=form_data.password, mfa_code=otp_password
)
@@ -69,13 +71,13 @@ def login_access_token(
# 是否显示配置向导
show_wizard = (
not get_configured_system_config().get(SystemConfigKey.SetupWizardState)
and not settings.ADVANCED_MODE
and not runtime_config.advanced_mode
)
access_token = create_access_token(
userid=user_or_message.id,
username=user_or_message.name,
super_user=user_or_message.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
expires_delta=timedelta(minutes=runtime_config.access_token_expire_minutes),
level=level,
)
set_or_refresh_resource_token_cookie(
+34 -41
View File
@@ -84,6 +84,39 @@ def create_jsonrpc_error(
return error
async def _dispatch_jsonrpc_method(
method: Any,
params: Dict[str, Any],
request_id: Union[str, int, None],
) -> Union[JSONResponse, Response]:
"""分派一个已经通过基础格式校验的 MCP JSON-RPC 方法。"""
if method == "initialize":
result = await handle_initialize(params)
return JSONResponse(content=create_jsonrpc_response(request_id, result))
if method == "notifications/initialized":
if request_id is None:
return Response(status_code=204)
return JSONResponse(
status_code=400,
content=create_jsonrpc_error(
request_id, -32600, "initialized must be a notification"
),
)
if method == "tools/list":
result = await handle_tools_list()
return JSONResponse(content=create_jsonrpc_response(request_id, result))
if method == "tools/call":
result = await handle_tools_call(params)
return JSONResponse(content=create_jsonrpc_response(request_id, result))
if method == "ping":
return JSONResponse(content=create_jsonrpc_response(request_id, {}))
return JSONResponse(
content=create_jsonrpc_error(
request_id, -32601, f"Method not found: {method}"
)
)
@router.post(
"",
summary="MCP JSON-RPC 端点",
@@ -130,48 +163,8 @@ async def mcp_jsonrpc(
params = body.get("params", {})
request_id = body.get("id")
# 如果有 id,则为请求;没有 id 则为通知
is_notification = request_id is None
try:
# 处理初始化请求
if method == "initialize":
result = await handle_initialize(params)
return JSONResponse(content=create_jsonrpc_response(request_id, result))
# 处理已初始化通知
elif method == "notifications/initialized":
if is_notification:
return Response(status_code=204)
else:
return JSONResponse(
status_code=400,
content=create_jsonrpc_error(
request_id, -32600, "initialized must be a notification"
),
)
# 处理工具列表请求
if method == "tools/list":
result = await handle_tools_list()
return JSONResponse(content=create_jsonrpc_response(request_id, result))
# 处理工具调用请求
elif method == "tools/call":
result = await handle_tools_call(params)
return JSONResponse(content=create_jsonrpc_response(request_id, result))
# 处理 ping 请求
elif method == "ping":
return JSONResponse(content=create_jsonrpc_response(request_id, {}))
# 未知方法
else:
return JSONResponse(
content=create_jsonrpc_error(
request_id, -32601, f"Method not found: {method}"
)
)
return await _dispatch_jsonrpc_method(method, params, request_id)
except ValueError as e:
logger.warning(f"MCP 请求参数错误: {e}")