fix(subscribe): match explicit cumulative episode ranges (#7294)

Refs #7289
This commit is contained in:
jxxghp
2026-11-04 07:02:50 +08:00
committed by GitHub
parent 68cfc377ee
commit 92195a2b84
4 changed files with 299 additions and 13 deletions

View File

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

View File

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

View File

@@ -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):
"""
整包候选缺少种子信息时不比较优先级或提交下载,保留原始缺集范围。

View 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)