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

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

290 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
订阅执行批次状态与取消端点。
"""
import time
from collections.abc import AsyncIterator
from dataclasses import asdict
from datetime import datetime, timedelta, timezone
from typing import Any, List, Optional
from fastapi import Depends, HTTPException, Path, Query, Request
from starlette.responses import Response, StreamingResponse
from app.adapters.web.security.access import verify_resource_token
from app.api.context import get_host_runtime
from app.api.dependencies.auth import get_current_active_user_async
from app.api.dependencies.subscription import (
get_subscription_execution_status_service,
get_subscription_query_service,
subscription_log_query_scope,
)
from app.api.endpoints.system import _build_log_zip_response
from app.api.principal import ApiPrincipal
from app.api.response import ResponseAPIRouter
from app.application.subscription.query import SubscriptionQueryService
from app.application.subscription.status import SubscriptionExecutionStatusService
from app.application.system import LogNotFoundError
from app.schemas.response import Response as _SchemaResponse
from app.schemas.subscribe import SubscriptionActivityEvent as _SchemaSubscriptionActivityEvent
from app.schemas.subscribe import SubscriptionActivityPage as _SchemaSubscriptionActivityPage
from app.schemas.subscribe import SubscriptionBatchStatus as _SchemaSubscriptionBatchStatus
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.startup.composition.context import HostRuntime
router = ResponseAPIRouter()
@router.get(
"/{subscription_id}/activity",
summary="查看订阅业务活动",
response_model=_SchemaSubscriptionActivityPage,
)
async def list_subscription_activity(
subscription_id: int = Path(..., ge=1),
history: bool = False,
kind: Optional[str] = Query(None, pattern="^(created|updated|status|completed|refresh|search)$"),
days: Optional[int] = Query(None, ge=1, le=365),
before: Optional[str] = Query(None, pattern="^[a-zA-Z0-9_-]{1,64}$"),
limit: int = Query(30, ge=1, le=100),
current_user: ApiPrincipal = Depends(get_current_active_user_async),
runtime: HostRuntime = Depends(get_host_runtime),
) -> _SchemaSubscriptionActivityPage:
"""
按当前 owner 校验活动或完成历史,分页仅返回轻量概要。
"""
repository = runtime.subscription.activity_repository
if repository is None:
raise HTTPException(status_code=503, detail="订阅活动暂时不可用")
since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat(timespec="milliseconds") if days else None
result = await repository.list_accessible(
subscription_id, current_user.name, current_user.is_superuser,
history=history, kind=kind, since=since, before=before, limit=limit,
)
if result is None:
raise HTTPException(status_code=404, detail="订阅不存在或无权查看")
events, next_cursor = result
return _SchemaSubscriptionActivityPage.model_validate({
"items": [
{**{key: value for key, value in asdict(event).items() if key not in {"activity_key", "tracking", "details"}},
"details_loaded": False,
"details": {key: value for key, value in event.details.items() if key in {"reason", "retry_at", "phase", "changes"}}}
for event in events
],
"next_cursor": next_cursor,
})
@router.get(
"/{subscription_id}/activity/{event_id}",
summary="查看订阅活动详情",
response_model=_SchemaSubscriptionActivityEvent,
)
async def get_subscription_activity(
subscription_id: int = Path(..., ge=1),
event_id: str = Path(..., pattern="^[a-zA-Z0-9_-]{1,64}$"),
history: bool = False,
current_user: ApiPrincipal = Depends(get_current_active_user_async),
runtime: HostRuntime = Depends(get_host_runtime),
) -> _SchemaSubscriptionActivityEvent:
"""
按需读取冻结条件、资源判断和恢复记录,并再次检查订阅归属。
"""
repository = runtime.subscription.activity_repository
if repository is None:
raise HTTPException(status_code=503, detail="订阅活动暂时不可用")
event = await repository.get_accessible(
subscription_id, event_id, current_user.name, current_user.is_superuser, history=history,
)
if event is None:
raise HTTPException(status_code=404, detail="活动不存在或无权查看")
return _SchemaSubscriptionActivityEvent.model_validate({
key: value for key, value in asdict(event).items() if key not in {"activity_key", "tracking"}
})
async def _accessible_subscription_ids(
query: SubscriptionQueryService,
current_user: ApiPrincipal,
) -> Optional[set[int]]:
"""
返回普通用户可访问订阅 ID;超级用户以 None 表示不限制。
"""
if current_user.is_superuser:
return None
subscribes = await query.list_public(current_user.name)
return {item.id for item in subscribes if item.id is not None}
@router.get(
"/execution/batches",
summary="查看订阅搜索进度",
response_model=List[_SchemaSubscriptionBatchStatus],
)
async def list_subscription_execution_batches(
limit: int = 10,
status_service: SubscriptionExecutionStatusService = Depends(get_subscription_execution_status_service),
query: SubscriptionQueryService = Depends(get_subscription_query_service),
current_user: ApiPrincipal = Depends(get_current_active_user_async),
) -> Any:
"""
返回当前用户完整可见的最近搜索批次。
"""
accessible_ids = await _accessible_subscription_ids(query, current_user)
batches = await status_service.list_batches(
accessible_subscription_ids=accessible_ids,
limit=limit,
)
return [_SchemaSubscriptionBatchStatus.model_validate(batch) for batch in batches]
@router.get(
"/execution/batches/{batch_id}",
summary="查看一次订阅搜索",
response_model=_SchemaSubscriptionBatchStatus,
)
async def get_subscription_execution_batch(
batch_id: str,
status_service: SubscriptionExecutionStatusService = Depends(get_subscription_execution_status_service),
query: SubscriptionQueryService = Depends(get_subscription_query_service),
current_user: ApiPrincipal = Depends(get_current_active_user_async),
) -> Any:
"""
按稳定 ID 返回当前用户可访问的搜索批次。
"""
accessible_ids = await _accessible_subscription_ids(query, current_user)
batch = await status_service.get_batch(
batch_id,
accessible_subscription_ids=accessible_ids,
)
if batch is None:
raise HTTPException(status_code=404, detail="没有找到这次搜索,请刷新后重试")
return _SchemaSubscriptionBatchStatus.model_validate(batch)
@router.put(
"/execution/batches/{batch_id}/cancel",
summary="停止一次订阅搜索",
response_model=_SchemaResponse[None],
)
async def cancel_subscription_execution_batch(
batch_id: str,
status_service: SubscriptionExecutionStatusService = Depends(get_subscription_execution_status_service),
query: SubscriptionQueryService = Depends(get_subscription_query_service),
current_user: ApiPrincipal = Depends(get_current_active_user_async),
) -> Any:
"""
在权限校验后请求取消尚未越过下载副作用边界的任务。
"""
accessible_ids = await _accessible_subscription_ids(query, current_user)
batch = await status_service.get_batch(
batch_id,
accessible_subscription_ids=accessible_ids,
)
if batch is None:
return _SchemaResponse(success=False, message="没有找到这次搜索,请刷新后重试")
cancelled = await status_service.request_cancel(batch_id)
return _SchemaResponse(
success=bool(cancelled),
message="" if cancelled else "这次搜索已经结束,暂时无法停止",
)
async def _require_subscription_log_access(
subscription_id: int,
query: SubscriptionQueryService,
user: _SchemaTokenPayload,
) -> None:
"""
日志仅对当前订阅 owner 或管理员开放,未知和越权 ID 均返回 404。
"""
subscribe = await query.get_public(subscription_id)
if subscribe is None or (not user.super_user and subscribe.username != user.username):
raise HTTPException(status_code=404, detail="订阅不存在或无权查看")
async def _authorize_subscription_logs(subscription_id: int, user: _SchemaTokenPayload, runtime: HostRuntime) -> None:
"""
在独立短会话中确认归属,返回后不持有数据库事务。
"""
async with subscription_log_query_scope(runtime) as query:
await _require_subscription_log_access(subscription_id, query, user)
@router.get(
"/{subscription_id}/logs",
summary="查看单个订阅的执行日志",
response_model=None,
response_class=StreamingResponse,
responses={200: {"content": {"text/event-stream": {}, "text/plain": {}}}},
)
async def get_subscription_logs(
request: Request,
subscription_id: int = Path(gt=0),
length: int = Query(default=100, ge=-1, le=600),
user: _SchemaTokenPayload = Depends(verify_resource_token),
runtime: HostRuntime = Depends(get_host_runtime),
) -> Response:
"""
按订阅权限输出原始文本或实时流;首次执行前保持空流等待日志生成。
"""
await _authorize_subscription_logs(subscription_id, user, runtime)
logfile = f"subscriptions/{subscription_id}.log"
if length == -1:
try:
content = await runtime.system.read_log(logfile)
except LogNotFoundError:
content = ""
return Response(content, media_type="text/plain", headers={"Cache-Control": "no-store"})
last_check = time.monotonic()
async def disconnected() -> bool:
"""
断开或归属变更时停止流;周期重读归属并验证资源令牌的有效性。
"""
nonlocal last_check
if await request.is_disconnected():
return True
if time.monotonic() - last_check >= 5:
try:
verify_resource_token(request.cookies.get(runtime.system.settings.get("PROJECT_NAME")))
await _authorize_subscription_logs(subscription_id, user, runtime)
except HTTPException:
return True
last_check = time.monotonic()
return False
source = await runtime.system.follow_log(logfile, max(1, length), disconnected)
async def events() -> AsyncIterator[str]:
"""
逐行编码 SSE,心跳不进入前端日志列表。
"""
async for line in source:
content = line
yield "".join(f"data: {part}\n" for part in content.splitlines()) + "\n" if content else ": keepalive\n\n"
return StreamingResponse(events(), media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
@router.get(
"/{subscription_id}/logs/download",
summary="下载单个订阅的执行日志",
response_model=None,
response_class=Response,
responses={200: {"content": {"application/zip": {}}}},
)
async def download_subscription_logs(
subscription_id: int = Path(gt=0),
user: _SchemaTokenPayload = Depends(verify_resource_token),
runtime: HostRuntime = Depends(get_host_runtime),
) -> Response:
"""
在归属检查后下载当前订阅的日志及滚动备份,客户端不能指定路径。
"""
await _authorize_subscription_logs(subscription_id, user, runtime)
return await _build_log_zip_response(
f"subscriptions/{subscription_id}", runtime,
allow_empty=True,
)