diff --git a/app/chain/download.py b/app/chain/download.py index 7fe76c68..0113222a 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -492,7 +492,8 @@ class DownloadChain(ChainBase): season=not_exist.season, episodes=need, total_episode=not_exist.total_episode, - start_episode=not_exist.start_episode + start_episode=not_exist.start_episode, + require_complete_coverage=not_exist.require_complete_coverage ) else: no_exists[_mid].pop(_sea) @@ -511,6 +512,34 @@ class DownloadChain(ChainBase): return 9999 return no_exist[season].total_episode + def __get_no_exist_media(_mid: Union[int, str], season: int) -> Optional[NotExistMediaInfo]: + """ + 获取指定媒体和季的缺失信息。 + """ + if not no_exists or not no_exists.get(_mid): + return None + return no_exists.get(_mid).get(season) + + def __get_required_episodes(_mid: Union[int, str], season: int) -> Set[int]: + """ + 获取整季候选必须覆盖的目标集范围。 + """ + tv = __get_no_exist_media(_mid, season) + if not tv: + return set() + if not tv.total_episode: + return set() + start = tv.start_episode or 1 + return set(range(start, tv.total_episode + 1)) + + def __requires_complete_coverage(_tv: Optional[NotExistMediaInfo]) -> bool: + """ + 判断当前缺失范围是否要求候选资源完整覆盖目标范围。 + """ + if not _tv: + return False + return bool(_tv.require_complete_coverage) + def __apply_allowed_episodes(_need_episodes, _context: Context) -> Set[int]: """ 根据候选携带的允许集裁剪 need_episodes,返回真正可下载的剧集集合。 @@ -616,13 +645,23 @@ class DownloadChain(ChainBase): logger.info(f"{meta.org_string} 解析种子文件集数为 {torrent_episodes}") if not torrent_episodes: continue + torrent_episodes_set = set(torrent_episodes) # 更新集数范围 begin_ep = min(torrent_episodes) end_ep = max(torrent_episodes) meta.set_episodes(begin=begin_ep, end=end_ep) - # 需要总集数 + # 需要目标集范围;完整覆盖场景必须覆盖范围内每一集,不能只按数量判断。 + need_tv_info = __get_no_exist_media(need_mid, torrent_season[0]) + required_episodes = __get_required_episodes(need_mid, torrent_season[0]) \ + if __requires_complete_coverage(need_tv_info) else set() need_total = __get_season_episodes(need_mid, torrent_season[0]) - if len(torrent_episodes) < need_total: + if required_episodes and not required_episodes.issubset(torrent_episodes_set): + missing_episodes = sorted(required_episodes.difference(torrent_episodes_set)) + logger.info( + f"{meta.org_string} 解析文件集数未覆盖目标范围," + f"缺少 {StringUtils.format_ep(missing_episodes)},先放弃这个种子") + continue + if not required_episodes and need_total and len(torrent_episodes) < need_total: logger.info( f"{meta.org_string} 解析文件集数发现不是完整合集,先放弃这个种子") continue @@ -713,8 +752,15 @@ class DownloadChain(ChainBase): effective_need = __apply_allowed_episodes(need_episodes, context) if not effective_need: continue - # 为需要集的子集则下载 - if torrent_episodes.issubset(effective_need): + if __requires_complete_coverage(tv): + # 完整覆盖任务要求候选集数覆盖目标范围,允许资源包含范围外的额外集。 + required_episodes = __get_required_episodes(need_mid, need_season) + match_episodes = required_episodes.issubset(torrent_episodes) \ + if required_episodes else False + else: + # 普通缺集下载保持原语义:候选自身必须是所需集的子集。 + match_episodes = torrent_episodes.issubset(effective_need) + if match_episodes: # 下载 logger.info(f"开始下载 {meta.title} ...") download_id = self.download_single(context, save_path=save_path, @@ -752,6 +798,8 @@ class DownloadChain(ChainBase): need_season = sea # 当前需要集 need_episodes = tv.episodes + if __requires_complete_coverage(tv): + continue # 没有集的不处理 if not need_episodes: continue diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index ac4501d5..8cc03a9a 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -3079,7 +3079,9 @@ class SubscribeChain(ChainBase): season=subscribe.season, episodes=pending_episodes, total_episode=subscribe.total_episode, - start_episode=subscribe.start_episode or 1) + start_episode=subscribe.start_episode or 1, + # 完整覆盖约束会影响整季文件探测、显式集列表匹配和多集拆包降级。 + require_complete_coverage=self.__is_full_best_version_enabled(subscribe)) } } else: diff --git a/app/schemas/mediaserver.py b/app/schemas/mediaserver.py index 71905a42..fcfc381f 100644 --- a/app/schemas/mediaserver.py +++ b/app/schemas/mediaserver.py @@ -34,6 +34,8 @@ class NotExistMediaInfo(BaseModel): total_episode: Optional[int] = 0 # 开始集 start_episode: Optional[int] = 0 + # 候选资源须完整覆盖目标范围 + require_complete_coverage: Optional[bool] = False class RefreshMediaItem(BaseModel): diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index f1aca479..d173b947 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -1,10 +1,12 @@ from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock import app.chain.download as download_module from app.chain.download import DownloadChain from app.core.context import Context, MediaInfo, TorrentInfo from app.core.metainfo import MetaInfo +from app.schemas import NotExistMediaInfo from app.schemas.types import MediaType @@ -95,3 +97,231 @@ def test_download_single_submits_download_added_to_background(monkeypatch): download_dir=Path("/downloads"), torrent_content=b"torrent-content", ) + + +class _FakeBatchTorrentHelper: + """ + 为批量下载测试提供稳定排序和种子文件集数解析。 + """ + + episodes = [] + + def sort_group_torrents(self, contexts): + return contexts + + def get_torrent_episodes(self, _files): + return list(self.episodes) + + +def _build_tv_context(episode_list=None): + """ + 构造标题未显式标集数的单季电视剧候选。 + """ + episodes = episode_list or [] + return SimpleNamespace( + media_info=SimpleNamespace(type=MediaType.TV, tmdb_id=1, douban_id=None), + meta_info=SimpleNamespace( + season_list=[1], + episode_list=episodes, + title="Test Show", + org_string="Test Show S01 2160p", + set_episodes=lambda begin, end: None, + ), + torrent_info=SimpleNamespace(title="Test Show S01 2160p", site_name="TestSite"), + allowed_episodes=None, + ) + + +def test_batch_download_rejects_complete_coverage_when_files_do_not_cover_target(monkeypatch): + """ + 完整覆盖要求不能让 1-13 这种局部包冒充 1-143 的目标范围。 + """ + _FakeBatchTorrentHelper.episodes = list(range(1, 14)) + monkeypatch.setattr(download_module, "TorrentHelper", _FakeBatchTorrentHelper) + monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) + + chain = DownloadChain.__new__(DownloadChain) + chain.download_torrent = MagicMock(return_value=(b"torrent-content", "", ["demo.mkv"])) + chain.download_single = MagicMock(return_value="hash") + + context = _build_tv_context() + no_exists = { + 1: { + 1: NotExistMediaInfo( + season=1, + episodes=[], + total_episode=143, + start_episode=1, + require_complete_coverage=True, + ) + } + } + + downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) + + assert downloads == [] + assert lefts == no_exists + chain.download_single.assert_not_called() + + +def test_batch_download_accepts_complete_coverage_when_files_cover_target_range(monkeypatch): + """ + 自定义起始集场景按目标范围覆盖判断,100-143 可满足 start=100、total=143。 + """ + _FakeBatchTorrentHelper.episodes = list(range(100, 144)) + monkeypatch.setattr(download_module, "TorrentHelper", _FakeBatchTorrentHelper) + monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) + + chain = DownloadChain.__new__(DownloadChain) + chain.download_torrent = MagicMock(return_value=(b"torrent-content", "", ["demo.mkv"])) + chain.download_single = MagicMock(return_value="hash") + + context = _build_tv_context() + no_exists = { + 1: { + 1: NotExistMediaInfo( + season=1, + episodes=[], + total_episode=143, + start_episode=100, + require_complete_coverage=True, + ) + } + } + + downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) + + assert downloads == [context] + assert lefts == {} + chain.download_single.assert_called_once() + + +def test_batch_download_accepts_complete_coverage_when_title_episodes_cover_target(monkeypatch): + """ + 显式标出完整范围的候选也可满足完整覆盖任务。 + """ + _FakeBatchTorrentHelper.episodes = [] + monkeypatch.setattr(download_module, "TorrentHelper", _FakeBatchTorrentHelper) + monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) + + chain = DownloadChain.__new__(DownloadChain) + chain.download_torrent = MagicMock() + chain.download_single = MagicMock(return_value="hash") + + context = _build_tv_context(episode_list=list(range(1, 144))) + no_exists = { + 1: { + 1: NotExistMediaInfo( + season=1, + episodes=[], + total_episode=143, + start_episode=1, + require_complete_coverage=True, + ) + } + } + + downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) + + assert downloads == [context] + assert lefts == {} + chain.download_torrent.assert_not_called() + chain.download_single.assert_called_once() + + +def test_batch_download_rejects_complete_coverage_when_title_episodes_are_partial(monkeypatch): + """ + 显式标出局部范围的候选不能满足完整覆盖任务。 + """ + _FakeBatchTorrentHelper.episodes = [] + monkeypatch.setattr(download_module, "TorrentHelper", _FakeBatchTorrentHelper) + monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) + + chain = DownloadChain.__new__(DownloadChain) + chain.download_torrent = MagicMock() + chain.download_single = MagicMock(return_value="hash") + + context = _build_tv_context(episode_list=list(range(1, 14))) + no_exists = { + 1: { + 1: NotExistMediaInfo( + season=1, + episodes=[], + total_episode=143, + start_episode=1, + require_complete_coverage=True, + ) + } + } + + downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) + + assert downloads == [] + assert lefts == no_exists + chain.download_torrent.assert_not_called() + chain.download_single.assert_not_called() + + +def test_batch_download_complete_coverage_ignores_allowed_episode_narrowing(monkeypatch): + """ + 完整覆盖任务不能因候选允许集裁剪而把局部包误判为覆盖目标范围。 + """ + _FakeBatchTorrentHelper.episodes = [] + monkeypatch.setattr(download_module, "TorrentHelper", _FakeBatchTorrentHelper) + monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) + + chain = DownloadChain.__new__(DownloadChain) + chain.download_torrent = MagicMock() + chain.download_single = MagicMock(return_value="hash") + + context = _build_tv_context(episode_list=[1, 2]) + context.allowed_episodes = {1, 2} + no_exists = { + 1: { + 1: NotExistMediaInfo( + season=1, + episodes=[], + total_episode=12, + start_episode=1, + require_complete_coverage=True, + ) + } + } + + downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) + + assert downloads == [] + assert lefts == no_exists + chain.download_torrent.assert_not_called() + chain.download_single.assert_not_called() + + +def test_batch_download_keeps_count_check_without_complete_coverage(monkeypatch): + """ + 普通整季缺失仍沿用数量判断,避免完整覆盖语义影响非严格场景。 + """ + _FakeBatchTorrentHelper.episodes = list(range(2, 145)) + monkeypatch.setattr(download_module, "TorrentHelper", _FakeBatchTorrentHelper) + monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) + + chain = DownloadChain.__new__(DownloadChain) + chain.download_torrent = MagicMock(return_value=(b"torrent-content", "", ["demo.mkv"])) + chain.download_single = MagicMock(return_value="hash") + + context = _build_tv_context() + no_exists = { + 1: { + 1: NotExistMediaInfo( + season=1, + episodes=[], + total_episode=143, + start_episode=1, + ) + } + } + + downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) + + assert downloads == [context] + assert lefts == {} + chain.download_single.assert_called_once()