mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
fix(subscribe): handle absolute numbered season packs (#5866)
This commit is contained in:
+18
-6
@@ -554,6 +554,14 @@ class DownloadChain(ChainBase):
|
|||||||
effective &= set(allowed)
|
effective &= set(allowed)
|
||||||
return effective
|
return effective
|
||||||
|
|
||||||
|
def __get_located_target_episodes(_context: Context) -> Set[int]:
|
||||||
|
"""
|
||||||
|
返回集数定位后的目标季内集数;缺失定位不扩大候选范围。
|
||||||
|
"""
|
||||||
|
if not _context.located_episodes:
|
||||||
|
return set()
|
||||||
|
return set(_context.located_episodes)
|
||||||
|
|
||||||
# 发送资源选择事件,允许外部修改上下文数据
|
# 发送资源选择事件,允许外部修改上下文数据
|
||||||
logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}")
|
logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}")
|
||||||
event_data = ResourceSelectionEventData(
|
event_data = ResourceSelectionEventData(
|
||||||
@@ -621,8 +629,9 @@ class DownloadChain(ChainBase):
|
|||||||
# 没有季的默认为第1季
|
# 没有季的默认为第1季
|
||||||
if not torrent_season:
|
if not torrent_season:
|
||||||
torrent_season = [1]
|
torrent_season = [1]
|
||||||
# 种子有集的不要
|
# 常规标题带集数的资源不走整季路径;已完成高置信集数定位的候选仍需打开种子文件确认。
|
||||||
if meta.episode_list:
|
located_target_episodes = __get_located_target_episodes(context)
|
||||||
|
if meta.episode_list and not located_target_episodes:
|
||||||
continue
|
continue
|
||||||
# 匹配TMDBID
|
# 匹配TMDBID
|
||||||
if need_mid == media.tmdb_id or need_mid == media.douban_id:
|
if need_mid == media.tmdb_id or need_mid == media.douban_id:
|
||||||
@@ -743,8 +752,9 @@ class DownloadChain(ChainBase):
|
|||||||
# 只处理单季含集的种子
|
# 只处理单季含集的种子
|
||||||
if len(torrent_season) != 1 or torrent_season[0] != need_season:
|
if len(torrent_season) != 1 or torrent_season[0] != need_season:
|
||||||
continue
|
continue
|
||||||
# 种子集列表
|
# 种子集列表;累计总集编号等场景优先使用订阅阶段定位出的季内目标集。
|
||||||
torrent_episodes = set(meta.episode_list)
|
located_target_episodes = __get_located_target_episodes(context)
|
||||||
|
torrent_episodes = located_target_episodes or set(meta.episode_list)
|
||||||
# 整季的不处理
|
# 整季的不处理
|
||||||
if not torrent_episodes:
|
if not torrent_episodes:
|
||||||
continue
|
continue
|
||||||
@@ -826,10 +836,12 @@ class DownloadChain(ChainBase):
|
|||||||
effective_need = __apply_allowed_episodes(need_episodes, context)
|
effective_need = __apply_allowed_episodes(need_episodes, context)
|
||||||
if not effective_need:
|
if not effective_need:
|
||||||
continue
|
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) \
|
if (media.tmdb_id == need_mid or media.douban_id == need_mid) \
|
||||||
and (not meta.episode_list
|
and (not match_episode_candidates
|
||||||
or set(meta.episode_list).intersection(effective_need)) \
|
or match_episode_candidates.intersection(effective_need)) \
|
||||||
and len(meta.season_list) == 1 \
|
and len(meta.season_list) == 1 \
|
||||||
and meta.season_list[0] == need_season:
|
and meta.season_list[0] == need_season:
|
||||||
# 检查种子看是否有需要的集
|
# 检查种子看是否有需要的集
|
||||||
|
|||||||
+124
-18
@@ -24,7 +24,7 @@ from app.helper.interaction import (
|
|||||||
from app.chain.tmdb import TmdbChain
|
from app.chain.tmdb import TmdbChain
|
||||||
from app.chain.torrents import TorrentsChain
|
from app.chain.torrents import TorrentsChain
|
||||||
from app.core.config import settings, global_vars
|
from app.core.config import settings, global_vars
|
||||||
from app.core.context import TorrentInfo, Context, MediaInfo
|
from app.core.context import TorrentInfo, Context, MediaInfo, EpisodeLocation
|
||||||
from app.core.event import eventmanager, Event
|
from app.core.event import eventmanager, Event
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.core.meta.words import WordsMatcher
|
from app.core.meta.words import WordsMatcher
|
||||||
@@ -283,6 +283,7 @@ class SubscribeChain(ChainBase):
|
|||||||
subscribe: Subscribe,
|
subscribe: Subscribe,
|
||||||
context: Context,
|
context: Context,
|
||||||
priority: int,
|
priority: int,
|
||||||
|
episode_location: Optional[EpisodeLocation] = None,
|
||||||
) -> List[int]:
|
) -> List[int]:
|
||||||
"""
|
"""
|
||||||
获取当前资源中仍值得继续洗版的剧集。
|
获取当前资源中仍值得继续洗版的剧集。
|
||||||
@@ -296,7 +297,11 @@ class SubscribeChain(ChainBase):
|
|||||||
|
|
||||||
selected_episodes = getattr(context, "selected_episodes", None)
|
selected_episodes = getattr(context, "selected_episodes", None)
|
||||||
if selected_episodes is None:
|
if selected_episodes is None:
|
||||||
selected_episodes = context.meta_info.episode_list if context.meta_info else []
|
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 [])
|
||||||
if not selected_episodes:
|
if not selected_episodes:
|
||||||
episode_priority = cls.__get_episode_priority(subscribe)
|
episode_priority = cls.__get_episode_priority(subscribe)
|
||||||
return sorted([
|
return sorted([
|
||||||
@@ -318,6 +323,59 @@ class SubscribeChain(ChainBase):
|
|||||||
interested.append(episode_num)
|
interested.append(episode_num)
|
||||||
return sorted(set(interested))
|
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
|
@classmethod
|
||||||
def __is_full_best_version_enabled(cls, subscribe: Subscribe) -> bool:
|
def __is_full_best_version_enabled(cls, subscribe: Subscribe) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -330,17 +388,24 @@ class SubscribeChain(ChainBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def __is_full_season_resource(cls, meta: MetaBase, subscribe: Subscribe) -> bool:
|
def __is_full_season_resource(
|
||||||
|
cls,
|
||||||
|
meta: MetaBase,
|
||||||
|
subscribe: Subscribe,
|
||||||
|
episode_location: Optional[EpisodeLocation] = None,
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
判断候选资源是否覆盖订阅目标全集范围。
|
判断候选资源是否覆盖订阅目标全集范围。
|
||||||
"""
|
"""
|
||||||
season_list = meta.season_list or [1]
|
season_list = meta.season_list or [1]
|
||||||
if len(season_list) != 1:
|
if subscribe.season is not None:
|
||||||
return False
|
if len(season_list) != 1:
|
||||||
if subscribe.season is not None and season_list[0] != subscribe.season:
|
return False
|
||||||
return False
|
if season_list[0] != subscribe.season:
|
||||||
|
return False
|
||||||
|
|
||||||
episodes = meta.episode_list
|
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)
|
||||||
if not episodes:
|
if not episodes:
|
||||||
# 资源未标出单集时按整季包处理,后续下载前仍会解析种子文件确认完整性。
|
# 资源未标出单集时按整季包处理,后续下载前仍会解析种子文件确认完整性。
|
||||||
return True
|
return True
|
||||||
@@ -351,14 +416,23 @@ class SubscribeChain(ChainBase):
|
|||||||
return target_episodes.issubset(set(episodes))
|
return target_episodes.issubset(set(episodes))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def __is_full_season_best_version_resource(cls, meta: MetaBase, subscribe: Subscribe) -> bool:
|
def __is_full_season_best_version_resource(
|
||||||
|
cls,
|
||||||
|
meta: MetaBase,
|
||||||
|
subscribe: Subscribe,
|
||||||
|
episode_location: Optional[EpisodeLocation] = None,
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
判断候选资源是否符合全集洗版资源约束。
|
判断候选资源是否符合全集洗版资源约束。
|
||||||
"""
|
"""
|
||||||
if not cls.__is_full_best_version_enabled(subscribe):
|
if not cls.__is_full_best_version_enabled(subscribe):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return cls.__is_full_season_resource(meta=meta, subscribe=subscribe)
|
return cls.__is_full_season_resource(
|
||||||
|
meta=meta,
|
||||||
|
subscribe=subscribe,
|
||||||
|
episode_location=episode_location,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def __is_full_season_priority_higher_than_all_targets(cls, subscribe: Subscribe, priority: int) -> bool:
|
def __is_full_season_priority_higher_than_all_targets(cls, subscribe: Subscribe, priority: int) -> bool:
|
||||||
@@ -1036,13 +1110,21 @@ class SubscribeChain(ChainBase):
|
|||||||
torrent_meta = context.meta_info
|
torrent_meta = context.meta_info
|
||||||
torrent_info = context.torrent_info
|
torrent_info = context.torrent_info
|
||||||
torrent_mediainfo = context.media_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 subscribe.best_version:
|
||||||
if (
|
if (
|
||||||
torrent_mediainfo.type == MediaType.TV
|
torrent_mediainfo.type == MediaType.TV
|
||||||
and not self.__is_full_season_best_version_resource(
|
and not self.__is_full_season_best_version_resource(
|
||||||
meta=torrent_meta, subscribe=subscribe
|
meta=torrent_meta,
|
||||||
|
subscribe=subscribe,
|
||||||
|
episode_location=episode_location,
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1053,7 +1135,9 @@ class SubscribeChain(ChainBase):
|
|||||||
if (
|
if (
|
||||||
torrent_mediainfo.type == MediaType.TV
|
torrent_mediainfo.type == MediaType.TV
|
||||||
and not self._is_episode_range_covered(
|
and not self._is_episode_range_covered(
|
||||||
meta=torrent_meta, subscribe=subscribe
|
meta=torrent_meta,
|
||||||
|
subscribe=subscribe,
|
||||||
|
episode_location=episode_location,
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1066,6 +1150,7 @@ class SubscribeChain(ChainBase):
|
|||||||
subscribe=subscribe,
|
subscribe=subscribe,
|
||||||
context=context,
|
context=context,
|
||||||
priority=torrent_info.pri_order,
|
priority=torrent_info.pri_order,
|
||||||
|
episode_location=episode_location,
|
||||||
)
|
)
|
||||||
if not interested_episodes:
|
if not interested_episodes:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1158,7 +1243,15 @@ class SubscribeChain(ChainBase):
|
|||||||
updated = False
|
updated = False
|
||||||
for download in downloads:
|
for download in downloads:
|
||||||
download_priority = download.torrent_info.pri_order
|
download_priority = download.torrent_info.pri_order
|
||||||
downloaded_episodes = self.__get_downloaded_episodes([download])
|
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,
|
||||||
|
)
|
||||||
if not downloaded_episodes and self.__is_full_season_resource(download.meta_info, subscribe):
|
if not downloaded_episodes and self.__is_full_season_resource(download.meta_info, subscribe):
|
||||||
# 整包下载时资源标题常不携带集数,视为覆盖当前订阅的全部目标集。
|
# 整包下载时资源标题常不携带集数,视为覆盖当前订阅的全部目标集。
|
||||||
downloaded_episodes = self.__get_best_version_target_episodes(subscribe)
|
downloaded_episodes = self.__get_best_version_target_episodes(subscribe)
|
||||||
@@ -1519,6 +1612,7 @@ class SubscribeChain(ChainBase):
|
|||||||
|
|
||||||
# 如果是电视剧
|
# 如果是电视剧
|
||||||
if torrent_mediainfo.type == MediaType.TV:
|
if torrent_mediainfo.type == MediaType.TV:
|
||||||
|
episode_location = self.__locate_context_episodes(context=_context, subscribe=subscribe)
|
||||||
# 有多季的不要
|
# 有多季的不要
|
||||||
if len(torrent_meta.season_list) > 1:
|
if len(torrent_meta.season_list) > 1:
|
||||||
logger.debug(f'{torrent_info.title} 有多季,不处理')
|
logger.debug(f'{torrent_info.title} 有多季,不处理')
|
||||||
@@ -1538,20 +1632,24 @@ class SubscribeChain(ChainBase):
|
|||||||
# 缺失集
|
# 缺失集
|
||||||
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
|
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
|
||||||
if no_exists_info:
|
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 \
|
if no_exists_info.episodes and \
|
||||||
torrent_meta.episode_list and \
|
torrent_episodes and \
|
||||||
not set(no_exists_info.episodes).intersection(
|
not set(no_exists_info.episodes).intersection(
|
||||||
set(torrent_meta.episode_list)
|
set(torrent_episodes)
|
||||||
):
|
):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集'
|
f'{torrent_info.title} 对应剧集 {torrent_episodes} 未包含缺失的剧集'
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
if not self.__is_full_season_best_version_resource(
|
if not self.__is_full_season_best_version_resource(
|
||||||
meta=torrent_meta,
|
meta=torrent_meta,
|
||||||
subscribe=subscribe,
|
subscribe=subscribe,
|
||||||
|
episode_location=episode_location,
|
||||||
):
|
):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"{subscribe.name} 正在全集洗版,{torrent_info.title} 不是全集资源"
|
f"{subscribe.name} 正在全集洗版,{torrent_info.title} 不是全集资源"
|
||||||
@@ -1563,6 +1661,7 @@ class SubscribeChain(ChainBase):
|
|||||||
and not self._is_episode_range_covered(
|
and not self._is_episode_range_covered(
|
||||||
meta=torrent_meta,
|
meta=torrent_meta,
|
||||||
subscribe=subscribe,
|
subscribe=subscribe,
|
||||||
|
episode_location=episode_location,
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -1598,6 +1697,7 @@ class SubscribeChain(ChainBase):
|
|||||||
subscribe=subscribe,
|
subscribe=subscribe,
|
||||||
context=_context,
|
context=_context,
|
||||||
priority=torrent_info.pri_order,
|
priority=torrent_info.pri_order,
|
||||||
|
episode_location=episode_location,
|
||||||
)
|
)
|
||||||
if not interested_episodes:
|
if not interested_episodes:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -3228,11 +3328,17 @@ class SubscribeChain(ChainBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _is_episode_range_covered(cls, meta: MetaBase, subscribe: Subscribe) -> bool:
|
def _is_episode_range_covered(
|
||||||
|
cls,
|
||||||
|
meta: MetaBase,
|
||||||
|
subscribe: Subscribe,
|
||||||
|
episode_location: Optional[EpisodeLocation] = None,
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
判断种子是否覆盖当前仍需洗版的剧集范围。
|
判断种子是否覆盖当前仍需洗版的剧集范围。
|
||||||
"""
|
"""
|
||||||
episodes = meta.episode_list
|
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)
|
||||||
if not episodes:
|
if not episodes:
|
||||||
# 没有剧集信息,表示该种子为合集
|
# 没有剧集信息,表示该种子为合集
|
||||||
return True
|
return True
|
||||||
|
|||||||
+84
-1
@@ -1,7 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Dict, Any, Tuple, Optional, Set
|
from typing import List, Dict, Any, Tuple, Optional, Set, ClassVar
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
@@ -808,6 +808,72 @@ class MediaInfo:
|
|||||||
self.episode_groups = []
|
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
|
||||||
|
# 定位置信度,当前消费值:high;low 预留给只允许进入种子文件确认的弱定位。
|
||||||
|
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
|
@dataclass
|
||||||
class Context:
|
class Context:
|
||||||
"""
|
"""
|
||||||
@@ -832,6 +898,22 @@ class Context:
|
|||||||
media_info_is_target: bool = False
|
media_info_is_target: bool = False
|
||||||
# 调用方对本候选允许下载的剧集集合,None 表示不限制,空集合表示拒绝交付任何集。
|
# 调用方对本候选允许下载的剧集集合,None 表示不限制,空集合表示拒绝交付任何集。
|
||||||
allowed_episodes: Optional[Set[int]] = 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):
|
def to_dict(self):
|
||||||
"""
|
"""
|
||||||
@@ -848,4 +930,5 @@ class Context:
|
|||||||
"media_info_is_target": self.media_info_is_target,
|
"media_info_is_target": self.media_info_is_target,
|
||||||
# 保留 None / 空集 / 非空集 三态语义,避免下游误把"显式拒绝"当成"不限制"。
|
# 保留 None / 空集 / 非空集 三态语义,避免下游误把"显式拒绝"当成"不限制"。
|
||||||
"allowed_episodes": sorted(self.allowed_episodes) if self.allowed_episodes is not None else 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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from unittest.mock import MagicMock
|
|||||||
import app.chain.download as download_module
|
import app.chain.download as download_module
|
||||||
from app.chain.download import DownloadChain
|
from app.chain.download import DownloadChain
|
||||||
from app.core.context import Context, MediaInfo, TorrentInfo
|
from app.core.context import Context, MediaInfo, TorrentInfo
|
||||||
from app.core.metainfo import MetaInfo
|
|
||||||
from app.schemas import NotExistMediaInfo
|
from app.schemas import NotExistMediaInfo
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ def test_download_single_submits_download_added_to_background(monkeypatch):
|
|||||||
chain.post_message = MagicMock()
|
chain.post_message = MagicMock()
|
||||||
|
|
||||||
context = Context(
|
context = Context(
|
||||||
meta_info=MetaInfo("Demo Movie 2024"),
|
meta_info=SimpleNamespace(episode_list=[], season=None, episode=None),
|
||||||
media_info=MediaInfo(
|
media_info=MediaInfo(
|
||||||
type=MediaType.MOVIE,
|
type=MediaType.MOVIE,
|
||||||
title="Demo Movie",
|
title="Demo Movie",
|
||||||
@@ -129,6 +128,7 @@ def _build_tv_context(episode_list=None):
|
|||||||
),
|
),
|
||||||
torrent_info=SimpleNamespace(title="Test Show S01 2160p", site_name="TestSite"),
|
torrent_info=SimpleNamespace(title="Test Show S01 2160p", site_name="TestSite"),
|
||||||
allowed_episodes=None,
|
allowed_episodes=None,
|
||||||
|
located_episodes=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -325,3 +325,73 @@ def test_batch_download_keeps_count_check_without_complete_coverage(monkeypatch)
|
|||||||
assert downloads == [context]
|
assert downloads == [context]
|
||||||
assert lefts == {}
|
assert lefts == {}
|
||||||
chain.download_single.assert_called_once()
|
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}
|
||||||
|
|||||||
+460
-22
@@ -13,8 +13,7 @@ def _load_subscribe_chain_class():
|
|||||||
"""隔离加载 SubscribeChain,避免测试依赖完整运行时环境。"""
|
"""隔离加载 SubscribeChain,避免测试依赖完整运行时环境。"""
|
||||||
module_name = "_test_subscribe_chain"
|
module_name = "_test_subscribe_chain"
|
||||||
if module_name in sys.modules:
|
if module_name in sys.modules:
|
||||||
module = sys.modules[module_name]
|
return sys.modules[module_name], sys.modules[module_name].SubscribeChain
|
||||||
return module, module.SubscribeChain
|
|
||||||
|
|
||||||
original_modules = {}
|
original_modules = {}
|
||||||
|
|
||||||
@@ -74,6 +73,33 @@ def _load_subscribe_chain_class():
|
|||||||
context_module.Context = SimpleNamespace
|
context_module.Context = SimpleNamespace
|
||||||
context_module.MediaInfo = 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"))
|
event_module = ensure_module("app.core.event", types.ModuleType("app.core.event"))
|
||||||
|
|
||||||
class _EventManager:
|
class _EventManager:
|
||||||
@@ -158,6 +184,14 @@ def _load_subscribe_chain_class():
|
|||||||
self.convert_type = kwargs.get("convert_type")
|
self.convert_type = kwargs.get("convert_type")
|
||||||
self.media_dict = kwargs.get("media_dict")
|
self.media_dict = kwargs.get("media_dict")
|
||||||
|
|
||||||
|
class _SubscribeEpisodesRefreshEventData:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.__dict__.update(kwargs)
|
||||||
|
|
||||||
|
class _SubscribeCompletionCheckEventData:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.__dict__.update(kwargs)
|
||||||
|
|
||||||
schemas_module.Notification = _Notification
|
schemas_module.Notification = _Notification
|
||||||
schemas_module.Subscribe = _SubscribeSchema
|
schemas_module.Subscribe = _SubscribeSchema
|
||||||
schemas_module.NotExistMediaInfo = _NotExistMediaInfo
|
schemas_module.NotExistMediaInfo = _NotExistMediaInfo
|
||||||
@@ -166,6 +200,8 @@ def _load_subscribe_chain_class():
|
|||||||
schemas_module.SubscribeDownloadFileInfo = _SubscribeDownloadFileInfo
|
schemas_module.SubscribeDownloadFileInfo = _SubscribeDownloadFileInfo
|
||||||
schemas_module.SubscribeLibraryFileInfo = _SubscribeLibraryFileInfo
|
schemas_module.SubscribeLibraryFileInfo = _SubscribeLibraryFileInfo
|
||||||
schemas_module.MediaRecognizeConvertEventData = _MediaRecognizeConvertEventData
|
schemas_module.MediaRecognizeConvertEventData = _MediaRecognizeConvertEventData
|
||||||
|
schemas_module.SubscribeEpisodesRefreshEventData = _SubscribeEpisodesRefreshEventData
|
||||||
|
schemas_module.SubscribeCompletionCheckEventData = _SubscribeCompletionCheckEventData
|
||||||
|
|
||||||
logger_module = ensure_module("app.log", types.ModuleType("app.log"))
|
logger_module = ensure_module("app.log", types.ModuleType("app.log"))
|
||||||
|
|
||||||
@@ -306,6 +342,25 @@ def _load_subscribe_chain_class():
|
|||||||
SUBSCRIBE_CHAIN_MODULE, SubscribeChain = _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):
|
class SubscribeChainTest(TestCase):
|
||||||
def _build_subscribe(self, **overrides):
|
def _build_subscribe(self, **overrides):
|
||||||
data = {
|
data = {
|
||||||
@@ -344,6 +399,7 @@ class SubscribeChainTest(TestCase):
|
|||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
torrent_info=SimpleNamespace(pri_order=priority),
|
torrent_info=SimpleNamespace(pri_order=priority),
|
||||||
selected_episodes=selected_episodes,
|
selected_episodes=selected_episodes,
|
||||||
|
located_episodes=None,
|
||||||
meta_info=SimpleNamespace(season_list=[1], episode_list=meta_episodes or []),
|
meta_info=SimpleNamespace(season_list=[1], episode_list=meta_episodes or []),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -481,6 +537,32 @@ 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):
|
def test_full_best_version_rejects_episode_resource(self):
|
||||||
subscribe = self._build_subscribe(best_version_full=1, total_episode=3)
|
subscribe = self._build_subscribe(best_version_full=1, total_episode=3)
|
||||||
|
|
||||||
@@ -507,6 +589,333 @@ 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": "地狱乐 / 地獄楽 / Hell’s 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):
|
def test_episode_best_version_downloads_full_pack_before_episode_fallback(self):
|
||||||
subscribe = self._build_subscribe(best_version_full=0, total_episode=3)
|
subscribe = self._build_subscribe(best_version_full=0, total_episode=3)
|
||||||
full_pack_context = SimpleNamespace(
|
full_pack_context = SimpleNamespace(
|
||||||
@@ -773,6 +1182,49 @@ class SubscribeChainTest(TestCase):
|
|||||||
self.assertNotIn("lack_episode", payload)
|
self.assertNotIn("lack_episode", payload)
|
||||||
finish_mock.assert_called_once_with(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
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):
|
def test_full_best_version_updates_all_episodes_when_pack_has_no_episode_metadata(self):
|
||||||
subscribe = self._build_subscribe(
|
subscribe = self._build_subscribe(
|
||||||
best_version_full=1,
|
best_version_full=1,
|
||||||
@@ -887,10 +1339,7 @@ class SubscribeChainTest(TestCase):
|
|||||||
episode_priority={"1": 100, "2": 99},
|
episode_priority={"1": 100, "2": 99},
|
||||||
current_priority=100,
|
current_priority=100,
|
||||||
)
|
)
|
||||||
context = SimpleNamespace(
|
context = _build_context(meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3]))
|
||||||
meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3]),
|
|
||||||
selected_episodes=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
|
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
|
||||||
subscribe=subscribe,
|
subscribe=subscribe,
|
||||||
@@ -915,10 +1364,7 @@ class SubscribeChainTest(TestCase):
|
|||||||
},
|
},
|
||||||
current_priority=99,
|
current_priority=99,
|
||||||
)
|
)
|
||||||
context = SimpleNamespace(
|
context = _build_context(meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))))
|
||||||
meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))),
|
|
||||||
selected_episodes=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
|
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
|
||||||
subscribe=subscribe,
|
subscribe=subscribe,
|
||||||
@@ -949,10 +1395,7 @@ class SubscribeFilterAllowedEpisodesTest(TestCase):
|
|||||||
},
|
},
|
||||||
current_priority=99,
|
current_priority=99,
|
||||||
)
|
)
|
||||||
context = SimpleNamespace(
|
context = _build_context(meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))))
|
||||||
meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))),
|
|
||||||
selected_episodes=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
|
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
|
||||||
subscribe=subscribe,
|
subscribe=subscribe,
|
||||||
@@ -975,10 +1418,7 @@ class SubscribeFilterAllowedEpisodesTest(TestCase):
|
|||||||
episode_priority={"1": 100, "2": 99, "3": 99},
|
episode_priority={"1": 100, "2": 99, "3": 99},
|
||||||
current_priority=99,
|
current_priority=99,
|
||||||
)
|
)
|
||||||
context = SimpleNamespace(
|
context = _build_context(meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3]))
|
||||||
meta_info=SimpleNamespace(season_list=[1], episode_list=[2, 3]),
|
|
||||||
selected_episodes=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
|
interested = SubscribeChain._SubscribeChain__get_best_version_interested_episodes(
|
||||||
subscribe=subscribe,
|
subscribe=subscribe,
|
||||||
@@ -1007,10 +1447,8 @@ class SubscribeFilterAllowedEpisodesTest(TestCase):
|
|||||||
},
|
},
|
||||||
current_priority=99,
|
current_priority=99,
|
||||||
)
|
)
|
||||||
original_context = SimpleNamespace(
|
original_context = _build_context(
|
||||||
meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105))),
|
meta_info=SimpleNamespace(season_list=[1], episode_list=list(range(53, 105)))
|
||||||
selected_episodes=None,
|
|
||||||
allowed_episodes=None,
|
|
||||||
)
|
)
|
||||||
_context = copy.copy(original_context)
|
_context = copy.copy(original_context)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user