fix(subscribe): reconcile inferred media type conflicts (#7166)

This commit is contained in:
jxxghp
2026-10-23 23:08:10 +08:00
committed by GitHub
parent 228bbf59d3
commit b51f93f60f
5 changed files with 354 additions and 12 deletions

View File

@@ -76,6 +76,9 @@ class CandidateIndex:
) -> CandidateGroups:
"""
保守路由可能命中的候选,并在外部事实查询前应用站点范围。
无显式 ID 的候选识别类型可由后续严格复核纠正,不能据此提前排除;
标题解析出的类型和季范围仍须符合订阅要求。
"""
if subscribe.custom_words:
positions = set(range(len(self._ordered)))
@@ -102,7 +105,9 @@ class CandidateIndex:
if site_ids and getattr(torrent_info, "site", None) not in site_ids:
continue
if not subscribe.custom_words and (
not self.media_type_matches(context, subscribe)
not self.media_type_matches(
context, subscribe, include_recognized=position not in self._reconcilable
)
or not self.season_matches(context, subscribe)
):
continue
@@ -165,9 +170,17 @@ class CandidateIndex:
)
@classmethod
def media_type_matches(cls, context: Context, subscribe: SubscriptionSnapshot) -> bool:
def media_type_matches(
cls,
context: Context,
subscribe: SubscriptionSnapshot,
*,
include_recognized: bool = True,
) -> bool:
"""
类型已知且冲突时拒绝,缺失类型保持保守候选。
:param include_recognized: 是否同时检查识别结果的类型;仅在路由可复核推断时忽略
"""
subscribe_type = cls.normalize_media_type(subscribe.type)
media_info = getattr(context, "media_info", None)
@@ -175,7 +188,7 @@ class CandidateIndex:
context_types = {
cls.normalize_media_type(value)
for value in (
getattr(media_info, "type", None),
getattr(media_info, "type", None) if include_recognized else None,
getattr(meta_info, "type", None),
)
}

View File

@@ -30,7 +30,7 @@ from app.foundation.url import UrlUtils
from app.runtime.cache import FileCache, TTLCache
from app.runtime.log import logger
from app.schemas.media import resolve_media_identity
from app.schemas.types import MediaType, SystemConfigKey
from app.schemas.types import MediaSource, MediaType, SystemConfigKey
_SIZE_UNIT = 1024 * 1024
@@ -912,10 +912,18 @@ class TorrentHelper:
) -> Tuple[bool, str]:
"""
判断媒体 ID 冲突时是否存在足以覆盖候选识别的同作品证据。
TMDB 的电影与电视剧数字 ID 不可跨类型比较;类型冲突时仍需资源精确年份、
其他共同标识或一致的作品元数据,不能仅凭相同 TMDB ID 覆盖候选。
:return: 是否有同作品证据,以及用于诊断的证据或拒绝原因
"""
target_identity = resolve_media_identity(media=target_mediainfo)
candidate_identity = resolve_media_identity(media=candidate_mediainfo)
if all(target_identity) and target_identity == candidate_identity:
same_type = target_mediainfo.type == candidate_mediainfo.type
if all(target_identity) and target_identity == candidate_identity and (
same_type or target_identity[0] != MediaSource.TMDB
):
return True, f"媒体身份 {target_identity[0]}:{target_identity[1]}"
torrent_year = str(getattr(torrent_meta, "year", None) or "")[:4]
@@ -931,6 +939,8 @@ class TorrentHelper:
return True, f"资源年份 {torrent_year}"
for field in ("tmdb_id", "imdb_id", "tvdb_id", "douban_id", "bangumi_id"):
if field == "tmdb_id" and not same_type:
continue
target_id = getattr(target_mediainfo, field, None)
candidate_id = getattr(candidate_mediainfo, field, None)
if target_id and candidate_id and str(target_id) == str(candidate_id):

View File

@@ -192,15 +192,19 @@ def _reconcile_candidate_media(
context: Context,
) -> Optional[MediaInfo]:
"""
在推断媒体 ID 冲突时,以订阅目标对候选标题做严格复核。
在推断媒体 ID 或类型冲突时,以订阅目标对候选标题做严格复核。
标题携带明确媒体身份或目标缺少同来源 ID 时保持严格拒绝;只有普通
标题推断产生冲突且标题、别名、类型和年份复核通过时才回填订阅目标。
标题携带明确媒体身份或目标缺少可比较身份时保持严格拒绝;只有普通
标题推断产生冲突且标题、别名、类型和同作品证据复核通过时才回填订阅目标。
电影和电视剧可能共用数字 ID,因此身份相同时仍须复核类型冲突。
"""
candidate_source, candidate_id = resolve_media_identity(media=candidate_mediainfo)
target_source, target_id = resolve_media_identity(media=target_mediainfo)
type_conflict = candidate_mediainfo.type != target_mediainfo.type
conflicts = []
if candidate_source and candidate_id and (candidate_source != target_source or candidate_id != target_id):
if candidate_source and candidate_id and (
candidate_source != target_source or candidate_id != target_id or type_conflict
):
conflicts.append(
(
str(candidate_source),
@@ -215,6 +219,11 @@ def _reconcile_candidate_media(
f"{source} {candidate_id} != {target_id if target_id is not None else '缺失'}"
for source, candidate_id, target_id in conflicts
)
if type_conflict:
conflict_text = (
f"媒体类型 {(candidate_mediainfo.type or MediaType.UNKNOWN).value} != "
f"{(target_mediainfo.type or MediaType.UNKNOWN).value};{conflict_text}"
)
if any(target_id is None for _, _, target_id in conflicts):
logger.debug(
f"{torrent_info.site_name} - {torrent_info.title} 候选媒体ID与订阅目标不可比较:{conflict_text}"
@@ -615,6 +624,8 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
) -> _MatchOutcome:
"""
在单条订阅准入期间完成匹配、下载和完成判定。
候选推断的媒体类型与身份一并严格复核,复核通过后再应用目标季集范围。
"""
if execution_context.should_stop():
return "skipped"
@@ -735,8 +746,6 @@ class SubscribeMatchOwner(_SubscribeOwnerBase):
_context.media_info_is_target = True
if torrent_mediainfo and resolve_media_identity(media=torrent_mediainfo)[1]:
if torrent_mediainfo.type != mediainfo.type:
continue
torrent_mediainfo = self._SubscribeChain__reconcile_candidate_media(
target_mediainfo=mediainfo,
candidate_mediainfo=cast(MediaInfo, torrent_mediainfo),

View File

@@ -53,7 +53,7 @@
"app/chain/scraping.py:ScrapingChain.scrape_music_metadata": 17,
"app/chain/subscribe/completion.py:SubscribeCompletionOwner._SubscribeChain__get_subscribe_no_exits": 16,
"app/chain/subscribe/completion.py:SubscribeCompletionOwner._SubscribeChain__record_subscribe_download_facts": 19,
"app/chain/subscribe/match.py:SubscribeMatchOwner._execute_match": 47,
"app/chain/subscribe/match.py:SubscribeMatchOwner._execute_match": 46,
"app/chain/subscribe/query.py:SubscribeQueryOwner.follow": 19,
"app/chain/subscribe/query.py:SubscribeQueryOwner.subscribe_files_info": 24,
"app/chain/subscribe/refresh.py:SubscribeRefreshOwner.backfill_existing_episodes": 16,

View File

@@ -0,0 +1,310 @@
"""
验证订阅候选被推断成其他媒体类型时的严格复核与缓存隔离。
"""
from collections.abc import Iterator
from dataclasses import replace
from types import SimpleNamespace
from typing import Optional
from unittest.mock import Mock
import pytest
from app.application.subscription.candidates import CandidateIndex
from app.application.subscription.contract import SubscriptionSnapshot
from app.application.subscription.execution import (
SubscriptionExecutionAdmission,
SubscriptionExecutionContext,
)
from app.application.subscription.facts import FreshFactLease
from app.application.torrent.download import 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.types import MediaSource, MediaType
@pytest.fixture
def conflict_case(monkeypatch: pytest.MonkeyPatch) -> Iterator[SimpleNamespace]:
"""
构造《空枪》资源被识别为《V星入侵》的离线订阅匹配场景。
保留真实候选索引、标题解析和匹配逻辑,仅替换媒体查询、持久化和下载边界。
"""
target = MediaInfo(
media_source=MediaSource.TMDB,
media_id="1567937",
tmdb_id=1567937,
type=MediaType.MOVIE,
title="空枪",
original_title="空枪",
year="2026",
)
candidate = MediaInfo(
media_source=MediaSource.TMDB,
media_id="21494",
tmdb_id=21494,
type=MediaType.TV,
title="V星入侵",
original_title="V",
year="2009",
)
torrent = TorrentInfo(
title="V 2026 1080p WEB-DL DDP5.1 H265 2Audio-HDSWEB",
description="空枪 | 主演: 朱一龙 檀健次 梁家辉 【国粤双语】",
category=MediaType.MOVIE.value,
site=1,
site_name="测试站点",
)
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
context = Context(
meta_info=meta,
media_info=candidate,
torrent_info=torrent,
resource_source="rss",
match_source=MediaSource.TMDB.value,
candidate_recognized=True,
media_info_is_target=False,
)
subscribe = SubscriptionSnapshot(
id=1,
name=target.title,
year=target.year,
type=target.type.value,
media_source=target.media_source,
media_id=target.media_id,
state="R",
)
chain = object.__new__(SubscribeChain)
chain.subscription_repository = SimpleNamespace(get=lambda _subscription_id: None)
chain.get_sub_sites = Mock(return_value=[])
chain.get_params = Mock(return_value={})
chain.check_and_handle_existing_media = Mock(return_value=(False, {}))
chain.filter_torrents = Mock(side_effect=lambda **kwargs: kwargs["torrent_list"])
chain.finish_subscribe_or_not = Mock()
download = Mock(return_value=([], {}))
chain._SubscribeChain__download_best_version_with_full_pack_first = download
media_chain = SimpleNamespace(
recognize_media=Mock(return_value=target),
supplement_media_info=lambda media: media,
)
monkeypatch.setattr(subscribe_match, "MediaChain", lambda: media_chain)
monkeypatch.setattr(
subscribe_match,
"get_configured_system_config",
lambda: SimpleNamespace(get=lambda _key: []),
)
monkeypatch.setattr(
subscribe_match, "runtime_stop_state", SimpleNamespace(is_system_stopped=False)
)
admission = SubscriptionExecutionAdmission()
lease = admission.try_acquire(subscription_id=subscribe.id, operation="match", ttl_seconds=60)
assert lease is not None
yield SimpleNamespace(
target=target,
candidate=candidate,
torrent=torrent,
meta=meta,
context=context,
subscribe=subscribe,
chain=chain,
download=download,
execution=SubscriptionExecutionContext(lease=lease, admission=admission),
)
assert admission.release(lease)
def _execute_match(case: SimpleNamespace) -> str:
"""
经真实候选路由和单订阅匹配流程执行一次资源检查。
"""
candidates = {"example.com": [case.context]}
return case.chain._execute_match(
subscribe=case.subscribe,
processed_torrents=candidates,
candidate_index=CandidateIndex(candidates),
fresh_fact_lease=FreshFactLease(),
execution_context=case.execution,
)
def _reconcile(case: SimpleNamespace) -> Optional[MediaInfo]:
"""
直接验证冲突复核边界,避免索引提前拒绝掩盖复核回归。
"""
return SubscribeChain._SubscribeChain__reconcile_candidate_media(
target_mediainfo=case.target,
candidate_mediainfo=case.candidate,
torrent_meta=case.meta,
torrent_info=case.torrent,
context=case.context,
)
def test_inferred_type_conflict_reaches_subscription_review(conflict_case: SimpleNamespace) -> None:
"""
只由识别结果产生的类型冲突不能在候选路由阶段丢失。
"""
case = conflict_case
index = CandidateIndex({"example.com": [case.context]})
assert index.route_for_match(case.subscribe) == {"example.com": [case.context]}
assert index.select_cache_candidates(case.subscribe, allow_title_match=True) == []
@pytest.mark.parametrize("candidate_id", ["21494", "1567937"])
def test_movie_subtitle_reconciles_inferred_tv_without_changing_cache(
conflict_case: SimpleNamespace, candidate_id: str
) -> None:
"""
副标题及精确年份可纠正跨类型推断,相同数字 ID 也必须复核且不污染 RSS 缓存。
"""
case = conflict_case
case.candidate.media_id = candidate_id
case.candidate.tmdb_id = int(candidate_id)
assert _execute_match(case) == "completed"
case.download.assert_called_once()
matched = case.download.call_args.kwargs["contexts"]
assert len(matched) == 1
assert matched[0].media_info.media_id == case.target.media_id
assert matched[0].media_info.type == MediaType.MOVIE
assert matched[0].match_source == "title"
assert matched[0].candidate_recognized is False
assert matched[0].media_info_is_target is True
assert case.context.media_info is case.candidate
assert case.candidate.type == MediaType.TV
assert case.candidate.media_id == candidate_id
assert case.context.match_source == MediaSource.TMDB.value
assert case.context.candidate_recognized is True
assert case.context.media_info_is_target is False
@pytest.mark.parametrize("candidate_id", ["21494", "1567937"])
def test_type_conflict_with_same_numeric_id_still_requires_title_review(
conflict_case: SimpleNamespace, candidate_id: str
) -> None:
"""
TMDB 的电影与电视剧数字 ID 相同时仍不能绕过标题校验。
"""
case = conflict_case
case.candidate.media_id = candidate_id
case.candidate.tmdb_id = int(candidate_id)
case.torrent.description = "其他电影"
assert _reconcile(case) is None
assert case.context.media_info is case.candidate
@pytest.mark.parametrize("constraint", ["year", "missing_year", "title_type", "site_type", "explicit_id"])
@pytest.mark.parametrize("candidate_id", ["21494", "1567937"])
def test_cross_type_review_preserves_resource_constraints(
conflict_case: SimpleNamespace, constraint: str, candidate_id: str
) -> None:
"""
跨类型复核仍拒绝年份、季集类型、站点分类或明确媒体 ID 的冲突。
"""
case = conflict_case
case.candidate.media_id = candidate_id
case.candidate.tmdb_id = int(candidate_id)
if constraint == "year":
case.meta.year = "2025"
elif constraint == "missing_year":
case.meta.year = None
elif constraint == "title_type":
case.meta.type = MediaType.TV
case.meta.begin_season = 1
elif constraint == "site_type":
case.torrent.category = MediaType.TV.value
else:
case.meta.media_source = MediaSource.TMDB
case.meta.media_id = case.candidate.media_id
assert _reconcile(case) is None
assert case.context.media_info is case.candidate
assert _execute_match(case) == "completed"
case.download.assert_not_called()
@pytest.mark.parametrize("candidate_id", ["21494", "1567937"])
def test_no_year_tv_title_cannot_override_other_type_without_work_evidence(
conflict_case: SimpleNamespace, candidate_id: str
) -> None:
"""
无年份剧集标题虽命中,仍需同作品证据才能覆盖错误类型。
"""
case = conflict_case
case.target.type = MediaType.TV
case.target.season_years = {1: "2026"}
case.candidate.type = MediaType.MOVIE
case.candidate.media_id = candidate_id
case.candidate.tmdb_id = case.target.tmdb_id
case.torrent.title = "空枪 S01E01 1080p WEB-DL"
case.torrent.category = MediaType.TV.value
case.meta = MetaInfo(title=case.torrent.title)
case.context.meta_info = case.meta
case.subscribe = replace(case.subscribe, type=MediaType.TV.value, season=1)
assert _reconcile(case) is None
assert _execute_match(case) == "completed"
case.download.assert_not_called()
def test_cross_type_rejection_logs_type_and_review_reason(
conflict_case: SimpleNamespace, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
冲突诊断须同时记录两种媒体类型和标题复核失败原因。
"""
case = conflict_case
case.torrent.description = "其他电影"
messages = []
monkeypatch.setattr(subscribe_match.logger, "debug", messages.append)
assert _reconcile(case) is None
assert any("电视剧" in message and "电影" in message and "标题复核失败" in message for message in messages)
def test_tv_title_and_year_can_reconcile_inferred_movie(conflict_case: SimpleNamespace) -> None:
"""
电视剧被误识别为电影时,同样可经标题及目标季年份复核后提交。
"""
case = conflict_case
case.target.type = MediaType.TV
case.target.season_years = {1: "2026"}
case.candidate.type = MediaType.MOVIE
case.torrent.title = "空枪 2026 S01E01 1080p WEB-DL"
case.torrent.category = MediaType.TV.value
case.meta = MetaInfo(title=case.torrent.title)
case.context.meta_info = case.meta
case.subscribe = replace(case.subscribe, type=MediaType.TV.value, season=1)
assert _execute_match(case) == "completed"
case.download.assert_called_once()
matched = case.download.call_args.kwargs["contexts"][0]
assert matched.media_info.type == MediaType.TV
assert matched.media_info.media_id == case.target.media_id
assert matched.match_source == "title"
def test_global_identity_remains_valid_cross_type_evidence(conflict_case: SimpleNamespace) -> None:
"""
IMDb 这类全局媒体身份仍可证明同作品,不受 TMDB 的类型命名空间限制影响。
"""
case = conflict_case
case.meta.year = None
for media in (case.target, case.candidate):
media.media_source = MediaSource.IMDb
media.media_id = "tt1234567"
matched, evidence = TorrentHelper.match_same_work_evidence(
target_mediainfo=case.target,
candidate_mediainfo=case.candidate,
torrent_meta=case.meta,
)
assert matched is True
assert "tt1234567" in evidence