Files
MoviePilot/app/application/servarr.py
Aqr-K 951cdc5b27 fix: arbitrate subscription identity and restore workflow state (#7147)
* fix: arbitrate subscription identity and restore workflow state

* test: preserve legacy empty-owner subscription transactions

* fix: align shared atomic configuration method with lifecycle prerequisite

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 08:14:15 +08:00

254 lines
8.4 KiB
Python
Raw Permalink 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.
"""
Servarr 兼容接口使用的订阅投影和数据用例。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, List, Optional, Protocol
from app.schemas.types import MediaSource, MediaType
if TYPE_CHECKING:
from app.application.subscription.delete import AsyncUnitOfWork
@dataclass(frozen=True, slots=True)
class ServarrSubscription:
"""
隔离 Servarr 端点与订阅 ORM 模型的稳定投影。
:param id: MoviePilot 订阅记录主键
:param name: 订阅媒体名称,旧记录可能未填写
:param year: 媒体发行年份字符串,未知时为 None
:param type: MoviePilot 媒体类型名称,未知时为 None
:param season: 电视剧订阅季号,电影或未指定季时为 None
:param poster: 媒体海报地址,未知时为 None
:param media_source: 统一媒体身份的数据来源,旧记录可为空
:param media_id: 对应来源的原生媒体标识,旧记录可为空
"""
id: int
name: Optional[str]
year: Optional[str]
type: Optional[str]
season: Optional[int]
poster: Optional[str]
media_source: Optional[str]
media_id: Optional[str]
class ServarrAsyncSubscriptionRepository(Protocol):
"""
Servarr 异步订阅用例需要的最小仓储端口。
"""
async def async_list(self) -> list[Any]:
"""
读取全部订阅。
:return: 全部订阅记录列表,无记录时为空列表
"""
...
async def async_get(self, subscribe_id: int) -> Optional[Any]:
"""
按主键读取订阅。
:param subscribe_id: MoviePilot 订阅主键
:return: 匹配的订阅记录,不存在时为 None
"""
...
async def async_list_by_media_identity(
self,
media_source: MediaSource,
media_id: str,
music_type: Optional[str] = None,
) -> list[Any]:
"""
按媒体身份读取订阅。
:param media_source: 统一媒体身份的数据来源
:param media_id: 对应来源的原生媒体标识
:param music_type: 音乐实体类型过滤;None 不限定类型,recording 兼容未标记类型的旧单曲记录
:return: 匹配该媒体身份的订阅记录列表,无匹配时为空列表
"""
...
async def async_exists(
self,
media_source: MediaSource,
media_id: str,
season: Optional[int] = None,
episode_group: Optional[str] = None,
music_type: Optional[str] = None,
mtype: Optional[str] = None,
) -> Optional[Any]:
"""
按媒体身份读取命中的订阅。
:param media_source: 统一媒体身份的数据来源
:param media_id: 对应来源的原生媒体标识
:param season: 电视剧季号,None 表示不附加季号过滤
:param episode_group: 剧集组标识,None 只匹配未设置剧集组的记录
:param music_type: 音乐实体类型过滤;None 不限定类型,recording 兼容未标记类型的旧单曲记录
:param mtype: 媒体类型,防止电影和电视剧的同编号冲突
:return: 满足媒体身份及附加条件的订阅记录,不存在时为 None
"""
...
async def async_delete(self, subscribe_id: int) -> None:
"""
按主键删除订阅。
:param subscribe_id: 待删除的 MoviePilot 订阅主键
"""
...
class ServarrSyncSubscriptionRepository(Protocol):
"""
Servarr 同步 lookup 用例需要的最小仓储端口。
"""
def list_by_media_identity(
self,
media_source: MediaSource,
media_id: str,
music_type: Optional[str] = None,
) -> list[Any]:
"""
按媒体身份读取订阅。
:param media_source: 统一媒体身份的数据来源
:param media_id: 对应来源的原生媒体标识
:param music_type: 音乐实体类型过滤;None 不限定类型,recording 兼容未标记类型的旧单曲记录
:return: 匹配该媒体身份的订阅记录列表,无匹配时为空列表
"""
...
class ServarrSubscriptionService:
"""
提供 Servarr 路由所需的订阅查询、查重和删除能力。
"""
def __init__(
self,
*,
async_repository: ServarrAsyncSubscriptionRepository,
sync_repository: ServarrSyncSubscriptionRepository,
unit_of_work: Optional[AsyncUnitOfWork] = None,
) -> None:
"""
保存订阅仓储,并按需保存请求级异步事务端口。
"""
self._async_repository = async_repository
self._sync_repository = sync_repository
self._unit_of_work = unit_of_work
async def list(self) -> List[ServarrSubscription]:
"""
读取全部订阅并转换为脱离 ORM 会话的投影。
"""
return [self._project(record) for record in await self._async_repository.async_list()]
async def get(self, subscribe_id: int) -> Optional[ServarrSubscription]:
"""
按主键读取订阅投影。
"""
record = await self._async_repository.async_get(subscribe_id)
return self._project(record) if record else None
async def list_by_media_identity(
self,
media_source: MediaSource,
media_id: str,
) -> List[ServarrSubscription]:
"""
异步按媒体身份读取订阅投影。
"""
records = await self._async_repository.async_list_by_media_identity(
media_source=media_source,
media_id=media_id,
)
return [self._project(record) for record in records]
def list_by_media_identity_sync(
self,
media_source: MediaSource,
media_id: str,
) -> List[ServarrSubscription]:
"""
同步按媒体身份读取订阅投影。
"""
records = self._sync_repository.list_by_media_identity(
media_source=media_source,
media_id=media_id,
)
return [self._project(record) for record in records]
async def exists(
self,
*,
media_source: MediaSource,
media_id: str,
season: Optional[int] = None,
) -> bool:
"""
判断指定媒体身份和季是否已有订阅。
"""
return await self.get_existing(media_source=media_source, media_id=media_id, season=season) is not None
async def get_existing(
self, *, media_source: MediaSource, media_id: str, season: Optional[int] = None,
) -> Optional[ServarrSubscription]:
"""
返回命中身份的实际订阅,供重复添加响应保留正确主键。
:param media_source: 订阅数据来源
:param media_id: 来源原生标识
:param season: 电视剧季号;None 时不筛选季
:return: 已有订阅的稳定投影,未命中为 None
"""
record = await self._async_repository.async_exists(
media_source=media_source,
media_id=media_id,
season=season,
mtype=MediaType.TV.value if season is not None else MediaType.MOVIE.value,
)
return self._project(record) if record is not None else None
async def delete(self, subscribe_id: int) -> bool:
"""
删除存在的订阅并报告是否实际命中。
"""
if not await self._async_repository.async_get(subscribe_id):
return False
try:
await self._async_repository.async_delete(subscribe_id)
if self._unit_of_work is not None:
await self._unit_of_work.commit()
except Exception:
if self._unit_of_work is not None:
await self._unit_of_work.rollback()
raise
return True
@staticmethod
def _project(record: Any) -> ServarrSubscription:
"""
从数据库记录复制 Servarr 路由所需的最小字段。
"""
return ServarrSubscription(
id=record.id,
name=getattr(record, "name", None),
year=getattr(record, "year", None),
type=getattr(record, "type", None),
season=getattr(record, "season", None),
poster=getattr(record, "poster", None),
media_source=getattr(record, "media_source", None),
media_id=getattr(record, "media_id", None),
)