diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 8cc03a9a..1ae69e02 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -37,7 +37,8 @@ from app.db.systemconfig_oper import SystemConfigOper from app.helper.server import MoviePilotServerHelper from app.helper.torrent import TorrentHelper from app.log import logger -from app.schemas import MediaRecognizeConvertEventData +from app.schemas import (MediaRecognizeConvertEventData, SubscribeEpisodesRefreshEventData, + SubscribeCompletionCheckEventData) from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, \ ContentType @@ -640,6 +641,10 @@ class SubscribeChain(ChainBase): logger.error(f"媒体信息中没有季集信息,标题:{title},tmdbid:{tmdbid},doubanid:{doubanid}") return None, "媒体信息中没有季集信息" total_episode = len(mediainfo.seasons.get(season) or []) + # 允许外部覆盖按 TMDB 算出的总集数(如待定集数) + total_episode = self.__apply_episodes_refresh( + total_episode, season=season, mediainfo=mediainfo, + tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, scene="create") if not total_episode: logger.error(f'未获取到总集数,标题:{title},tmdbid:{tmdbid}, doubanid:{doubanid}') return None, f"未获取到第 {season} 季的总集数" @@ -821,6 +826,10 @@ class SubscribeChain(ChainBase): logger.error(f"媒体信息中没有季集信息,标题:{title},tmdbid:{tmdbid},doubanid:{doubanid}") return None, "媒体信息中没有季集信息" total_episode = len(mediainfo.seasons.get(season) or []) + # 允许外部覆盖按 TMDB 算出的总集数(如待定集数) + total_episode = await self.__async_apply_episodes_refresh( + total_episode, season=season, mediainfo=mediainfo, + tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, scene="create") if not total_episode: logger.error(f'未获取到总集数,标题:{title},tmdbid:{tmdbid}, doubanid:{doubanid}') return None, f"未获取到第 {season} 季的总集数" @@ -1687,8 +1696,12 @@ class SubscribeChain(ChainBase): current_priority = None if not subscribe.manual_total_episode and len(episodes): total_episode = len(episodes) - # 总集数增长按 delta 同步抬升 lack - lack_episode = (subscribe.lack_episode or 0) + (total_episode - (subscribe.total_episode or 0)) + # 允许外部覆盖按 TMDB 算出的总集数(如待定集数) + total_episode = self.__apply_episodes_refresh( + total_episode, season=subscribe.season, mediainfo=mediainfo, + tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid, + subscribe_id=subscribe.id, scene="refresh") + lack_episode = max((subscribe.lack_episode or 0) + (total_episode - (subscribe.total_episode or 0)), 0) if subscribe.best_version and subscribe.type == MediaType.TV.value: # 为新增集补齐 episode_priority 初始项(priority=0) old_total_episode = subscribe.total_episode or 0 @@ -1990,6 +2003,15 @@ class SubscribeChain(ChainBase): # 如果订阅状态为待定(P),说明订阅信息尚未完全更新,无法完成订阅 if subscribe.state == "P": return + # 发送订阅完成判定事件,在写入 DB 前,允许外部据完结策略否决本次自动完成 + completion_event = eventmanager.send_event( + ChainEventType.SubscribeCompletionCheck, + SubscribeCompletionCheckEventData(subscribe=subscribe, mediainfo=mediainfo, meta=meta)) + if completion_event and completion_event.event_data: + completion_data: SubscribeCompletionCheckEventData = completion_event.event_data + if completion_data.cancel: + logger.info(f'{mediainfo.title_year} 完成被 [{completion_data.source}] 否决:{completion_data.reason}') + return # 完成订阅 msgstr = "订阅" if not subscribe.best_version else "洗版" logger.info(f'{mediainfo.title_year} 完成{msgstr}') @@ -3124,7 +3146,52 @@ class SubscribeChain(ChainBase): return False, no_exists @staticmethod - def __refresh_total_episode_before_completion(subscribe: Subscribe, mediainfo: MediaInfo): + def __apply_episodes_refresh(current_total: int, season: Optional[int], *, + mediainfo: Optional[MediaInfo] = None, + tmdbid: Optional[int] = None, + doubanid: Optional[str] = None, + subscribe_id: Optional[int] = None, + scene: Optional[str] = None) -> int: + """ + 发送订阅总集数推算事件,允许外部据自身策略覆盖按 TMDB 季集数算出的总集数。 + + 用途:插件在"待定集数"等场景经事件注入 total_episode + 无监听者或外部未覆盖时返回入参原值,保证零行为变更。 + :param current_total: 主程序按 TMDB 季集数算出的默认总集数 + :param season: 季号 + :return: 最终采用的总集数 + """ + event_data = SubscribeEpisodesRefreshEventData( + tmdbid=tmdbid, doubanid=doubanid, season=season, mediainfo=mediainfo, + current_total_episode=current_total, subscribe_id=subscribe_id, scene=scene) + event = eventmanager.send_event(ChainEventType.SubscribeEpisodesRefresh, event_data) + if event and event.event_data: + result: SubscribeEpisodesRefreshEventData = event.event_data + if result.updated and result.total_episode: + return result.total_episode + return current_total + + @staticmethod + async def __async_apply_episodes_refresh(current_total: int, season: Optional[int], *, + mediainfo: Optional[MediaInfo] = None, + tmdbid: Optional[int] = None, + doubanid: Optional[str] = None, + subscribe_id: Optional[int] = None, + scene: Optional[str] = None) -> int: + """ + __apply_episodes_refresh 的异步版本 + """ + event_data = SubscribeEpisodesRefreshEventData( + tmdbid=tmdbid, doubanid=doubanid, season=season, mediainfo=mediainfo, + current_total_episode=current_total, subscribe_id=subscribe_id, scene=scene) + event = await eventmanager.async_send_event(ChainEventType.SubscribeEpisodesRefresh, event_data) + if event and event.event_data: + result: SubscribeEpisodesRefreshEventData = event.event_data + if result.updated and result.total_episode: + return result.total_episode + return current_total + + def __refresh_total_episode_before_completion(self, subscribe: Subscribe, mediainfo: MediaInfo): """ 在完成判断前,按最新识别结果兜底修正订阅总集数,防止旧总集数导致误完成。 """ @@ -3136,6 +3203,11 @@ class SubscribeChain(ChainBase): return new_total_episode = len((mediainfo.seasons or {}).get(subscribe.season) or []) + # 允许外部覆盖按 TMDB 算出的总集数(如待定集数),后续“只增不减”仍作用于覆盖后的结果,避免误减导致提前完成。 + new_total_episode = self.__apply_episodes_refresh( + new_total_episode, season=subscribe.season, mediainfo=mediainfo, + tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid, + subscribe_id=subscribe.id, scene="precheck") old_total_episode = subscribe.total_episode or 0 if not new_total_episode or new_total_episode <= old_total_episode: return diff --git a/app/schemas/event.py b/app/schemas/event.py index 2be75dbf..39982970 100644 --- a/app/schemas/event.py +++ b/app/schemas/event.py @@ -549,3 +549,71 @@ class StorageOperSelectionEventData(ChainEventData): # 输出参数 storage_oper: Optional[Callable] = Field(default=None, description="存储操作对象") + + +class SubscribeEpisodesRefreshEventData(ChainEventData): + """ + SubscribeEpisodesRefresh 事件的数据模型 + + 主程序在推算订阅某季总集数时发出,携带按 TMDB 季集数算出的默认值,外部可据自身策略覆盖total_episode(如待定集数) + + Attributes: + # 输入参数 + tmdbid (Optional[int]): TMDB ID + doubanid (Optional[str]): 豆瓣 ID + season (Optional[int]): 季号 + mediainfo (Any): 媒体信息 + current_total_episode (int): 主程序按 TMDB 季集数算出的默认总集数 + subscribe_id (Optional[int]): 订阅 ID;订阅创建场景下尚未入库,为空 + scene (Optional[str]): 触发场景,create/refresh/precheck + + # 输出参数 + updated (bool): 外部是否覆盖了总集数,默认 False + total_episode (Optional[int]): 覆盖后的总集数,仅在 updated=True 时生效 + source (str): 覆盖来源 + reason (str): 覆盖原因 + """ + + # 输入参数 + tmdbid: Optional[int] = Field(default=None, description="TMDB ID") + doubanid: Optional[str] = Field(default=None, description="豆瓣 ID") + season: Optional[int] = Field(default=None, description="季号") + mediainfo: Any = Field(default=None, description="媒体信息") + current_total_episode: int = Field(default=0, description="按 TMDB 季集数算出的默认总集数") + subscribe_id: Optional[int] = Field(default=None, description="订阅 ID;创建场景为空") + scene: Optional[str] = Field(default=None, description="触发场景:create/refresh/precheck") + + # 输出参数 + updated: bool = Field(default=False, description="外部是否覆盖了总集数") + total_episode: Optional[int] = Field(default=None, description="覆盖后的总集数") + source: str = Field(default="未知来源", description="覆盖来源") + reason: str = Field(default="", description="覆盖原因") + + +class SubscribeCompletionCheckEventData(ChainEventData): + """ + SubscribeCompletionCheck 事件的数据模型 + + 在订阅被自动判定完成、即将收口(写历史并删除)之前发出,允许外部据完结策略否决本次完成 + + Attributes: + # 输入参数 + subscribe (Any): 订阅对象 + mediainfo (Any): 媒体信息 + meta (Any): 元数据 + + # 输出参数 + cancel (bool): 是否否决本次完成,默认 False + source (str): 否决来源 + reason (str): 否决原因 + """ + + # 输入参数 + subscribe: Any = Field(default=None, description="订阅对象") + mediainfo: Any = Field(default=None, description="媒体信息") + meta: Any = Field(default=None, description="元数据") + + # 输出参数 + cancel: bool = Field(default=False, description="是否否决本次完成") + source: str = Field(default="未知来源", description="否决来源") + reason: str = Field(default="", description="否决原因") diff --git a/app/schemas/types.py b/app/schemas/types.py index 89048b1e..e5c1f1e8 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -179,6 +179,10 @@ class ChainEventType(Enum): StorageOperSelection = "storage.operation" # Agent LLM 供应商选择 AgentLLMProvider = "agent.llm.provider" + # 订阅总集数刷新 + SubscribeEpisodesRefresh = "subscribe.episodes.refresh" + # 订阅完成检查 + SubscribeCompletionCheck = "subscribe.completion.check" # 系统配置Key字典