mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-12 16:02:35 +08:00
feat(subscribe): expose missing target resolver (#5958)
* feat(subscribe): expose missing target resolver * fix(subscribe): refine missing resolver semantics
This commit is contained in:
@@ -76,16 +76,17 @@ class SubscribeChain(ChainBase):
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def __get_episode_priority(cls, subscribe: Subscribe) -> Dict[str, int]:
|
def __get_episode_priority(cls, subscribe: Subscribe,
|
||||||
|
total_episode: Optional[int] = None) -> Dict[str, int]:
|
||||||
"""
|
"""
|
||||||
获取订阅按集洗版优先级状态。
|
获取订阅按集洗版优先级状态。
|
||||||
"""
|
"""
|
||||||
episode_priority = cls.__normalize_episode_priority(getattr(subscribe, "episode_priority", None))
|
episode_priority = cls.__normalize_episode_priority(subscribe.episode_priority)
|
||||||
if episode_priority:
|
if episode_priority:
|
||||||
return episode_priority
|
return episode_priority
|
||||||
|
|
||||||
if subscribe.best_version and subscribe.type == MediaType.TV.value and subscribe.current_priority is not None:
|
if subscribe.best_version and subscribe.type == MediaType.TV.value and subscribe.current_priority is not None:
|
||||||
target_episodes = cls.__get_best_version_target_episodes(subscribe)
|
target_episodes = cls.__get_best_version_target_episodes(subscribe, total_episode=total_episode)
|
||||||
return {
|
return {
|
||||||
str(episode): int(subscribe.current_priority)
|
str(episode): int(subscribe.current_priority)
|
||||||
for episode in target_episodes
|
for episode in target_episodes
|
||||||
@@ -100,7 +101,8 @@ class SubscribeChain(ChainBase):
|
|||||||
return cls.__get_episode_priority(subscribe)
|
return cls.__get_episode_priority(subscribe)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def __get_best_version_target_episodes(cls, subscribe: Subscribe) -> List[int]:
|
def __get_best_version_target_episodes(cls, subscribe: Subscribe,
|
||||||
|
total_episode: Optional[int] = None) -> List[int]:
|
||||||
"""
|
"""
|
||||||
获取洗版订阅目标剧集范围。
|
获取洗版订阅目标剧集范围。
|
||||||
"""
|
"""
|
||||||
@@ -108,36 +110,75 @@ class SubscribeChain(ChainBase):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
start_episode = subscribe.start_episode or 1
|
start_episode = subscribe.start_episode or 1
|
||||||
total_episode = subscribe.total_episode or 0
|
total_episode = total_episode or subscribe.total_episode or 0
|
||||||
if total_episode < start_episode:
|
if total_episode < start_episode:
|
||||||
return []
|
return []
|
||||||
return list(range(start_episode, total_episode + 1))
|
return list(range(start_episode, total_episode + 1))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __get_downloaded_best_version_episodes(cls, subscribe: Subscribe,
|
||||||
|
total_episode: Optional[int] = None) -> List[int]:
|
||||||
|
"""
|
||||||
|
获取洗版订阅目标范围内已下载到任意版本的剧集。
|
||||||
|
|
||||||
|
分集洗版的完成态要求 priority==100,但订阅目标满足查询有时只需要确认
|
||||||
|
目标集是否已下载过任意版本,因此这里按 note 与 episode_priority>0 统计。
|
||||||
|
"""
|
||||||
|
if subscribe.type != MediaType.TV.value:
|
||||||
|
return []
|
||||||
|
|
||||||
|
start_episode = subscribe.start_episode or 1
|
||||||
|
total_episode = total_episode or subscribe.total_episode or 0
|
||||||
|
if total_episode < start_episode:
|
||||||
|
return []
|
||||||
|
target_episodes = set(range(start_episode, total_episode + 1))
|
||||||
|
downloaded = set()
|
||||||
|
for episode in subscribe.note or []:
|
||||||
|
try:
|
||||||
|
episode_number = int(episode)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if episode_number in target_episodes:
|
||||||
|
downloaded.add(episode_number)
|
||||||
|
for episode, priority in (subscribe.episode_priority or {}).items():
|
||||||
|
if not str(episode).isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if float(priority) > 0:
|
||||||
|
episode_number = int(episode)
|
||||||
|
if episode_number in target_episodes:
|
||||||
|
downloaded.add(episode_number)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return sorted(downloaded)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def __get_pending_best_version_episodes_with_priority(
|
def __get_pending_best_version_episodes_with_priority(
|
||||||
cls,
|
cls,
|
||||||
subscribe: Subscribe,
|
subscribe: Subscribe,
|
||||||
episode_priority: Optional[dict] = None,
|
episode_priority: Optional[dict] = None,
|
||||||
|
total_episode: Optional[int] = None,
|
||||||
) -> List[int]:
|
) -> List[int]:
|
||||||
"""
|
"""
|
||||||
使用指定按集优先级状态获取当前仍需继续洗版的剧集。
|
使用指定按集优先级状态获取当前仍需继续洗版的剧集。
|
||||||
"""
|
"""
|
||||||
target_episodes = cls.__get_best_version_target_episodes(subscribe)
|
target_episodes = cls.__get_best_version_target_episodes(subscribe, total_episode=total_episode)
|
||||||
if not target_episodes:
|
if not target_episodes:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if episode_priority is None:
|
if episode_priority is None:
|
||||||
normalized = cls.__get_episode_priority(subscribe)
|
normalized = cls.__get_episode_priority(subscribe, total_episode=total_episode)
|
||||||
else:
|
else:
|
||||||
normalized = cls.__normalize_episode_priority(episode_priority)
|
normalized = cls.__normalize_episode_priority(episode_priority)
|
||||||
return [episode for episode in target_episodes if normalized.get(str(episode)) != 100]
|
return [episode for episode in target_episodes if normalized.get(str(episode)) != 100]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_pending_best_version_episodes(cls, subscribe: Subscribe) -> List[int]:
|
def _get_pending_best_version_episodes(cls, subscribe: Subscribe,
|
||||||
|
total_episode: Optional[int] = None) -> List[int]:
|
||||||
"""
|
"""
|
||||||
获取当前仍需继续洗版的剧集。
|
获取当前仍需继续洗版的剧集。
|
||||||
"""
|
"""
|
||||||
return cls.__get_pending_best_version_episodes_with_priority(subscribe)
|
return cls.__get_pending_best_version_episodes_with_priority(subscribe, total_episode=total_episode)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def compute_completed_episode(cls, subscribe: Subscribe) -> Optional[int]:
|
def compute_completed_episode(cls, subscribe: Subscribe) -> Optional[int]:
|
||||||
@@ -324,7 +365,7 @@ class SubscribeChain(ChainBase):
|
|||||||
判断当前订阅是否启用了电视剧全集洗版。
|
判断当前订阅是否启用了电视剧全集洗版。
|
||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
bool(getattr(subscribe, "best_version_full", 0))
|
bool(subscribe.best_version_full)
|
||||||
and bool(subscribe.best_version)
|
and bool(subscribe.best_version)
|
||||||
and subscribe.type == MediaType.TV.value
|
and subscribe.type == MediaType.TV.value
|
||||||
)
|
)
|
||||||
@@ -2813,7 +2854,8 @@ class SubscribeChain(ChainBase):
|
|||||||
season=begin_season,
|
season=begin_season,
|
||||||
episodes=episodes,
|
episodes=episodes,
|
||||||
total_episode=total_episode,
|
total_episode=total_episode,
|
||||||
start_episode=start_episode
|
start_episode=start_episode,
|
||||||
|
require_complete_coverage=no_exist_season.require_complete_coverage
|
||||||
)
|
)
|
||||||
# 根据订阅已下载集数更新缺失集数
|
# 根据订阅已下载集数更新缺失集数
|
||||||
if downloaded_episodes:
|
if downloaded_episodes:
|
||||||
@@ -2841,6 +2883,7 @@ class SubscribeChain(ChainBase):
|
|||||||
episodes=episodes,
|
episodes=episodes,
|
||||||
total_episode=total,
|
total_episode=total,
|
||||||
start_episode=start,
|
start_episode=start,
|
||||||
|
require_complete_coverage=no_exist_season.require_complete_coverage
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 开始集数
|
# 开始集数
|
||||||
@@ -2855,6 +2898,7 @@ class SubscribeChain(ChainBase):
|
|||||||
episodes=episodes,
|
episodes=episodes,
|
||||||
total_episode=total_episode,
|
total_episode=total_episode,
|
||||||
start_episode=start,
|
start_episode=start,
|
||||||
|
require_complete_coverage=False,
|
||||||
)
|
)
|
||||||
logger.info(f'订阅 {subscribe_name} 缺失剧集数更新为:{no_exists}')
|
logger.info(f'订阅 {subscribe_name} 缺失剧集数更新为:{no_exists}')
|
||||||
return False, no_exists
|
return False, no_exists
|
||||||
@@ -3070,72 +3114,12 @@ class SubscribeChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
self.__refresh_total_episode_before_completion(subscribe=subscribe, mediainfo=mediainfo)
|
self.__refresh_total_episode_before_completion(subscribe=subscribe, mediainfo=mediainfo)
|
||||||
|
|
||||||
# 非洗版
|
exist_flag, no_exists = self.resolve_subscribe_missing(
|
||||||
if not subscribe.best_version:
|
subscribe=subscribe,
|
||||||
# 每季总集数
|
meta=meta,
|
||||||
totals = {}
|
mediainfo=mediainfo,
|
||||||
if subscribe.season and subscribe.total_episode:
|
mediakey=mediakey,
|
||||||
totals = {
|
)
|
||||||
subscribe.season: subscribe.total_episode
|
|
||||||
}
|
|
||||||
# 查询媒体库缺失的媒体信息
|
|
||||||
exist_flag, no_exists = DownloadChain().get_no_exists_info(
|
|
||||||
meta=meta,
|
|
||||||
mediainfo=mediainfo,
|
|
||||||
totals=totals
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# 洗版,如果已经满足了优先级,则认为已经洗版完成
|
|
||||||
if self.__is_best_version_complete(subscribe):
|
|
||||||
exist_flag = True
|
|
||||||
no_exists = {}
|
|
||||||
else:
|
|
||||||
exist_flag = False
|
|
||||||
if meta.type == MediaType.TV:
|
|
||||||
pending_episodes = [] if self.__is_full_best_version_enabled(
|
|
||||||
subscribe
|
|
||||||
) else self._get_pending_best_version_episodes(subscribe)
|
|
||||||
# 对于电视剧,构造缺失的媒体信息
|
|
||||||
no_exists = {
|
|
||||||
mediakey: {
|
|
||||||
subscribe.season: schemas.NotExistMediaInfo(
|
|
||||||
season=subscribe.season,
|
|
||||||
episodes=pending_episodes,
|
|
||||||
total_episode=subscribe.total_episode,
|
|
||||||
start_episode=subscribe.start_episode or 1,
|
|
||||||
# 完整覆盖约束会影响整季文件探测、显式集列表匹配和多集拆包降级。
|
|
||||||
require_complete_coverage=self.__is_full_best_version_enabled(subscribe))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
no_exists = {}
|
|
||||||
|
|
||||||
# 如果媒体已存在,执行订阅完成操作
|
|
||||||
if exist_flag:
|
|
||||||
if not subscribe.best_version:
|
|
||||||
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
|
|
||||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True)
|
|
||||||
return True, no_exists
|
|
||||||
|
|
||||||
# 获取已下载的集数或电影
|
|
||||||
downloaded = self.__get_downloaded(subscribe)
|
|
||||||
if self.__is_full_best_version_enabled(subscribe):
|
|
||||||
# 全集洗版必须保留整季缺失范围,避免下载链路从整包中拆选单集。
|
|
||||||
downloaded = []
|
|
||||||
if meta.type == MediaType.TV:
|
|
||||||
# 对于电视剧类型,整合缺失集数并剔除已下载的集数
|
|
||||||
exist_flag, no_exists = self.__get_subscribe_no_exits(
|
|
||||||
subscribe_name=f'{subscribe.name} {meta.season}',
|
|
||||||
no_exists=no_exists,
|
|
||||||
mediakey=mediakey,
|
|
||||||
begin_season=meta.begin_season,
|
|
||||||
total_episode=subscribe.total_episode,
|
|
||||||
start_episode=subscribe.start_episode,
|
|
||||||
downloaded_episodes=downloaded
|
|
||||||
)
|
|
||||||
elif meta.type == MediaType.MOVIE:
|
|
||||||
# 对于电影类型,直接根据是否已下载判断
|
|
||||||
exist_flag = bool(downloaded)
|
|
||||||
|
|
||||||
# 如果已下载完毕,执行订阅完成操作
|
# 如果已下载完毕,执行订阅完成操作
|
||||||
if exist_flag:
|
if exist_flag:
|
||||||
@@ -3146,6 +3130,113 @@ class SubscribeChain(ChainBase):
|
|||||||
# 返回结果,表示媒体未完全下载或存在
|
# 返回结果,表示媒体未完全下载或存在
|
||||||
return False, no_exists
|
return False, no_exists
|
||||||
|
|
||||||
|
def resolve_subscribe_missing(self, subscribe: Subscribe, meta: MetaBase,
|
||||||
|
mediainfo: MediaInfo,
|
||||||
|
mediakey: Optional[Union[str, int]] = None,
|
||||||
|
best_version_accept_downloaded: bool = False):
|
||||||
|
"""
|
||||||
|
按主程序订阅口径查询当前目标是否仍有缺失,不推进订阅状态。
|
||||||
|
|
||||||
|
该方法只组合媒体库缺集、订阅范围、下载历史和洗版优先级,用于外部策略在
|
||||||
|
完成前复用主程序"还要不要搜索/下载"的判断口径。它不得完成订阅、写入
|
||||||
|
lack_episode、发送事件或修改数据库。
|
||||||
|
|
||||||
|
best_version_accept_downloaded 仅用于分集洗版的外部完成守卫:为 True 时,
|
||||||
|
priority>0 的目标集视为已满足;默认 False 保持主程序洗版完成需 priority==100
|
||||||
|
的搜索/完成口径。
|
||||||
|
"""
|
||||||
|
mediakey = mediakey or subscribe.tmdbid or subscribe.doubanid
|
||||||
|
effective_total_episode = self.__resolve_effective_total_episode(subscribe, mediainfo)
|
||||||
|
|
||||||
|
if not subscribe.best_version:
|
||||||
|
totals = {}
|
||||||
|
if subscribe.season and effective_total_episode:
|
||||||
|
totals = {
|
||||||
|
subscribe.season: effective_total_episode
|
||||||
|
}
|
||||||
|
exist_flag, no_exists = DownloadChain().get_no_exists_info(
|
||||||
|
meta=meta,
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
totals=totals
|
||||||
|
)
|
||||||
|
elif meta.type != MediaType.TV and self.__is_best_version_complete(subscribe):
|
||||||
|
return True, {}
|
||||||
|
else:
|
||||||
|
exist_flag = False
|
||||||
|
if meta.type == MediaType.TV:
|
||||||
|
if self.__is_full_best_version_enabled(subscribe):
|
||||||
|
pending_episodes = []
|
||||||
|
elif best_version_accept_downloaded:
|
||||||
|
downloaded = set(self.__get_downloaded_best_version_episodes(
|
||||||
|
subscribe, total_episode=effective_total_episode
|
||||||
|
))
|
||||||
|
start_episode = subscribe.start_episode or 1
|
||||||
|
pending_episodes = [
|
||||||
|
episode for episode in range(start_episode, effective_total_episode + 1)
|
||||||
|
if episode not in downloaded
|
||||||
|
]
|
||||||
|
if not pending_episodes:
|
||||||
|
return True, {}
|
||||||
|
else:
|
||||||
|
pending_episodes = self._get_pending_best_version_episodes(
|
||||||
|
subscribe, total_episode=effective_total_episode
|
||||||
|
)
|
||||||
|
if not pending_episodes:
|
||||||
|
return True, {}
|
||||||
|
no_exists = {
|
||||||
|
mediakey: {
|
||||||
|
subscribe.season: schemas.NotExistMediaInfo(
|
||||||
|
season=subscribe.season,
|
||||||
|
episodes=pending_episodes,
|
||||||
|
total_episode=effective_total_episode,
|
||||||
|
start_episode=subscribe.start_episode or 1,
|
||||||
|
require_complete_coverage=self.__is_full_best_version_enabled(subscribe))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
no_exists = {}
|
||||||
|
|
||||||
|
if exist_flag:
|
||||||
|
return True, no_exists
|
||||||
|
|
||||||
|
downloaded = self.__get_downloaded(subscribe)
|
||||||
|
if self.__is_full_best_version_enabled(subscribe):
|
||||||
|
downloaded = []
|
||||||
|
if meta.type == MediaType.TV:
|
||||||
|
return self.__get_subscribe_no_exits(
|
||||||
|
subscribe_name=f'{subscribe.name} {meta.season}',
|
||||||
|
no_exists=no_exists,
|
||||||
|
mediakey=mediakey,
|
||||||
|
begin_season=meta.begin_season,
|
||||||
|
total_episode=effective_total_episode,
|
||||||
|
start_episode=subscribe.start_episode,
|
||||||
|
downloaded_episodes=downloaded
|
||||||
|
)
|
||||||
|
if meta.type == MediaType.MOVIE:
|
||||||
|
return bool(downloaded), no_exists
|
||||||
|
return False, no_exists
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __resolve_effective_total_episode(subscribe: Subscribe, mediainfo: MediaInfo) -> int:
|
||||||
|
"""
|
||||||
|
只读计算完成前有效总集数,不触发事件、不写回订阅。
|
||||||
|
|
||||||
|
主流程会通过 ``__refresh_total_episode_before_completion`` 持久化增长后的总集数;
|
||||||
|
该查询接口只需要同样避免旧 total 造成误判,因此仅使用当前 mediainfo 中更大的
|
||||||
|
季集数作为临时目标范围。
|
||||||
|
"""
|
||||||
|
current_total = subscribe.total_episode or 0
|
||||||
|
if subscribe.type != MediaType.TV.value:
|
||||||
|
return current_total
|
||||||
|
if subscribe.manual_total_episode:
|
||||||
|
return current_total
|
||||||
|
if subscribe.season is None:
|
||||||
|
return current_total
|
||||||
|
media_total = len((mediainfo.seasons or {}).get(subscribe.season) or [])
|
||||||
|
if media_total > current_total:
|
||||||
|
return media_total
|
||||||
|
return current_total
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __apply_episodes_refresh(current_total: int, season: Optional[int], *,
|
def __apply_episodes_refresh(current_total: int, season: Optional[int], *,
|
||||||
mediainfo: Optional[MediaInfo] = None,
|
mediainfo: Optional[MediaInfo] = None,
|
||||||
|
|||||||
@@ -126,11 +126,13 @@ def _load_subscribe_chain_class():
|
|||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
|
|
||||||
class _NotExistMediaInfo:
|
class _NotExistMediaInfo:
|
||||||
def __init__(self, season=None, episodes=None, total_episode=None, start_episode=None):
|
def __init__(self, season=None, episodes=None, total_episode=None, start_episode=None,
|
||||||
|
require_complete_coverage=False):
|
||||||
self.season = season
|
self.season = season
|
||||||
self.episodes = episodes or []
|
self.episodes = episodes or []
|
||||||
self.total_episode = total_episode
|
self.total_episode = total_episode
|
||||||
self.start_episode = start_episode
|
self.start_episode = start_episode
|
||||||
|
self.require_complete_coverage = require_complete_coverage
|
||||||
|
|
||||||
class _SubscribeEpisodeInfo:
|
class _SubscribeEpisodeInfo:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -461,7 +463,13 @@ class SubscribeChainTest(TestCase):
|
|||||||
"""自定义开始集跳过季初集数时,缺失整季需要转成显式目标集。"""
|
"""自定义开始集跳过季初集数时,缺失整季需要转成显式目标集。"""
|
||||||
no_exists = {
|
no_exists = {
|
||||||
"media-key": {
|
"media-key": {
|
||||||
1: SimpleNamespace(season=1, episodes=[], total_episode=48, start_episode=1)
|
1: SimpleNamespace(
|
||||||
|
season=1,
|
||||||
|
episodes=[],
|
||||||
|
total_episode=48,
|
||||||
|
start_episode=1,
|
||||||
|
require_complete_coverage=False,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,7 +491,13 @@ class SubscribeChainTest(TestCase):
|
|||||||
"""自定义开始集没有缩小范围时,仍保留空集列表表示整季缺失。"""
|
"""自定义开始集没有缩小范围时,仍保留空集列表表示整季缺失。"""
|
||||||
no_exists = {
|
no_exists = {
|
||||||
"media-key": {
|
"media-key": {
|
||||||
1: SimpleNamespace(season=1, episodes=[], total_episode=48, start_episode=1)
|
1: SimpleNamespace(
|
||||||
|
season=1,
|
||||||
|
episodes=[],
|
||||||
|
total_episode=48,
|
||||||
|
start_episode=1,
|
||||||
|
require_complete_coverage=False,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +515,331 @@ class SubscribeChainTest(TestCase):
|
|||||||
self.assertEqual(result["media-key"][1].start_episode, 1)
|
self.assertEqual(result["media-key"][1].start_episode, 1)
|
||||||
self.assertEqual(result["media-key"][1].total_episode, 48)
|
self.assertEqual(result["media-key"][1].total_episode, 48)
|
||||||
|
|
||||||
|
def test_resolve_subscribe_missing_combines_library_gap_and_download_history_without_side_effects(self):
|
||||||
|
"""目标满足查询应复用主程序媒体库缺集与订阅下载历史的合并口径,且不推进订阅状态。"""
|
||||||
|
subscribe = self._build_subscribe(
|
||||||
|
best_version=0,
|
||||||
|
total_episode=20,
|
||||||
|
lack_episode=10,
|
||||||
|
note=list(range(11, 21)),
|
||||||
|
)
|
||||||
|
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
|
||||||
|
mediainfo = SimpleNamespace(
|
||||||
|
type=MediaType.TV,
|
||||||
|
seasons={1: list(range(1, 21))},
|
||||||
|
title_year="Test Show (2026)",
|
||||||
|
)
|
||||||
|
library_missing = {
|
||||||
|
1: {
|
||||||
|
1: SimpleNamespace(
|
||||||
|
season=1,
|
||||||
|
episodes=list(range(11, 21)),
|
||||||
|
total_episode=20,
|
||||||
|
start_episode=11,
|
||||||
|
require_complete_coverage=False,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updates = []
|
||||||
|
|
||||||
|
class _DownloadChain:
|
||||||
|
def get_no_exists_info(self, **kwargs):
|
||||||
|
self.kwargs = kwargs
|
||||||
|
return False, library_missing
|
||||||
|
|
||||||
|
class _SubscribeOper:
|
||||||
|
def update(self, subscribe_id, payload):
|
||||||
|
updates.append((subscribe_id, payload))
|
||||||
|
|
||||||
|
chain = SubscribeChain()
|
||||||
|
chain.finish_subscribe_or_not = lambda **_kwargs: self.fail("resolve_subscribe_missing must not finish")
|
||||||
|
|
||||||
|
with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _DownloadChain), patch.object(
|
||||||
|
SUBSCRIBE_CHAIN_MODULE,
|
||||||
|
"SubscribeOper",
|
||||||
|
_SubscribeOper,
|
||||||
|
):
|
||||||
|
satisfied, no_exists = chain.resolve_subscribe_missing(
|
||||||
|
subscribe=subscribe,
|
||||||
|
meta=meta,
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
mediakey=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(satisfied)
|
||||||
|
self.assertEqual(no_exists, {})
|
||||||
|
self.assertEqual(updates, [])
|
||||||
|
|
||||||
|
def test_resolve_subscribe_missing_keeps_library_gap_when_download_history_does_not_cover_it(self):
|
||||||
|
"""订阅前媒体库已有部分剧集时,目标满足查询应保留仍需下载的媒体库缺口。"""
|
||||||
|
subscribe = self._build_subscribe(
|
||||||
|
best_version=0,
|
||||||
|
total_episode=20,
|
||||||
|
lack_episode=20,
|
||||||
|
note=[],
|
||||||
|
)
|
||||||
|
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
|
||||||
|
mediainfo = SimpleNamespace(
|
||||||
|
type=MediaType.TV,
|
||||||
|
seasons={1: list(range(1, 21))},
|
||||||
|
title_year="Test Show (2026)",
|
||||||
|
)
|
||||||
|
library_missing = {
|
||||||
|
1: {
|
||||||
|
1: SimpleNamespace(
|
||||||
|
season=1,
|
||||||
|
episodes=list(range(11, 21)),
|
||||||
|
total_episode=20,
|
||||||
|
start_episode=11,
|
||||||
|
require_complete_coverage=False,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadChain:
|
||||||
|
def get_no_exists_info(self, **_kwargs):
|
||||||
|
return False, library_missing
|
||||||
|
|
||||||
|
with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _DownloadChain):
|
||||||
|
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
|
||||||
|
subscribe=subscribe,
|
||||||
|
meta=meta,
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
mediakey=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(satisfied)
|
||||||
|
self.assertEqual(no_exists[1][1].episodes, list(range(11, 21)))
|
||||||
|
self.assertEqual(no_exists[1][1].start_episode, 1)
|
||||||
|
self.assertEqual(no_exists[1][1].total_episode, 20)
|
||||||
|
|
||||||
|
def test_resolve_subscribe_missing_uses_readonly_effective_total_from_mediainfo(self):
|
||||||
|
"""只读目标查询应使用最新媒体信息扩大有效总集数,但不能写回订阅或发送刷新事件。"""
|
||||||
|
subscribe = self._build_subscribe(
|
||||||
|
best_version=0,
|
||||||
|
total_episode=10,
|
||||||
|
lack_episode=0,
|
||||||
|
note=list(range(1, 11)),
|
||||||
|
)
|
||||||
|
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
|
||||||
|
mediainfo = SimpleNamespace(
|
||||||
|
type=MediaType.TV,
|
||||||
|
seasons={1: list(range(1, 21))},
|
||||||
|
title_year="Test Show (2026)",
|
||||||
|
)
|
||||||
|
captured_totals = []
|
||||||
|
|
||||||
|
class _DownloadChain:
|
||||||
|
def get_no_exists_info(self, **kwargs):
|
||||||
|
captured_totals.append(kwargs["totals"])
|
||||||
|
return False, {
|
||||||
|
1: {
|
||||||
|
1: SimpleNamespace(
|
||||||
|
season=1,
|
||||||
|
episodes=list(range(11, 21)),
|
||||||
|
total_episode=20,
|
||||||
|
start_episode=11,
|
||||||
|
require_complete_coverage=False,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventManager:
|
||||||
|
def send_event(self, *_args, **_kwargs):
|
||||||
|
raise AssertionError("resolve_subscribe_missing must not send refresh events")
|
||||||
|
|
||||||
|
with patch.object(SUBSCRIBE_CHAIN_MODULE, "DownloadChain", _DownloadChain), patch.object(
|
||||||
|
SUBSCRIBE_CHAIN_MODULE,
|
||||||
|
"eventmanager",
|
||||||
|
_EventManager(),
|
||||||
|
):
|
||||||
|
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
|
||||||
|
subscribe=subscribe,
|
||||||
|
meta=meta,
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
mediakey=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(satisfied)
|
||||||
|
self.assertEqual(captured_totals, [{1: 20}])
|
||||||
|
self.assertEqual(no_exists[1][1].episodes, list(range(11, 21)))
|
||||||
|
self.assertEqual(subscribe.total_episode, 10)
|
||||||
|
self.assertEqual(subscribe.lack_episode, 0)
|
||||||
|
self.assertEqual(subscribe.note, list(range(1, 11)))
|
||||||
|
|
||||||
|
def test_resolve_subscribe_missing_accepts_downloaded_episode_best_version_targets(self):
|
||||||
|
"""外部完成守卫可按任意已下载版本判定分集洗版目标已满足。"""
|
||||||
|
subscribe = self._build_subscribe(
|
||||||
|
best_version=1,
|
||||||
|
best_version_full=0,
|
||||||
|
total_episode=3,
|
||||||
|
note=[1],
|
||||||
|
episode_priority={"2": 80, "3": 99},
|
||||||
|
)
|
||||||
|
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
|
||||||
|
mediainfo = SimpleNamespace(
|
||||||
|
type=MediaType.TV,
|
||||||
|
seasons={1: [1, 2, 3]},
|
||||||
|
title_year="Test Show (2026)",
|
||||||
|
)
|
||||||
|
|
||||||
|
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
|
||||||
|
subscribe=subscribe,
|
||||||
|
meta=meta,
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
mediakey=1,
|
||||||
|
best_version_accept_downloaded=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(satisfied)
|
||||||
|
self.assertEqual(no_exists, {})
|
||||||
|
|
||||||
|
def test_resolve_subscribe_missing_default_best_version_requires_top_priority(self):
|
||||||
|
"""主程序洗版完成口径默认仍要求目标分集达到最高优先级。"""
|
||||||
|
subscribe = self._build_subscribe(
|
||||||
|
best_version=1,
|
||||||
|
best_version_full=0,
|
||||||
|
total_episode=3,
|
||||||
|
note=[1],
|
||||||
|
episode_priority={"2": 80, "3": 99},
|
||||||
|
)
|
||||||
|
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
|
||||||
|
mediainfo = SimpleNamespace(
|
||||||
|
type=MediaType.TV,
|
||||||
|
seasons={1: [1, 2, 3]},
|
||||||
|
title_year="Test Show (2026)",
|
||||||
|
)
|
||||||
|
|
||||||
|
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
|
||||||
|
subscribe=subscribe,
|
||||||
|
meta=meta,
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
mediakey=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(satisfied)
|
||||||
|
self.assertEqual(no_exists[1][1].episodes, [1, 2, 3])
|
||||||
|
self.assertEqual(no_exists[1][1].total_episode, 3)
|
||||||
|
|
||||||
|
def test_resolve_subscribe_missing_default_best_version_uses_readonly_effective_total(self):
|
||||||
|
"""只读目标查询扩大有效总集数时,默认洗版口径应把新增集纳入待洗范围。"""
|
||||||
|
subscribe = self._build_subscribe(
|
||||||
|
best_version=1,
|
||||||
|
best_version_full=0,
|
||||||
|
total_episode=3,
|
||||||
|
episode_priority={"1": 100, "2": 100, "3": 100},
|
||||||
|
)
|
||||||
|
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
|
||||||
|
mediainfo = SimpleNamespace(
|
||||||
|
type=MediaType.TV,
|
||||||
|
seasons={1: [1, 2, 3, 4, 5]},
|
||||||
|
title_year="Test Show (2026)",
|
||||||
|
)
|
||||||
|
|
||||||
|
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
|
||||||
|
subscribe=subscribe,
|
||||||
|
meta=meta,
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
mediakey=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(satisfied)
|
||||||
|
self.assertEqual(no_exists[1][1].episodes, [4, 5])
|
||||||
|
self.assertEqual(no_exists[1][1].total_episode, 5)
|
||||||
|
self.assertEqual(subscribe.total_episode, 3)
|
||||||
|
|
||||||
|
def test_resolve_subscribe_missing_accept_downloaded_keeps_best_version_gap(self):
|
||||||
|
"""任意版本满足口径仍应保留从未下载过的目标分集。"""
|
||||||
|
subscribe = self._build_subscribe(
|
||||||
|
best_version=1,
|
||||||
|
best_version_full=0,
|
||||||
|
total_episode=3,
|
||||||
|
note=[1],
|
||||||
|
episode_priority={"2": 80},
|
||||||
|
)
|
||||||
|
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
|
||||||
|
mediainfo = SimpleNamespace(
|
||||||
|
type=MediaType.TV,
|
||||||
|
seasons={1: [1, 2, 3]},
|
||||||
|
title_year="Test Show (2026)",
|
||||||
|
)
|
||||||
|
|
||||||
|
satisfied, no_exists = SubscribeChain().resolve_subscribe_missing(
|
||||||
|
subscribe=subscribe,
|
||||||
|
meta=meta,
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
mediakey=1,
|
||||||
|
best_version_accept_downloaded=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(satisfied)
|
||||||
|
self.assertEqual(no_exists[1][1].episodes, [3])
|
||||||
|
self.assertEqual(no_exists[1][1].total_episode, 3)
|
||||||
|
|
||||||
|
def test_get_subscribe_no_exists_preserves_complete_coverage_requirement(self):
|
||||||
|
"""缺集裁剪重建 NotExistMediaInfo 时必须保留全集洗版完整覆盖约束。"""
|
||||||
|
no_exists = {
|
||||||
|
"media-key": {
|
||||||
|
1: SimpleNamespace(
|
||||||
|
season=1,
|
||||||
|
episodes=list(range(1, 13)),
|
||||||
|
total_episode=12,
|
||||||
|
start_episode=1,
|
||||||
|
require_complete_coverage=True,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exist_flag, result = SubscribeChain._SubscribeChain__get_subscribe_no_exits(
|
||||||
|
subscribe_name="主角 S01",
|
||||||
|
no_exists=no_exists,
|
||||||
|
mediakey="media-key",
|
||||||
|
begin_season=1,
|
||||||
|
total_episode=12,
|
||||||
|
start_episode=1,
|
||||||
|
downloaded_episodes=[1, 2, 3],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(exist_flag)
|
||||||
|
self.assertTrue(result["media-key"][1].require_complete_coverage)
|
||||||
|
self.assertEqual(result["media-key"][1].episodes, list(range(4, 13)))
|
||||||
|
|
||||||
|
def test_check_existing_media_refreshes_total_before_resolving_missing(self):
|
||||||
|
"""主流程应先执行完成前总集数刷新,再复用无副作用缺集查询口径。"""
|
||||||
|
subscribe = self._build_subscribe(best_version=0, total_episode=10, lack_episode=0)
|
||||||
|
meta = SimpleNamespace(type=MediaType.TV, begin_season=1, season=1)
|
||||||
|
mediainfo = SimpleNamespace(type=MediaType.TV, title_year="Test Show (2026)")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_refresh(_self, subscribe, mediainfo):
|
||||||
|
calls.append(("refresh", subscribe.total_episode))
|
||||||
|
subscribe.total_episode = 20
|
||||||
|
|
||||||
|
def fake_resolve(_self, subscribe, meta, mediainfo, mediakey=None):
|
||||||
|
calls.append(("resolve", subscribe.total_episode))
|
||||||
|
return False, {"media-key": {1: SimpleNamespace(episodes=[11], total_episode=20, start_episode=1)}}
|
||||||
|
|
||||||
|
chain = SubscribeChain()
|
||||||
|
with patch.object(
|
||||||
|
SubscribeChain,
|
||||||
|
"_SubscribeChain__refresh_total_episode_before_completion",
|
||||||
|
fake_refresh,
|
||||||
|
), patch.object(
|
||||||
|
SubscribeChain,
|
||||||
|
"resolve_subscribe_missing",
|
||||||
|
fake_resolve,
|
||||||
|
):
|
||||||
|
exist_flag, no_exists = chain.check_and_handle_existing_media(
|
||||||
|
subscribe=subscribe,
|
||||||
|
meta=meta,
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
mediakey="media-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(exist_flag)
|
||||||
|
self.assertEqual(calls, [("refresh", 10), ("resolve", 20)])
|
||||||
|
self.assertEqual(no_exists["media-key"][1].episodes, [11])
|
||||||
|
|
||||||
def test_best_version_full_pack_first_keeps_whole_missing_for_custom_start_episode(self):
|
def test_best_version_full_pack_first_keeps_whole_missing_for_custom_start_episode(self):
|
||||||
"""分集洗版优先全集时,空集列表仍表示下载链按整季资源处理。"""
|
"""分集洗版优先全集时,空集列表仍表示下载链按整季资源处理。"""
|
||||||
subscribe = self._build_subscribe(
|
subscribe = self._build_subscribe(
|
||||||
@@ -588,7 +927,13 @@ class SubscribeChainTest(TestCase):
|
|||||||
)
|
)
|
||||||
no_exists = {
|
no_exists = {
|
||||||
"media-key": {
|
"media-key": {
|
||||||
1: SimpleNamespace(season=1, episodes=[2], total_episode=3, start_episode=1)
|
1: SimpleNamespace(
|
||||||
|
season=1,
|
||||||
|
episodes=[2],
|
||||||
|
total_episode=3,
|
||||||
|
start_episode=1,
|
||||||
|
require_complete_coverage=False,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
calls = []
|
calls = []
|
||||||
@@ -632,7 +977,13 @@ class SubscribeChainTest(TestCase):
|
|||||||
)
|
)
|
||||||
no_exists = {
|
no_exists = {
|
||||||
"media-key": {
|
"media-key": {
|
||||||
1: SimpleNamespace(season=1, episodes=[2], total_episode=3, start_episode=1)
|
1: SimpleNamespace(
|
||||||
|
season=1,
|
||||||
|
episodes=[2],
|
||||||
|
total_episode=3,
|
||||||
|
start_episode=1,
|
||||||
|
require_complete_coverage=False,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
calls = []
|
calls = []
|
||||||
@@ -680,7 +1031,13 @@ class SubscribeChainTest(TestCase):
|
|||||||
)
|
)
|
||||||
no_exists = {
|
no_exists = {
|
||||||
"media-key": {
|
"media-key": {
|
||||||
1: SimpleNamespace(season=1, episodes=[2], total_episode=3, start_episode=1)
|
1: SimpleNamespace(
|
||||||
|
season=1,
|
||||||
|
episodes=[2],
|
||||||
|
total_episode=3,
|
||||||
|
start_episode=1,
|
||||||
|
require_complete_coverage=False,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
calls = []
|
calls = []
|
||||||
@@ -721,7 +1078,13 @@ class SubscribeChainTest(TestCase):
|
|||||||
)
|
)
|
||||||
no_exists = {
|
no_exists = {
|
||||||
"media-key": {
|
"media-key": {
|
||||||
1: SimpleNamespace(season=1, episodes=[2], total_episode=3, start_episode=1)
|
1: SimpleNamespace(
|
||||||
|
season=1,
|
||||||
|
episodes=[2],
|
||||||
|
total_episode=3,
|
||||||
|
start_episode=1,
|
||||||
|
require_complete_coverage=False,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
calls = []
|
calls = []
|
||||||
|
|||||||
Reference in New Issue
Block a user