Files
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

356 lines
12 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.
"""
订阅存在性、来源定位和类型状态查询应用服务。
"""
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime, timedelta
from typing import Any, Optional
from app.application.subscription.contract import (
SubscriptionHistoryQueryPort,
SubscriptionIdentity,
SubscriptionQueryPort,
SubscriptionSnapshot,
)
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.schemas.common import JsonData
from app.schemas.media import resolve_media_identity
from app.schemas.subscribe import Subscribe as SubscribeView
from app.schemas.subscribe import compute_subscribe_completed_tracks
from app.schemas.types import MediaSource, MediaType
class SubscriptionQueryService:
"""
封装不修改订阅状态的三个公开查询用例。
"""
_SOURCE_FIELDS = {
"type",
"season",
"media_source",
"media_id",
"music_type",
"episode_group",
}
_LEGACY_ID_FIELDS: tuple[tuple[str, MediaSource], ...] = (
("tmdbid", MediaSource.TMDB),
("doubanid", MediaSource.Douban),
("bangumiid", MediaSource.Bangumi),
("anilistid", MediaSource.AniList),
("imdbid", MediaSource.IMDb),
("tvdbid", MediaSource.TVDB),
)
def __init__(
self,
repository: SubscriptionQueryPort,
*,
async_repository: Optional[SubscriptionQueryPort] = None,
history_repository: Optional[SubscriptionHistoryQueryPort] = None,
) -> None:
"""
保存订阅查询仓储端口。
"""
self._repository = repository
self._async_repository = async_repository
self._history_repository = history_repository
@staticmethod
def _to_public_view(record: Any) -> SubscribeView:
"""
把活动订阅内部快照投影为公开 DTO,并派生专辑曲目进度。
"""
payload = record.to_dict()
payload["completed_tracks"] = compute_subscribe_completed_tracks(record)
payload.pop("downloaded_tracks", None)
return SubscribeView.model_validate(payload)
async def list_public(
self,
username: Optional[str] = None,
page: Optional[int] = None,
count: Optional[int] = None,
) -> list[SubscribeView]:
"""
按 owner 和可选数据库窗口读取公开订阅 DTO。
"""
if self._async_repository is None:
raise RuntimeError("异步订阅查询端口未注册")
if username:
if page is None and count is None:
records = await self._async_repository.async_list_by_username(
username=username
)
else:
records = await self._async_repository.async_list_by_username(
username=username,
page=page,
count=count,
)
else:
if page is None and count is None:
records = await self._async_repository.async_list()
else:
records = await self._async_repository.async_list(
page=page,
count=count,
)
return [self._to_public_view(record) for record in records]
async def count_public(self, username: Optional[str] = None) -> int:
"""
按 owner 范围返回公开订阅精确总数。
"""
if self._async_repository is None:
raise RuntimeError("异步订阅查询端口未注册")
return await self._async_repository.async_count(username=username)
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 self._to_public_view(record) if record else None
async def list_by_media_identity(
self,
media_source: MediaSource,
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 [
self._to_public_view(record)
for record in records
if self._matches_music_type(record, music_type)
]
async def list_by_video_metadata(
self,
*,
title: str,
year: Optional[str],
media_type: MediaType,
season: Optional[int] = None,
) -> list[SubscribeView]:
"""
按影视类型、规范标题和可用年份读取跨来源订阅,优先精确年份。
"""
if self._async_repository is None:
raise RuntimeError("异步订阅查询端口未注册")
if media_type not in (MediaType.MOVIE, MediaType.TV):
return []
records = await self._async_repository.async_list_by_title(
title=title,
season=season,
)
expected_year = str(year or "").strip()
matching_type = [record for record in records if record.type == media_type.value]
exact_year = [
record for record in matching_type
if expected_year and str(record.year or "").strip() == expected_year
]
unknown_year = [record for record in matching_type if not str(record.year or "").strip()]
return [self._to_public_view(record) for record in exact_year + unknown_year]
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 = self._to_public_view(record)
if item.type == MediaType.TV.value:
item.total_episode = 0
item.lack_episode = 0
result.append(item)
return result
async def count_history(
self,
mtype: str,
*,
username: Optional[str] = None,
) -> int:
"""
按与历史列表相同的 owner 范围返回精确总数。
"""
if self._history_repository is None:
raise RuntimeError("订阅历史查询端口未注册")
if username:
return await self._history_repository.async_count_by_type_and_username(
mtype,
username,
)
return await self._history_repository.async_count_by_type(mtype)
@staticmethod
def _matches_music_type(
record: SubscriptionSnapshot,
music_type: Optional[str],
) -> bool:
"""
把迁移前未标注音乐类型的记录兼容为单曲。
"""
if not music_type:
return True
value = record.music_type
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)
if media_source is None or media_id is None:
return False
return self._repository.exists(
SubscriptionIdentity(
media_source=media_source,
media_id=str(media_id),
type=mediainfo.type.value if mediainfo.type else None,
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,
)
)
@classmethod
def _has_media_identity(cls, identity: Mapping[str, JsonData]) -> bool:
"""
判断来源关键字是否已带成对的媒体身份。
"""
media_id = identity.get("media_id")
return bool(identity.get("media_source")) and media_id not in (
None,
"",
0,
"0",
)
@classmethod
def _legacy_media_identity(
cls,
source_keyword: Mapping[str, JsonData],
) -> dict[str, JsonData]:
"""
把 v2 订阅来源里的 tmdbid/doubanid 等补成 media_source + media_id。
"""
for field, source in cls._LEGACY_ID_FIELDS:
raw = source_keyword.get(field)
if raw in (None, "", 0, "0"):
continue
return {
"media_source": source,
"media_id": str(raw),
}
return {}
def get_by_source(
self,
source_keyword: Optional[Mapping[str, JsonData]],
) -> Optional[SubscriptionSnapshot]:
"""
从已解析来源关键字筛出稳定身份字段并读取订阅。
"""
if not source_keyword:
return None
identity = {key: value for key, value in source_keyword.items() if key in self._SOURCE_FIELDS}
if not self._has_media_identity(identity):
identity.update(self._legacy_media_identity(source_keyword))
if not identity.get("type") or not self._has_media_identity(identity):
return None
raw_source = identity.get("media_source")
raw_id = identity.get("media_id")
raw_type = identity.get("type")
if not isinstance(raw_source, (str, MediaSource)):
return None
if not isinstance(raw_id, (str, int)) or not isinstance(raw_type, str):
return None
raw_season = identity.get("season")
raw_music_type = identity.get("music_type")
raw_episode_group = identity.get("episode_group")
return self._repository.get_by(
SubscriptionIdentity(
media_source=MediaSource(raw_source),
media_id=str(raw_id),
type=raw_type,
episode_group=raw_episode_group if isinstance(raw_episode_group, str) else None,
season=raw_season if isinstance(raw_season, int) else None,
music_type=raw_music_type if isinstance(raw_music_type, str) else None,
)
)
def has_music(self, searchable_states: str) -> bool:
"""
判断给定可搜索状态内是否至少存在一个音乐订阅。
"""
return any(
subscribe.type == MediaType.MUSIC.value for subscribe in self._repository.list(searchable_states) or []
)
def subscription_search_due(
subscribe: SubscriptionSnapshot,
default_interval: int,
now: datetime,
) -> bool:
"""
按最近一次主动搜索计算定时搜索到期;旧记录以本地创建时间起算。
"""
if subscribe.state not in {"R", "P"}:
return False
previous = subscribe.last_search or subscribe.date
if not previous:
return True
try:
started = datetime.fromisoformat(previous)
except ValueError:
return True
# 旧创建时间没有时区,保持其本地时间语义;新搜索时间始终携带 UTC 时区。
if started.tzinfo is None:
started = started.astimezone()
interval = subscribe.search_interval or default_interval
return now >= started + timedelta(hours=interval)