Files
MoviePilot/app/api/dependencies/subscription.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

326 lines
11 KiB
Python

"""
订阅领域的请求级 command/query 依赖。
"""
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from typing import cast
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.adapters.external.server import MoviePilotServerHelper
from app.api.context import (
get_async_session,
get_background_task_registry,
get_host_runtime,
get_subscription_history_repository,
get_subscription_outbox,
get_subscription_outbox_store,
get_subscription_repository,
get_subscription_session,
get_subscription_transaction,
get_sync_session,
resolve_background_task_registry,
)
from app.application.outbox import AsyncOutboxDispatchStore, AsyncOutboxStager
from app.application.scheduling import start_scheduler_job
from app.application.servarr import ServarrSubscriptionService
from app.application.subscription.contract import (
SessionSubscriptionPort,
SubscriptionHistoryStagingPort,
SubscriptionQueryPort,
SubscriptionStagingPort,
)
from app.application.subscription.delete import (
AsyncUnitOfWork as DeleteUnitOfWork,
)
from app.application.subscription.delete import (
DeleteSubscribeCommand,
)
from app.application.subscription.execution import SubscriptionSearchRepository
from app.application.subscription.identity import (
DeleteSubscriptionsByIdentityCommand,
)
from app.application.subscription.mutation import (
SubscriptionMutationService,
)
from app.application.subscription.query import SubscriptionQueryService
from app.application.subscription.search import (
SearchSubscriptionsCommand,
SubscriptionSearchSubmission,
)
from app.application.subscription.status import (
SubscriptionExecutionReadRepository,
SubscriptionExecutionStatusService,
)
from app.application.subscription.write import (
SubscriptionBatchWritePort,
)
from app.runtime.events import eventmanager
from app.runtime.log import logger
from app.runtime.tasks import TaskRegistry
from app.schemas.common import JsonData
from app.schemas.types import EventType
from app.startup.composition.context import HostRuntime
async def _publish_subscribe_deleted(
payload: Mapping[str, JsonData],
) -> None:
"""
通过宿主事件总线发布已提交的订阅删除事件。
"""
event_payload = payload if isinstance(payload, dict) else dict(payload)
await eventmanager.async_send_event(EventType.SubscribeDeleted, event_payload)
async def _publish_subscribe_modified(payload: Mapping[str, JsonData]) -> None:
"""
通过宿主事件总线发布已提交的订阅修改事件。
"""
await eventmanager.async_send_event(EventType.SubscribeModified, dict(payload))
async def _report_subscribe_deleted(payload: Mapping[str, JsonData]) -> bool:
"""
把只读删除快照复制给远端上报边界。
"""
return bool(await MoviePilotServerHelper.async_sub_done_durable(dict(payload)))
def get_delete_subscribe_command(
repository_port: SubscriptionStagingPort = Depends(get_subscription_repository),
unit_of_work: object = Depends(get_subscription_transaction),
outbox: AsyncOutboxStager = Depends(get_subscription_outbox),
dispatch_store: AsyncOutboxDispatchStore = Depends(get_subscription_outbox_store),
) -> DeleteSubscribeCommand:
"""
组装请求级订阅删除用例及其具体适配器。
"""
return DeleteSubscribeCommand(
repository=repository_port,
unit_of_work=cast(DeleteUnitOfWork, unit_of_work),
publish_deleted=_publish_subscribe_deleted,
report_deleted=_report_subscribe_deleted,
outbox=outbox,
dispatch_store=dispatch_store,
)
def _log_subscribe_deleted_event_error(
subscribe_id: int,
error: Exception,
) -> None:
"""
记录按媒体身份删除时的单条事件失败并允许后续事件继续。
"""
logger.error(
f"发送订阅删除事件失败:{subscribe_id} - {error}",
exc_info=True,
)
def get_delete_subscriptions_by_identity_command(
repository_port: SubscriptionStagingPort = Depends(get_subscription_repository),
unit_of_work: object = Depends(get_subscription_transaction),
outbox: AsyncOutboxStager = Depends(get_subscription_outbox),
dispatch_store: AsyncOutboxDispatchStore = Depends(get_subscription_outbox_store),
) -> DeleteSubscriptionsByIdentityCommand:
"""
组装请求级按媒体身份删除订阅用例。
"""
return DeleteSubscriptionsByIdentityCommand(
repository=repository_port,
unit_of_work=cast(DeleteUnitOfWork, unit_of_work),
publish_deleted=_publish_subscribe_deleted,
handle_event_error=_log_subscribe_deleted_event_error,
outbox=outbox,
dispatch_store=dispatch_store,
)
def _resume_submitted_subscription_search(
subscribe_ids: tuple[int, ...],
) -> None:
"""
唤醒搜索队列,并优先处理本次手工选择的订阅。
"""
start_scheduler_job(
"subscribe_search_queue",
limit=max(1, len(subscribe_ids)),
manual_sids=subscribe_ids,
)
def get_search_subscriptions_command(
task_registry: TaskRegistry = Depends(get_background_task_registry),
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> SearchSubscriptionsCommand:
"""
组装手工搜索用例,请求内只入队,实际搜索交给后台继续。
"""
registry = resolve_background_task_registry(task_registry)
search_repository = get_subscription_search_repository(runtime)
async def submit_search(
subscribe_ids: tuple[int, ...],
single: bool,
) -> SubscriptionSearchSubmission:
"""
在线程 owner 中完成轻量入队,并返回前端可立即跟踪的批次。
"""
enqueue_task = registry.create_sync(
search_repository.enqueue,
subscription_ids=subscribe_ids,
source="manual",
priority=120 if single else 100,
owner="api.subscribe.search.enqueue",
)
enqueued = await enqueue_task
registry.create_sync(
_resume_submitted_subscription_search,
subscribe_ids,
owner="api.subscribe.search.run",
)
return SubscriptionSearchSubmission(
batch_ids=enqueued.active_batch_ids,
target_count=len(subscribe_ids),
queued_count=enqueued.created_count,
ongoing_count=enqueued.coalesced_count,
single=single,
)
return SearchSubscriptionsCommand(
repository=runtime.subscription.repository(db),
submit_search=submit_search,
)
def get_subscription_query_service(
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
) -> SubscriptionQueryService:
"""
组装订阅和订阅历史异步查询服务。
"""
return SubscriptionQueryService(
repository=runtime.subscription.repository(db),
async_repository=runtime.subscription.repository(db),
history_repository=runtime.subscription.history_repository(db),
)
def get_subscription_execution_status_service(
db: AsyncSession = Depends(get_async_session),
runtime: HostRuntime = Depends(get_host_runtime),
task_registry: TaskRegistry = Depends(get_background_task_registry),
) -> SubscriptionExecutionStatusService:
"""
组装请求级订阅执行状态投影与取消服务。
"""
factory = runtime.subscription.execution_status_repository
if factory is None:
raise RuntimeError("订阅执行状态仓储未注册")
repository = factory(db)
search_repository = get_subscription_search_repository(runtime)
registry = resolve_background_task_registry(task_registry)
async def request_cancel(batch_id: str) -> bool:
"""
在线程 owner 内提交同步队列写入,并等待其真实终态。
"""
task = registry.create_sync(
search_repository.request_cancel,
batch_id,
owner="api.subscribe.execution.cancel",
)
return bool(await task)
return SubscriptionExecutionStatusService(
repository=cast(SubscriptionExecutionReadRepository, repository),
request_cancel=request_cancel,
)
def get_subscription_search_repository(
runtime: HostRuntime = Depends(get_host_runtime),
) -> SubscriptionSearchRepository:
"""
返回宿主组合根持有的订阅搜索队列端口。
"""
if runtime.subscription.search_repository is None:
raise RuntimeError("订阅搜索队列未注册")
return cast(SubscriptionSearchRepository, runtime.subscription.search_repository)
def get_subscription_mutation_service(
repository_port: SessionSubscriptionPort = Depends(get_subscription_repository),
history_repository: SubscriptionHistoryStagingPort = Depends(get_subscription_history_repository),
unit_of_work: object = Depends(get_subscription_transaction),
outbox: AsyncOutboxStager = Depends(get_subscription_outbox),
dispatch_store: AsyncOutboxDispatchStore = Depends(get_subscription_outbox_store),
) -> SubscriptionMutationService:
"""
组装异步订阅写服务。
"""
return SubscriptionMutationService(
repository=repository_port,
history_repository=history_repository,
unit_of_work=cast(DeleteUnitOfWork, unit_of_work),
outbox=outbox,
dispatch_store=dispatch_store,
publish_modified=_publish_subscribe_modified,
)
def get_servarr_subscription_batch_writer(
repository_port: SubscriptionStagingPort = Depends(get_subscription_repository),
unit_of_work: object = Depends(get_subscription_transaction),
outbox: AsyncOutboxStager = Depends(get_subscription_outbox),
dispatch_store: AsyncOutboxDispatchStore = Depends(get_subscription_outbox_store),
runtime: HostRuntime = Depends(get_host_runtime),
) -> SubscriptionBatchWritePort:
"""
组装 Servarr 多季订阅使用的请求级原子批量写端口。
"""
return runtime.subscription.batch_writer(
repository=repository_port,
unit_of_work=cast(DeleteUnitOfWork, unit_of_work),
outbox=outbox,
dispatch_store=dispatch_store,
)
def get_servarr_subscription_service(
async_db: AsyncSession = Depends(get_subscription_session),
db: Session = Depends(get_sync_session),
unit_of_work: object = Depends(get_subscription_transaction),
runtime: HostRuntime = Depends(get_host_runtime),
) -> ServarrSubscriptionService:
"""
组装 Servarr 兼容路由的请求级订阅数据用例。
"""
return ServarrSubscriptionService(
async_repository=runtime.subscription.repository(async_db),
sync_repository=runtime.subscription.repository(db),
unit_of_work=cast(DeleteUnitOfWork, unit_of_work),
)
@asynccontextmanager
async def subscription_log_query_scope(runtime: HostRuntime) -> AsyncIterator[SubscriptionQueryService]:
"""
日志权限轮询使用短会话,每次检查后释放连接,避免 SSE 占用数据库连接池。
"""
sessions = runtime.subscription.async_session()
try:
session = await anext(sessions)
repository = cast(SubscriptionQueryPort, runtime.subscription.repository(session))
yield SubscriptionQueryService(repository, async_repository=repository)
finally:
await sessions.aclose()