fix(subscribe): require complete coverage for full best versions (#5857)

This commit is contained in:
InfinityPacer
2026-05-29 14:10:29 +08:00
committed by GitHub
parent 762a7fbba7
commit b45956f850
4 changed files with 288 additions and 6 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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):

View File

@@ -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()