mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-09 05:16:56 +08:00
feat(subscribe): expose cached site candidates (#6062)
This commit is contained in:
@@ -317,6 +317,71 @@ class SubscribeChain(ChainBase):
|
||||
update_data.update(cls.__prepare_subscribe_progress_fields(subscribe=subscribe, no_exists={}))
|
||||
return update_data
|
||||
|
||||
@classmethod
|
||||
def __prepare_best_version_total_change_fields(
|
||||
cls,
|
||||
subscribe: Subscribe,
|
||||
total_episode: int,
|
||||
old_total_episode: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
准备洗版电视剧总集数变化后需要写库的字段。
|
||||
|
||||
总集数变化会改变目标范围,按集优先级只保留新范围内的目标集,避免范围外
|
||||
旧状态继续参与完成集、缺失集和当前优先级计算。
|
||||
"""
|
||||
update_data: Dict[str, Any] = {"total_episode": total_episode}
|
||||
target_episodes = set(cls.__get_best_version_target_episodes(
|
||||
subscribe,
|
||||
total_episode=total_episode,
|
||||
))
|
||||
episode_priority = cls.__get_episode_priority(
|
||||
subscribe,
|
||||
total_episode=old_total_episode,
|
||||
)
|
||||
filtered_priority = {
|
||||
str(episode): priority
|
||||
for episode, priority in episode_priority.items()
|
||||
if int(episode) in target_episodes
|
||||
}
|
||||
subscribe.total_episode = total_episode
|
||||
subscribe.episode_priority = filtered_priority
|
||||
current_priority = 0 if not target_episodes else cls.get_best_version_current_priority(
|
||||
subscribe,
|
||||
episode_priority=filtered_priority,
|
||||
)
|
||||
subscribe.current_priority = current_priority
|
||||
update_data["episode_priority"] = filtered_priority
|
||||
update_data["current_priority"] = current_priority
|
||||
update_data.update(cls.__prepare_subscribe_progress_fields(subscribe=subscribe, no_exists={}))
|
||||
return update_data
|
||||
|
||||
@classmethod
|
||||
def __prepare_total_episode_change_fields(
|
||||
cls,
|
||||
subscribe: Subscribe,
|
||||
total_episode: int,
|
||||
old_total_episode: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
准备已有订阅总集数持久化字段,并同步内存对象上的总集数快照。
|
||||
"""
|
||||
if subscribe.best_version and subscribe.type == MediaType.TV.value:
|
||||
return cls.__prepare_best_version_total_change_fields(
|
||||
subscribe=subscribe,
|
||||
total_episode=total_episode,
|
||||
old_total_episode=old_total_episode,
|
||||
)
|
||||
|
||||
subscribe.total_episode = total_episode
|
||||
return {
|
||||
"total_episode": total_episode,
|
||||
"lack_episode": max(
|
||||
(subscribe.lack_episode or 0) + (total_episode - old_total_episode),
|
||||
0,
|
||||
),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def __is_best_version_complete(cls, subscribe: Subscribe) -> bool:
|
||||
"""
|
||||
@@ -387,9 +452,10 @@ class SubscribeChain(ChainBase):
|
||||
获取已完成洗版的剧集。
|
||||
"""
|
||||
episode_priority = cls.__get_episode_priority(subscribe)
|
||||
target_episodes = set(cls.__get_best_version_target_episodes(subscribe))
|
||||
return sorted(
|
||||
int(episode) for episode, priority in episode_priority.items()
|
||||
if str(episode).isdigit() and priority == 100
|
||||
if str(episode).isdigit() and int(episode) in target_episodes and priority == 100
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -773,11 +839,13 @@ class SubscribeChain(ChainBase):
|
||||
if not mediainfo.seasons:
|
||||
logger.error(f"媒体信息中没有季集信息,标题:{title},tmdbid:{tmdbid},doubanid:{doubanid}")
|
||||
return None, "媒体信息中没有季集信息"
|
||||
total_episode = len(mediainfo.seasons.get(season) or [])
|
||||
# 允许外部覆盖按 TMDB 算出的总集数(如待定集数)
|
||||
current_total_episode = len(mediainfo.seasons.get(season) or [])
|
||||
# 创建场景没有旧订阅事实,仅允许外部补正未知或扩展总集数。
|
||||
total_episode = self.__apply_episodes_refresh(
|
||||
total_episode, season=season, mediainfo=mediainfo,
|
||||
current_total_episode, season=season, mediainfo=mediainfo,
|
||||
tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, scene="create")
|
||||
if current_total_episode and total_episode < current_total_episode:
|
||||
total_episode = current_total_episode
|
||||
if not total_episode:
|
||||
logger.error(f'未获取到总集数,标题:{title},tmdbid:{tmdbid}, doubanid:{doubanid}')
|
||||
return None, f"未获取到第 {season} 季的总集数"
|
||||
@@ -958,11 +1026,13 @@ class SubscribeChain(ChainBase):
|
||||
if not mediainfo.seasons:
|
||||
logger.error(f"媒体信息中没有季集信息,标题:{title},tmdbid:{tmdbid},doubanid:{doubanid}")
|
||||
return None, "媒体信息中没有季集信息"
|
||||
total_episode = len(mediainfo.seasons.get(season) or [])
|
||||
# 允许外部覆盖按 TMDB 算出的总集数(如待定集数)
|
||||
current_total_episode = len(mediainfo.seasons.get(season) or [])
|
||||
# 创建场景没有旧订阅事实,仅允许外部补正未知或扩展总集数。
|
||||
total_episode = await self.__async_apply_episodes_refresh(
|
||||
total_episode, season=season, mediainfo=mediainfo,
|
||||
current_total_episode, season=season, mediainfo=mediainfo,
|
||||
tmdbid=mediainfo.tmdb_id, doubanid=mediainfo.douban_id, scene="create")
|
||||
if current_total_episode and total_episode < current_total_episode:
|
||||
total_episode = current_total_episode
|
||||
if not total_episode:
|
||||
logger.error(f'未获取到总集数,标题:{title},tmdbid:{tmdbid}, doubanid:{doubanid}')
|
||||
return None, f"未获取到第 {season} 季的总集数"
|
||||
@@ -1913,28 +1983,20 @@ class SubscribeChain(ChainBase):
|
||||
# 对于电视剧,获取当前季的总集数
|
||||
episodes = mediainfo.seasons.get(subscribe.season) or []
|
||||
progress_update = {}
|
||||
if not subscribe.manual_total_episode and len(episodes):
|
||||
total_episode = len(episodes)
|
||||
# 允许外部覆盖按 TMDB 算出的总集数(如待定集数)
|
||||
if subscribe.type == MediaType.TV.value and not subscribe.manual_total_episode and len(episodes):
|
||||
current_total_episode = len(episodes)
|
||||
# 外部事件只能向上覆盖主程序本次识别到的 TMDB 当前季总集数,已有订阅按最终 total 跟随持久化。
|
||||
total_episode = self.__apply_episodes_refresh(
|
||||
total_episode, season=subscribe.season, mediainfo=mediainfo,
|
||||
current_total_episode, season=subscribe.season, mediainfo=mediainfo,
|
||||
tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid,
|
||||
subscribe_id=subscribe.id, scene="refresh")
|
||||
if total_episode > (subscribe.total_episode or 0):
|
||||
if subscribe.best_version and subscribe.type == MediaType.TV.value:
|
||||
progress_update = self.__prepare_best_version_total_expansion_fields(
|
||||
subscribe=subscribe,
|
||||
total_episode=total_episode,
|
||||
)
|
||||
else:
|
||||
old_total_episode = subscribe.total_episode or 0
|
||||
progress_update = {
|
||||
"total_episode": total_episode,
|
||||
"lack_episode": max(
|
||||
(subscribe.lack_episode or 0) + (total_episode - old_total_episode),
|
||||
0,
|
||||
),
|
||||
}
|
||||
old_total_episode = subscribe.total_episode or 0
|
||||
if total_episode and total_episode != old_total_episode:
|
||||
progress_update = self.__prepare_total_episode_change_fields(
|
||||
subscribe=subscribe,
|
||||
total_episode=total_episode,
|
||||
old_total_episode=old_total_episode,
|
||||
)
|
||||
else:
|
||||
total_episode = subscribe.total_episode
|
||||
progress_update = {"lack_episode": subscribe.lack_episode}
|
||||
@@ -3724,11 +3786,11 @@ class SubscribeChain(ChainBase):
|
||||
subscribe_id: Optional[int] = None,
|
||||
scene: Optional[str] = None) -> int:
|
||||
"""
|
||||
发送订阅总集数推算事件,允许外部据自身策略覆盖按 TMDB 季集数算出的总集数。
|
||||
发送订阅总集数推算事件,允许外部把主程序本次识别到的 TMDB 当前季总集数向上覆盖。
|
||||
|
||||
用途:插件在"待定集数"等场景经事件注入 total_episode
|
||||
无监听者或外部未覆盖时返回入参原值,保证零行为变更。
|
||||
:param current_total: 主程序按 TMDB 季集数算出的默认总集数
|
||||
:param current_total: 主程序本次识别到的 TMDB 当前季总集数
|
||||
:param season: 季号
|
||||
:return: 最终采用的总集数
|
||||
"""
|
||||
@@ -3739,6 +3801,7 @@ class SubscribeChain(ChainBase):
|
||||
if event and event.event_data:
|
||||
result: SubscribeEpisodesRefreshEventData = event.event_data
|
||||
if result.updated and result.total_episode:
|
||||
result.total_episode = max(current_total or 0, result.total_episode)
|
||||
return result.total_episode
|
||||
return current_total
|
||||
|
||||
@@ -3759,6 +3822,7 @@ class SubscribeChain(ChainBase):
|
||||
if event and event.event_data:
|
||||
result: SubscribeEpisodesRefreshEventData = event.event_data
|
||||
if result.updated and result.total_episode:
|
||||
result.total_episode = max(current_total or 0, result.total_episode)
|
||||
return result.total_episode
|
||||
return current_total
|
||||
|
||||
@@ -3773,30 +3837,22 @@ class SubscribeChain(ChainBase):
|
||||
if subscribe.season is None:
|
||||
return
|
||||
|
||||
new_total_episode = len((mediainfo.seasons or {}).get(subscribe.season) or [])
|
||||
# 允许外部覆盖按 TMDB 算出的总集数(如待定集数),后续“只增不减”仍作用于覆盖后的结果,避免误减导致提前完成。
|
||||
current_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,
|
||||
current_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:
|
||||
if not new_total_episode or new_total_episode == old_total_episode:
|
||||
return
|
||||
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if subscribe.best_version and subscribe.type == MediaType.TV.value:
|
||||
update_data = self.__prepare_best_version_total_expansion_fields(
|
||||
subscribe=subscribe,
|
||||
total_episode=new_total_episode,
|
||||
)
|
||||
else:
|
||||
update_data = {
|
||||
"total_episode": new_total_episode,
|
||||
"lack_episode": max(
|
||||
(subscribe.lack_episode or 0) + (new_total_episode - old_total_episode),
|
||||
0,
|
||||
),
|
||||
}
|
||||
update_data = self.__prepare_total_episode_change_fields(
|
||||
subscribe=subscribe,
|
||||
total_episode=new_total_episode,
|
||||
old_total_episode=old_total_episode,
|
||||
)
|
||||
update_data["last_update"] = now
|
||||
SubscribeOper().update(subscribe.id, update_data)
|
||||
for key, value in update_data.items():
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import re
|
||||
import traceback
|
||||
from typing import Callable, Dict, List, Union, Optional
|
||||
@@ -92,6 +93,238 @@ class TorrentsChain(ChainBase):
|
||||
|
||||
return torrents_cache
|
||||
|
||||
def get_subscribe_cache_candidates(
|
||||
self,
|
||||
subscribe,
|
||||
stype: Optional[str] = None,
|
||||
allow_title_match: bool = False,
|
||||
) -> List[Context]:
|
||||
"""
|
||||
按订阅身份读取 RSS/spider 缓存候选,返回不会回写缓存的 Context 副本。
|
||||
|
||||
主程序只提供缓存读取与轻量候选筛选,不在这里判断站点证据能否扩展
|
||||
订阅目标或放行完成;标题兜底候选会显式标记为低置信来源。
|
||||
"""
|
||||
results: List[Context] = []
|
||||
for contexts in (self.get_torrents(stype=stype) or {}).values():
|
||||
for context in contexts or []:
|
||||
if not context:
|
||||
continue
|
||||
copied = copy.deepcopy(context)
|
||||
if self._context_matches_subscribe(copied, subscribe):
|
||||
results.append(copied)
|
||||
continue
|
||||
if allow_title_match and self._context_title_matches_subscribe(copied, subscribe):
|
||||
self._mark_title_match_candidate(copied, subscribe)
|
||||
results.append(copied)
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def _context_matches_subscribe(cls, context: Context, subscribe) -> bool:
|
||||
"""
|
||||
严格身份匹配:候选自身识别出的媒体 ID 命中订阅,且季信息不排除订阅季。
|
||||
"""
|
||||
if not cls._context_media_type_matches(context, subscribe):
|
||||
return False
|
||||
if not cls._context_season_matches_subscribe(context, subscribe):
|
||||
return False
|
||||
|
||||
subscribe_tmdbid = cls._normalize_id(getattr(subscribe, "tmdbid", None))
|
||||
subscribe_doubanid = cls._normalize_id(getattr(subscribe, "doubanid", None))
|
||||
context_tmdbids = cls._context_tmdb_ids(context)
|
||||
context_doubanids = cls._context_douban_ids(context)
|
||||
|
||||
return bool(
|
||||
subscribe_tmdbid and subscribe_tmdbid in context_tmdbids
|
||||
or subscribe_doubanid and subscribe_doubanid in context_doubanids
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _context_title_matches_subscribe(cls, context: Context, subscribe) -> bool:
|
||||
"""
|
||||
标题兜底只服务诊断:仅允许身份缺失候选按标题命中,显式冲突 ID 不兜底。
|
||||
"""
|
||||
if cls._context_has_media_identity(context):
|
||||
return False
|
||||
if not cls._context_media_type_matches(context, subscribe):
|
||||
return False
|
||||
if not cls._context_season_matches_subscribe(context, subscribe):
|
||||
return False
|
||||
|
||||
subscribe_title = cls._normalize_title(getattr(subscribe, "name", None))
|
||||
if not subscribe_title:
|
||||
return False
|
||||
|
||||
meta_info = getattr(context, "meta_info", None)
|
||||
torrent_info = getattr(context, "torrent_info", None)
|
||||
candidate_titles = [
|
||||
getattr(torrent_info, "title", None),
|
||||
getattr(meta_info, "title", None),
|
||||
getattr(meta_info, "name", None),
|
||||
]
|
||||
return any(
|
||||
subscribe_title in candidate_title
|
||||
for candidate_title in (cls._normalize_title(title) for title in candidate_titles)
|
||||
if candidate_title
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _mark_title_match_candidate(context: Context, subscribe) -> None:
|
||||
"""
|
||||
标记标题兜底候选,避免下游把目标媒体回填误认为候选自身识别结果。
|
||||
"""
|
||||
context.match_source = "title"
|
||||
context.candidate_recognized = False
|
||||
context.media_info_is_target = True
|
||||
context.media_info = MediaInfo(
|
||||
type=getattr(subscribe, "type", None),
|
||||
title=getattr(subscribe, "name", None),
|
||||
tmdb_id=getattr(subscribe, "tmdbid", None),
|
||||
douban_id=getattr(subscribe, "doubanid", None),
|
||||
season=getattr(subscribe, "season", None),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _context_media_type_matches(cls, context: Context, subscribe) -> bool:
|
||||
"""
|
||||
类型已知且冲突时拒绝;缺失类型不作为缓存候选过滤条件。
|
||||
"""
|
||||
subscribe_type = cls._normalize_media_type(getattr(subscribe, "type", None))
|
||||
media_info = getattr(context, "media_info", None)
|
||||
meta_info = getattr(context, "meta_info", None)
|
||||
context_types = {
|
||||
cls._normalize_media_type(value)
|
||||
for value in (
|
||||
getattr(media_info, "type", None),
|
||||
getattr(meta_info, "type", None),
|
||||
)
|
||||
}
|
||||
context_types.discard(None)
|
||||
return not subscribe_type or not context_types or all(
|
||||
context_type == subscribe_type for context_type in context_types
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _context_season_matches_subscribe(cls, context: Context, subscribe) -> bool:
|
||||
"""
|
||||
资源季信息只要明确排除订阅季就拒绝;跨季覆盖目标季留给插件诊断。
|
||||
"""
|
||||
target_season = cls._normalize_int(getattr(subscribe, "season", None))
|
||||
if target_season is None:
|
||||
return True
|
||||
|
||||
meta_info = getattr(context, "meta_info", None)
|
||||
explicit_meta_seasons = cls._context_meta_seasons(meta_info)
|
||||
if explicit_meta_seasons:
|
||||
return target_season in explicit_meta_seasons
|
||||
|
||||
media_info = getattr(context, "media_info", None)
|
||||
media_season = cls._normalize_int(getattr(media_info, "season", None))
|
||||
return media_season is None or target_season == media_season
|
||||
|
||||
@classmethod
|
||||
def _context_meta_seasons(cls, meta_info) -> set[int]:
|
||||
"""
|
||||
提取标题解析出的显式季范围;多季包以该范围为准。
|
||||
"""
|
||||
meta_fields = vars(meta_info) if meta_info else {}
|
||||
if "season_list" in meta_fields:
|
||||
season_list = {
|
||||
season
|
||||
for season in (
|
||||
cls._normalize_int(item)
|
||||
for item in (meta_fields.get("season_list") or [])
|
||||
)
|
||||
if season is not None
|
||||
}
|
||||
if season_list:
|
||||
return season_list
|
||||
begin_season = cls._normalize_int(getattr(meta_info, "begin_season", None))
|
||||
end_season = cls._normalize_int(getattr(meta_info, "end_season", None))
|
||||
if begin_season is not None and end_season is not None:
|
||||
start, end = sorted((begin_season, end_season))
|
||||
return set(range(start, end + 1))
|
||||
if begin_season is not None:
|
||||
return {begin_season}
|
||||
if end_season is not None:
|
||||
return {end_season}
|
||||
return set()
|
||||
|
||||
@staticmethod
|
||||
def _context_has_media_identity(context: Context) -> bool:
|
||||
"""
|
||||
判断候选是否已经带有明确媒体 ID。
|
||||
"""
|
||||
return bool(TorrentsChain._context_tmdb_ids(context) or TorrentsChain._context_douban_ids(context))
|
||||
|
||||
@staticmethod
|
||||
def _context_tmdb_ids(context: Context) -> set[str]:
|
||||
"""
|
||||
提取候选已有 TMDB ID,兼容 media_info 与标题显式标签。
|
||||
"""
|
||||
media_info = getattr(context, "media_info", None)
|
||||
meta_info = getattr(context, "meta_info", None)
|
||||
return {
|
||||
value for value in (
|
||||
TorrentsChain._normalize_id(getattr(media_info, "tmdb_id", None)),
|
||||
TorrentsChain._normalize_id(getattr(meta_info, "tmdbid", None)),
|
||||
) if value
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _context_douban_ids(context: Context) -> set[str]:
|
||||
"""
|
||||
提取候选已有豆瓣 ID,兼容 media_info 与标题显式标签。
|
||||
"""
|
||||
media_info = getattr(context, "media_info", None)
|
||||
meta_info = getattr(context, "meta_info", None)
|
||||
return {
|
||||
value for value in (
|
||||
TorrentsChain._normalize_id(getattr(media_info, "douban_id", None)),
|
||||
TorrentsChain._normalize_id(getattr(meta_info, "doubanid", None)),
|
||||
) if value
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_id(value) -> Optional[str]:
|
||||
"""
|
||||
统一比较媒体 ID,避免 int/string 形态差异影响缓存候选筛选。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
return value or None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_int(value) -> Optional[int]:
|
||||
"""
|
||||
将季号等动态字段转为 int,无法解析时视为缺失。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_media_type(value) -> Optional[str]:
|
||||
"""
|
||||
统一 MediaType 枚举与字符串形态。
|
||||
"""
|
||||
if isinstance(value, MediaType):
|
||||
value = value.value
|
||||
if value == MediaType.UNKNOWN.value:
|
||||
return None
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _normalize_title(value) -> str:
|
||||
"""
|
||||
归一标题用于低置信标题兜底匹配。
|
||||
"""
|
||||
return (StringUtils.clear_upper(value or "") or "").strip()
|
||||
|
||||
def clear_torrents(self):
|
||||
"""
|
||||
清理种子缓存数据
|
||||
|
||||
@@ -570,7 +570,8 @@ class SubscribeEpisodesRefreshEventData(ChainEventData):
|
||||
"""
|
||||
SubscribeEpisodesRefresh 事件的数据模型
|
||||
|
||||
主程序在推算订阅某季总集数时发出,携带按 TMDB 季集数算出的默认值,外部可据自身策略覆盖total_episode(如待定集数)
|
||||
主程序在推算订阅某季总集数时发出,携带主程序本次识别到的 TMDB 当前季总集数;
|
||||
外部可据自身策略向上覆盖 total_episode(如待定集数),低于 current_total_episode 的覆盖值会被主程序钳制。
|
||||
|
||||
Attributes:
|
||||
# 输入参数
|
||||
@@ -578,13 +579,13 @@ class SubscribeEpisodesRefreshEventData(ChainEventData):
|
||||
doubanid (Optional[str]): 豆瓣 ID
|
||||
season (Optional[int]): 季号
|
||||
mediainfo (Any): 媒体信息
|
||||
current_total_episode (int): 主程序按 TMDB 季集数算出的默认总集数
|
||||
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 时生效
|
||||
total_episode (Optional[int]): 覆盖后的总集数,仅在 updated=True 时生效;低于 current_total_episode 时由主程序钳制
|
||||
source (str): 覆盖来源
|
||||
reason (str): 覆盖原因
|
||||
"""
|
||||
@@ -594,13 +595,13 @@ class SubscribeEpisodesRefreshEventData(ChainEventData):
|
||||
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 季集数算出的默认总集数")
|
||||
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="覆盖后的总集数")
|
||||
total_episode: Optional[int] = Field(default=None, description="覆盖后的总集数;低于主程序本次识别到的 TMDB 当前季总集数时由主程序钳制")
|
||||
source: str = Field(default="未知来源", description="覆盖来源")
|
||||
reason: str = Field(default="", description="覆盖原因")
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
@@ -206,6 +207,10 @@ def _load_subscribe_chain_class():
|
||||
|
||||
class _SubscribeEpisodesRefreshEventData:
|
||||
def __init__(self, **kwargs):
|
||||
self.updated = kwargs.get("updated", False)
|
||||
self.total_episode = kwargs.get("total_episode")
|
||||
self.source = kwargs.get("source", "未知来源")
|
||||
self.reason = kwargs.get("reason", "")
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
class _SubscribeCompletionCheckEventData:
|
||||
@@ -2423,17 +2428,84 @@ class SubscribeProgressEntrypointTest(TestCase):
|
||||
class SubscribeProgressConsolidationTest(TestCase):
|
||||
def _mediainfo(self, total_episode=5):
|
||||
return SimpleNamespace(
|
||||
type=MediaType.TV,
|
||||
seasons={1: [object() for _ in range(total_episode)]},
|
||||
title="总集增长剧",
|
||||
title_year="总集增长剧 (2026)",
|
||||
year="2026",
|
||||
tmdb_id=31000,
|
||||
douban_id=None,
|
||||
bangumi_id=None,
|
||||
vote_average=9.5,
|
||||
overview="overview",
|
||||
imdb_id="tt1234567",
|
||||
tvdb_id=99,
|
||||
get_poster_image=lambda: "poster",
|
||||
get_backdrop_image=lambda: "backdrop",
|
||||
get_message_image=lambda: "message-image",
|
||||
to_dict=lambda: {},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _event_manager(total_episode=None, *, updated=True):
|
||||
captured = []
|
||||
|
||||
def _apply(event_type, event_data):
|
||||
captured.append((event_type, event_data))
|
||||
if hasattr(event_data, "current_total_episode"):
|
||||
event_data.updated = updated
|
||||
event_data.total_episode = total_episode
|
||||
event_data.source = "unit"
|
||||
event_data.reason = "unit"
|
||||
return SimpleNamespace(event_data=event_data)
|
||||
|
||||
class _EventManager:
|
||||
def send_event(self, event_type, event_data):
|
||||
return _apply(event_type, event_data)
|
||||
|
||||
async def async_send_event(self, event_type, event_data):
|
||||
return _apply(event_type, event_data)
|
||||
|
||||
return _EventManager(), captured
|
||||
|
||||
def test_apply_episodes_refresh_clamps_external_total_to_current_total(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
eventmanager, captured = self._event_manager(5)
|
||||
|
||||
with patch.object(module, "eventmanager", eventmanager):
|
||||
result = SubscribeChain._SubscribeChain__apply_episodes_refresh(
|
||||
10,
|
||||
season=1,
|
||||
mediainfo=self._mediainfo(total_episode=10),
|
||||
tmdbid=31030,
|
||||
doubanid=None,
|
||||
subscribe_id=31,
|
||||
scene="precheck",
|
||||
)
|
||||
|
||||
self.assertEqual(result, 10)
|
||||
self.assertEqual(captured[0][1].current_total_episode, 10)
|
||||
self.assertEqual(captured[0][1].total_episode, 10)
|
||||
|
||||
def test_async_apply_episodes_refresh_clamps_external_total_to_current_total(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
eventmanager, captured = self._event_manager(5)
|
||||
|
||||
with patch.object(module, "eventmanager", eventmanager):
|
||||
result = asyncio.run(SubscribeChain._SubscribeChain__async_apply_episodes_refresh(
|
||||
10,
|
||||
season=1,
|
||||
mediainfo=self._mediainfo(total_episode=10),
|
||||
tmdbid=31030,
|
||||
doubanid=None,
|
||||
subscribe_id=31,
|
||||
scene="precheck",
|
||||
))
|
||||
|
||||
self.assertEqual(result, 10)
|
||||
self.assertEqual(captured[0][1].current_total_episode, 10)
|
||||
self.assertEqual(captured[0][1].total_episode, 10)
|
||||
|
||||
def test_refresh_total_episode_before_completion_reuses_progress_priority_snapshot(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
subscribe = module.Subscribe(
|
||||
@@ -2476,6 +2548,317 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
self.assertEqual(updates[-1][1]["lack_episode"], 2)
|
||||
self.assertEqual(updates[-1][1]["current_priority"], 0)
|
||||
|
||||
def test_refresh_total_episode_before_completion_follows_recognized_total_decrease(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
subscribe = module.Subscribe(
|
||||
id=34,
|
||||
name="总集回落剧",
|
||||
type=MediaType.TV.value,
|
||||
season=1,
|
||||
total_episode=100,
|
||||
start_episode=1,
|
||||
lack_episode=100,
|
||||
best_version=0,
|
||||
best_version_full=0,
|
||||
current_priority=None,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
tmdbid=31034,
|
||||
doubanid=None,
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
eventmanager, captured = self._event_manager(updated=False)
|
||||
|
||||
class _SubscribeOper:
|
||||
def update(self, subscribe_id, payload):
|
||||
updates.append((subscribe_id, payload))
|
||||
|
||||
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object(
|
||||
module,
|
||||
"eventmanager",
|
||||
eventmanager,
|
||||
):
|
||||
SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion(
|
||||
subscribe,
|
||||
self._mediainfo(total_episode=90),
|
||||
)
|
||||
|
||||
self.assertEqual(captured[0][1].current_total_episode, 90)
|
||||
self.assertEqual(subscribe.total_episode, 90)
|
||||
self.assertEqual(subscribe.lack_episode, 90)
|
||||
self.assertEqual(updates[-1][1]["total_episode"], 90)
|
||||
self.assertEqual(updates[-1][1]["lack_episode"], 90)
|
||||
|
||||
def test_refresh_total_episode_before_completion_filters_best_version_priority_on_decrease(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
subscribe = module.Subscribe(
|
||||
id=40,
|
||||
name="洗版回落剧",
|
||||
type=MediaType.TV.value,
|
||||
season=1,
|
||||
total_episode=100,
|
||||
start_episode=1,
|
||||
lack_episode=0,
|
||||
best_version=1,
|
||||
best_version_full=0,
|
||||
current_priority=100,
|
||||
episode_priority={str(episode): 100 for episode in range(1, 101)},
|
||||
note=[],
|
||||
tmdbid=31040,
|
||||
doubanid=None,
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
eventmanager, _ = self._event_manager(updated=False)
|
||||
|
||||
class _SubscribeOper:
|
||||
def update(self, subscribe_id, payload):
|
||||
updates.append((subscribe_id, payload))
|
||||
|
||||
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object(
|
||||
module,
|
||||
"eventmanager",
|
||||
eventmanager,
|
||||
):
|
||||
SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion(
|
||||
subscribe,
|
||||
self._mediainfo(total_episode=10),
|
||||
)
|
||||
|
||||
expected_priority = {str(episode): 100 for episode in range(1, 11)}
|
||||
payload = updates[-1][1]
|
||||
self.assertEqual(subscribe.total_episode, 10)
|
||||
self.assertEqual(subscribe.episode_priority, expected_priority)
|
||||
self.assertEqual(payload["episode_priority"], expected_priority)
|
||||
self.assertEqual(subscribe.lack_episode, 0)
|
||||
self.assertEqual(subscribe.current_priority, 100)
|
||||
self.assertEqual(
|
||||
SubscribeChain._SubscribeChain__get_best_version_completed_episodes(subscribe),
|
||||
list(range(1, 11)),
|
||||
)
|
||||
|
||||
def test_refresh_total_episode_before_completion_resets_legacy_current_priority_when_filtered_empty(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
subscribe = module.Subscribe(
|
||||
id=42,
|
||||
name="洗版空优先级回落剧",
|
||||
type=MediaType.TV.value,
|
||||
season=1,
|
||||
total_episode=100,
|
||||
start_episode=1,
|
||||
lack_episode=0,
|
||||
best_version=1,
|
||||
best_version_full=0,
|
||||
current_priority=100,
|
||||
episode_priority={str(episode): 100 for episode in range(11, 101)},
|
||||
note=[],
|
||||
tmdbid=31042,
|
||||
doubanid=None,
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
eventmanager, _ = self._event_manager(updated=False)
|
||||
|
||||
class _SubscribeOper:
|
||||
def update(self, subscribe_id, payload):
|
||||
updates.append((subscribe_id, payload))
|
||||
|
||||
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object(
|
||||
module,
|
||||
"eventmanager",
|
||||
eventmanager,
|
||||
):
|
||||
SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion(
|
||||
subscribe,
|
||||
self._mediainfo(total_episode=10),
|
||||
)
|
||||
|
||||
payload = updates[-1][1]
|
||||
self.assertEqual(subscribe.total_episode, 10)
|
||||
self.assertEqual(subscribe.episode_priority, {})
|
||||
self.assertEqual(subscribe.current_priority, 0)
|
||||
self.assertEqual(subscribe.lack_episode, 10)
|
||||
self.assertEqual(payload["episode_priority"], {})
|
||||
self.assertEqual(payload["current_priority"], 0)
|
||||
self.assertEqual(payload["lack_episode"], 10)
|
||||
self.assertEqual(
|
||||
SubscribeChain._SubscribeChain__get_best_version_completed_episodes(subscribe),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_refresh_total_episode_before_completion_resets_priority_when_target_range_empty(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
subscribe = module.Subscribe(
|
||||
id=44,
|
||||
name="洗版目标范围为空回落剧",
|
||||
type=MediaType.TV.value,
|
||||
season=1,
|
||||
total_episode=100,
|
||||
start_episode=11,
|
||||
lack_episode=0,
|
||||
best_version=1,
|
||||
best_version_full=0,
|
||||
current_priority=100,
|
||||
episode_priority={str(episode): 100 for episode in range(11, 101)},
|
||||
note=[],
|
||||
tmdbid=31044,
|
||||
doubanid=None,
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
eventmanager, _ = self._event_manager(updated=False)
|
||||
|
||||
class _SubscribeOper:
|
||||
def update(self, subscribe_id, payload):
|
||||
updates.append((subscribe_id, payload))
|
||||
|
||||
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object(
|
||||
module,
|
||||
"eventmanager",
|
||||
eventmanager,
|
||||
):
|
||||
SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion(
|
||||
subscribe,
|
||||
self._mediainfo(total_episode=10),
|
||||
)
|
||||
|
||||
payload = updates[-1][1]
|
||||
self.assertEqual(subscribe.total_episode, 10)
|
||||
self.assertEqual(subscribe.episode_priority, {})
|
||||
self.assertEqual(subscribe.current_priority, 0)
|
||||
self.assertEqual(subscribe.lack_episode, 0)
|
||||
self.assertEqual(payload["episode_priority"], {})
|
||||
self.assertEqual(payload["current_priority"], 0)
|
||||
self.assertEqual(payload["lack_episode"], 0)
|
||||
self.assertEqual(
|
||||
SubscribeChain._SubscribeChain__get_best_version_completed_episodes(subscribe),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_refresh_total_episode_before_completion_clamps_lower_event_total_to_recognized_total(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
subscribe = module.Subscribe(
|
||||
id=35,
|
||||
name="总集事件压低剧",
|
||||
type=MediaType.TV.value,
|
||||
season=1,
|
||||
total_episode=100,
|
||||
start_episode=1,
|
||||
lack_episode=100,
|
||||
best_version=0,
|
||||
best_version_full=0,
|
||||
current_priority=None,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
tmdbid=31035,
|
||||
doubanid=None,
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
eventmanager, _ = self._event_manager(9)
|
||||
|
||||
class _SubscribeOper:
|
||||
def update(self, subscribe_id, payload):
|
||||
updates.append((subscribe_id, payload))
|
||||
|
||||
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object(
|
||||
module,
|
||||
"eventmanager",
|
||||
eventmanager,
|
||||
):
|
||||
SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion(
|
||||
subscribe,
|
||||
self._mediainfo(total_episode=10),
|
||||
)
|
||||
|
||||
self.assertEqual(subscribe.total_episode, 10)
|
||||
self.assertEqual(subscribe.lack_episode, 10)
|
||||
self.assertEqual(updates[-1][1]["total_episode"], 10)
|
||||
self.assertEqual(updates[-1][1]["lack_episode"], 10)
|
||||
|
||||
def test_refresh_total_episode_before_completion_rejects_manual_total_decrease(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
subscribe = module.Subscribe(
|
||||
id=37,
|
||||
name="手动总集数剧",
|
||||
type=MediaType.TV.value,
|
||||
season=1,
|
||||
total_episode=100,
|
||||
start_episode=1,
|
||||
lack_episode=100,
|
||||
best_version=0,
|
||||
best_version_full=0,
|
||||
current_priority=None,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
tmdbid=31037,
|
||||
doubanid=None,
|
||||
manual_total_episode=1,
|
||||
)
|
||||
|
||||
class _EventManager:
|
||||
def send_event(self, *_args, **_kwargs):
|
||||
raise AssertionError("manual total episode must not ask external refresh")
|
||||
|
||||
class _SubscribeOper:
|
||||
def update(self, *_args, **_kwargs):
|
||||
raise AssertionError("manual total episode must not be updated")
|
||||
|
||||
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object(
|
||||
module,
|
||||
"eventmanager",
|
||||
_EventManager(),
|
||||
):
|
||||
SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion(
|
||||
subscribe,
|
||||
self._mediainfo(total_episode=10),
|
||||
)
|
||||
|
||||
self.assertEqual(subscribe.total_episode, 100)
|
||||
self.assertEqual(subscribe.lack_episode, 100)
|
||||
|
||||
def test_refresh_total_episode_before_completion_rejects_non_tv_decrease(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
subscribe = module.Subscribe(
|
||||
id=38,
|
||||
name="非电视剧",
|
||||
type=MediaType.MOVIE.value,
|
||||
season=1,
|
||||
total_episode=100,
|
||||
start_episode=1,
|
||||
lack_episode=100,
|
||||
best_version=0,
|
||||
best_version_full=0,
|
||||
current_priority=None,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
tmdbid=31038,
|
||||
doubanid=None,
|
||||
manual_total_episode=0,
|
||||
)
|
||||
|
||||
class _EventManager:
|
||||
def send_event(self, *_args, **_kwargs):
|
||||
raise AssertionError("non-tv subscribe must not ask external refresh")
|
||||
|
||||
class _SubscribeOper:
|
||||
def update(self, *_args, **_kwargs):
|
||||
raise AssertionError("non-tv subscribe must not be updated")
|
||||
|
||||
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object(
|
||||
module,
|
||||
"eventmanager",
|
||||
_EventManager(),
|
||||
):
|
||||
SubscribeChain()._SubscribeChain__refresh_total_episode_before_completion(
|
||||
subscribe,
|
||||
self._mediainfo(total_episode=10),
|
||||
)
|
||||
|
||||
self.assertEqual(subscribe.total_episode, 100)
|
||||
self.assertEqual(subscribe.lack_episode, 100)
|
||||
|
||||
def test_check_total_growth_reuses_progress_priority_snapshot(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
subscribe = module.Subscribe(
|
||||
@@ -2521,6 +2904,182 @@ class SubscribeProgressConsolidationTest(TestCase):
|
||||
self.assertEqual(payload["lack_episode"], 2)
|
||||
self.assertEqual(payload["current_priority"], 0)
|
||||
|
||||
def test_check_total_growth_still_uses_larger_event_total(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
subscribe = module.Subscribe(
|
||||
id=39,
|
||||
name="总集事件增长剧",
|
||||
type=MediaType.TV.value,
|
||||
season=1,
|
||||
total_episode=100,
|
||||
start_episode=1,
|
||||
lack_episode=100,
|
||||
best_version=0,
|
||||
best_version_full=0,
|
||||
current_priority=None,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
year="2026",
|
||||
episode_group=None,
|
||||
tmdbid=31039,
|
||||
doubanid=None,
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
eventmanager, captured = self._event_manager(120)
|
||||
|
||||
class _SubscribeOper:
|
||||
def list(self):
|
||||
return [subscribe]
|
||||
|
||||
def update(self, subscribe_id, payload):
|
||||
updates.append((subscribe_id, payload))
|
||||
|
||||
chain = SubscribeChain()
|
||||
chain.recognize_media = lambda **kwargs: self._mediainfo(total_episode=10)
|
||||
|
||||
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object(
|
||||
module,
|
||||
"eventmanager",
|
||||
eventmanager,
|
||||
):
|
||||
chain.check()
|
||||
|
||||
payload = updates[-1][1]
|
||||
self.assertEqual(captured[0][1].current_total_episode, 10)
|
||||
self.assertEqual(payload["total_episode"], 120)
|
||||
self.assertEqual(payload["lack_episode"], 120)
|
||||
|
||||
def test_check_total_refresh_follows_recognized_total_decrease(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
subscribe = module.Subscribe(
|
||||
id=43,
|
||||
name="总集巡检回落剧",
|
||||
type=MediaType.TV.value,
|
||||
season=1,
|
||||
total_episode=100,
|
||||
start_episode=1,
|
||||
lack_episode=100,
|
||||
best_version=0,
|
||||
best_version_full=0,
|
||||
current_priority=None,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
year="2026",
|
||||
episode_group=None,
|
||||
tmdbid=31043,
|
||||
doubanid=None,
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
eventmanager, captured = self._event_manager(updated=False)
|
||||
|
||||
class _SubscribeOper:
|
||||
def list(self):
|
||||
return [subscribe]
|
||||
|
||||
def update(self, subscribe_id, payload):
|
||||
updates.append((subscribe_id, payload))
|
||||
|
||||
chain = SubscribeChain()
|
||||
chain.recognize_media = lambda **kwargs: self._mediainfo(total_episode=90)
|
||||
|
||||
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object(
|
||||
module,
|
||||
"eventmanager",
|
||||
eventmanager,
|
||||
):
|
||||
chain.check()
|
||||
|
||||
payload = updates[-1][1]
|
||||
self.assertEqual(captured[0][1].current_total_episode, 90)
|
||||
self.assertEqual(payload["total_episode"], 90)
|
||||
self.assertEqual(payload["lack_episode"], 90)
|
||||
|
||||
def test_check_total_refresh_skips_non_tv_even_when_mediainfo_has_seasons(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
subscribe = module.Subscribe(
|
||||
id=45,
|
||||
name="电影误带季集",
|
||||
type=MediaType.MOVIE.value,
|
||||
season=1,
|
||||
total_episode=100,
|
||||
start_episode=1,
|
||||
lack_episode=100,
|
||||
best_version=0,
|
||||
best_version_full=0,
|
||||
current_priority=None,
|
||||
episode_priority={},
|
||||
note=[],
|
||||
year="2026",
|
||||
episode_group=None,
|
||||
tmdbid=31045,
|
||||
doubanid=None,
|
||||
manual_total_episode=0,
|
||||
)
|
||||
updates = []
|
||||
mediainfo = self._mediainfo(total_episode=10)
|
||||
mediainfo.type = MediaType.MOVIE
|
||||
|
||||
class _SubscribeOper:
|
||||
def list(self):
|
||||
return [subscribe]
|
||||
|
||||
def update(self, subscribe_id, payload):
|
||||
updates.append((subscribe_id, payload))
|
||||
|
||||
class _EventManager:
|
||||
def send_event(self, *_args, **_kwargs):
|
||||
raise AssertionError("non-tv subscribe must not ask external refresh")
|
||||
|
||||
chain = SubscribeChain()
|
||||
chain.recognize_media = lambda **kwargs: mediainfo
|
||||
|
||||
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object(
|
||||
module,
|
||||
"eventmanager",
|
||||
_EventManager(),
|
||||
):
|
||||
chain.check()
|
||||
|
||||
self.assertEqual(updates[-1][1]["total_episode"], 100)
|
||||
self.assertEqual(updates[-1][1]["lack_episode"], 100)
|
||||
|
||||
def test_add_create_clamps_event_decrease_to_recognized_total(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
added = []
|
||||
eventmanager, captured = self._event_manager(5)
|
||||
mediainfo = self._mediainfo(total_episode=10)
|
||||
chain = SubscribeChain()
|
||||
chain.recognize_media = lambda **_kwargs: mediainfo
|
||||
chain.obtain_images = lambda **_kwargs: None
|
||||
|
||||
class _SubscribeOper:
|
||||
def add(self, **kwargs):
|
||||
added.append(kwargs)
|
||||
return 41, None
|
||||
|
||||
with patch.object(module, "SubscribeOper", return_value=_SubscribeOper()), patch.object(
|
||||
module,
|
||||
"eventmanager",
|
||||
eventmanager,
|
||||
):
|
||||
sid, err_msg = chain.add(
|
||||
title="总集创建剧",
|
||||
year="2026",
|
||||
mtype=MediaType.TV,
|
||||
tmdbid=31041,
|
||||
season=1,
|
||||
message=False,
|
||||
)
|
||||
|
||||
self.assertEqual(sid, 41)
|
||||
self.assertIsNone(err_msg)
|
||||
self.assertEqual(captured[0][1].scene, "create")
|
||||
self.assertEqual(captured[0][1].current_total_episode, 10)
|
||||
self.assertEqual(added[-1]["total_episode"], 10)
|
||||
self.assertEqual(added[-1]["lack_episode"], 10)
|
||||
|
||||
def test_completed_episode_uses_schema_function_directly_for_best_version(self):
|
||||
module, SubscribeChain = _load_subscribe_chain_class()
|
||||
values = {
|
||||
|
||||
181
tests/test_torrents_subscribe_candidates.py
Normal file
181
tests/test_torrents_subscribe_candidates.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.core.context import Context, MediaInfo, TorrentInfo
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
def _chain() -> TorrentsChain:
|
||||
"""构造不触发外部依赖初始化的种子链实例。"""
|
||||
return object.__new__(TorrentsChain)
|
||||
|
||||
|
||||
def _subscribe(**kwargs):
|
||||
defaults = {
|
||||
"tmdbid": 100,
|
||||
"doubanid": None,
|
||||
"season": 1,
|
||||
"name": "测试剧",
|
||||
"type": MediaType.TV.value,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def _ctx(
|
||||
title: str = "测试剧 S01E05",
|
||||
*,
|
||||
tmdb_id: int = 100,
|
||||
douban_id: str = None,
|
||||
meta_tmdbid: int = None,
|
||||
meta_doubanid: str = None,
|
||||
meta_type=MediaType.TV,
|
||||
media_season: int = None,
|
||||
begin_season: int = 1,
|
||||
end_season: int = None,
|
||||
) -> Context:
|
||||
return Context(
|
||||
meta_info=SimpleNamespace(
|
||||
title=title,
|
||||
name="测试剧",
|
||||
type=meta_type,
|
||||
tmdbid=meta_tmdbid,
|
||||
doubanid=meta_doubanid,
|
||||
begin_season=begin_season,
|
||||
end_season=end_season,
|
||||
begin_episode=5,
|
||||
episode_list=[5],
|
||||
),
|
||||
media_info=MediaInfo(
|
||||
type=MediaType.TV,
|
||||
tmdb_id=tmdb_id,
|
||||
douban_id=douban_id,
|
||||
season=media_season,
|
||||
),
|
||||
torrent_info=TorrentInfo(title=title),
|
||||
resource_source="rss",
|
||||
match_source="tmdbid" if tmdb_id else "unknown",
|
||||
candidate_recognized=bool(tmdb_id or douban_id),
|
||||
media_info_is_target=False,
|
||||
)
|
||||
|
||||
|
||||
def test_cache_candidates_return_deep_copies(monkeypatch):
|
||||
source = _ctx()
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
result = _chain().get_subscribe_cache_candidates(_subscribe(), stype="rss")
|
||||
result[0].meta_info.title = "changed"
|
||||
result[0].media_info.tmdb_id = 999
|
||||
|
||||
assert result[0] is not source
|
||||
assert result[0].meta_info is not source.meta_info
|
||||
assert result[0].media_info is not source.media_info
|
||||
assert source.meta_info.title == "测试剧 S01E05"
|
||||
assert source.media_info.tmdb_id == 100
|
||||
|
||||
|
||||
def test_cache_candidates_reject_season_conflict(monkeypatch):
|
||||
source = _ctx(media_season=2, begin_season=2)
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
assert _chain().get_subscribe_cache_candidates(_subscribe(), stype="rss") == []
|
||||
|
||||
|
||||
def test_cache_candidates_keep_multi_season_candidate_covering_target(monkeypatch):
|
||||
source = _ctx(media_season=None, begin_season=1, end_season=2)
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
result = _chain().get_subscribe_cache_candidates(_subscribe(), stype="rss")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].match_source == "tmdbid"
|
||||
|
||||
|
||||
def test_cache_candidates_keep_multi_season_candidate_when_media_season_is_range_start(monkeypatch):
|
||||
source = _ctx(media_season=1, begin_season=1, end_season=2)
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
result = _chain().get_subscribe_cache_candidates(_subscribe(season=2), stype="rss")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].match_source == "tmdbid"
|
||||
|
||||
|
||||
def test_cache_candidates_ignore_default_meta_season_list_when_no_explicit_meta_season(monkeypatch):
|
||||
class _MetaWithDefaultSeasonList:
|
||||
title = "测试剧 E05"
|
||||
name = "测试剧"
|
||||
type = MediaType.TV
|
||||
begin_season = None
|
||||
end_season = None
|
||||
|
||||
@property
|
||||
def season_list(self):
|
||||
return [1]
|
||||
|
||||
source = _ctx(media_season=2, begin_season=None)
|
||||
source.meta_info = _MetaWithDefaultSeasonList()
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
result = _chain().get_subscribe_cache_candidates(_subscribe(season=2), stype="rss")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].match_source == "tmdbid"
|
||||
|
||||
|
||||
def test_title_fallback_requires_explicit_flag(monkeypatch):
|
||||
source = _ctx(tmdb_id=None)
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
assert _chain().get_subscribe_cache_candidates(_subscribe(), stype="rss") == []
|
||||
|
||||
|
||||
def test_title_fallback_is_diagnostic_only_and_uses_target_media(monkeypatch):
|
||||
source = _ctx(tmdb_id=None)
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
result = _chain().get_subscribe_cache_candidates(_subscribe(doubanid="200"), stype="rss", allow_title_match=True)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].match_source == "title"
|
||||
assert result[0].candidate_recognized is False
|
||||
assert result[0].media_info_is_target is True
|
||||
assert result[0].media_info.tmdb_id == 100
|
||||
assert result[0].media_info.douban_id == "200"
|
||||
assert source.media_info.tmdb_id is None
|
||||
|
||||
|
||||
def test_title_fallback_rejects_meta_type_conflict(monkeypatch):
|
||||
source = _ctx(tmdb_id=None, meta_type=MediaType.MOVIE)
|
||||
source.media_info.type = None
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
assert _chain().get_subscribe_cache_candidates(
|
||||
_subscribe(),
|
||||
stype="rss",
|
||||
allow_title_match=True,
|
||||
) == []
|
||||
|
||||
|
||||
def test_title_fallback_rejects_explicit_conflicting_identity(monkeypatch):
|
||||
source = _ctx(tmdb_id=999)
|
||||
source.match_source = "tmdbid"
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
assert _chain().get_subscribe_cache_candidates(
|
||||
_subscribe(),
|
||||
stype="rss",
|
||||
allow_title_match=True,
|
||||
) == []
|
||||
|
||||
|
||||
def test_title_fallback_rejects_meta_explicit_conflicting_identity(monkeypatch):
|
||||
source = _ctx(tmdb_id=None, meta_tmdbid=999)
|
||||
monkeypatch.setattr(TorrentsChain, "get_torrents", lambda self, stype=None: {"site": [source]})
|
||||
|
||||
assert _chain().get_subscribe_cache_candidates(
|
||||
_subscribe(),
|
||||
stype="rss",
|
||||
allow_title_match=True,
|
||||
) == []
|
||||
Reference in New Issue
Block a user