Files
MoviePilot/app/application/subscription/status.py

279 lines
9.9 KiB
Python

"""订阅执行状态的业务投影与访问范围治理。"""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Optional, Protocol
from app.application.download.admission import SubscriptionDownloadSnapshot
from app.application.subscription.execution import SearchBatchSnapshot, SearchTaskSnapshot
@dataclass(frozen=True, slots=True)
class SubscriptionExecutionStatus:
"""一个订阅跨搜索与下载账本合并后的用户可见状态。"""
state: str
phase: str
updated_at: str
source: Optional[str] = None
batch_id: Optional[str] = None
task_id: Optional[str] = None
current_site_id: Optional[int] = None
error: Optional[str] = None
can_cancel: bool = False
can_retry: bool = False
requires_reconciliation: bool = False
@dataclass(frozen=True, slots=True)
class SubscriptionBatchStatus:
"""订阅搜索批次的进度、当前工作和操作能力。"""
batch_id: str
source: str
state: str
phase: str
total_count: int
processed_count: int
finished_count: int
failed_count: int
cancelled_count: int
created_at: str
updated_at: str
current_subscription_id: Optional[int] = None
current_site_id: Optional[int] = None
error: Optional[str] = None
can_cancel: bool = False
class SubscriptionExecutionReadRepository(Protocol):
"""请求级读取搜索任务、批次与下载提交事实的端口。"""
async def latest_search_tasks(
self,
subscription_ids: tuple[int, ...],
) -> dict[int, SearchTaskSnapshot]:
"""返回每条订阅最近更新的搜索任务。"""
...
async def latest_download_submissions(
self,
subscription_ids: tuple[int, ...],
) -> dict[int, SubscriptionDownloadSnapshot]:
"""返回每条订阅最近更新的下载提交。"""
...
async def list_batches(
self,
*,
limit: int,
) -> list[SearchBatchSnapshot]:
"""返回最近更新的搜索批次。"""
...
async def get_batch(self, batch_id: str) -> Optional[SearchBatchSnapshot]:
"""按稳定 ID 返回一个搜索批次。"""
...
async def list_batch_tasks(self, batch_id: str) -> list[SearchTaskSnapshot]:
"""按稳定位置返回批次任务。"""
...
class SubscriptionExecutionStatusService:
"""把搜索队列与下载幂等账本投影为稳定业务状态。"""
_DOWNLOAD_STATES = {
"submitting": "submitting",
"accepted": "accepted",
"succeeded": "completed",
"retryable": "retryable",
"reconcile_required": "reconcile_required",
"cancelled": "cancelled",
}
_ACTIVE_STATES = {
"queued",
"running",
"matching",
"searching",
"waiting_site_budget",
"preparing",
"submitting",
"accepted",
"cancelling",
}
_DOWNLOAD_OVERRIDE_STATES = {
"submitting",
"accepted",
"retryable",
"reconcile_required",
}
def __init__(
self,
repository: SubscriptionExecutionReadRepository,
request_cancel: Optional[Callable[[str], Awaitable[bool]]] = None,
) -> None:
"""保存请求会话绑定的状态读取端口和可选取消用例。"""
self._repository = repository
self._request_cancel = request_cancel
async def request_cancel(self, batch_id: str) -> bool:
"""通过组合根提供的执行边界请求取消搜索批次。"""
if self._request_cancel is None:
raise RuntimeError("订阅搜索批次取消能力未注册")
return bool(await self._request_cancel(batch_id))
async def for_subscriptions(
self,
subscription_ids: tuple[int, ...],
) -> dict[int, SubscriptionExecutionStatus]:
"""批量投影订阅状态,避免列表接口逐条查询。"""
ids = tuple(dict.fromkeys(subscription_ids))
if not ids:
return {}
tasks = await self._repository.latest_search_tasks(ids)
downloads = await self._repository.latest_download_submissions(ids)
result: dict[int, SubscriptionExecutionStatus] = {}
for subscription_id in ids:
task = tasks.get(subscription_id)
download = downloads.get(subscription_id)
if download and self._download_wins(task, download):
result[subscription_id] = self._from_download(download, task)
elif task:
result[subscription_id] = self._from_task(task)
return result
async def list_batches(
self,
*,
accessible_subscription_ids: Optional[set[int]],
limit: int = 10,
) -> list[SubscriptionBatchStatus]:
"""列出访问范围完整覆盖的最近批次。"""
batches = await self._repository.list_batches(limit=max(1, min(limit, 50)))
result = []
for batch in batches:
tasks = await self._repository.list_batch_tasks(batch.batch_id)
if not self._can_access_tasks(tasks, accessible_subscription_ids):
continue
result.append(self._from_batch(batch, tasks))
return result
async def get_batch(
self,
batch_id: str,
*,
accessible_subscription_ids: Optional[set[int]],
) -> Optional[SubscriptionBatchStatus]:
"""读取一个访问范围完整覆盖的批次。"""
batch = await self._repository.get_batch(batch_id)
if batch is None:
return None
tasks = await self._repository.list_batch_tasks(batch_id)
if not self._can_access_tasks(tasks, accessible_subscription_ids):
return None
return self._from_batch(batch, tasks)
@classmethod
def _download_wins(
cls,
task: Optional[SearchTaskSnapshot],
download: SubscriptionDownloadSnapshot,
) -> bool:
"""下载风险状态优先,其余事实按更新时间选择。"""
if download.state in cls._DOWNLOAD_OVERRIDE_STATES:
return True
return task is None or download.updated_at >= task.updated_at
@classmethod
def _from_task(cls, task: SearchTaskSnapshot) -> SubscriptionExecutionStatus:
"""把搜索任务状态归一为稳定业务词汇。"""
if task.cancel_requested and task.state == "running":
state = phase = "cancelling"
elif task.state == "running":
state = phase = task.phase or "running"
else:
state = phase = task.state
return SubscriptionExecutionStatus(
state=state,
phase=phase,
source=task.source,
batch_id=task.batch_id,
task_id=task.task_id,
current_site_id=task.current_site_id,
updated_at=task.updated_at,
error=cls._safe_error(task.last_error),
can_cancel=state in cls._ACTIVE_STATES,
can_retry=state == "failed",
)
@classmethod
def _from_download(
cls,
download: SubscriptionDownloadSnapshot,
task: Optional[SearchTaskSnapshot],
) -> SubscriptionExecutionStatus:
"""把下载提交账本状态投影为业务状态并保留搜索来源。"""
state = cls._DOWNLOAD_STATES.get(download.state, download.state)
return SubscriptionExecutionStatus(
state=state,
phase=state,
source=task.source if task else None,
batch_id=task.batch_id if task else None,
task_id=download.task_id or (task.task_id if task else None),
current_site_id=task.current_site_id if task else None,
updated_at=download.updated_at,
error=cls._safe_error(download.last_error),
can_cancel=state == "submitting",
can_retry=state == "retryable",
requires_reconciliation=state == "reconcile_required",
)
@classmethod
def _from_batch(
cls,
batch: SearchBatchSnapshot,
tasks: list[SearchTaskSnapshot],
) -> SubscriptionBatchStatus:
"""组合批次计数与当前运行任务。"""
current = next((task for task in tasks if task.state == "running"), None)
if current is None:
current = next((task for task in tasks if task.state == "queued"), None)
processed = batch.finished_count + batch.failed_count + batch.cancelled_count
phase = current.phase if current else batch.state
return SubscriptionBatchStatus(
batch_id=batch.batch_id,
source=batch.source,
state=batch.state,
phase=phase,
total_count=batch.total_count,
processed_count=processed,
finished_count=batch.finished_count,
failed_count=batch.failed_count,
cancelled_count=batch.cancelled_count,
current_subscription_id=current.subscription_id if current else None,
current_site_id=current.current_site_id if current else None,
created_at=batch.created_at,
updated_at=batch.updated_at,
error=cls._safe_error(batch.last_error),
can_cancel=batch.state in {"queued", "running", "cancelling"} and not batch.cancel_requested,
)
@staticmethod
def _can_access_tasks(
tasks: list[SearchTaskSnapshot],
accessible_subscription_ids: Optional[set[int]],
) -> bool:
"""超级用户不限制;普通用户必须拥有批次内全部订阅。"""
if accessible_subscription_ids is None:
return True
return bool(tasks) and all(task.subscription_id in accessible_subscription_ids for task in tasks)
@staticmethod
def _safe_error(error: Optional[str]) -> Optional[str]:
"""压平并限制内部错误文本,避免把堆栈或超长响应暴露给界面。"""
if not error:
return None
return " ".join(str(error).split())[:500]