mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-06 23:28:53 +08:00
fix(subscribe): match explicit cumulative episode ranges (#7294)
Refs #7289
This commit is contained in:
@@ -798,7 +798,13 @@ class TorrentHelper:
|
||||
self._invalid_torrents[url] = True
|
||||
|
||||
@staticmethod
|
||||
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaBase, torrent: TorrentInfo) -> bool:
|
||||
def match_torrent(
|
||||
mediainfo: MediaInfo,
|
||||
torrent_meta: MetaBase,
|
||||
torrent: TorrentInfo,
|
||||
*,
|
||||
season_episodes: Optional[Dict[int, List[int]]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
检查种子是否匹配媒体信息
|
||||
|
||||
@@ -807,6 +813,8 @@ class TorrentHelper:
|
||||
:param mediainfo: 需要匹配的媒体信息
|
||||
:param torrent_meta: 种子识别信息
|
||||
:param torrent: 种子信息
|
||||
:param season_episodes: 本次明确请求的季集号,可扩展默认季内集数上限;空集列表不扩展
|
||||
:return: 资源按显式媒体身份或名称、年份及季集范围匹配时返回 True
|
||||
"""
|
||||
# 显式标题标签与识别结果使用同一主身份比较。
|
||||
torrent_identity = resolve_media_identity(media=torrent_meta)
|
||||
@@ -850,11 +858,12 @@ class TorrentHelper:
|
||||
str(int(mediainfo.year) + 1)]:
|
||||
logger.debug(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.year}')
|
||||
return False
|
||||
# 无年份同名剧仅靠标题无法消歧,先用目标剧已知的季集拓扑排除不可能候选。
|
||||
# 先用目标剧已知的季集拓扑和本轮明确范围排除不可能的同名候选。
|
||||
if mediainfo.type == MediaType.TV and not TorrentHelper._match_tv_topology(
|
||||
mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
torrent=torrent,
|
||||
season_episodes=season_episodes,
|
||||
):
|
||||
return False
|
||||
if meta_names.intersection(media_titles):
|
||||
@@ -886,9 +895,19 @@ class TorrentHelper:
|
||||
mediainfo: MediaInfo,
|
||||
torrent_meta: MetaBase,
|
||||
torrent: TorrentInfo,
|
||||
season_episodes: Optional[Dict[int, List[int]]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
用目标剧已知的季集范围排除无年份同名候选。
|
||||
用目标剧已知的季集范围排除不可能的同名候选。
|
||||
|
||||
显式请求的集号与订阅缺集计算使用同一编号体系,允许累计集号扩展默认上限;
|
||||
普通搜索沿用媒体源范围,季号检查仍使用媒体源事实。
|
||||
|
||||
:param mediainfo: 已确定身份的目标媒体,保留其原始季集数据
|
||||
:param torrent_meta: 从资源自身标题解析的季集号
|
||||
:param torrent: 资源信息,用于记录拒绝原因
|
||||
:param season_episodes: 本次明确请求的季集号;未指定或空集列表不扩展上限
|
||||
:return: 资源季集范围未超出媒体源与显式请求的已知上限时返回 True
|
||||
"""
|
||||
begin_season = getattr(torrent_meta, "begin_season", None)
|
||||
end_season = getattr(torrent_meta, "end_season", None)
|
||||
@@ -928,7 +947,38 @@ class TorrentHelper:
|
||||
if len(torrent_seasons) != 1:
|
||||
return True
|
||||
season = next(iter(torrent_seasons))
|
||||
target_episodes = (getattr(mediainfo, "seasons", None) or {}).get(season) or []
|
||||
episode_limit = TorrentHelper._tv_episode_limit(
|
||||
mediainfo, season, known_seasons, (season_episodes or {}).get(season),
|
||||
)
|
||||
torrent_episodes = getattr(torrent_meta, "episode_list", None) or []
|
||||
if episode_limit is not None and torrent_episodes and max(torrent_episodes) > episode_limit:
|
||||
logger.debug(
|
||||
f'{torrent.site_name} - {torrent.title} 最大集数 {max(torrent_episodes)} '
|
||||
f'超出目标媒体第 {season} 季集数上限 {episode_limit}'
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _tv_episode_limit(
|
||||
mediainfo: MediaInfo,
|
||||
season: int,
|
||||
known_seasons: set[int],
|
||||
requested_episodes: Optional[List[int]],
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
从媒体源与显式请求计算目标季的已知集号上限,不修改原始媒体信息。
|
||||
|
||||
未知上限时继续交给后续选集处理;已知上限只能被本轮明确请求的集号扩展,
|
||||
不能因缺集列表较短而缩小正常合集的允许范围。
|
||||
|
||||
:param mediainfo: 目标媒体的默认或剧集组季集信息
|
||||
:param season: 当前资源对应的单个季号
|
||||
:param known_seasons: 已知季号,只有单季媒体可使用全剧总集数作为回退
|
||||
:param requested_episodes: 本次明确请求的当前季集号;空列表不扩展上限
|
||||
:return: 可接受的最大集号;媒体源未提供上限时返回 None
|
||||
"""
|
||||
target_episodes: List[int] = (getattr(mediainfo, "seasons", None) or {}).get(season) or []
|
||||
if not target_episodes and len(known_seasons) == 1:
|
||||
try:
|
||||
episode_count = int(getattr(mediainfo, "number_of_episodes", None) or 0)
|
||||
@@ -936,14 +986,9 @@ class TorrentHelper:
|
||||
episode_count = 0
|
||||
if episode_count:
|
||||
target_episodes = list(range(1, episode_count + 1))
|
||||
torrent_episodes = getattr(torrent_meta, "episode_list", None) or []
|
||||
if target_episodes and torrent_episodes and max(torrent_episodes) > max(target_episodes):
|
||||
logger.debug(
|
||||
f'{torrent.site_name} - {torrent.title} 最大集数 {max(torrent_episodes)} '
|
||||
f'超出目标媒体第 {season} 季总集数 {max(target_episodes)}'
|
||||
)
|
||||
return False
|
||||
return True
|
||||
if not target_episodes:
|
||||
return None
|
||||
return max(target_episodes + (requested_episodes or []))
|
||||
|
||||
@staticmethod
|
||||
def requires_identity_disambiguation(
|
||||
|
||||
@@ -201,9 +201,12 @@ def _match_source(
|
||||
torrent_meta: MetaBase,
|
||||
mediainfo: MediaInfo,
|
||||
cache: DisambiguationCache,
|
||||
season_episodes: Optional[Dict[int, List[int]]] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
返回资源命中的身份依据,未匹配时返回 None。
|
||||
|
||||
:param season_episodes: 本次明确请求的季集号,仅用于兼容不同于默认季内集号的范围
|
||||
"""
|
||||
torrent_source, torrent_media_id = resolve_media_identity(media=torrent)
|
||||
if torrent_source == MediaSource.IMDb and mediainfo.imdb_id and torrent_media_id == str(mediainfo.imdb_id):
|
||||
@@ -214,6 +217,7 @@ def _match_source(
|
||||
mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
torrent=torrent,
|
||||
season_episodes=season_episodes,
|
||||
):
|
||||
return None
|
||||
if TorrentHelper.requires_identity_disambiguation(
|
||||
@@ -280,6 +284,7 @@ def _match_torrents(
|
||||
torrent_meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
cache=cache,
|
||||
season_episodes=season_episodes,
|
||||
)
|
||||
if source:
|
||||
diagnostics["matched"] += 1
|
||||
@@ -376,16 +381,26 @@ class SearchResultOwner(_SearchOwnerBase):
|
||||
) -> tuple[list[SearchResourceEvidence], dict[str, Context]]:
|
||||
"""
|
||||
过滤前识别新增资源;识别未知不会成为缺席证据或永久负缓存。
|
||||
|
||||
:param targets: 本轮已绑定目标媒体的缺集键,显式集号同时用于匹配累计编号资源
|
||||
"""
|
||||
evidence = []
|
||||
contexts = {}
|
||||
season_episodes: Dict[int, List[int]] = {}
|
||||
if mediainfo.type == MediaType.TV:
|
||||
for target in targets:
|
||||
season, episode = target.split(":", 1)
|
||||
if episode != "season":
|
||||
season_episodes.setdefault(int(season), []).append(int(episode))
|
||||
for torrent in torrents:
|
||||
# 去重键含标题和描述,内容变化即为新资源;识别规则变化由检查点合同触发重搜。
|
||||
key = SearchResultOwner._resource_identity(torrent)
|
||||
record = cache.get(key)
|
||||
if record is None or record["targets"] is None:
|
||||
meta = _torrent_meta(torrent=torrent, custom_words=custom_words, mediainfo=mediainfo)
|
||||
source = _match_source(torrent, meta, mediainfo, disambiguation) if torrent.title else None
|
||||
source = _match_source(
|
||||
torrent, meta, mediainfo, disambiguation, season_episodes=season_episodes,
|
||||
) if torrent.title else None
|
||||
unknown = not torrent.title or (_disambiguation_key(meta) in disambiguation
|
||||
and disambiguation[_disambiguation_key(meta)] is None)
|
||||
matched_targets = _resource_targets(meta, targets, mediainfo.type) if source else set()
|
||||
|
||||
@@ -83,6 +83,62 @@ def _missing(scope, item):
|
||||
subscribe=item, meta=build_subscribe_meta(item), mediainfo=scope.media, mediakey="tmdb:1")[1]
|
||||
|
||||
|
||||
def test_cumulative_episode_subscription_reaches_download_submission(subscription, monkeypatch):
|
||||
"""
|
||||
人工累计范围经真实缺集计算、分页识别和下载选集后提交 1180 集,且不提前完成订阅。
|
||||
"""
|
||||
scope = subscription
|
||||
subscribe = replace(
|
||||
scope.initial, name="One Piece", year="1999", season=23,
|
||||
start_episode=1156, total_episode=2000, note=list(range(1156, 1180)),
|
||||
custom_words=r"(?i)(One[.\s]Piece.*?)S01E(?=\d{4}) => \1S23E",
|
||||
)
|
||||
scope.current["value"] = subscribe
|
||||
scope.media.title = scope.media.original_title = "One Piece"
|
||||
scope.media.names = ["One Piece"]
|
||||
scope.media.year = "1999"
|
||||
scope.media.season = 23
|
||||
scope.media.season_years = {1: "1999", 23: "2026"}
|
||||
scope.media.seasons = {1: list(range(1, 62)), 23: list(range(1, 26))}
|
||||
scope.download.download_torrent.return_value = (b"torrent", None, [1180])
|
||||
target = SubscriptionSearchTarget(
|
||||
subscribe, build_subscribe_meta(subscribe), scope.media, _missing(scope, subscribe), "tmdb:1",
|
||||
)
|
||||
search = SearchChain()
|
||||
monkeypatch.setattr(search, "_sync_indexers", lambda _sites: [{"id": 1, "name": "A"}])
|
||||
monkeypatch.setattr(search, "get_search_page_size", lambda **_params: 100)
|
||||
monkeypatch.setattr(search, "search_plugin_torrents", lambda **_params: [])
|
||||
monkeypatch.setattr(scope.chain, "get_sub_sites", lambda _subscribe: [1])
|
||||
calls = []
|
||||
|
||||
def request(**params):
|
||||
"""
|
||||
仅回放站点原始累计资源,保留真实识别词和所有后续匹配逻辑。
|
||||
"""
|
||||
calls.append(params["page"])
|
||||
items = [TorrentInfo(
|
||||
site=1, site_name="A", title="One Piece 1999 S01E1180 2160p WEB-DL H265 AAC-PTerWEB",
|
||||
enclosure="https://site.example/download?id=1180", pri_order=100,
|
||||
)] if params["page"] == 0 else []
|
||||
report_site_search_outcome(attempted=True, outcome="success")
|
||||
report_site_search_page(raw_count=len(items), has_more=False)
|
||||
return items
|
||||
|
||||
monkeypatch.setattr(search, "search_site_torrents", request)
|
||||
|
||||
_process_paged_subscription(
|
||||
scope.chain, subscribe, search, target, SystemConfigKey.SubscribeFilterRuleGroups, None,
|
||||
)
|
||||
|
||||
assert calls == [0]
|
||||
scope.download.download_single.assert_called_once()
|
||||
assert 1180 in scope.current["value"].note
|
||||
assert 1180 not in _missing(scope, scope.current["value"])["tmdb:1"][23].episodes
|
||||
assert 1181 in _missing(scope, scope.current["value"])["tmdb:1"][23].episodes
|
||||
assert scope.media.seasons[23] == list(range(1, 26))
|
||||
scope.finished.assert_not_called()
|
||||
|
||||
|
||||
def test_full_pack_candidate_without_torrent_cannot_submit_download(subscription, monkeypatch):
|
||||
"""
|
||||
整包候选缺少种子信息时不比较优先级或提交下载,保留原始缺集范围。
|
||||
|
||||
170
tests/test_subscription_episode_numbering.py
Normal file
170
tests/test_subscription_episode_numbering.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
累计集号订阅与默认季内集号校验的兼容回归。
|
||||
"""
|
||||
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.subscription.contract import SubscriptionSnapshot, build_subscribe_meta
|
||||
from app.chain.search.facade import SearchChain
|
||||
from app.chain.search.result import SearchResultOwner, TorrentHelper
|
||||
from app.chain.subscribe import match as subscribe_match
|
||||
from app.chain.subscribe.facade import SubscribeChain
|
||||
from app.domain.context import Context, MediaInfo, TorrentInfo
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.schemas.mediaserver import NotExistMediaInfo
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def numbering():
|
||||
"""
|
||||
保留 Issue #7289 的媒体身份、累计范围、识别词与默认季内集数。
|
||||
"""
|
||||
subscribe = SubscriptionSnapshot(
|
||||
id=7289, name="航海王", year="1999", type=MediaType.TV.value,
|
||||
media_source=MediaSource.TMDB, media_id="37854", season=23,
|
||||
start_episode=1156, total_episode=2000, manual_total_episode=1,
|
||||
note=list(range(1156, 1180)), state="R",
|
||||
custom_words=r"(?i)(One[.\s]Piece.*?)S01E(?=\d{4}) => \1S23E",
|
||||
)
|
||||
media = MediaInfo(
|
||||
media_source=MediaSource.TMDB, media_id="37854", tmdb_id=37854,
|
||||
title="航海王", original_title="ワンピース", names=["One Piece"],
|
||||
type=MediaType.TV, year="1999", season=23,
|
||||
season_years={1: "1999", 23: "2026"}, seasons={1: list(range(1, 62)), 23: list(range(1, 26))},
|
||||
)
|
||||
torrent = TorrentInfo(
|
||||
site=1, site_name="测试站点",
|
||||
title="One Piece 1999 S01E1180 2160p WEB-DL H265 AAC-PTerWEB",
|
||||
category=MediaType.TV.value,
|
||||
)
|
||||
missing = {"tmdb:37854": {23: NotExistMediaInfo(
|
||||
season=23, episodes=list(range(1180, 2001)), start_episode=1156, total_episode=2000,
|
||||
)}}
|
||||
return SimpleNamespace(subscribe=subscribe, media=media, torrent=torrent, missing=missing)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("paged", [False, True])
|
||||
def test_search_accepts_explicit_cumulative_episode_scope(numbering, monkeypatch, paged):
|
||||
"""
|
||||
普通结果解析和逐页身份识别均使用本次缺集范围,并保留原始媒体季内集数。
|
||||
"""
|
||||
scope = numbering
|
||||
words = scope.subscribe.custom_words.splitlines()
|
||||
monkeypatch.setattr(TorrentHelper, "sort_torrents", staticmethod(lambda contexts: contexts))
|
||||
if paged:
|
||||
evidence, contexts = SearchResultOwner._identify_page(
|
||||
torrents=[scope.torrent], mediainfo=scope.media,
|
||||
targets={f"23:{episode}" for episode in range(1180, 2001)},
|
||||
custom_words=words, cache={}, disambiguation={},
|
||||
)
|
||||
assert evidence[0].targets == frozenset({"23:1180"})
|
||||
result = list(contexts.values())
|
||||
else:
|
||||
result = object.__new__(SearchChain)._parse_result(
|
||||
torrents=[scope.torrent], mediainfo=scope.media, rule_groups=[],
|
||||
season_episodes={23: scope.missing["tmdb:37854"][23].episodes}, custom_words=words,
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].meta_info.season_list == [23]
|
||||
assert result[0].meta_info.episode_list == [1180]
|
||||
assert scope.media.seasons[23] == list(range(1, 26))
|
||||
assert scope.torrent.title == "One Piece 1999 S01E1180 2160p WEB-DL H265 AAC-PTerWEB"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("title", "episodes", "expected"),
|
||||
[
|
||||
("One Piece 1999 S23E1180", {23: [1180]}, True),
|
||||
("One Piece 1999 S23E1180", None, False),
|
||||
("One Piece 1999 S23E1180", {23: []}, False),
|
||||
("One Piece 1999 S23E1180", {1: [1180]}, False),
|
||||
("One Piece 1999 S23E2001", {23: list(range(1156, 2001))}, False),
|
||||
("One Piece 1999 S24E1180", {24: [1180]}, False),
|
||||
("One Piece 2000 S23E1180", {23: [1180]}, False),
|
||||
("Other Show 1999 S23E1180", {23: [1180]}, False),
|
||||
("One Piece S23E1180", None, False),
|
||||
("One Piece 1999 S23E01-E25", {23: [1]}, True),
|
||||
],
|
||||
)
|
||||
def test_explicit_scope_keeps_identity_and_topology_guards(numbering, title, episodes, expected):
|
||||
"""
|
||||
显式集号只扩展目标季的集数上限,不放宽名称、年份和季号,也不缩小普通合集。
|
||||
"""
|
||||
torrent = TorrentInfo(title=title, site_name="测试站点", category=MediaType.TV.value)
|
||||
assert TorrentHelper.match_torrent(
|
||||
numbering.media, MetaInfo(title), torrent, season_episodes=episodes,
|
||||
) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("manual", [False, True])
|
||||
def test_rss_match_keeps_cumulative_subscription_scope(numbering, monkeypatch, manual):
|
||||
"""
|
||||
RSS 标题兜底继续按订阅缺集范围交付累计集号候选,人工标记不改变已有匹配行为。
|
||||
"""
|
||||
scope = numbering
|
||||
subscribe = replace(scope.subscribe, manual_total_episode=int(manual))
|
||||
candidate = Context(meta_info=MetaInfo(scope.torrent.title), torrent_info=scope.torrent)
|
||||
torrents = {"site.example": [candidate]}
|
||||
chain = object.__new__(SubscribeChain)
|
||||
chain.check_and_handle_existing_media = Mock(return_value=(False, scope.missing))
|
||||
chain.get_params = Mock(return_value={})
|
||||
chain.filter_torrents = Mock(side_effect=lambda **params: params["torrent_list"])
|
||||
chain.finish_subscribe_or_not = Mock()
|
||||
chain.subscription_repository = SimpleNamespace(get=Mock(return_value=subscribe))
|
||||
download = Mock(return_value=([], scope.missing))
|
||||
chain._SubscribeChain__download_best_version_with_full_pack_first = download
|
||||
media_chain = SimpleNamespace(
|
||||
supplement_media_info=lambda media: media,
|
||||
recognize_by_meta=Mock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(subscribe_match, "MediaChain", lambda: media_chain)
|
||||
monkeypatch.setattr(subscribe_match, "_prepare_subscription_match", Mock(return_value=(
|
||||
build_subscribe_meta(subscribe), scope.media, torrents, [], [],
|
||||
)))
|
||||
execution = SimpleNamespace(should_stop=lambda: False, report_phase=Mock())
|
||||
|
||||
assert chain._execute_match(
|
||||
subscribe=subscribe, processed_torrents=torrents, candidate_index=None,
|
||||
fresh_fact_lease=None, execution_context=execution,
|
||||
) == "completed"
|
||||
|
||||
download.assert_called_once()
|
||||
matched = download.call_args.kwargs["contexts"]
|
||||
assert len(matched) == 1
|
||||
assert matched[0].meta_info.episode_list == [1180]
|
||||
assert matched[0].meta_info.season_list == [23]
|
||||
assert matched[0].media_info_is_target
|
||||
assert not matched[0].candidate_recognized
|
||||
assert candidate.meta_info.season_list == [1]
|
||||
|
||||
|
||||
def test_requested_scope_preserves_packs_when_episode_count_is_unknown(numbering):
|
||||
"""
|
||||
媒体源没有集数上限时,缺少一集不能把覆盖该集的正常合集当作越界资源。
|
||||
"""
|
||||
numbering.media.seasons[23] = []
|
||||
torrent = TorrentInfo(title="One Piece 1999 S23E01-E30", site_name="测试站点")
|
||||
assert TorrentHelper.match_torrent(
|
||||
numbering.media, MetaInfo(torrent.title), torrent, season_episodes={23: [1]},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [25, 0, None, "unknown"])
|
||||
@pytest.mark.parametrize("explicit", [False, True])
|
||||
def test_single_season_total_fallback_keeps_numbering_contract(numbering, count, explicit):
|
||||
"""
|
||||
单季总集数回退只在已知有效上限时约束资源,并允许显式累计集号扩展上限。
|
||||
"""
|
||||
media = numbering.media
|
||||
media.seasons = {23: []}
|
||||
media.season_years = {23: "1999"}
|
||||
media.number_of_episodes = count
|
||||
torrent = TorrentInfo(title="One Piece 1999 S23E1180", site_name="测试站点")
|
||||
assert TorrentHelper.match_torrent(
|
||||
media, MetaInfo(torrent.title), torrent, season_episodes={23: [1180]} if explicit else None,
|
||||
) is (explicit or count != 25)
|
||||
Reference in New Issue
Block a user