Files
MoviePilot/tests/test_download_batch_phases.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

429 lines
16 KiB
Python

"""
保护批量下载各阶段之间的状态传递与副作用顺序。
"""
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import app.chain.download.batch as download_batch
import app.chain.download.selection as download_selection
from app.chain.download import DownloadChain
from app.domain.context import Context, MediaInfo, TorrentInfo
from app.domain.metainfo import MetaInfo
from app.schemas.mediaserver import NotExistMediaInfo
from app.schemas.types import MediaSource, MediaType, NotificationChannel
def _context(
name: str,
media_id: str = "1",
episodes: tuple[int, ...] = (),
season: int = 1,
media_type: MediaType = MediaType.TV,
) -> Context:
meta = MetaInfo(f"{name} S{season:02d}")
if episodes:
meta.set_episodes(begin=min(episodes), end=max(episodes))
return Context(
meta_info=meta,
media_info=MediaInfo(
title=name, type=media_type, media_source=MediaSource.TMDB, media_id=media_id,
),
torrent_info=TorrentInfo(title=name, site_name="test", site=1),
)
def _missing(
episodes: tuple[int, ...] = (),
total: int = 4,
season: int = 1,
complete: bool = False,
) -> NotExistMediaInfo:
return NotExistMediaInfo(
season=season, episodes=list(episodes), total_episode=total,
start_episode=1, require_complete_coverage=complete,
)
@pytest.fixture
def batch(monkeypatch):
"""
只替换事件、排序、持久化及下载边界,保留真实阶段选择和失败指纹。
"""
chain = object.__new__(DownloadChain)
chain.eventmanager = SimpleNamespace(send_event=MagicMock(return_value=None))
chain._active_download_failure_fingerprints = MagicMock(return_value={})
chain._record_download_failure = MagicMock()
chain._log_download_failure_cooldown = MagicMock()
chain.download_single = MagicMock(return_value="download-id")
chain.download_torrent = MagicMock(return_value=(b"torrent", None, [1, 2, 3, 4]))
helper = SimpleNamespace(
sort_torrents=MagicMock(side_effect=lambda contexts: contexts),
get_torrent_episodes=MagicMock(side_effect=lambda files, **_kwargs: files),
)
stop = SimpleNamespace(is_system_stopped=False)
monkeypatch.setattr(download_batch, "TorrentHelper", lambda: helper)
monkeypatch.setattr(download_selection, "TorrentHelper", lambda: helper)
monkeypatch.setattr(download_batch, "runtime_stop_state", stop)
monkeypatch.setattr(download_selection, "runtime_stop_state", stop)
return SimpleNamespace(chain=chain, helper=helper, stop=stop)
def _submitted(chain) -> list[Context]:
return [
call.args[0] if call.args else call.kwargs["context"]
for call in chain.download_single.call_args_list
]
def test_batch_preserves_phase_order_and_submission_options(batch):
"""
批量下载保持各媒体阶段顺序,并完整传递提交选项。
"""
partial = _context("partial", media_id="3")
labelled = _context("labelled", media_id="2", episodes=(2,))
whole = _context("whole")
movie = _context("movie", media_type=MediaType.MOVIE)
music = _context("music", media_type=MediaType.MUSIC)
missing = {
"tmdb:3": {1: _missing((3,))},
"tmdb:2": {1: _missing((2,))},
"tmdb:1": {1: _missing()},
}
options = {
"save_path": "smb:/server/media",
"channel": NotificationChannel.Telegram,
"source": "Subscribe|42",
"userid": "user-id",
"username": "plugin-name",
"downloader": "test-downloader",
"custom_words": "first\nsecond",
"governance": object(),
}
result, remaining = batch.chain.batch_download(
[partial, labelled, whole, movie, music], missing, **options,
)
assert result == [movie, music, whole, labelled, partial]
assert _submitted(batch.chain) == result
assert remaining is missing
assert remaining == {}
assert batch.chain.eventmanager.send_event.call_count == 1
assert batch.helper.sort_torrents.call_count == 1
assert batch.chain._active_download_failure_fingerprints.call_count == 1
for call in batch.chain.download_single.call_args_list:
assert {key: call.kwargs[key] for key in options} == options
calls = batch.chain.download_single.call_args_list
assert "torrent_content" not in calls[0].kwargs
assert calls[2].kwargs["torrent_content"] == b"torrent"
assert "torrent_content" not in calls[3].kwargs
assert calls[4].kwargs["episodes"] == {3}
assert all(
call.kwargs["custom_words"] == ["first", "second"]
for call in batch.helper.get_torrent_episodes.call_args_list
)
def test_incomplete_season_becomes_labelled_candidate_after_file_inspection(batch):
"""
整季候选内容不足时按解析出的剧集转入带集数候选阶段。
"""
candidate = _context("incomplete")
missing = {"tmdb:1": {1: _missing()}}
batch.chain.download_torrent.return_value = (b"torrent", None, [1, 2])
result, remaining = batch.chain.batch_download([candidate], missing)
assert result == [candidate]
assert remaining is missing
assert remaining["tmdb:1"][1].episodes == [3, 4]
assert candidate.meta_info.episode_list == [1, 2]
batch.chain.download_torrent.assert_called_once()
batch.chain.download_single.assert_called_once()
assert "torrent_content" not in batch.chain.download_single.call_args.kwargs
batch.chain._record_download_failure.assert_not_called()
def test_failed_season_submission_stays_in_cooldown_after_metadata_changes(batch):
"""
整季下载失败后,即使剧集元数据变化也继续受到失败冷却约束。
"""
whole = _context("whole")
fallback = _context("fallback", episodes=(1,))
missing = {"tmdb:1": {1: _missing(total=3)}}
batch.chain.download_torrent.return_value = (b"torrent", None, [1, 2, 3])
batch.chain.download_single.side_effect = [None, "fallback-id"]
result, remaining = batch.chain.batch_download([whole, fallback], missing)
assert result == [fallback]
assert _submitted(batch.chain) == [whole, fallback]
assert remaining["tmdb:1"][1].episodes == [2, 3]
assert whole.meta_info.episode_list == [1, 2, 3]
batch.chain.download_torrent.assert_called_once()
assert batch.chain._log_download_failure_cooldown.call_count == 2
def test_empty_torrent_is_recorded_once_and_not_retried_in_later_phases(batch):
"""
空种子失败只记录一次,后续阶段不重复尝试。
"""
whole = _context("empty")
fallback = _context("fallback", episodes=(1,))
missing = {"tmdb:1": {1: _missing(total=3)}}
batch.chain.download_torrent.return_value = (None, "failed", [])
result, remaining = batch.chain.batch_download(
[whole, fallback], missing, source="Subscribe|42", downloader="test",
)
assert result == [fallback]
assert remaining["tmdb:1"][1].episodes == [2, 3]
batch.chain.download_torrent.assert_called_once()
batch.chain._record_download_failure.assert_called_once_with(
context=whole, error_msg="下载种子内容为空", downloader="test", source="Subscribe|42",
)
def test_uninspectable_magnet_is_not_cooled_down_as_a_download_failure(batch):
"""
无法检查文件的磁力链接不被误判为下载失败冷却。
"""
whole = _context("magnet")
fallback = _context("fallback", episodes=(1,))
missing = {"tmdb:1": {1: _missing(total=3)}}
batch.chain.download_torrent.return_value = ("magnet:?xt=test", None, [])
result, remaining = batch.chain.batch_download([whole, fallback], missing)
assert result == [fallback]
assert remaining["tmdb:1"][1].episodes == [2, 3]
assert batch.chain.download_torrent.call_count == 2
batch.chain._record_download_failure.assert_not_called()
batch.chain._log_download_failure_cooldown.assert_not_called()
batch.helper.get_torrent_episodes.assert_not_called()
def test_partial_packs_only_settle_successful_allowed_episode_selections(batch):
"""
分集包仅结算成功提交且在允许范围内的剧集。
"""
failed = _context("failed", episodes=(1, 4))
first = _context("first")
first.allowed_episodes = {1, 3}
second = _context("second")
missing = {"tmdb:1": {1: _missing((1, 2, 3))}}
batch.chain.download_torrent.side_effect = [
(b"failed", None, [1, 2, 4]),
(b"first", None, [1, 3, 4]),
(b"second", None, [2, 4]),
]
needs_at_submission = []
def submit(**kwargs):
needs_at_submission.append(list(missing["tmdb:1"][1].episodes))
return None if kwargs["context"] is failed else "id"
batch.chain.download_single.side_effect = submit
result, remaining = batch.chain.batch_download([failed, first, second], missing)
assert result == [first, second]
assert remaining is missing
assert remaining == {}
assert needs_at_submission == [[1, 2, 3], [1, 2, 3], [2]]
assert [
call.kwargs["episodes"] for call in batch.chain.download_single.call_args_list
] == [{1, 2}, {1, 3}, {2}]
assert first.meta_info.episode_list == [1, 2, 3, 4]
assert second.meta_info.episode_list == [2, 3, 4]
assert failed.meta_info.episode_list == [1, 2, 3, 4]
@pytest.mark.parametrize("missing", [None, {}])
def test_batch_preserves_optional_missing_result_and_resets_per_call_state(batch, missing):
"""
批量调用保留可选缺集结果的对象语义,并重置每次调用状态。
"""
candidate = _context("movie", media_type=MediaType.MOVIE)
batch.chain.download_single.side_effect = [None, "retry-id"]
first, first_remaining = batch.chain.batch_download([candidate], missing)
second, second_remaining = batch.chain.batch_download([candidate], missing)
assert first == []
assert second == [candidate]
assert first_remaining is missing
assert second_remaining is missing
assert batch.chain.download_single.call_count == 2
def test_reentrant_batch_call_keeps_outer_candidates_and_accounting(batch):
"""
下载回调触发另一批次时,两次调用不能共用候选、结果或缺集状态。
"""
movie = _context("outer-movie", media_type=MediaType.MOVIE)
nested = _context("nested-movie", media_type=MediaType.MOVIE)
whole = _context("whole")
missing = {"tmdb:1": {1: _missing()}}
nested_results = []
def submit(*args, **kwargs):
context = args[0] if args else kwargs["context"]
if context is movie:
nested_results.append(batch.chain.batch_download([nested]))
return "id"
batch.chain.download_single.side_effect = submit
result, remaining = batch.chain.batch_download([whole, movie], missing)
assert result == [movie, whole]
assert nested_results == [([nested], None)]
assert remaining is missing
assert remaining == {}
assert _submitted(batch.chain) == [movie, nested, whole]
def test_stop_during_movie_submission_prevents_later_phase_side_effects(batch):
"""
电影提交期间收到停止信号后,不再执行后续阶段的副作用。
"""
movie = _context("movie", media_type=MediaType.MOVIE)
whole = _context("whole")
missing = {"tmdb:1": {1: _missing()}}
def submit(*_args, **_kwargs):
batch.stop.is_system_stopped = True
return "movie-id"
batch.chain.download_single.side_effect = submit
result, remaining = batch.chain.batch_download([whole, movie], missing)
assert result == [movie]
assert remaining == {"tmdb:1": {1: _missing()}}
batch.chain.download_single.assert_called_once()
batch.chain.download_torrent.assert_not_called()
def test_multiseason_pack_submits_without_reading_torrent_files(batch):
"""
多季合集直接提交,不额外读取种子文件确认覆盖范围。
"""
candidate = _context("multiseason", season=0)
candidate.meta_info.end_season = 1
missing = {"tmdb:1": {0: _missing(season=0), 1: _missing()}}
result, remaining = batch.chain.batch_download([candidate], missing)
assert result == [candidate]
assert remaining is missing
assert remaining == {}
batch.chain.download_torrent.assert_not_called()
assert batch.chain.download_single.call_args.args == (candidate,)
assert candidate.confirmed_full_coverage is False
def test_partial_failure_does_not_rewrite_candidate_episode_range(batch):
"""
分集提交失败不得改写候选的原始剧集范围。
"""
candidate = _context("partial")
missing = {"tmdb:1": {1: _missing((2,))}}
batch.chain.download_single.return_value = None
batch.chain.download_torrent.return_value = (b"torrent", None, [1, 2, 3])
result, remaining = batch.chain.batch_download([candidate], missing)
assert result == []
assert remaining["tmdb:1"][1].episodes == [2]
assert candidate.meta_info.episode_list == []
batch.chain.download_single.assert_called_once()
def test_failed_complete_coverage_submission_does_not_confirm_or_settle(batch):
"""
要求完整覆盖的提交失败时,不确认覆盖完成或结算缺集。
"""
candidate = _context("complete")
missing = {"tmdb:1": {1: _missing(complete=True)}}
batch.chain.download_single.return_value = None
result, remaining = batch.chain.batch_download([candidate], missing)
assert result == []
assert remaining["tmdb:1"][1].require_complete_coverage is True
assert remaining["tmdb:1"][1].episodes == []
assert candidate.confirmed_full_coverage is False
batch.chain.download_single.assert_called_once()
def test_higher_priority_labelled_full_pack_wins_whole_season_phase(batch):
"""
标注完整集数的高优先级包与无集数整季包同轮竞争,不被后者抢先清空缺季。
"""
labelled = _context("labelled", episodes=(1, 2, 3, 4))
whole = _context("whole")
missing = {"tmdb:1": {1: _missing(complete=True)}}
result, remaining = batch.chain.batch_download([labelled, whole], missing)
assert result == [labelled]
assert remaining == {}
batch.chain.download_torrent.assert_called_once()
assert batch.chain.download_single.call_args.kwargs["torrent_content"] == b"torrent"
assert labelled.confirmed_full_coverage is True
@pytest.mark.parametrize(
("episodes", "total"),
[((1, 2), 4), ((1, 2, 3, 4), 0)],
ids=["partial-range", "unknown-total"],
)
def test_labelled_pack_without_full_coverage_stays_out_of_whole_season_phase(batch, episodes, total):
"""
未覆盖整季的带集数候选不得进入整季优先阶段。
"""
labelled = _context("labelled", episodes=episodes)
whole = _context("whole")
missing = {"tmdb:1": {1: _missing(total=total)}}
result, _remaining = batch.chain.batch_download([labelled, whole], missing)
assert result == [whole]
batch.chain.download_torrent.assert_called_once()
assert batch.chain.download_single.call_args.kwargs["context"] is whole
def test_labelled_full_pack_respects_candidate_allowed_episodes(batch):
"""
带集数完整合集仍必须遵守候选允许下载的剧集范围。
"""
labelled = _context("labelled", episodes=(1, 2, 3, 4))
labelled.allowed_episodes = {1, 2}
missing = {"tmdb:1": {1: _missing()}}
result, remaining = batch.chain.batch_download([labelled], missing)
assert result == []
assert remaining["tmdb:1"][1].episodes == []
batch.chain.download_torrent.assert_not_called()
batch.chain.download_single.assert_not_called()
def test_labelled_full_pack_magnet_falls_back_to_episode_pack_phase(batch):
"""
无法检查内容的完整合集磁力链接回退到分集包阶段提交。
"""
labelled = _context("labelled", episodes=(1, 2, 3, 4))
missing = {"tmdb:1": {1: _missing(complete=True)}}
batch.chain.download_torrent.return_value = ("magnet:?xt=test", None, [])
result, remaining = batch.chain.batch_download([labelled], missing)
assert result == [labelled]
assert remaining == {}
batch.chain.download_torrent.assert_called_once()
assert "torrent_content" not in batch.chain.download_single.call_args.kwargs
batch.chain._record_download_failure.assert_not_called()