diff --git a/app/chain/download.py b/app/chain/download.py index 4c8b34af..8e3e7191 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -562,6 +562,12 @@ class DownloadChain(ChainBase): return set() return set(_context.located_episodes) + def __get_movie_download_key(_context: Context) -> str: + """ + 获取电影下载去重键,确保失败候选不会阻断后续同名资源尝试。 + """ + return _context.media_info.title_year + # 发送资源选择事件,允许外部修改上下文数据 logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}") event_data = ResourceSelectionEventData( @@ -578,14 +584,18 @@ class DownloadChain(ChainBase): f"{len(event_data.updated_contexts)} items (source: {event_data.source})") contexts = event_data.updated_contexts - # 分组排序 - contexts = TorrentHelper().sort_group_torrents(contexts) + # 仅排序,不提前按媒体控重;下载失败时需要继续尝试同组后续候选。 + contexts = TorrentHelper().sort_torrents(contexts) # 如果是电影,直接下载 + downloaded_movies = set() for context in contexts: if global_vars.is_system_stopped: break if context.media_info.type == MediaType.MOVIE: + movie_key = __get_movie_download_key(context) + if movie_key in downloaded_movies: + continue logger.info(f"开始下载电影 {context.torrent_info.title} ...") if self.download_single(context, save_path=save_path, channel=channel, source=source, userid=userid, username=username, @@ -593,6 +603,7 @@ class DownloadChain(ChainBase): # 下载成功 logger.info(f"{context.torrent_info.title} 添加下载成功") downloaded_list.append(context) + downloaded_movies.add(movie_key) # 电视剧整季匹配 if no_exists: diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index f03c7fdf..c1e6eccf 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -105,9 +105,31 @@ class _FakeBatchTorrentHelper: episodes = [] - def sort_group_torrents(self, contexts): + def sort_torrents(self, contexts): + """ + 保持测试输入顺序,避免依赖真实站点优先级配置。 + """ return contexts + def sort_group_torrents(self, contexts): + """ + 模拟真实提前控重行为,回归时会丢掉同一媒体季集的后续候选。 + """ + results = [] + added = set() + for context in contexts: + media = context.media_info + meta = context.meta_info + if media.type == MediaType.TV: + media_name = f"{media.title_year}{meta.season_episode}" + else: + media_name = media.title_year + if media_name in added: + continue + added.add(media_name) + results.append(context) + return results + def get_torrent_episodes(self, _files): return list(self.episodes) @@ -118,9 +140,15 @@ 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), + media_info=SimpleNamespace( + type=MediaType.TV, + title_year="Test Show (2026)", + tmdb_id=1, + douban_id=None, + ), meta_info=SimpleNamespace( season_list=[1], + season_episode="S01E01", episode_list=episodes, title="Test Show", org_string="Test Show S01 2160p", @@ -164,6 +192,74 @@ def test_batch_download_rejects_complete_coverage_when_files_do_not_cover_target chain.download_single.assert_not_called() +def test_batch_download_tries_next_episode_candidate_when_first_download_fails(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_single = MagicMock(side_effect=[None, "hash"]) + + first_context = _build_tv_context(episode_list=[1]) + first_context.torrent_info.title = "Test Show S01E01 First" + second_context = _build_tv_context(episode_list=[1]) + second_context.torrent_info.title = "Test Show S01E01 Second" + no_exists = { + 1: { + 1: NotExistMediaInfo( + season=1, + episodes=[1], + total_episode=1, + start_episode=1, + ) + } + } + + downloads, lefts = chain.batch_download( + contexts=[first_context, second_context], + no_exists=no_exists, + ) + + assert downloads == [second_context] + assert lefts == {} + assert chain.download_single.call_count == 2 + assert chain.download_single.call_args_list[0].args[0] is first_context + assert chain.download_single.call_args_list[1].args[0] is second_context + + +def test_batch_download_does_not_download_duplicate_movie_after_success(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_single = MagicMock(return_value="hash") + + first_context = SimpleNamespace( + media_info=SimpleNamespace(type=MediaType.MOVIE, title_year="Demo Movie (2026)"), + meta_info=SimpleNamespace(season_episode=""), + torrent_info=SimpleNamespace(title="Demo Movie First"), + ) + second_context = SimpleNamespace( + media_info=SimpleNamespace(type=MediaType.MOVIE, title_year="Demo Movie (2026)"), + meta_info=SimpleNamespace(season_episode=""), + torrent_info=SimpleNamespace(title="Demo Movie Second"), + ) + + downloads, lefts = chain.batch_download(contexts=[first_context, second_context]) + + assert downloads == [first_context] + assert lefts is None + chain.download_single.assert_called_once() + assert chain.download_single.call_args.args[0] is first_context + + def test_batch_download_accepts_complete_coverage_when_files_cover_target_range(monkeypatch): """ 自定义起始集场景按目标范围覆盖判断,100-143 可满足 start=100、total=143。