fix(subscribe): 修复 RSS 媒体 ID 误识别跳过 (#6266)

This commit is contained in:
Yang Feng
2026-08-10 17:55:46 +08:00
committed by GitHub
parent 5acd8ab514
commit 16840c6767
2 changed files with 273 additions and 7 deletions

View File

@@ -2370,26 +2370,34 @@ class SubscribeChain(ChainBase):
)[1]:
if torrent_mediainfo.type != mediainfo.type:
continue
if torrent_mediainfo.tmdb_id \
and torrent_mediainfo.tmdb_id != mediainfo.tmdb_id:
torrent_mediainfo = self.__reconcile_candidate_media(
target_mediainfo=mediainfo,
candidate_mediainfo=torrent_mediainfo,
torrent_meta=torrent_meta,
torrent_info=torrent_info,
context=_context,
)
if not torrent_mediainfo:
continue
if torrent_mediainfo.douban_id \
and torrent_mediainfo.douban_id != mediainfo.douban_id:
continue
logger.info(
f'{mediainfo.title_year} 通过媒体ID匹配到可选资源{torrent_info.site_name} - {torrent_info.title}')
match_source = _context.match_source
if match_source == "title":
# 标题兜底使用的是订阅目标 media_info不能标记为候选自身识别结果。
_context.candidate_recognized = False
_context.media_info_is_target = True
match_label = "标题复核"
elif match_source == "unknown":
_context.match_source = self.__get_media_id_match_source(torrent_mediainfo)
_context.candidate_recognized = True
_context.media_info_is_target = False
match_label = "媒体ID"
else:
_context.candidate_recognized = True
_context.media_info_is_target = False
match_label = "媒体ID"
logger.info(
f'{mediainfo.title_year} 通过{match_label}匹配到可选资源:'
f'{torrent_info.site_name} - {torrent_info.title}'
)
else:
continue
@@ -4809,6 +4817,80 @@ class SubscribeChain(ChainBase):
return "plugin"
return "unknown"
@staticmethod
def __reconcile_candidate_media(
target_mediainfo: MediaInfo,
candidate_mediainfo: MediaInfo,
torrent_meta: MetaBase,
torrent_info: TorrentInfo,
context: Context,
) -> Optional[MediaInfo]:
"""
在推断媒体 ID 冲突时,以订阅目标对候选标题做严格复核。
标题携带明确媒体身份或目标缺少同来源 ID 时保持严格拒绝;只有普通
标题推断产生冲突且标题、别名、类型和年份复核通过时才回填订阅目标。
:param target_mediainfo: 订阅目标媒体信息
:param candidate_mediainfo: RSS 或首页候选的识别结果
:param torrent_meta: 候选标题解析信息
:param torrent_info: 候选种子信息
:param context: 当前订阅使用的候选上下文副本
:return: 可继续匹配的媒体信息,拒绝时返回 None
"""
identity_pairs = (
("TMDB", candidate_mediainfo.tmdb_id, target_mediainfo.tmdb_id),
("豆瓣", candidate_mediainfo.douban_id, target_mediainfo.douban_id),
)
conflicts = [
(source, str(candidate_id), str(target_id) if target_id is not None else None)
for source, candidate_id, target_id in identity_pairs
if candidate_id is not None
and str(candidate_id) != (str(target_id) if target_id is not None else None)
]
if not conflicts:
return candidate_mediainfo
conflict_text = "".join(
f"{source} {candidate_id} != {target_id if target_id is not None else '缺失'}"
for source, candidate_id, target_id in conflicts
)
if any(target_id is None for _, _, target_id in conflicts):
logger.debug(
f'{torrent_info.site_name} - {torrent_info.title} 候选媒体ID与订阅目标不可比较'
f'{conflict_text}'
)
return None
explicit_source, explicit_id = resolve_media_identity(media=torrent_meta)
if explicit_source and explicit_id:
logger.debug(
f'{torrent_info.site_name} - {torrent_info.title} 标题含明确媒体ID '
f'{explicit_source}:{explicit_id},保持严格校验:{conflict_text}'
)
return None
if not TorrentHelper.match_torrent(
mediainfo=target_mediainfo,
torrent_meta=torrent_meta,
torrent=torrent_info,
):
logger.debug(
f'{torrent_info.site_name} - {torrent_info.title} 候选媒体ID冲突且标题复核失败'
f'{conflict_text}'
)
return None
context.media_info = target_mediainfo
context.match_source = "title"
context.candidate_recognized = False
context.media_info_is_target = True
logger.debug(
f'{target_mediainfo.title_year} 候选媒体ID冲突{conflict_text}'
f'经标题或别名复核匹配到订阅目标:{torrent_info.site_name} - {torrent_info.title}'
)
return target_mediainfo
@staticmethod
def get_states_for_search(state: str) -> str:
"""

View File

@@ -0,0 +1,184 @@
from types import SimpleNamespace
from app.chain.subscribe import SubscribeChain
from app.core.context import Context, MediaInfo, TorrentInfo
from app.schemas.types import MediaType
def _target_media(tmdb_id=106449, douban_id=None) -> MediaInfo:
"""构造订阅目标媒体。"""
return MediaInfo(
title="凡人修仙传",
original_title="凡人修仙传",
names=["A Record Of A Mortals Journey To Immortality"],
type=MediaType.TV,
year="2020",
season_years={1: "2020"},
tmdb_id=tmdb_id,
douban_id=douban_id,
)
def _candidate_media(tmdb_id=285479, douban_id=None) -> MediaInfo:
"""构造由 RSS 标题推断出的候选媒体。"""
return MediaInfo(
title="凡人修仙传",
type=MediaType.TV,
year="2020",
tmdb_id=tmdb_id,
douban_id=douban_id,
)
def _torrent_meta(*, en_name="A Record Of A Mortals Journey To Immortality", tmdbid=None):
"""构造不触发外部识别的标题解析结果。"""
return SimpleNamespace(
tmdbid=tmdbid,
doubanid=None,
bangumiid=None,
anilistid=None,
media_source="themoviedb" if tmdbid else None,
media_id=str(tmdbid) if tmdbid else None,
cn_name="",
en_name=en_name,
type=MediaType.TV,
year=None,
org_string=f"{en_name} S01E186 2160p WEB-DL",
)
def _context(meta, candidate, torrent) -> Context:
"""构造 RSS 候选上下文。"""
return Context(
meta_info=meta,
media_info=candidate,
torrent_info=torrent,
resource_source="rss",
match_source="tmdbid",
candidate_recognized=True,
media_info_is_target=False,
)
def _reconcile(target, candidate, meta, torrent, context):
"""调用订阅链的候选媒体冲突复核。"""
return SubscribeChain._SubscribeChain__reconcile_candidate_media(
target_mediainfo=target,
candidate_mediainfo=candidate,
torrent_meta=meta,
torrent_info=torrent,
context=context,
)
def test_inferred_tmdb_conflict_falls_back_to_strict_alias_match():
"""标题推断到重复 TMDB 条目时应允许订阅目标别名复核。"""
target = _target_media()
candidate = _candidate_media()
meta = _torrent_meta()
torrent = TorrentInfo(
title="A Record Of A Mortals Journey To Immortality S01E186 2160p WEB-DL",
site_name="测试站点",
category=MediaType.TV.value,
)
context = _context(meta, candidate, torrent)
result = _reconcile(target, candidate, meta, torrent, context)
assert result is target
assert context.media_info is target
assert context.match_source == "title"
assert context.candidate_recognized is False
assert context.media_info_is_target is True
def test_inferred_tmdb_conflict_rejects_nonmatching_title():
"""ID 冲突且标题和别名不匹配时应继续拒绝候选。"""
target = _target_media()
candidate = _candidate_media()
meta = _torrent_meta(en_name="Different Series")
torrent = TorrentInfo(
title="Different Series S01E186 2160p WEB-DL",
site_name="测试站点",
category=MediaType.TV.value,
)
context = _context(meta, candidate, torrent)
assert _reconcile(target, candidate, meta, torrent, context) is None
assert context.media_info is candidate
assert context.match_source == "tmdbid"
assert context.candidate_recognized is True
assert context.media_info_is_target is False
def test_inferred_douban_conflict_falls_back_to_strict_alias_match():
"""豆瓣 ID 推断冲突时应使用同一套严格标题复核。"""
target = _target_media(tmdb_id=None, douban_id="30170816")
candidate = _candidate_media(tmdb_id=None, douban_id="36612345")
meta = _torrent_meta()
torrent = TorrentInfo(
title="A Record Of A Mortals Journey To Immortality S01E186 2160p WEB-DL",
site_name="测试站点",
category=MediaType.TV.value,
)
context = _context(meta, candidate, torrent)
result = _reconcile(target, candidate, meta, torrent, context)
assert result is target
assert context.media_info is target
assert context.match_source == "title"
def test_explicit_tmdb_identity_keeps_strict_conflict_rejection():
"""标题显式携带媒体 ID 时不得被标题别名覆盖。"""
target = _target_media()
candidate = _candidate_media()
meta = _torrent_meta(tmdbid=285479)
torrent = TorrentInfo(
title="A Record Of A Mortals Journey To Immortality S01E186 {tmdbid=285479}",
site_name="测试站点",
category=MediaType.TV.value,
)
context = _context(meta, candidate, torrent)
assert _reconcile(target, candidate, meta, torrent, context) is None
assert context.media_info is candidate
assert context.match_source == "tmdbid"
def test_conflict_rejects_target_without_same_source_identity():
"""订阅目标缺少候选同来源 ID 时不得用标题跨来源放行。"""
target = _target_media(tmdb_id=None)
candidate = _candidate_media()
meta = _torrent_meta()
torrent = TorrentInfo(
title="A Record Of A Mortals Journey To Immortality S01E186",
site_name="测试站点",
category=MediaType.TV.value,
)
context = _context(meta, candidate, torrent)
assert _reconcile(target, candidate, meta, torrent, context) is None
assert context.media_info is candidate
def test_matching_media_id_preserves_candidate_identity():
"""媒体 ID 原本一致时应保持候选识别上下文。"""
target = _target_media()
candidate = _candidate_media(tmdb_id=106449)
meta = _torrent_meta()
torrent = TorrentInfo(
title="A Record Of A Mortals Journey To Immortality S01E186",
site_name="测试站点",
category=MediaType.TV.value,
)
context = _context(meta, candidate, torrent)
result = _reconcile(target, candidate, meta, torrent, context)
assert result is candidate
assert context.media_info is candidate
assert context.match_source == "tmdbid"
assert context.candidate_recognized is True
assert context.media_info_is_target is False