mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 05:17:23 +08:00
fix(subscribe): reconcile inferred media type conflicts (#7166)
This commit is contained in:
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
310
tests/test_subscribe_type_conflict.py
Normal file
310
tests/test_subscribe_type_conflict.py
Normal 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
|
||||
Reference in New Issue
Block a user