mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 07:56:52 +08:00
refactor: 收口 V3 分层架构与插件兼容边界
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
"""订阅写操作用例及其数据端口。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class SubscriptionMutationRepository(Protocol):
|
||||
"""订阅写用例需要的异步数据端口。"""
|
||||
|
||||
async def async_get(self, subscribe_id: int) -> Any | None:
|
||||
"""按 ID 获取订阅。"""
|
||||
|
||||
async def async_update(self, subscribe_id: int, payload: dict[str, Any]) -> Any | None:
|
||||
"""更新订阅。"""
|
||||
|
||||
def get(self, subscribe_id: int) -> Any | None:
|
||||
"""同步按 ID 获取订阅。"""
|
||||
|
||||
|
||||
class SubscriptionHistoryMutationRepository(Protocol):
|
||||
"""订阅历史删除用例需要的最小数据端口。"""
|
||||
|
||||
async def async_get(self, history_id: int) -> Any | None:
|
||||
"""按 ID 获取订阅历史。"""
|
||||
|
||||
async def async_delete(self, history_id: int) -> None:
|
||||
"""删除订阅历史。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionActor:
|
||||
"""订阅写操作的权限主体。"""
|
||||
|
||||
name: str
|
||||
is_superuser: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionMutation:
|
||||
"""一次订阅变更前后的稳定快照。"""
|
||||
|
||||
old: dict[str, Any]
|
||||
new: dict[str, Any]
|
||||
|
||||
|
||||
class SubscriptionMutationService:
|
||||
"""编排订阅访问控制、更新和历史删除。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: SubscriptionMutationRepository,
|
||||
history_repository: SubscriptionHistoryMutationRepository | None = None,
|
||||
) -> None:
|
||||
"""注入订阅和订阅历史数据端口。"""
|
||||
self._repository = repository
|
||||
self._history_repository = history_repository
|
||||
|
||||
async def get_accessible(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
actor: SubscriptionActor,
|
||||
) -> Any | None:
|
||||
"""读取当前主体可访问的订阅。"""
|
||||
subscribe = await self._repository.async_get(subscribe_id)
|
||||
return subscribe if self.can_access(subscribe, actor) else None
|
||||
|
||||
def get_accessible_sync(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
actor: SubscriptionActor,
|
||||
) -> Any | None:
|
||||
"""同步读取当前主体可访问的订阅。"""
|
||||
subscribe = self._repository.get(subscribe_id)
|
||||
return subscribe if self.can_access(subscribe, actor) else None
|
||||
|
||||
async def update(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
payload: dict[str, Any],
|
||||
actor: SubscriptionActor,
|
||||
existing: Any | None = None,
|
||||
) -> SubscriptionMutation | None:
|
||||
"""更新当前主体可访问的订阅并返回前后快照。"""
|
||||
subscribe = existing or await self.get_accessible(subscribe_id, actor)
|
||||
if subscribe and not self.can_access(subscribe, actor):
|
||||
return None
|
||||
if not subscribe:
|
||||
return None
|
||||
old = subscribe.to_dict()
|
||||
updated = await self._repository.async_update(subscribe_id, payload)
|
||||
return SubscriptionMutation(old=old, new=updated.to_dict() if updated else {})
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
state: str,
|
||||
actor: SubscriptionActor,
|
||||
) -> SubscriptionMutation | None:
|
||||
"""更新订阅状态并返回前后快照。"""
|
||||
return await self.update(subscribe_id, {"state": state}, actor)
|
||||
|
||||
async def reset(
|
||||
self,
|
||||
subscribe_id: int,
|
||||
actor: SubscriptionActor,
|
||||
) -> SubscriptionMutation | None:
|
||||
"""重置订阅进度和手工集数标记。"""
|
||||
subscribe = await self.get_accessible(subscribe_id, actor)
|
||||
if not subscribe:
|
||||
return None
|
||||
payload = {
|
||||
"note": [],
|
||||
"lack_episode": subscribe.total_episode,
|
||||
"current_priority": None,
|
||||
"current_audio_format": None,
|
||||
"current_bitrate": None,
|
||||
"current_bit_depth": None,
|
||||
"current_sample_rate": None,
|
||||
"episode_priority": {},
|
||||
"manual_total_episode": 0,
|
||||
"state": "R",
|
||||
}
|
||||
old = subscribe.to_dict()
|
||||
updated = await self._repository.async_update(subscribe_id, payload)
|
||||
return SubscriptionMutation(old=old, new=updated.to_dict() if updated else {})
|
||||
|
||||
async def delete_history(
|
||||
self,
|
||||
history_id: int,
|
||||
actor: SubscriptionActor,
|
||||
) -> bool:
|
||||
"""删除当前主体可访问的订阅历史。"""
|
||||
if self._history_repository is None:
|
||||
raise RuntimeError("订阅历史数据端口未配置")
|
||||
history = await self._history_repository.async_get(history_id)
|
||||
if not self.can_access(history, actor):
|
||||
return False
|
||||
await self._history_repository.async_delete(history_id)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def can_access(subscribe: Any, actor: SubscriptionActor) -> bool:
|
||||
"""判断主体是否可访问订阅或订阅历史。"""
|
||||
if not subscribe:
|
||||
return False
|
||||
if actor.is_superuser:
|
||||
return True
|
||||
username = getattr(subscribe, "username", None)
|
||||
return bool(username) and username == actor.name
|
||||
@@ -8,6 +8,7 @@ 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):
|
||||
@@ -26,6 +27,54 @@ class SubscriptionQueryRepository(Protocol):
|
||||
...
|
||||
|
||||
|
||||
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:
|
||||
"""封装不修改订阅状态的三个公开查询用例。"""
|
||||
|
||||
@@ -37,9 +86,102 @@ class SubscriptionQueryService:
|
||||
"music_type",
|
||||
}
|
||||
|
||||
def __init__(self, repository: SubscriptionQueryRepository) -> None:
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user