From 83fc6b5d32b39d599d306a6f11b364581beb57d2 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 26 Sep 2026 09:28:23 +0800 Subject: [PATCH] fix(search): match AniList season entries with numbered torrents --- app/application/torrent/download.py | 2 +- app/chain/search/plan.py | 5 +- app/domain/projection/anilist.py | 65 +++++++++++++---- tests/test_anilist_media_source.py | 104 +++++++++++++++++++++++++++- 4 files changed, 157 insertions(+), 19 deletions(-) diff --git a/app/application/torrent/download.py b/app/application/torrent/download.py index fb1b44760..7c5b0f409 100644 --- a/app/application/torrent/download.py +++ b/app/application/torrent/download.py @@ -697,7 +697,7 @@ class TorrentHelper: 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"): + for field in ("tmdb_id", "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): diff --git a/app/chain/search/plan.py b/app/chain/search/plan.py index 9aa5cfb50..029bb4f54 100644 --- a/app/chain/search/plan.py +++ b/app/chain/search/plan.py @@ -1,7 +1,7 @@ """搜索身份、关键词和缺集计划 owner。""" from copy import deepcopy -from typing import Dict, List, Optional, Tuple, Union, cast +from typing import Dict, List, Optional, Tuple, Union from app.application.configuration import ( get_chain_runtime_config_snapshot, @@ -58,7 +58,8 @@ class SearchPlanOwner(_SearchOwnerBase): if not isinstance(mediainfo, MusicInfo) and not mediainfo.tmdb_id: meta = MetaInfo(title=mediainfo.title) mediainfo.title = meta.name - mediainfo.season = cast(int, meta.begin_season) + if meta.begin_season is not None: + mediainfo.season = meta.begin_season return mediainfo @staticmethod diff --git a/app/domain/projection/anilist.py b/app/domain/projection/anilist.py index 580da4f2d..1f7de9930 100644 --- a/app/domain/projection/anilist.py +++ b/app/domain/projection/anilist.py @@ -13,6 +13,41 @@ from app.schemas.types import MediaSource, MediaType MOVIE_FORMATS = frozenset({"MOVIE"}) CHINESE_TITLE_PATTERN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]") JAPANESE_KANA_PATTERN = re.compile(r"[\u3040-\u30ff]") +SEASON_TITLE_PATTERN = re.compile( + r"^(?P.+?)(?:\s+(?:Season\s*|S)(?P\d{1,2})" + r"|\s+(?P[2-9])|\s+(?PII|III|IV|V|VI|VII|VIII|IX|X)" + r"|(?P[ⅡⅢⅣⅤⅥⅦⅧⅨⅩ]))$", + re.IGNORECASE, +) +ROMAN_SEASONS = {value: season for season, value in enumerate( + ("", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X") +) if season > 1} +UNICODE_SEASONS = {value: season for season, value in enumerate("ⅡⅢⅣⅤⅥⅦⅧⅨⅩ", start=2)} + + +def _season_title(value: str) -> tuple[int, str] | None: + """从明确的续季后缀提取整部剧标题和季号,保留其他片名原样。""" + match = SEASON_TITLE_PATTERN.fullmatch(value.strip()) + if not match: + return None + season = ( + int(match.group("season") or match.group("number")) + if match.group("season") or match.group("number") + else ROMAN_SEASONS.get((match.group("roman") or "").upper()) + or UNICODE_SEASONS.get(match.group("unicode")) + ) + base = match.group("base").strip() + return (season, base) if season and season > 1 and base else None + + +def _season_aliases(titles: list[str]) -> tuple[int | None, list[str]]: + """仅在各别名的续季编号一致时补充可匹配整部剧种子的基础标题。""" + parsed = [_season_title(title) for title in titles if title] + found = [item for item in parsed if item] + seasons = {season for season, _ in found} + if len(seasons) != 1: + return None, [] + return seasons.pop(), list(dict.fromkeys(base for _, base in found)) def resolve_media_type(info: Mapping[str, Any]) -> MediaType: @@ -74,21 +109,20 @@ def project( builder.set("title", title) builder.set_missing("en_title", titles.get("english")) builder.set_missing("original_title", titles.get("native") or titles.get("romaji")) - builder.set( - "names", - list( - dict.fromkeys( - value - for value in [ - titles.get("english"), - titles.get("romaji"), - titles.get("native"), - *(info.get("synonyms") or []), - ] - if value and value != title - ) - ), + source_titles = [ + value for value in ( + titles.get("english"), + titles.get("romaji"), + titles.get("native"), + *(info.get("synonyms") or []), + ) if isinstance(value, str) and value + ] + inferred_season, base_aliases = _season_aliases( + [title, *source_titles] if isinstance(title, str) else source_titles ) + builder.set("names", list(dict.fromkeys( + value for value in [*source_titles, *base_aliases] if value != title + ))) start_date = info.get("startDate") end_date = info.get("endDate") @@ -140,7 +174,8 @@ def project( builder.set_missing("directors", info.get("directors") or []) if builder.get("season") is None: - builder.set("season", MetaInfo(str(title)).begin_season if title else None) + parsed_season = MetaInfo(str(title)).begin_season if title else None + builder.set("season", parsed_season if parsed_season is not None else inferred_season) episode_count = info.get("episodes") if builder.get("type") == MediaType.TV and isinstance(episode_count, int) and episode_count > 0: season = builder.get("season") if builder.get("season") is not None else 1 diff --git a/tests/test_anilist_media_source.py b/tests/test_anilist_media_source.py index 2cf34b952..4fa77ed81 100644 --- a/tests/test_anilist_media_source.py +++ b/tests/test_anilist_media_source.py @@ -4,8 +4,12 @@ from xml.dom import minidom import pytest -from app.domain.context import MediaInfo +from app.application.torrent.download import TorrentHelper +from app.chain.search import SearchChain +from app.chain.search.plan import SearchPlanOwner +from app.domain.context import MediaInfo, TorrentInfo from app.domain.meta.metabase import MetaBase +from app.domain.metainfo import MetaInfo from app.domain.scraper import MediaScraperHelper from app.modules.anilist import AniListModule from app.modules.anilist.anilist import AniListApi @@ -101,6 +105,104 @@ def test_anilist_id_recognition_normalizes_media_info(anilist_info: dict) -> Non module.anilist_api.detail.assert_called_once_with(154587) +def test_anilist_sequel_entry_matches_series_numbered_torrent( + anilist_info: dict, monkeypatch: pytest.MonkeyPatch, +) -> None: + """AniList 独立续季条目应按真实季号和基础别名匹配整部剧编号的资源。""" + info = { + **anilist_info, + "id": 135865, + "title": { + "chinese": "幼女戦記Ⅱ", + "native": "幼女戦記Ⅱ", + "romaji": "Youjo Senki II", + "english": "Saga of Tanya the Evil Season 2", + }, + "synonyms": ["Youjo Senki 2"], + "startDate": {"year": 2026}, + "episodes": 12, + } + module = AniListModule() + module.anilist_api = Mock() + module.anilist_api.detail.return_value = info + media = module.recognize_media(media_source=MediaSource.AniList, media_id="135865") + + assert media is not None + assert media.season == 2 + assert media.seasons == {2: list(range(1, 13))} + assert media.season_years == {2: "2026"} + assert "Youjo Senki" in media.names + prepared = SearchPlanOwner._prepare_media_input(media) + season_episodes, _ = SearchPlanOwner._prepare_params(prepared) + assert prepared.season == 2 + assert season_episodes == {2: []} + + torrent = TorrentInfo( + site_name="测试站点", + title="Youjo Senki S02E12 2026 1080p CR WEB-DL x264 AAC-AnimeS@ADWeb", + category=MediaType.TV.value, + ) + torrent_meta = MetaInfo(torrent.title) + assert TorrentHelper.match_season_episodes(torrent, torrent_meta, season_episodes) + assert TorrentHelper.match_torrent(prepared, torrent_meta, torrent) + + wrong_season = TorrentInfo( + site_name="测试站点", + title="Youjo Senki S01E12 2026 1080p WEB-DL", + category=MediaType.TV.value, + ) + wrong_meta = MetaInfo(wrong_season.title) + assert not TorrentHelper.match_season_episodes(wrong_season, wrong_meta, season_episodes) + assert not TorrentHelper.match_torrent(prepared, wrong_meta, wrong_season) + monkeypatch.setattr(TorrentHelper, "sort_torrents", staticmethod(lambda contexts: contexts)) + contexts = object.__new__(SearchChain)._parse_result( + torrents=[torrent, wrong_season], + mediainfo=prepared, + season_episodes=season_episodes, + rule_groups=[], + ) + assert len(contexts) == 1 + assert contexts[0].torrent_info.title == torrent.title + + +def test_anilist_sequel_without_torrent_year_uses_shared_tmdb_identity() -> None: + """无年份基础标题需要候选与 AniList 条目共享 TMDB 身份才能消歧。""" + target = MediaInfo( + media_source=MediaSource.AniList, + media_id="135865", + tmdb_id=69346, + title="幼女戦記Ⅱ", + original_title="幼女戦記Ⅱ", + names=["Youjo Senki", "Youjo Senki II"], + type=MediaType.TV, + year="2026", + season=2, + seasons={2: list(range(1, 13))}, + season_years={2: "2026"}, + ) + torrent_meta = MetaInfo("Youjo Senki S02E12 1080p WEB-DL") + matching = MediaInfo( + media_source=MediaSource.TMDB, + media_id="69346", + tmdb_id=69346, + title="幼女战记", + type=MediaType.TV, + year="2017", + ) + conflicting = MediaInfo( + media_source=MediaSource.TMDB, + media_id="99999", + tmdb_id=99999, + title="另一部作品", + type=MediaType.TV, + year="2017", + ) + + assert TorrentHelper.requires_identity_disambiguation(target, torrent_meta) + assert TorrentHelper.match_same_work_evidence(target, matching, torrent_meta)[0] + assert not TorrentHelper.match_same_work_evidence(target, conflicting, torrent_meta)[0] + + def test_anilist_title_recognition_respects_request_source(anilist_info: dict) -> None: """标题识别仅在本次请求明确选择AniList时使用AniList候选项。""" module = AniListModule()