fix(search): disambiguate yearless same-title resources

This commit is contained in:
jxxghp
2026-08-22 11:35:01 +08:00
parent 5d98f397cb
commit 2d982f08fa
6 changed files with 435 additions and 12 deletions
+169
View File
@@ -482,6 +482,13 @@ 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,
):
return False
# 比对标题和原语种标题
if meta_names.intersection(media_titles):
logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}')
@@ -515,6 +522,168 @@ class TorrentHelper:
logger.debug(f'{torrent.site_name} - {torrent.title} 标题不匹配,识别名称:{meta_names}')
return False
@staticmethod
def _match_tv_topology(
mediainfo: MediaInfo,
torrent_meta: MetaBase,
torrent: TorrentInfo,
) -> bool:
"""用目标剧已知的季集范围排除无年份同名候选。"""
begin_season = getattr(torrent_meta, "begin_season", None)
end_season = getattr(torrent_meta, "end_season", None)
if begin_season is None:
torrent_seasons = set()
elif end_season is None:
torrent_seasons = {begin_season}
else:
start, end = sorted((begin_season, end_season))
torrent_seasons = set(range(start, end + 1))
known_seasons = set()
for season_map in (
getattr(mediainfo, "seasons", None),
getattr(mediainfo, "season_years", None),
):
for season in (season_map or {}).keys():
try:
known_seasons.add(int(season))
except (TypeError, ValueError):
continue
if not known_seasons:
try:
season_count = int(getattr(mediainfo, "number_of_seasons", None) or 0)
except (TypeError, ValueError):
season_count = 0
if season_count:
known_seasons = set(range(1, season_count + 1))
if torrent_seasons and known_seasons and not torrent_seasons.issubset(known_seasons):
logger.debug(
f'{torrent.site_name} - {torrent.title} 季范围 {sorted(torrent_seasons)} '
f'超出目标媒体季范围 {sorted(known_seasons)}'
)
return False
if len(torrent_seasons) != 1:
return True
season = next(iter(torrent_seasons))
target_episodes = (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)
except (TypeError, ValueError):
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
@staticmethod
def requires_identity_disambiguation(
mediainfo: MediaInfo,
torrent_meta: MetaBase,
) -> bool:
"""判断无年份标题是否只通过目标别名命中,需进一步核验媒体身份。"""
if (
mediainfo.type != MediaType.TV
or getattr(torrent_meta, "year", None)
or not getattr(mediainfo, "year", None)
):
return False
media_titles = {
text_tools.normalize_upper(value)
for value in (
getattr(mediainfo, "title", None),
getattr(mediainfo, "original_title", None),
)
if value
}
media_names = {
text_tools.normalize_upper(value)
for value in (getattr(mediainfo, "names", None) or [])
if value
}
meta_names = {
text_tools.normalize_upper(value)
for value in (
getattr(torrent_meta, "cn_name", None),
getattr(torrent_meta, "en_name", None),
)
if value
}
return bool(meta_names.intersection(media_names)) and not bool(
meta_names.intersection(media_titles)
)
@staticmethod
def match_same_work_evidence(
target_mediainfo: MediaInfo,
candidate_mediainfo: MediaInfo,
torrent_meta: MetaBase,
) -> Tuple[bool, str]:
"""判断媒体 ID 冲突时是否存在足以覆盖候选识别的同作品证据。"""
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:
return True, f"媒体身份 {target_identity[0]}:{target_identity[1]}"
torrent_year = str(getattr(torrent_meta, "year", None) or "")[:4]
target_years = {
str(year)[:4]
for year in (
[getattr(target_mediainfo, "year", None)]
+ list((getattr(target_mediainfo, "season_years", None) or {}).values())
)
if year
}
if torrent_year and torrent_year in target_years:
return True, f"资源年份 {torrent_year}"
for field in ("imdb_id", "tvdb_id", "douban_id", "bangumi_id"):
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):
return True, f"共同 {field}"
target_year = str(getattr(target_mediainfo, "year", None) or "")[:4]
candidate_year = str(getattr(candidate_mediainfo, "year", None) or "")[:4]
if target_year and candidate_year and target_year != candidate_year:
return False, f"首播年份冲突 {candidate_year} != {target_year}"
target_original_title = text_tools.normalize_upper(
getattr(target_mediainfo, "original_title", None) or ""
)
candidate_original_title = text_tools.normalize_upper(
getattr(candidate_mediainfo, "original_title", None) or ""
)
if (
target_original_title
and candidate_original_title
and target_original_title != candidate_original_title
):
return False, "原始标题冲突"
target_language = str(
getattr(target_mediainfo, "original_language", None) or ""
).casefold()
candidate_language = str(
getattr(candidate_mediainfo, "original_language", None) or ""
).casefold()
if target_language and candidate_language and target_language != candidate_language:
return False, f"原始语言冲突 {candidate_language} != {target_language}"
if target_year and candidate_year:
return True, f"共同首播年份 {target_year}"
if target_original_title and candidate_original_title:
return True, "共同原始标题"
return False, "资源无年份,候选与目标也没有可核验的共同元数据"
@staticmethod
def filter_torrent(torrent_info: TorrentInfo,
filter_params: Dict[str, Any]) -> bool:
+33
View File
@@ -1331,6 +1331,7 @@ class SearchChain(ChainBase):
# 开始匹配
_match_torrents = []
disambiguation_cache: Dict[Tuple[str, str, str], Optional[MediaInfo]] = {}
try:
# 英文标题应该在别名/原标题中,不需要再匹配
logger.info(f"开始匹配结果 标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
@@ -1370,6 +1371,38 @@ class SearchChain(ChainBase):
if TorrentHelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
torrent=torrent):
if TorrentHelper.requires_identity_disambiguation(
mediainfo=mediainfo,
torrent_meta=torrent_meta,
):
disambiguation_key = (
torrent_meta.cn_name or "",
torrent_meta.en_name or "",
torrent_meta.year or "",
)
if disambiguation_key not in disambiguation_cache:
disambiguation_cache[disambiguation_key] = MediaChain().recognize_by_meta(
torrent_meta,
obtain_images=False,
)
candidate_mediainfo = disambiguation_cache[disambiguation_key]
if not candidate_mediainfo:
logger.info(
f'{torrent.site_name} - {torrent.title} '
f'仅通过无年份别名命中且候选媒体身份无法确认,已跳过'
)
continue
evidence_matched, evidence = TorrentHelper.match_same_work_evidence(
target_mediainfo=mediainfo,
candidate_mediainfo=candidate_mediainfo,
torrent_meta=torrent_meta,
)
if not evidence_matched:
logger.info(
f'{torrent.site_name} - {torrent.title} '
f'无年份同名候选未通过消歧:{evidence}'
)
continue
# 匹配成功
_match_torrents.append((torrent, torrent_meta, "title"))
continue
+23 -1
View File
@@ -2030,6 +2030,15 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
if TorrentHelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
torrent=torrent_info):
if TorrentHelper.requires_identity_disambiguation(
mediainfo=mediainfo,
torrent_meta=torrent_meta,
):
logger.info(
f'{torrent_info.site_name} - {torrent_info.title} '
f'仅通过无年份别名命中且候选媒体身份无法确认,已跳过'
)
continue
# 匹配成功
logger.info(
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
@@ -3839,13 +3848,26 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase):
)
return None
evidence_matched, evidence = TorrentHelper.match_same_work_evidence(
target_mediainfo=target_mediainfo,
candidate_mediainfo=candidate_mediainfo,
torrent_meta=torrent_meta,
)
if not evidence_matched:
logger.debug(
f'{torrent_info.site_name} - {torrent_info.title} 候选媒体ID冲突且缺少同作品证据:'
f'{evidence}{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}'
f'经标题或别名{evidence}复核匹配到订阅目标:'
f'{torrent_info.site_name} - {torrent_info.title}'
)
return target_mediainfo
+24
View File
@@ -5,6 +5,7 @@ from unittest.mock import patch
import pytest
from app.domain.context import MediaInfo
from app.domain.metainfo import MetaInfo, MetaInfoPath, find_metainfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
@@ -160,6 +161,29 @@ def test_torrent_title_match_ignores_question_mark_variants():
)
def test_torrent_title_match_rejects_season_absent_from_target_series():
"""无年份同名剧的资源季超出目标剧季范围时应拒绝。"""
mediainfo = MediaInfo(
title="家族计划",
original_title="가족계획",
names=["Family Matters"],
type=MediaType.TV,
year="2024",
number_of_seasons=1,
seasons={1: list(range(1, 7))},
season_years={1: "2024"},
)
torrent_meta = MetaInfo("Family Matters S02 1080p WEBRip DD2.0 x264-TrollHD")
torrent = SimpleNamespace(
site_name="测试站点",
title=torrent_meta.org_string,
category=MediaType.TV.value,
description=None,
)
assert not TorrentHelper.match_torrent(mediainfo, torrent_meta, torrent)
def test_python_metainfo_fallback_preserves_xxx_movie_title():
"""Python 兜底解析不应删除合法 xXx 片名。"""
with patch("app.adapters.system.rust.parse_metainfo", return_value=None):
@@ -0,0 +1,104 @@
from unittest.mock import Mock
from app.chain import search as search_module
from app.chain.search import SearchChain
from app.domain.context import MediaInfo, TorrentInfo
from app.schemas.types import MediaSource, MediaType
def test_exact_search_rejects_no_year_alias_recognized_as_different_work(monkeypatch):
"""精确搜索中的无年份别名候选识别为不同作品时应拒绝。"""
target = MediaInfo(
media_source=MediaSource.TMDB,
media_id="236356",
tmdb_id=236356,
title="家族计划",
original_title="가족계획",
names=["Family Matters"],
type=MediaType.TV,
year="2024",
original_language="ko",
season_years={1: "2024"},
)
candidate = MediaInfo(
media_source=MediaSource.TMDB,
media_id="30161",
tmdb_id=30161,
title="Family Matters",
original_title="Family Matters",
type=MediaType.TV,
year="2008",
original_language="en",
)
torrent = TorrentInfo(
title="Family Matters S01 1080p WEBRip DD2.0 x264-TrollHD",
site_name="测试站点",
category=MediaType.TV.value,
)
media_chain = Mock()
media_chain.recognize_by_meta.return_value = candidate
monkeypatch.setattr(search_module, "MediaChain", Mock(return_value=media_chain))
monkeypatch.setattr(
search_module.TorrentHelper,
"sort_torrents",
staticmethod(lambda contexts: contexts),
)
chain = object.__new__(SearchChain)
contexts = chain._SearchChain__parse_result(
torrents=[torrent],
mediainfo=target,
rule_groups=[],
)
assert contexts == []
media_chain.recognize_by_meta.assert_called_once()
def test_exact_search_reuses_disambiguation_for_same_parsed_title(monkeypatch):
"""同一解析标题的多条资源应复用一次候选识别,避免重复外部查询。"""
target = MediaInfo(
media_source=MediaSource.TMDB,
media_id="236356",
tmdb_id=236356,
title="家族计划",
original_title="가족계획",
names=["Family Matters"],
type=MediaType.TV,
year="2024",
season_years={1: "2024"},
)
candidate = MediaInfo(
media_source=MediaSource.TMDB,
media_id="236356",
tmdb_id=236356,
title="家族计划",
type=MediaType.TV,
year="2024",
)
torrents = [
TorrentInfo(
title=f"Family Matters S01 1080p WEB-DL {group}",
site_name=f"测试站点{index}",
category=MediaType.TV.value,
)
for index, group in enumerate(("GROUP-A", "GROUP-B"), start=1)
]
media_chain = Mock()
media_chain.recognize_by_meta.return_value = candidate
monkeypatch.setattr(search_module, "MediaChain", Mock(return_value=media_chain))
monkeypatch.setattr(
search_module.TorrentHelper,
"sort_torrents",
staticmethod(lambda contexts: contexts),
)
chain = object.__new__(SearchChain)
contexts = chain._SearchChain__parse_result(
torrents=torrents,
mediainfo=target,
rule_groups=[],
)
assert len(contexts) == 2
media_chain.recognize_by_meta.assert_called_once()
+82 -11
View File
@@ -5,36 +5,44 @@ from app.domain.context import Context, MediaInfo, TorrentInfo
from app.schemas.types import MediaSource, MediaType
def _target_media(tmdb_id=106449, douban_id=None) -> MediaInfo:
def _target_media(tmdb_id=106449, douban_id=None, **kwargs) -> MediaInfo:
"""构造订阅目标媒体。"""
media_source = MediaSource.TMDB if tmdb_id is not None else MediaSource.Douban
media_id = str(tmdb_id) if tmdb_id is not None else douban_id
defaults = {
"title": "凡人修仙传",
"original_title": "凡人修仙传",
"names": ["A Record Of A Mortals Journey To Immortality"],
"type": MediaType.TV,
"year": "2020",
"season_years": {1: "2020"},
}
defaults.update(kwargs)
return MediaInfo(
media_source=media_source if media_id is not None else None,
media_id=media_id,
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,
**defaults,
)
def _candidate_media(tmdb_id=285479, douban_id=None) -> MediaInfo:
def _candidate_media(tmdb_id=285479, douban_id=None, **kwargs) -> MediaInfo:
"""构造由 RSS 标题推断出的候选媒体。"""
media_source = MediaSource.TMDB if tmdb_id is not None else MediaSource.Douban
media_id = str(tmdb_id) if tmdb_id is not None else douban_id
defaults = {
"title": "凡人修仙传",
"type": MediaType.TV,
"year": "2020",
}
defaults.update(kwargs)
return MediaInfo(
media_source=media_source if media_id is not None else None,
media_id=media_id,
title="凡人修仙传",
type=MediaType.TV,
year="2020",
tmdb_id=tmdb_id,
douban_id=douban_id,
**defaults,
)
@@ -96,6 +104,69 @@ def test_inferred_tmdb_conflict_falls_back_to_strict_alias_match():
assert context.media_info_is_target is True
def test_no_year_alias_rejects_candidate_with_different_first_air_year():
"""无年份别名命中另一个首播年份的同名作品时应拒绝。"""
target = _target_media(
tmdb_id=236356,
title="家族计划",
original_title="가족계획",
names=["Family Matters"],
year="2024",
original_language="ko",
season_years={1: "2024"},
)
candidate = _candidate_media(
tmdb_id=30161,
title="Family Matters",
original_title="Family Matters",
year="2008",
original_language="en",
)
meta = _torrent_meta(en_name="Family Matters")
torrent = TorrentInfo(
title="Family Matters S01 1080p WEBRip DD2.0 x264-TrollHD",
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_explicit_target_year_can_override_wrong_same_name_candidate():
"""资源明确携带目标年份时应允许纠正同名候选的错误识别。"""
target = _target_media(
tmdb_id=236356,
title="家族计划",
original_title="가족계획",
names=["Family Matters"],
year="2024",
original_language="ko",
season_years={1: "2024"},
)
candidate = _candidate_media(
tmdb_id=30161,
title="Family Matters",
original_title="Family Matters",
year="2008",
original_language="en",
)
meta = _torrent_meta(en_name="Family Matters")
meta.year = "2024"
torrent = TorrentInfo(
title="Family Matters 2024 S01 1080p WEB-DL",
site_name="测试站点",
category=MediaType.TV.value,
)
context = _context(meta, candidate, torrent)
assert _reconcile(target, candidate, meta, torrent, context) is target
assert context.media_info is target
assert context.match_source == "title"
def test_inferred_tmdb_conflict_rejects_nonmatching_title():
"""ID 冲突且标题和别名不匹配时应继续拒绝候选。"""
target = _target_media()