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

219 lines
7.1 KiB
Python
Raw 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.

"""订阅存在性、来源定位和类型状态查询应用服务。"""
from __future__ import annotations
from typing import Any, Optional, Protocol
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.schemas.media import resolve_media_identity
from app.schemas.types import MediaType
from app.schemas.workflow import Subscribe as SubscribeView
class SubscriptionQueryRepository(Protocol):
"""描述订阅查询切片所需的最小仓储能力。"""
def exists(self, **identity: Any) -> bool:
"""按完整订阅身份判断记录是否存在。"""
...
def get_by(self, **identity: Any) -> Optional[Any]:
"""按来源关键字中的订阅身份读取单条记录。"""
...
def list(self, state: Optional[str] = None) -> list[Any]:
"""按可选状态集合读取订阅记录。"""
...
class AsyncSubscriptionQueryRepository(Protocol):
"""公开订阅查询所需的异步持久化端口。"""
async def async_list(self) -> list[Any]:
"""读取全部订阅。"""
...
async def async_list_by_username(self, username: str) -> list[Any]:
"""读取指定用户订阅。"""
...
async def async_get(self, subscribe_id: int) -> Optional[Any]:
"""按 ID 读取订阅。"""
...
async def async_list_by_media_identity(
self,
media_source: Any,
media_id: str,
music_type: Optional[str] = None,
) -> list[Any]:
"""按规范媒体身份读取订阅。"""
...
class AsyncSubscriptionHistoryQueryRepository(Protocol):
"""订阅历史公开查询所需的异步持久化端口。"""
async def async_list_by_type(
self,
mtype: str,
page: int = 1,
count: int = 30,
) -> list[Any]:
"""按媒体类型分页读取订阅历史。"""
...
async def async_list_by_type_and_username(
self,
mtype: str,
username: str,
page: int = 1,
count: int = 30,
) -> list[Any]:
"""按媒体类型和用户分页读取订阅历史。"""
...
class SubscriptionQueryService:
"""封装不修改订阅状态的三个公开查询用例。"""
_SOURCE_FIELDS = {
"type",
"season",
"media_source",
"media_id",
"music_type",
}
def __init__(
self,
repository: SubscriptionQueryRepository,
*,
async_repository: Optional[AsyncSubscriptionQueryRepository] = None,
history_repository: Optional[AsyncSubscriptionHistoryQueryRepository] = None,
) -> None:
"""保存订阅查询仓储端口。"""
self._repository = repository
self._async_repository = async_repository
self._history_repository = history_repository
async def list_public(
self,
username: Optional[str] = None,
) -> list[SubscribeView]:
"""读取公开订阅列表并转换为稳定 DTO。"""
if self._async_repository is None:
raise RuntimeError("异步订阅查询端口未注册")
if username:
records = await self._async_repository.async_list_by_username(
username=username
)
else:
records = await self._async_repository.async_list()
return [SubscribeView.model_validate(record) for record in records]
async def get_public(self, subscribe_id: int) -> Optional[SubscribeView]:
"""按 ID 读取订阅 DTO。"""
if self._async_repository is None:
raise RuntimeError("异步订阅查询端口未注册")
record = await self._async_repository.async_get(subscribe_id)
return SubscribeView.model_validate(record) if record else None
async def list_by_media_identity(
self,
media_source: Any,
media_id: str,
music_type: Optional[str] = None,
) -> list[SubscribeView]:
"""按媒体身份读取订阅 DTO并兼容旧音乐记录。"""
if self._async_repository is None:
raise RuntimeError("异步订阅查询端口未注册")
records = await self._async_repository.async_list_by_media_identity(
media_source=media_source,
media_id=media_id,
music_type=music_type,
)
return [
SubscribeView.model_validate(record)
for record in records
if self._matches_music_type(record, music_type)
]
async def list_history(
self,
mtype: str,
*,
page: int = 1,
count: int = 30,
username: Optional[str] = None,
) -> list[SubscribeView]:
"""分页读取订阅历史 DTO。"""
if self._history_repository is None:
raise RuntimeError("订阅历史查询端口未注册")
if username:
records = await self._history_repository.async_list_by_type_and_username(
mtype,
username,
page,
count,
)
else:
records = await self._history_repository.async_list_by_type(
mtype,
page,
count,
)
result = []
for record in records:
item = SubscribeView.model_validate(record)
if item.type == MediaType.TV.value:
item.total_episode = 0
item.lack_episode = 0
result.append(item)
return result
@staticmethod
def _matches_music_type(record: Any, music_type: Optional[str]) -> bool:
"""把迁移前未标注音乐类型的记录兼容为单曲。"""
if not music_type:
return True
value = getattr(record, "music_type", None)
return value == music_type or (
music_type == "recording" and value is None
)
def exists(
self,
mediainfo: MediaInfo,
meta: Optional[MetaBase] = None,
) -> bool:
"""按媒体身份、季、剧集组和音乐实体类型判断订阅是否存在。"""
media_source, media_id = resolve_media_identity(media=mediainfo)
return bool(self._repository.exists(
media_source=media_source,
media_id=media_id,
music_type=getattr(mediainfo, "music_type", None)
if mediainfo.type == MediaType.MUSIC else None,
season=meta.begin_season if meta else None,
episode_group=mediainfo.episode_group,
))
def get_by_source(self, source_keyword: Optional[dict]) -> Optional[Any]:
"""从已解析来源关键字筛出稳定身份字段并读取订阅。"""
if not source_keyword:
return None
identity = {
key: value
for key, value in source_keyword.items()
if key in self._SOURCE_FIELDS
}
return self._repository.get_by(**identity)
def has_music(self, searchable_states: str) -> bool:
"""判断给定可搜索状态内是否至少存在一个音乐订阅。"""
return any(
subscribe.type == MediaType.MUSIC.value
for subscribe in self._repository.list(searchable_states) or []
)