mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-11 16:21:09 +08:00
279 lines
9.9 KiB
Python
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]
|