Files
MoviePilot/app/chain/subscribe/create.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

875 lines
35 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 collections.abc import Awaitable, Callable, Sequence
from typing import Any, Dict, Optional, Tuple, cast
from app.application.subscription.write import (
SubscriptionBatchWritePort,
SubscriptionCreateRequest,
add_subscribe,
async_add_subscribe,
build_subscription_create_request,
)
from app.chain.media import MediaChain
from app.chain.subscribe.context import _SubscribeCreateContext, _SubscribePostCommitContext
from app.chain.subscribe.contract import _SubscribeOwnerBase
from app.chain.subscribe.identity import media_recognize_kwargs
from app.domain.context import MediaInfo
from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.common import JsonData
from app.schemas.media import normalize_media_source, resolve_media_identity
from app.schemas.types import (
MediaSource,
MediaType,
NotificationChannel,
)
def _build_post_commit_callback(
owner: Any,
context: _SubscribePostCommitContext,
) -> Callable[[int], bool]:
"""
冻结同步订阅提交后的副作用上下文。
"""
def after_commit(subscribe_id: int) -> bool:
"""
把同步提交后的副作用委托给通知组件。
"""
return cast(bool, owner._SubscribeChain__post_subscribe_added(subscribe_id, context))
return after_commit
def _build_async_post_commit_callback(
owner: Any,
context: _SubscribePostCommitContext,
) -> Callable[[int], Awaitable[bool]]:
"""
冻结异步订阅提交后的副作用上下文。
"""
async def after_commit(subscribe_id: int) -> bool:
"""
把异步提交后的副作用委托给通知组件。
"""
return cast(bool, await owner._SubscribeChain__async_post_subscribe_added(subscribe_id, context))
return after_commit
class SubscribeCreateOwner(_SubscribeOwnerBase):
"""
订阅创建与批量写入编排,作为 SubscribeChain 的单一职责组件。
"""
def _SubscribeChain__get_default_kwargs(self, mtype: MediaType, **kwargs: Any) -> dict[str, Any]:
"""
合并调用方选项与对应媒体类型的订阅默认配置。
:param mtype: 订阅的媒体类型,音乐会关闭电视剧整包洗版和 IMDb 搜索选项
:param kwargs: 调用方提供的质量、关键词、站点、目录和洗版等订阅选项
:return: 合并后的订阅选项字典,空值是否回退按各配置项的既有规则处理
"""
defaults = {
"quality": self._SubscribeChain__get_default_subscribe_config(mtype, "quality")
if not kwargs.get("quality")
else kwargs.get("quality"),
"resolution": self._SubscribeChain__get_default_subscribe_config(mtype, "resolution")
if not kwargs.get("resolution")
else kwargs.get("resolution"),
"effect": self._SubscribeChain__get_default_subscribe_config(mtype, "effect")
if not kwargs.get("effect")
else kwargs.get("effect"),
"audio_quality": self._SubscribeChain__get_default_subscribe_config(mtype, "audio_quality")
if not kwargs.get("audio_quality")
else kwargs.get("audio_quality"),
"audio_format": self._SubscribeChain__get_default_subscribe_config(mtype, "audio_format")
if not kwargs.get("audio_format")
else kwargs.get("audio_format"),
"min_bitrate": self._SubscribeChain__get_default_subscribe_config(mtype, "min_bitrate")
if not kwargs.get("min_bitrate")
else kwargs.get("min_bitrate"),
"min_bit_depth": self._SubscribeChain__get_default_subscribe_config(mtype, "min_bit_depth")
if not kwargs.get("min_bit_depth")
else kwargs.get("min_bit_depth"),
"min_sample_rate": self._SubscribeChain__get_default_subscribe_config(mtype, "min_sample_rate")
if not kwargs.get("min_sample_rate")
else kwargs.get("min_sample_rate"),
"include": self._SubscribeChain__get_default_subscribe_config(mtype, "include")
if not kwargs.get("include")
else kwargs.get("include"),
"exclude": self._SubscribeChain__get_default_subscribe_config(mtype, "exclude")
if not kwargs.get("exclude")
else kwargs.get("exclude"),
"best_version": self._SubscribeChain__get_default_subscribe_config(mtype, "best_version")
if kwargs.get("best_version") is None
else kwargs.get("best_version"),
"best_version_full": self._SubscribeChain__get_default_subscribe_config(mtype, "best_version_full")
if kwargs.get("best_version_full") is None
else kwargs.get("best_version_full"),
"search_imdbid": self._SubscribeChain__get_default_subscribe_config(mtype, "search_imdbid")
if not kwargs.get("search_imdbid")
else kwargs.get("search_imdbid"),
"sites": self._SubscribeChain__get_default_subscribe_config(mtype, "sites") or None
if not kwargs.get("sites")
else kwargs.get("sites"),
"downloader": self._SubscribeChain__get_default_subscribe_config(mtype, "downloader")
if not kwargs.get("downloader")
else kwargs.get("downloader"),
"save_path": self._SubscribeChain__get_default_subscribe_config(mtype, "save_path")
if not kwargs.get("save_path")
else kwargs.get("save_path"),
"filter_groups": self._SubscribeChain__get_default_subscribe_config(mtype, "filter_groups")
if not kwargs.get("filter_groups")
else kwargs.get("filter_groups"),
}
if mtype == MediaType.MUSIC:
# 音乐允许按音质洗版,但没有电视剧整包洗版和 IMDB 搜索语义。
defaults.update(
{
"best_version_full": 0,
"search_imdbid": 0,
}
)
return defaults
@staticmethod
def _SubscribeChain__build_subscribe_create_context(
title: str,
year: str,
mtype: Optional[MediaType],
episode_group: Optional[str],
season: Optional[int],
channel: Optional[NotificationChannel],
source: Optional[str],
userid: Optional[str],
username: Optional[str],
message: bool,
exist_ok: bool,
media_source: Optional[MediaSource],
media_id: Optional[str],
options: Dict[str, Any],
) -> Tuple[Optional[_SubscribeCreateContext], Optional[str]]:
"""
规范订阅新增输入,并在任何识别 I/O 前拒绝不完整的显式媒体身份。
"""
explicit_identity = media_source is not None or media_id is not None
media_source, media_id = resolve_media_identity(
media_source=media_source,
media_id=media_id,
)
if explicit_identity and (not media_source or not media_id):
return None, "媒体来源和媒体 ID 必须同时提供"
metainfo = MetaMusic.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title)
if year:
metainfo.year = year
if mtype:
metainfo.type = mtype
if season is not None:
metainfo.type = MediaType.TV
metainfo.begin_season = season
if mtype == MediaType.MUSIC and media_id:
metainfo.media_id = str(media_id)
return _SubscribeCreateContext(
title=title,
year=year,
mtype=mtype,
episode_group=episode_group,
season=season,
channel=channel,
source=source,
userid=userid,
username=username,
message=bool(message),
exist_ok=bool(exist_ok),
options=options,
explicit_identity=explicit_identity,
media_source=media_source,
media_id=media_id,
requested_music_type=options.get("music_type"),
metainfo=metainfo,
), None
@staticmethod
def _SubscribeChain__normalize_recognized_subscribe_media(context: _SubscribeCreateContext) -> None:
"""
保留非 TMDB 影视标题清洗与从标题补季号的历史行为。
"""
mediainfo = context.mediainfo
if context.mtype == MediaType.MUSIC or not mediainfo or mediainfo.media_source == MediaSource.TMDB:
return
meta = MetaInfo(mediainfo.title or "")
mediainfo.title = meta.name
if context.season is None:
context.season = meta.begin_season
@staticmethod
def _SubscribeChain__tmdb_conversion_identity(
context: _SubscribeCreateContext,
) -> Optional[Tuple[MediaSource, str]]:
"""
识别源为 TMDB 时,返回需要换成 TMDB 整剧身份的影视来源身份;其余情况返回空。
"""
mediainfo = context.mediainfo
if not mediainfo or mediainfo.type not in (MediaType.MOVIE, MediaType.TV):
return None
source, media_id = resolve_media_identity(media=mediainfo)
if not source or not media_id or source == MediaSource.TMDB:
return None
if normalize_media_source(get_runtime_setting("RECOGNIZE_SOURCE", None)) != MediaSource.TMDB:
return None
return source, media_id
@staticmethod
def _SubscribeChain__apply_converted_subscribe_media(
context: _SubscribeCreateContext,
source: MediaSource,
media_id: str,
tmdbinfo: Optional[Dict[str, Any]],
converted: Optional[MediaInfo],
) -> None:
"""
转换成功才替换订阅目标;豆瓣季条目的季号来自标题或投影结果,整剧身份本身不带季。
"""
tmdb_id = tmdbinfo.get("id") if tmdbinfo else None
if not tmdb_id or not converted:
logger.warning(
f"未能将 {source}:{media_id} 转换为 TMDB 身份,订阅沿用原身份:"
f"{context.mediainfo.title_year if context.mediainfo else context.title}"
)
return
if context.season is None and converted.type == MediaType.TV:
projected_season = tmdbinfo.get("season") if tmdbinfo else None
if isinstance(projected_season, int) and projected_season > 0:
context.season = projected_season
logger.info(f"订阅身份已由 {source}:{media_id} 转换为 TMDB {tmdb_id}:{converted.title_year}")
context.mediainfo = converted
def _SubscribeChain__convert_subscribe_media_to_tmdb(self, context: _SubscribeCreateContext) -> None:
"""
同步把豆瓣、Bangumi 等影视身份换成 TMDB,和豆瓣想看、豆瓣榜单的订阅口径一致。
非 TMDB 来源把每一季当成独立条目,标题和年份都是季级的;若直接作为订阅主身份,
下载记录与整理会按季标题生成独立剧集目录,媒体库里就会出现多部同名剧。
"""
identity = self._SubscribeChain__tmdb_conversion_identity(context)
mediainfo = context.mediainfo
if not identity or mediainfo is None:
return
source, media_id = identity
tmdbinfo = MediaChain().convert_media_identity(
target_source=MediaSource.TMDB,
media_source=source,
media_id=media_id,
mtype=mediainfo.type,
season=context.season,
)
converted = None
if tmdbinfo and tmdbinfo.get("id"):
converted = MediaChain().recognize_media(
meta=context.metainfo,
mtype=mediainfo.type,
media_source=MediaSource.TMDB,
media_id=str(tmdbinfo.get("id")),
episode_group=context.episode_group,
cache=False,
)
self._SubscribeChain__apply_converted_subscribe_media(context, source, media_id, tmdbinfo, converted)
async def _SubscribeChain__async_convert_subscribe_media_to_tmdb(
self,
context: _SubscribeCreateContext,
) -> None:
"""
异步版本的 TMDB 身份转换,与同步入口共享判定和替换规则。
"""
identity = self._SubscribeChain__tmdb_conversion_identity(context)
mediainfo = context.mediainfo
if not identity or mediainfo is None:
return
source, media_id = identity
tmdbinfo = await MediaChain().async_convert_media_identity(
target_source=MediaSource.TMDB,
media_source=source,
media_id=media_id,
mtype=mediainfo.type,
season=context.season,
)
converted = None
if tmdbinfo and tmdbinfo.get("id"):
converted = await MediaChain().async_recognize_media(
meta=context.metainfo,
mtype=mediainfo.type,
media_source=MediaSource.TMDB,
media_id=str(tmdbinfo.get("id")),
episode_group=context.episode_group,
cache=False,
)
self._SubscribeChain__apply_converted_subscribe_media(context, source, media_id, tmdbinfo, converted)
def _SubscribeChain__recognize_subscribe_media(self, context: _SubscribeCreateContext) -> Optional[str]:
"""
同步识别订阅目标;显式身份失败时禁止按标题换成另一个媒体。
"""
if context.media_source and context.media_id:
context.mediainfo = MediaChain().recognize_media(
meta=context.metainfo,
mtype=context.mtype,
media_source=context.media_source,
media_id=context.media_id,
music_type=context.requested_music_type,
episode_group=context.episode_group,
cache=False,
)
self._SubscribeChain__normalize_recognized_subscribe_media(context)
if not context.mediainfo and not context.explicit_identity:
context.mediainfo = MediaChain().recognize_by_meta(
context.metainfo,
media_source=context.media_source,
episode_group=context.episode_group,
obtain_images=False,
music_type=context.requested_music_type,
)
if context.mtype == MediaType.MUSIC and context.mediainfo and not context.mediainfo.media_source:
context.mediainfo = None
self._SubscribeChain__convert_subscribe_media_to_tmdb(context)
return self._SubscribeChain__validate_recognized_subscribe_media(context)
async def _SubscribeChain__async_recognize_subscribe_media(
self,
context: _SubscribeCreateContext,
) -> Optional[str]:
"""
异步识别订阅目标,并与同步入口共享相同的规范化和校验规则。
"""
if context.media_source and context.media_id:
context.mediainfo = await MediaChain().async_recognize_media(
meta=context.metainfo,
mtype=context.mtype,
media_source=context.media_source,
media_id=context.media_id,
music_type=context.requested_music_type,
episode_group=context.episode_group,
cache=False,
)
self._SubscribeChain__normalize_recognized_subscribe_media(context)
if not context.mediainfo and not context.explicit_identity:
context.mediainfo = await MediaChain().async_recognize_by_meta(
context.metainfo,
media_source=context.media_source,
episode_group=context.episode_group,
obtain_images=False,
music_type=context.requested_music_type,
)
if context.mtype == MediaType.MUSIC and context.mediainfo and not context.mediainfo.media_source:
context.mediainfo = None
await self._SubscribeChain__async_convert_subscribe_media_to_tmdb(context)
return self._SubscribeChain__validate_recognized_subscribe_media(context)
def _SubscribeChain__validate_recognized_subscribe_media(
self,
context: _SubscribeCreateContext,
) -> Optional[str]:
"""
校验识别结果以及音乐订阅实体,返回兼容旧入口的错误文案。
"""
if not context.mediainfo:
logger.warning(
f"未识别到媒体信息,标题:{context.title},媒体来源:{context.media_source},"
f"媒体 ID:{context.media_id}"
)
return "未识别到媒体信息"
if context.mtype != MediaType.MUSIC:
return None
music_error = self._validate_music_subscribe_target(
context.mediainfo,
requested_music_type=context.requested_music_type,
)
if music_error:
logger.warning(f"音乐订阅目标校验失败:{context.title} - {music_error}")
return cast(Optional[str], music_error)
def _SubscribeChain__prepare_subscribe_episodes(self, context: _SubscribeCreateContext) -> Optional[str]:
"""
同步补齐电视剧季集信息,并保持外部集数刷新只能扩展创建目标。
"""
mediainfo = context.mediainfo
if mediainfo is None:
return "未识别到媒体信息"
if mediainfo.type != MediaType.TV:
context.season = None
return None
if context.season is None:
context.season = 1
if not context.options.get("total_episode"):
if not mediainfo.seasons or context.episode_group:
mediainfo = MediaChain().recognize_media(
mtype=mediainfo.type,
**media_recognize_kwargs(mediainfo),
episode_group=context.episode_group,
cache=False,
)
context.mediainfo = mediainfo
if mediainfo is None:
return "未识别到媒体信息"
error = self._SubscribeChain__validate_subscribe_seasons(context)
if error:
return error
current_total_episode = len(mediainfo.seasons.get(context.season) or [])
total_episode = self._SubscribeChain__apply_episodes_refresh(
current_total_episode,
season=context.season,
mediainfo=mediainfo,
media_source=resolve_media_identity(media=mediainfo)[0],
media_id=resolve_media_identity(media=mediainfo)[1],
scene="create",
)
error = self._SubscribeChain__store_subscribe_episode_total(
context,
current_total_episode,
total_episode,
)
if error:
return error
self._SubscribeChain__fill_subscribe_lack_episode(context)
return None
async def _SubscribeChain__async_prepare_subscribe_episodes(
self,
context: _SubscribeCreateContext,
) -> Optional[str]:
"""
异步补齐电视剧季集信息,并复用同步入口的结果校验和字段写入规则。
"""
mediainfo = context.mediainfo
if mediainfo is None:
return "未识别到媒体信息"
if mediainfo.type != MediaType.TV:
context.season = None
return None
if context.season is None:
context.season = 1
if not context.options.get("total_episode"):
if not mediainfo.seasons or context.episode_group:
mediainfo = await MediaChain().async_recognize_media(
mtype=mediainfo.type,
**media_recognize_kwargs(mediainfo),
episode_group=context.episode_group,
cache=False,
)
context.mediainfo = mediainfo
if mediainfo is None:
return "未识别到媒体信息"
error = self._SubscribeChain__validate_subscribe_seasons(context)
if error:
return error
current_total_episode = len(mediainfo.seasons.get(context.season) or [])
total_episode = await self._SubscribeChain__async_apply_episodes_refresh(
current_total_episode,
season=context.season,
mediainfo=mediainfo,
media_source=resolve_media_identity(media=mediainfo)[0],
media_id=resolve_media_identity(media=mediainfo)[1],
scene="create",
)
error = self._SubscribeChain__store_subscribe_episode_total(
context,
current_total_episode,
total_episode,
)
if error:
return error
self._SubscribeChain__fill_subscribe_lack_episode(context)
return None
@staticmethod
def _SubscribeChain__validate_subscribe_seasons(context: _SubscribeCreateContext) -> Optional[str]:
"""
校验补充识别结果是否仍包含创建电视剧订阅所需的季集信息。
"""
if not context.mediainfo:
logger.error("媒体信息识别失败!")
return "媒体信息识别失败"
if not context.mediainfo.seasons:
logger.error(f"媒体信息中没有季集信息,标题:{context.title}")
return "媒体信息中没有季集信息"
return None
@staticmethod
def _SubscribeChain__store_subscribe_episode_total(
context: _SubscribeCreateContext,
current_total_episode: int,
total_episode: int,
) -> Optional[str]:
"""
写入创建场景最终集数,阻止外部刷新把可靠的当前集数向下覆盖。
"""
if current_total_episode and total_episode < current_total_episode:
total_episode = current_total_episode
if not total_episode:
logger.error(f"未获取到总集数,标题:{context.title}")
return f"未获取到第 {context.season} 季的总集数"
context.options["total_episode"] = total_episode
return None
@staticmethod
def _SubscribeChain__fill_subscribe_lack_episode(context: _SubscribeCreateContext) -> None:
"""
未显式指定缺失集数时沿用总集数,保持旧创建默认值。
"""
if not context.options.get("lack_episode"):
context.options["lack_episode"] = context.options.get("total_episode")
def _SubscribeChain__finalize_subscribe_create_context(self, context: _SubscribeCreateContext) -> None:
"""
同步补图并写入规范媒体身份和订阅默认配置。
"""
mediainfo = context.mediainfo
if mediainfo is None:
return
if mediainfo.type != MediaType.MUSIC:
self.obtain_images(mediainfo=mediainfo)
self._SubscribeChain__apply_subscribe_create_defaults(context)
async def _SubscribeChain__async_finalize_subscribe_create_context(
self,
context: _SubscribeCreateContext,
) -> None:
"""
异步补图并写入规范媒体身份和订阅默认配置。
"""
mediainfo = context.mediainfo
if mediainfo is None:
return
if mediainfo.type != MediaType.MUSIC:
await self.async_obtain_images(mediainfo=mediainfo)
self._SubscribeChain__apply_subscribe_create_defaults(context)
def _SubscribeChain__apply_subscribe_create_defaults(self, context: _SubscribeCreateContext) -> None:
"""
以最终识别结果覆盖身份字段,并补齐当前媒体类型的默认订阅参数。
"""
mediainfo = context.mediainfo
if mediainfo is None:
return
context.media_source, context.media_id = resolve_media_identity(media=mediainfo)
context.options.update(
{
"media_source": context.media_source,
"media_id": context.media_id,
}
)
if mediainfo.type is not None:
context.options.update(self._SubscribeChain__get_default_kwargs(mediainfo.type, **context.options))
@staticmethod
def _SubscribeChain__subscribe_post_commit_context(
context: _SubscribeCreateContext,
notification: Optional[dict[str, Any]] = None,
) -> _SubscribePostCommitContext:
"""
从创建阶段状态冻结提交后副作用需要的最小快照。
"""
if context.mediainfo is None:
raise ValueError("订阅提交上下文缺少媒体信息")
return _SubscribePostCommitContext(
title=context.title,
year=context.year,
metainfo=context.metainfo,
mediainfo=context.mediainfo,
media_source=context.media_source,
media_id=context.media_id,
season=context.season,
channel=context.channel,
source=context.source,
userid=context.userid,
username=context.username,
message=context.message,
notification=notification,
)
def _SubscribeChain__persist_subscribe_create(self, context: _SubscribeCreateContext) -> Tuple[Optional[int], str]:
"""
同步提交订阅,并在提交成功后按原顺序执行消息、事件和统计。
"""
if context.mediainfo is None:
return None, "未识别到媒体信息"
mediainfo = context.mediainfo
post_commit_context = self._SubscribeChain__subscribe_post_commit_context(
context,
self._SubscribeChain__build_subscribe_notification(context),
)
try:
sid, err_msg = add_subscribe(
mediainfo=mediainfo,
subscribe_oper=self.subscription_repository,
season=context.season,
username=context.username,
after_commit=_build_post_commit_callback(self, post_commit_context),
notification=post_commit_context.notification,
occurrence_id=post_commit_context.occurrence_id,
**context.options,
)
except ValueError as error:
logger.error(f"订阅分类设置无效:{error}", exc_info=True)
err_msg = "订阅分类设置无效,请重新选择分类后重试"
self._SubscribeChain__notify_subscribe_create_failure(context, err_msg)
return None, err_msg
if not sid:
self._SubscribeChain__notify_subscribe_create_failure(context, err_msg)
return None, err_msg
return sid, err_msg
async def _SubscribeChain__async_persist_subscribe_create(
self,
context: _SubscribeCreateContext,
) -> Tuple[Optional[int], str]:
"""
异步提交订阅,并在提交成功后按原顺序执行消息、事件和统计。
"""
if context.mediainfo is None:
return None, "未识别到媒体信息"
mediainfo = context.mediainfo
post_commit_context = self._SubscribeChain__subscribe_post_commit_context(
context,
self._SubscribeChain__build_subscribe_notification(context),
)
try:
sid, err_msg = await async_add_subscribe(
mediainfo=mediainfo,
subscribe_oper=self.subscription_repository,
season=context.season,
username=context.username,
after_commit=_build_async_post_commit_callback(self, post_commit_context),
notification=post_commit_context.notification,
occurrence_id=post_commit_context.occurrence_id,
**context.options,
)
except ValueError as error:
logger.error(f"订阅分类设置无效:{error}", exc_info=True)
err_msg = "订阅分类设置无效,请重新选择分类后重试"
await self._SubscribeChain__async_notify_subscribe_create_failure(
context,
err_msg,
)
return None, err_msg
if not sid:
await self._SubscribeChain__async_notify_subscribe_create_failure(context, err_msg)
return None, err_msg
return sid, err_msg
def add(
self,
title: str,
year: str,
mtype: Optional[MediaType] = None,
episode_group: Optional[str] = None,
season: Optional[int] = None,
channel: Optional[NotificationChannel] = None,
source: Optional[str] = None,
userid: Optional[str] = None,
username: Optional[str] = None,
message: Optional[bool] = True,
exist_ok: Optional[bool] = False,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs: Any,
) -> Tuple[Optional[int], str]:
"""
识别媒体信息并添加订阅
"""
logger.info(f"开始添加订阅,标题:{title} ...")
context, error = self._SubscribeChain__build_subscribe_create_context(
title,
year,
mtype,
episode_group,
season,
channel,
source,
userid,
username,
bool(message),
bool(exist_ok),
media_source,
media_id,
kwargs,
)
if error or context is None:
return None, error or "订阅准备失败"
error = self._SubscribeChain__recognize_subscribe_media(context)
if error:
return None, error
if context.mediainfo is None:
return None, "未识别到媒体信息"
error = self._SubscribeChain__prepare_subscribe_episodes(context)
if error:
return None, error
self._SubscribeChain__finalize_subscribe_create_context(context)
return self._SubscribeChain__persist_subscribe_create(context)
async def async_add(
self,
title: str,
year: str,
mtype: Optional[MediaType] = None,
episode_group: Optional[str] = None,
season: Optional[int] = None,
channel: Optional[NotificationChannel] = None,
source: Optional[str] = None,
userid: Optional[str] = None,
username: Optional[str] = None,
message: Optional[bool] = True,
exist_ok: Optional[bool] = False,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs: Any,
) -> Tuple[Optional[int], str]:
"""
异步识别媒体信息并添加订阅
"""
logger.info(f"开始添加订阅,标题:{title} ...")
context, error = await self._SubscribeChain__async_prepare_subscribe_create(
title,
year,
mtype,
episode_group,
season,
channel,
source,
userid,
username,
bool(message),
bool(exist_ok),
media_source,
media_id,
kwargs,
)
if error or context is None or context.mediainfo is None:
return None, error or "订阅准备失败"
return await self._SubscribeChain__async_persist_subscribe_create(context)
async def _SubscribeChain__async_prepare_subscribe_create(
self,
title: str,
year: str,
mtype: Optional[MediaType],
episode_group: Optional[str],
season: Optional[int],
channel: Optional[NotificationChannel],
source: Optional[str],
userid: Optional[str],
username: Optional[str],
message: bool,
exist_ok: bool,
media_source: Optional[MediaSource],
media_id: Optional[str],
options: Dict[str, Any],
) -> Tuple[Optional[_SubscribeCreateContext], Optional[str]]:
"""
执行异步新增的识别、季集补齐和默认配置准备,但不触发持久化。
"""
context, error = self._SubscribeChain__build_subscribe_create_context(
title,
year,
mtype,
episode_group,
season,
channel,
source,
userid,
username,
message,
exist_ok,
media_source,
media_id,
options,
)
if error or context is None:
return None, error
error = await self._SubscribeChain__async_recognize_subscribe_media(context)
if error:
return None, error
error = await self._SubscribeChain__async_prepare_subscribe_episodes(context)
if error:
return None, error
await self._SubscribeChain__async_finalize_subscribe_create_context(context)
return context, None
async def async_add_batch(
self,
*,
title: str,
year: str,
seasons: Sequence[int],
batch_writer: SubscriptionBatchWritePort,
mtype: Optional[MediaType] = None,
episode_group: Optional[str] = None,
channel: Optional[NotificationChannel] = None,
source: Optional[str] = None,
userid: Optional[str] = None,
username: Optional[str] = None,
message: Optional[bool] = True,
exist_ok: Optional[bool] = False,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs: JsonData,
) -> Tuple[Optional[int], str]:
"""
先完整准备各季订阅,再交给共享事务批量写端口原子提交。
"""
requests: list[SubscriptionCreateRequest] = []
for season in seasons:
context, error = await self._SubscribeChain__async_prepare_subscribe_create(
title,
year,
mtype,
episode_group,
season,
channel,
source,
userid,
username,
bool(message),
bool(exist_ok),
media_source,
media_id,
dict(kwargs),
)
if error or context is None:
return None, error or "订阅准备失败"
assert context.mediainfo is not None
notification = self._SubscribeChain__build_subscribe_notification(context)
post_commit_context = self._SubscribeChain__subscribe_post_commit_context(
context,
notification,
)
request = build_subscription_create_request(
context.mediainfo,
notification=notification,
after_commit=_build_async_post_commit_callback(self, post_commit_context),
occurrence_id=post_commit_context.occurrence_id,
season=context.season,
username=context.username,
**context.options,
)
if request is None:
return None, "媒体身份不完整"
requests.append(request)
results = await batch_writer.async_add(requests)
if not results:
return None, "未提供订阅季"
result = results[-1]
return result.subscribe_id or None, result.message