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