revert: absolute numbered season pack locating (#5869)

This commit is contained in:
InfinityPacer
2026-06-01 21:09:23 +08:00
committed by GitHub
parent d353e7b208
commit e43adf51af
5 changed files with 49 additions and 748 deletions

View File

@@ -554,14 +554,6 @@ class DownloadChain(ChainBase):
effective &= set(allowed)
return effective
def __get_located_target_episodes(_context: Context) -> Set[int]:
"""
返回集数定位后的目标季内集数;缺失定位不扩大候选范围。
"""
if not _context.located_episodes:
return set()
return set(_context.located_episodes)
def __get_movie_download_key(_context: Context) -> str:
"""
获取电影下载去重键,确保失败候选不会阻断后续同名资源尝试。
@@ -640,9 +632,8 @@ class DownloadChain(ChainBase):
# 没有季的默认为第1季
if not torrent_season:
torrent_season = [1]
# 常规标题带集数的资源不走整季路径;已完成高置信集数定位的候选仍需打开种子文件确认。
located_target_episodes = __get_located_target_episodes(context)
if meta.episode_list and not located_target_episodes:
# 种子有集的不要
if meta.episode_list:
continue
# 匹配TMDBID
if need_mid == media.tmdb_id or need_mid == media.douban_id:
@@ -763,9 +754,8 @@ class DownloadChain(ChainBase):
# 只处理单季含集的种子
if len(torrent_season) != 1 or torrent_season[0] != need_season:
continue
# 种子集列表;累计总集编号等场景优先使用订阅阶段定位出的季内目标集。
located_target_episodes = __get_located_target_episodes(context)
torrent_episodes = located_target_episodes or set(meta.episode_list)
# 种子集列表
torrent_episodes = set(meta.episode_list)
# 整季的不处理
if not torrent_episodes:
continue
@@ -847,12 +837,10 @@ class DownloadChain(ChainBase):
effective_need = __apply_allowed_episodes(need_episodes, context)
if not effective_need:
continue
located_target_episodes = __get_located_target_episodes(context)
match_episode_candidates = located_target_episodes or set(meta.episode_list)
# 选中一个单季整季的或单季包括需要的所有集的
if (media.tmdb_id == need_mid or media.douban_id == need_mid) \
and (not match_episode_candidates
or match_episode_candidates.intersection(effective_need)) \
and (not meta.episode_list
or set(meta.episode_list).intersection(effective_need)) \
and len(meta.season_list) == 1 \
and meta.season_list[0] == need_season:
# 检查种子看是否有需要的集

View File

@@ -24,7 +24,7 @@ from app.helper.interaction import (
from app.chain.tmdb import TmdbChain
from app.chain.torrents import TorrentsChain
from app.core.config import settings, global_vars
from app.core.context import TorrentInfo, Context, MediaInfo, EpisodeLocation
from app.core.context import TorrentInfo, Context, MediaInfo
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase
from app.core.meta.words import WordsMatcher
@@ -283,7 +283,6 @@ class SubscribeChain(ChainBase):
subscribe: Subscribe,
context: Context,
priority: int,
episode_location: Optional[EpisodeLocation] = None,
) -> List[int]:
"""
获取当前资源中仍值得继续洗版的剧集。
@@ -297,11 +296,7 @@ class SubscribeChain(ChainBase):
selected_episodes = getattr(context, "selected_episodes", None)
if selected_episodes is None:
if episode_location is None:
episode_location = cls.__locate_context_episodes(context=context, subscribe=subscribe)
selected_episodes = episode_location.target_episodes \
if cls.__is_high_confidence_episode_location(episode_location) \
else (context.meta_info.episode_list if context.meta_info else [])
selected_episodes = context.meta_info.episode_list if context.meta_info else []
if not selected_episodes:
episode_priority = cls.__get_episode_priority(subscribe)
return sorted([
@@ -323,59 +318,6 @@ class SubscribeChain(ChainBase):
interested.append(episode_num)
return sorted(set(interested))
@classmethod
def __normalize_best_version_resource_episodes(
cls,
meta: MetaBase,
subscribe: Subscribe,
episodes: Optional[List[int]] = None,
) -> List[int]:
"""
将同季累计总集编号映射为订阅季内集数。
"""
raw_episodes = episodes if episodes is not None else (meta.episode_list if meta else [])
if not raw_episodes:
return []
try:
resource_episodes = sorted(set(int(episode) for episode in raw_episodes))
except (TypeError, ValueError):
return list(raw_episodes)
episode_location = EpisodeLocation.locate(
meta=meta,
target_season=subscribe.season,
target_episodes=cls.__get_best_version_target_episodes(subscribe),
episodes=resource_episodes,
)
if episode_location:
return episode_location.target_episodes
return resource_episodes
@classmethod
def __locate_context_episodes(cls, context: Context, subscribe: Subscribe) -> Optional[EpisodeLocation]:
"""
为候选上下文缓存集数定位结果,供订阅过滤和下载链保持同一套集数语义。
"""
if not context or not context.meta_info:
return None
episode_location = context.locate_episode(
target_season=subscribe.season,
target_episodes=cls.__get_best_version_target_episodes(subscribe),
)
# located_episodes 是面向后续流程的目标季内集数视图,只接受可直接参与匹配的高置信定位结果。
context.located_episodes = set(episode_location.target_episodes) \
if episode_location and episode_location.confidence == EpisodeLocation.CONFIDENCE_HIGH else None
return episode_location
@staticmethod
def __is_high_confidence_episode_location(episode_location: Optional[EpisodeLocation]) -> bool:
"""
判断集数定位结果是否可直接参与订阅过滤与状态更新。
"""
return bool(episode_location and episode_location.confidence == EpisodeLocation.CONFIDENCE_HIGH)
@classmethod
def __is_full_best_version_enabled(cls, subscribe: Subscribe) -> bool:
"""
@@ -388,24 +330,17 @@ class SubscribeChain(ChainBase):
)
@classmethod
def __is_full_season_resource(
cls,
meta: MetaBase,
subscribe: Subscribe,
episode_location: Optional[EpisodeLocation] = None,
) -> bool:
def __is_full_season_resource(cls, meta: MetaBase, subscribe: Subscribe) -> bool:
"""
判断候选资源是否覆盖订阅目标全集范围。
"""
season_list = meta.season_list or [1]
if subscribe.season is not None:
if len(season_list) != 1:
return False
if season_list[0] != subscribe.season:
return False
if len(season_list) != 1:
return False
if subscribe.season is not None and season_list[0] != subscribe.season:
return False
episodes = episode_location.target_episodes if cls.__is_high_confidence_episode_location(episode_location) \
else cls.__normalize_best_version_resource_episodes(meta=meta, subscribe=subscribe)
episodes = meta.episode_list
if not episodes:
# 资源未标出单集时按整季包处理,后续下载前仍会解析种子文件确认完整性。
return True
@@ -416,23 +351,14 @@ class SubscribeChain(ChainBase):
return target_episodes.issubset(set(episodes))
@classmethod
def __is_full_season_best_version_resource(
cls,
meta: MetaBase,
subscribe: Subscribe,
episode_location: Optional[EpisodeLocation] = None,
) -> bool:
def __is_full_season_best_version_resource(cls, meta: MetaBase, subscribe: Subscribe) -> bool:
"""
判断候选资源是否符合全集洗版资源约束。
"""
if not cls.__is_full_best_version_enabled(subscribe):
return True
return cls.__is_full_season_resource(
meta=meta,
subscribe=subscribe,
episode_location=episode_location,
)
return cls.__is_full_season_resource(meta=meta, subscribe=subscribe)
@classmethod
def __is_full_season_priority_higher_than_all_targets(cls, subscribe: Subscribe, priority: int) -> bool:
@@ -1110,21 +1036,13 @@ class SubscribeChain(ChainBase):
torrent_meta = context.meta_info
torrent_info = context.torrent_info
torrent_mediainfo = context.media_info
episode_location = None
if torrent_mediainfo.type == MediaType.TV:
episode_location = self.__locate_context_episodes(
context=context,
subscribe=subscribe,
)
# 洗版
if subscribe.best_version:
if (
torrent_mediainfo.type == MediaType.TV
and not self.__is_full_season_best_version_resource(
meta=torrent_meta,
subscribe=subscribe,
episode_location=episode_location,
meta=torrent_meta, subscribe=subscribe
)
):
logger.info(
@@ -1135,9 +1053,7 @@ class SubscribeChain(ChainBase):
if (
torrent_mediainfo.type == MediaType.TV
and not self._is_episode_range_covered(
meta=torrent_meta,
subscribe=subscribe,
episode_location=episode_location,
meta=torrent_meta, subscribe=subscribe
)
):
logger.info(
@@ -1150,7 +1066,6 @@ class SubscribeChain(ChainBase):
subscribe=subscribe,
context=context,
priority=torrent_info.pri_order,
episode_location=episode_location,
)
if not interested_episodes:
logger.info(
@@ -1243,15 +1158,7 @@ class SubscribeChain(ChainBase):
updated = False
for download in downloads:
download_priority = download.torrent_info.pri_order
if download.located_episodes:
downloaded_episodes = sorted(download.located_episodes)
else:
downloaded_episodes = self.__get_downloaded_episodes([download])
downloaded_episodes = self.__normalize_best_version_resource_episodes(
meta=download.meta_info,
subscribe=subscribe,
episodes=downloaded_episodes,
)
downloaded_episodes = self.__get_downloaded_episodes([download])
if not downloaded_episodes and self.__is_full_season_resource(download.meta_info, subscribe):
# 整包下载时资源标题常不携带集数,视为覆盖当前订阅的全部目标集。
downloaded_episodes = self.__get_best_version_target_episodes(subscribe)
@@ -1612,7 +1519,6 @@ class SubscribeChain(ChainBase):
# 如果是电视剧
if torrent_mediainfo.type == MediaType.TV:
episode_location = self.__locate_context_episodes(context=_context, subscribe=subscribe)
# 有多季的不要
if len(torrent_meta.season_list) > 1:
logger.debug(f'{torrent_info.title} 有多季,不处理')
@@ -1632,24 +1538,20 @@ class SubscribeChain(ChainBase):
# 缺失集
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
if no_exists_info:
torrent_episodes = episode_location.target_episodes \
if self.__is_high_confidence_episode_location(episode_location) \
else torrent_meta.episode_list
# 是否有交集
if no_exists_info.episodes and \
torrent_episodes and \
torrent_meta.episode_list and \
not set(no_exists_info.episodes).intersection(
set(torrent_episodes)
set(torrent_meta.episode_list)
):
logger.debug(
f'{torrent_info.title} 对应剧集 {torrent_episodes} 未包含缺失的剧集'
f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集'
)
continue
else:
if not self.__is_full_season_best_version_resource(
meta=torrent_meta,
subscribe=subscribe,
episode_location=episode_location,
):
logger.debug(
f"{subscribe.name} 正在全集洗版,{torrent_info.title} 不是全集资源"
@@ -1661,7 +1563,6 @@ class SubscribeChain(ChainBase):
and not self._is_episode_range_covered(
meta=torrent_meta,
subscribe=subscribe,
episode_location=episode_location,
)
):
logger.debug(
@@ -1697,7 +1598,6 @@ class SubscribeChain(ChainBase):
subscribe=subscribe,
context=_context,
priority=torrent_info.pri_order,
episode_location=episode_location,
)
if not interested_episodes:
logger.info(
@@ -3328,17 +3228,11 @@ class SubscribeChain(ChainBase):
)
@classmethod
def _is_episode_range_covered(
cls,
meta: MetaBase,
subscribe: Subscribe,
episode_location: Optional[EpisodeLocation] = None,
) -> bool:
def _is_episode_range_covered(cls, meta: MetaBase, subscribe: Subscribe) -> bool:
"""
判断种子是否覆盖当前仍需洗版的剧集范围。
"""
episodes = episode_location.target_episodes if cls.__is_high_confidence_episode_location(episode_location) \
else cls.__normalize_best_version_resource_episodes(meta=meta, subscribe=subscribe)
episodes = meta.episode_list
if not episodes:
# 没有剧集信息,表示该种子为合集
return True

View File

@@ -1,7 +1,7 @@
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Dict, Any, Tuple, Optional, Set, ClassVar
from typing import List, Dict, Any, Tuple, Optional, Set
from app.core.config import settings
from app.core.meta import MetaBase
@@ -808,72 +808,6 @@ class MediaInfo:
self.episode_groups = []
@dataclass
class EpisodeLocation:
"""
候选资源标题集数与订阅目标季内集数之间的定位结果。
"""
# 定位模式absolute_to_season 表示同季累计总集编号映射为订阅季内集数。
MODE_ABSOLUTE_TO_SEASON: ClassVar[str] = "absolute_to_season"
# 置信度high 表示可直接用于订阅过滤和下载匹配low 仅作为未来种子文件确认前的弱提示预留。
CONFIDENCE_HIGH: ClassVar[str] = "high"
CONFIDENCE_LOW: ClassVar[str] = "low"
# 标题或副标题中解析出的原始集数,例如累计总集编号 57-82
source_episodes: List[int] = field(default_factory=list)
# 映射到订阅目标季内的集数,例如第 5 季的 1-26
target_episodes: List[int] = field(default_factory=list)
# 定位模式当前有效值absolute_to_season。
mode: str = MODE_ABSOLUTE_TO_SEASON
# 定位置信度当前消费值highlow 预留给只允许进入种子文件确认的弱定位。
confidence: str = CONFIDENCE_HIGH
@classmethod
def locate(
cls,
meta: MetaBase,
target_season: Optional[int],
target_episodes: List[int],
episodes: Optional[List[int]] = None,
) -> Optional["EpisodeLocation"]:
"""
识别候选标题集数与订阅目标季内集数之间的确定性映射关系。
"""
if not meta:
return None
raw_episodes = episodes if episodes is not None else meta.episode_list
if not raw_episodes or not target_episodes:
return None
try:
source_episodes = sorted(set(int(episode) for episode in raw_episodes))
except (TypeError, ValueError):
return None
if set(source_episodes).intersection(set(target_episodes)):
return None
season_list = meta.season_list or []
if target_season is None or len(season_list or []) != 1 or season_list[0] != target_season:
return None
if len(source_episodes) != len(target_episodes):
return None
if source_episodes != list(range(source_episodes[0], source_episodes[-1] + 1)):
return None
# 累计总集编号通常表现为 Sxx + 连续高位区间,例如 S05 的 57-82
# 只有当候选区间完整覆盖订阅目标长度时才定位,避免部分分集包被误当全集。
return cls(
source_episodes=source_episodes,
target_episodes=target_episodes,
mode=cls.MODE_ABSOLUTE_TO_SEASON,
confidence=cls.CONFIDENCE_HIGH,
)
@dataclass
class Context:
"""
@@ -898,22 +832,6 @@ class Context:
media_info_is_target: bool = False
# 调用方对本候选允许下载的剧集集合None 表示不限制,空集合表示拒绝交付任何集。
allowed_episodes: Optional[Set[int]] = None
# 调用方完成高置信集数定位后写入的目标季内集数集合None 表示未定位或不应放大候选范围。
located_episodes: Optional[Set[int]] = None
def locate_episode(
self,
target_season: Optional[int],
target_episodes: List[int],
) -> Optional[EpisodeLocation]:
"""
根据当前候选元数据和订阅目标范围计算集数定位结果。
"""
return EpisodeLocation.locate(
meta=self.meta_info,
target_season=target_season,
target_episodes=target_episodes,
)
def to_dict(self):
"""
@@ -930,5 +848,4 @@ class Context:
"media_info_is_target": self.media_info_is_target,
# 保留 None / 空集 / 非空集 三态语义,避免下游误把"显式拒绝"当成"不限制"。
"allowed_episodes": sorted(self.allowed_episodes) if self.allowed_episodes is not None else None,
"located_episodes": sorted(self.located_episodes) if self.located_episodes is not None else None,
}

View File

@@ -5,6 +5,7 @@ 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
@@ -58,7 +59,7 @@ def test_download_single_submits_download_added_to_background(monkeypatch):
chain.post_message = MagicMock()
context = Context(
meta_info=SimpleNamespace(episode_list=[], season=None, episode=None),
meta_info=MetaInfo("Demo Movie 2024"),
media_info=MediaInfo(
type=MediaType.MOVIE,
title="Demo Movie",
@@ -156,7 +157,6 @@ def _build_tv_context(episode_list=None):
),
torrent_info=SimpleNamespace(title="Test Show S01 2160p", site_name="TestSite"),
allowed_episodes=None,
located_episodes=None,
)
@@ -421,73 +421,3 @@ def test_batch_download_keeps_count_check_without_complete_coverage(monkeypatch)
assert downloads == [context]
assert lefts == {}
chain.download_single.assert_called_once()
def test_batch_download_checks_files_for_located_absolute_numbered_full_pack(monkeypatch):
"""
普通订阅可借助集数定位把累计总集编号候选放入整包检查,再由种子文件确认季内集数覆盖。
"""
_FakeBatchTorrentHelper.episodes = list(range(1, 27))
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(episode_list=list(range(57, 83)))
context.meta_info.season_list = [5]
context.located_episodes = set(range(1, 27))
no_exists = {
1: {
5: NotExistMediaInfo(
season=5,
episodes=[],
total_episode=26,
start_episode=1,
)
}
}
downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists)
assert downloads == [context]
assert lefts == {}
chain.download_torrent.assert_called_once()
chain.download_single.assert_called_once()
def test_batch_download_selects_needed_files_for_located_absolute_numbered_pack(monkeypatch):
"""
分集洗版可借助集数定位进入拆包检查,并只选择订阅仍需要的季内集数。
"""
_FakeBatchTorrentHelper.episodes = list(range(1, 27))
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(episode_list=list(range(57, 83)))
context.meta_info.season_list = [5]
context.allowed_episodes = {1, 2}
context.located_episodes = set(range(1, 27))
no_exists = {
1: {
5: NotExistMediaInfo(
season=5,
episodes=[1, 2],
total_episode=26,
start_episode=1,
)
}
}
downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists)
assert downloads == [context]
assert lefts == {}
chain.download_torrent.assert_called_once()
chain.download_single.assert_called_once()
assert chain.download_single.call_args.kwargs["episodes"] == {1, 2}

View File

@@ -13,7 +13,8 @@ def _load_subscribe_chain_class():
"""隔离加载 SubscribeChain避免测试依赖完整运行时环境。"""
module_name = "_test_subscribe_chain"
if module_name in sys.modules:
return sys.modules[module_name], sys.modules[module_name].SubscribeChain
module = sys.modules[module_name]
return module, module.SubscribeChain
original_modules = {}
@@ -73,33 +74,6 @@ def _load_subscribe_chain_class():
context_module.Context = SimpleNamespace
context_module.MediaInfo = SimpleNamespace
class _EpisodeLocation(SimpleNamespace):
CONFIDENCE_HIGH = "high"
CONFIDENCE_LOW = "low"
MODE_ABSOLUTE_TO_SEASON = "absolute_to_season"
@classmethod
def locate(cls, meta, target_season, target_episodes, episodes=None):
raw_episodes = episodes if episodes is not None else meta.episode_list
source_episodes = sorted(set(int(episode) for episode in raw_episodes))
if set(source_episodes).intersection(set(target_episodes)):
return None
season_list = meta.season_list or []
if target_season is None or len(season_list) != 1 or season_list[0] != target_season:
return None
if len(source_episodes) != len(target_episodes):
return None
if source_episodes != list(range(source_episodes[0], source_episodes[-1] + 1)):
return None
return cls(
source_episodes=source_episodes,
target_episodes=target_episodes,
mode=cls.MODE_ABSOLUTE_TO_SEASON,
confidence=cls.CONFIDENCE_HIGH,
)
context_module.EpisodeLocation = _EpisodeLocation
event_module = ensure_module("app.core.event", types.ModuleType("app.core.event"))
class _EventManager:
@@ -342,25 +316,6 @@ def _load_subscribe_chain_class():
SUBSCRIBE_CHAIN_MODULE, SubscribeChain = _load_subscribe_chain_class()
def _build_context(meta_info, selected_episodes=None, allowed_episodes=None):
"""构造带集数定位能力的最小候选上下文。"""
class _ContextStub(SimpleNamespace):
def locate_episode(self, target_season, target_episodes):
return SUBSCRIBE_CHAIN_MODULE.EpisodeLocation.locate(
meta=self.meta_info,
target_season=target_season,
target_episodes=target_episodes,
)
return _ContextStub(
meta_info=meta_info,
selected_episodes=selected_episodes,
allowed_episodes=allowed_episodes,
located_episodes=None,
)
class SubscribeChainTest(TestCase):
def _build_subscribe(self, **overrides):
data = {
@@ -399,7 +354,6 @@ class SubscribeChainTest(TestCase):
return SimpleNamespace(
torrent_info=SimpleNamespace(pri_order=priority),
selected_episodes=selected_episodes,
located_episodes=None,
meta_info=SimpleNamespace(season_list=[1], episode_list=meta_episodes or []),
)
@@ -537,32 +491,6 @@ class SubscribeChainTest(TestCase):
)
)
absolute_subscribe = self._build_subscribe(
season=5,
total_episode=26,
episode_priority={},
)
self.assertTrue(
SubscribeChain._is_episode_range_covered(
meta=SimpleNamespace(
season_list=[5],
episode_list=list(range(57, 83)),
total_episode=26,
),
subscribe=absolute_subscribe,
)
)
self.assertFalse(
SubscribeChain._is_episode_range_covered(
meta=SimpleNamespace(
season_list=[5],
episode_list=list(range(57, 61)),
total_episode=4,
),
subscribe=absolute_subscribe,
)
)
def test_full_best_version_rejects_episode_resource(self):
subscribe = self._build_subscribe(best_version_full=1, total_episode=3)
@@ -589,333 +517,6 @@ class SubscribeChainTest(TestCase):
)
)
def test_full_best_version_accepts_absolute_numbered_full_pack(self):
"""同季全集资源使用累计总集编号时,洗版判断应按订阅季内集数定位。"""
subscribe = self._build_subscribe(
best_version_full=1,
season=5,
total_episode=26,
episode_priority={},
)
meta = SimpleNamespace(
season_list=[5],
episode_list=list(range(57, 83)),
total_episode=26,
)
context = _build_context(meta_info=meta)
self.assertTrue(
SubscribeChain._SubscribeChain__is_full_season_best_version_resource(
meta=meta,
subscribe=subscribe,
)
)
self.assertTrue(
SubscribeChain._is_episode_range_covered(
meta=meta,
subscribe=subscribe,
)
)
self.assertEqual(
SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
subscribe=subscribe,
context=context,
priority=100,
),
list(range(1, 27)),
)
def test_low_confidence_episode_location_does_not_drive_best_version_filter(self):
"""低置信定位不能直接参与洗版过滤,只能作为后续弱提示保留。"""
subscribe = self._build_subscribe(
best_version_full=1,
season=5,
total_episode=26,
episode_priority={},
)
meta = SimpleNamespace(
season_list=[1],
episode_list=list(range(57, 83)),
total_episode=26,
)
context = _build_context(meta_info=meta)
low_location = SUBSCRIBE_CHAIN_MODULE.EpisodeLocation(
source_episodes=list(range(57, 83)),
target_episodes=list(range(1, 27)),
mode=SUBSCRIBE_CHAIN_MODULE.EpisodeLocation.MODE_ABSOLUTE_TO_SEASON,
confidence=SUBSCRIBE_CHAIN_MODULE.EpisodeLocation.CONFIDENCE_LOW,
)
self.assertFalse(
SubscribeChain._is_episode_range_covered(
meta=meta,
subscribe=subscribe,
episode_location=low_location,
)
)
self.assertEqual(
SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
subscribe=subscribe,
context=context,
priority=100,
episode_location=low_location,
),
[],
)
def test_full_best_version_rejects_partial_absolute_numbered_pack(self):
"""累计总集编号只有完整覆盖订阅目标长度时才可按整包洗版处理。"""
subscribe = self._build_subscribe(
best_version_full=1,
season=5,
total_episode=26,
episode_priority={},
)
meta = SimpleNamespace(
season_list=[5],
episode_list=list(range(57, 61)),
total_episode=4,
)
self.assertFalse(
SubscribeChain._SubscribeChain__is_full_season_best_version_resource(
meta=meta,
subscribe=subscribe,
)
)
self.assertFalse(
SubscribeChain._is_episode_range_covered(
meta=meta,
subscribe=subscribe,
)
)
def test_full_best_version_rejects_absolute_numbered_pack_without_season_match(self):
"""累计总集编号缺少同季信号时不自动映射,避免跨季合集误匹配。"""
subscribe = self._build_subscribe(
best_version_full=1,
season=5,
total_episode=26,
episode_priority={},
)
meta = SimpleNamespace(
season_list=[1],
episode_list=list(range(57, 83)),
total_episode=26,
)
self.assertFalse(
SubscribeChain._SubscribeChain__is_full_season_best_version_resource(
meta=meta,
subscribe=subscribe,
)
)
def test_full_season_resource_matches_legacy_metainfo_cases(self):
"""保留 #5648 覆盖过的真实标题合集识别样本,避免 MetaInfo 集成回归。"""
import app.core.metainfo as metainfo_module
from app.core.metainfo import MetaInfo
class _SystemConfigOper:
"""提供空系统配置,避免真实 MetaInfo 解析依赖测试数据库。"""
def get(self, *args, **kwargs):
return None
cases = [
{
"title": "Cherry Season S01 2014 2160p 60fps WEB-DL H265 AAC-XXX",
"subtitle": "",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 51},
"expected": True,
},
{
"title": "【爪爪字幕组】★7月新番[欢迎来到实力至上主义的教室 第二季/Youkoso Jitsuryoku Shijou Shugi no Kyoushitsu e S2][11][1080p][HEVC][GB][MP4][招募翻译校对]",
"subtitle": "",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 13},
"expected": False,
},
{
"title": "[秋叶原冥途战争][Akiba Maid Sensou][2022][WEB-DL][1080][TV Series][第01话][LeagueWEB]",
"subtitle": "",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 12},
"expected": False,
},
{
"title": "Qi Refining for 3000 Years S01E06 2022 1080p B-Blobal WEB-DL X264 AAC-AnimeS@AdWeb",
"subtitle": "",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 16},
"expected": False,
},
{
"title": "The Heart of Genius S01 13-14 2022 1080p WEB-DL H264 AAC",
"subtitle": "",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 34},
"expected": False,
},
{
"title": "[xyx98]传颂之物/Utawarerumono/うたわれるもの[BDrip][1920x1080][TV 01-26 Fin][hevc-yuv420p10 flac_ac3][ENG PGS]",
"subtitle": "",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 26},
"expected": True,
},
{
"title": "I Woke Up a Vampire S02 2023 2160p NF WEB-DL DDP5.1 Atmos H 265-HHWEB",
"subtitle": "醒来变成吸血鬼 第二季 | 全8集 | 4K | 类型: 喜剧/家庭/奇幻 | 导演: TommyLynch | 主演: NikoCeci/ZebastinBorjeau/安娜·阿劳约/KaileenAngelicChang/KrisSiddiqi",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 8},
"expected": True,
},
{
"title": "Shadows of the Void S01 2024 1080p WEB-DL H264 AAC-HHWEB",
"subtitle": "虚无边境 | 第01-02集 | 1080p | 类型: 动画 | 导演: 巴西 | 主演: 山新/周一菡/皇贞季/Kenz/李佳怡 [内嵌中字]",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 13},
"expected": False,
},
{
"title": "Mai Xiang S01 2019 2160p WEB-DL H.265 DDP2.0-HHWEB",
"subtitle": "麦香 | 全36集 | 4K | 类型:剧情/爱情/家庭 | 主演:傅晶/章呈赫/王伟/沙景昌/何音",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 36},
"expected": True,
},
{
"title": "Jigokuraku S01E14-E25 2023 1080p CR WEB-DL x264 AAC-Nest@ADWeb",
"subtitle": "地狱乐 / 地獄楽 / Hells Paradise [14-25Fin] [中日双语字幕]",
"subscribe": {"season": None, "start_episode": 14, "total_episode": 25},
"expected": True,
},
{
"title": "Jigokuraku S01 2023 1080p BluRay Remux AVC FLAC 2.0-AnimeF@ADE",
"subtitle": "地狱乐/Hell's Paradise: Jigokuraku [01-13Fin] [中日双语字幕]",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 13},
"expected": True,
},
{
"title": "Jigokuraku S02E12 2026 1080p NF WEB-DL x264 AAC-ADWeb",
"subtitle": "地狱乐 第二季 地獄楽 第二期 第12集 | 类型: 动画",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 12},
"expected": False,
},
{
"title": "Jigokuraku S02E05-E07 2026 1080p NF WEB-DL x264 AAC-ADWeb",
"subtitle": "地狱乐 第二季 地獄楽 第二期 第05-07集 | 类型: 动画",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 12},
"expected": False,
},
{
"title": "Bungo Stray Dogs S01 2016 1080p KKTV WEB-DL x264 AAC-ADWeb",
"subtitle": "文豪野犬 文豪ストレイドッグス 又名: 文豪Stray Dogs 第一季 全12集 | 类型: 剧情 / 动作 / 动画 主演: 上村祐翔 / 宫野真守 / 细谷佳正 *内嵌繁体字幕*",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 12},
"expected": True,
},
{
"title": "Bungou Stray Dogs S1+S2+S3+OAD 1080p BDRip HEVC FLAC-Snow-Raws",
"subtitle": "文豪野犬 第1-3季",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 36},
"expected": True,
},
{
"title": "Bungou Stray Dogs S1+S2+S3+OAD 1080p BDRip HEVC FLAC-Snow-Raws",
"subtitle": "文豪野犬 第1-3季",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 60},
"expected": True,
},
{
"title": "Fu Gui S01 2005 2160p WEB-DL H265 AAC-HHWEB",
"subtitle": "福贵 | 全33集 | 4K | 类型: 剧情/家庭 | 导演: 朱正/袁进 | 主演: 陈创/刘敏涛/李丁/张鹰/温玉娟",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 33},
"expected": True,
},
{
"title": "The Story of Ming Lan S01 2018 2160p WEB-DL CHDWEB",
"subtitle": "知否知否应是绿肥红瘦 全78集 | 2160p | 国语/中字 | 60帧高码TV版 | 类型:剧情/爱情/古装 | 主演:赵丽颖/冯绍峰/朱一龙/施诗/张佳宁",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 78},
"expected": True,
},
{
"title": "Love Beyond the Grave S01 2026 2160p WEB-DL H265 AAC-HHWEB",
"subtitle": "白日提灯 / 慕胥辞 | 第18集 | 4K | 类型: 剧情 | 导演: 秦榛 | 主演: 迪丽热巴/陈飞宇/魏哲鸣/张俪/高鹤元",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 40},
"expected": False,
},
{
"title": "The Long Ballad S01 2021 2160p WEB-DL H265 AAC-HHWEB",
"subtitle": "长歌行 | 全49集 | 4K | 类型: 剧情/爱情/古装 | 主演: 迪丽热巴/吴磊/刘宇宁/赵露思/方逸伦",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 49},
"expected": True,
},
{
"title": "The Long Ballad S01E01-E04 2021 2160p WEB-DL H265 AAC-HHWEB",
"subtitle": "长歌行 | 第01-04集 | 4K | 类型: 剧情/爱情/古装 | 主演: 迪丽热巴/吴磊/刘宇宁/赵露思/方逸伦",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 49},
"expected": False,
},
{
"title": "Spy x Family S02 2023 1080p Baha WEB-DL x264 AAC-ADWeb",
"subtitle": "间谍过家家 第二季 / SPY×FAMILY Season 2 [01-12Fin] [简繁内封字幕]",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 12},
"expected": True,
},
{
"title": "Spy x Family S02E03-E07 2023 1080p Baha WEB-DL x264 AAC-ADWeb",
"subtitle": "间谍过家家 第二季 / SPY×FAMILY Season 2 第03-07集 [简繁内封字幕]",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 12},
"expected": False,
},
{
"title": "Naruto Shippuden S01-S21 Complete 1080p BluRay x264 AAC-ADWeb",
"subtitle": "火影忍者 疾风传 全500集 [1080p][简中字幕]",
"subscribe": {"season": None, "start_episode": 1, "total_episode": 500},
"expected": True,
},
{
"title": "Naruto Shippuden S01-S21 Complete 1080p BluRay x264 AAC-ADWeb",
"subtitle": "火影忍者 疾风传 第01-500集 [1080p][简中字幕]",
"subscribe": {"season": None, "start_episode": 201, "total_episode": 500},
"expected": True,
},
{
"title": "Immortality S05 2025 2160p WEB-DL HDR H265 AAC-ADWeb",
"subtitle": "永生 第五季 全26集 总第57-82集 / 永生之太元仙府 / 永生 新篇章 / Immortality: Taiyuan Immortal Mansion [Bilibili大陆] | 类型:动作 动画 奇幻",
"subscribe": {"season": 5, "start_episode": 1, "total_episode": 26},
"expected": True,
},
{
"title": "Immortality S05 2025 2160p WEB-DL HDR H265 AAC-ADWeb",
"subtitle": "永生 第五季 第57-60集 / 永生之太元仙府",
"subscribe": {"season": 5, "start_episode": 1, "total_episode": 26},
"expected": False,
},
]
metainfo_module._rust_default_parse_options.cache_clear()
metainfo_module._rust_custom_parse_options.cache_clear()
with patch("app.db.systemconfig_oper.SystemConfigOper", _SystemConfigOper), patch(
"app.core.meta.releasegroup.SystemConfigOper", _SystemConfigOper
), patch(
"app.core.meta.words.SystemConfigOper", _SystemConfigOper
), patch(
"app.core.meta.customization.SystemConfigOper", _SystemConfigOper
):
for case in cases:
meta = MetaInfo(
title=case["title"], subtitle=case["subtitle"], custom_words=["#"]
)
subscribe = self._build_subscribe(
best_version_full=1,
episode_priority={},
current_priority=0,
**case["subscribe"],
)
with self.subTest(title=case["title"], subtitle=case["subtitle"]):
self.assertEqual(
SubscribeChain._SubscribeChain__is_full_season_resource(
meta=meta,
subscribe=subscribe,
),
case["expected"],
)
def test_episode_best_version_downloads_full_pack_before_episode_fallback(self):
subscribe = self._build_subscribe(best_version_full=0, total_episode=3)
full_pack_context = SimpleNamespace(
@@ -1182,49 +783,6 @@ class SubscribeChainTest(TestCase):
self.assertNotIn("lack_episode", payload)
finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
def test_update_subscribe_priority_normalizes_absolute_numbered_full_pack(self):
"""累计总集编号整包下载完成后,应按订阅季内集数更新洗版状态。"""
subscribe = self._build_subscribe(
best_version_full=1,
season=5,
total_episode=26,
episode_priority={},
current_priority=0,
lack_episode=26,
)
download = SimpleNamespace(
torrent_info=SimpleNamespace(pri_order=100),
selected_episodes=None,
located_episodes=None,
meta_info=SimpleNamespace(
season_list=[5],
episode_list=list(range(57, 83)),
total_episode=26,
),
)
chain = SubscribeChain()
meta = SimpleNamespace()
mediainfo = SimpleNamespace(title_year="Immortality (2025)")
with patch.object(SUBSCRIBE_CHAIN_MODULE, "SubscribeOper") as subscribe_oper_cls, patch.object(
SubscribeChain,
"_SubscribeChain__finish_subscribe",
) as finish_mock:
subscribe_oper = subscribe_oper_cls.return_value
subscribe_oper.update.return_value = None
chain.update_subscribe_priority(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
downloads=[download],
)
payload = subscribe_oper.update.call_args.args[1]
self.assertEqual(payload["episode_priority"], {str(ep): 100 for ep in range(1, 27)})
self.assertEqual(payload["current_priority"], 100)
finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
def test_full_best_version_updates_all_episodes_when_pack_has_no_episode_metadata(self):
subscribe = self._build_subscribe(
best_version_full=1,
@@ -1339,7 +897,10 @@ class SubscribeChainTest(TestCase):
episode_priority={"1": 100, "2": 99},
current_priority=100,
)
context = _build_context(meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3]))
context = SimpleNamespace(
meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3]),
selected_episodes=None,
)
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
subscribe=subscribe,
@@ -1364,7 +925,10 @@ class SubscribeChainTest(TestCase):
},
current_priority=99,
)
context = _build_context(meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))))
context = SimpleNamespace(
meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))),
selected_episodes=None,
)
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
subscribe=subscribe,
@@ -1395,7 +959,10 @@ class SubscribeFilterAllowedEpisodesTest(TestCase):
},
current_priority=99,
)
context = _build_context(meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))))
context = SimpleNamespace(
meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))),
selected_episodes=None,
)
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
subscribe=subscribe,
@@ -1418,7 +985,10 @@ class SubscribeFilterAllowedEpisodesTest(TestCase):
episode_priority={"1": 100, "2": 99, "3": 99},
current_priority=99,
)
context = _build_context(meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3]))
context = SimpleNamespace(
meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3]),
selected_episodes=None,
)
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
subscribe=subscribe,
@@ -1447,8 +1017,10 @@ class SubscribeFilterAllowedEpisodesTest(TestCase):
},
current_priority=99,
)
original_context = _build_context(
meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105)))
original_context = SimpleNamespace(
meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))),
selected_episodes=None,
allowed_episodes=None,
)
_context = copy.copy(original_context)