fix(search): match AniList season entries with numbered torrents

This commit is contained in:
jxxghp
2026-09-26 09:28:23 +08:00
parent 71a2725776
commit 83fc6b5d32
4 changed files with 157 additions and 19 deletions

View File

@@ -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):

View File

@@ -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

View File

@@ -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<base>.+?)(?:\s+(?:Season\s*|S)(?P<season>\d{1,2})"
r"|\s+(?P<number>[2-9])|\s+(?P<roman>II|III|IV|V|VI|VII|VIII|IX|X)"
r"|(?P<unicode>[ⅡⅢⅣⅤⅥⅦⅧⅨⅩ]))$",
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

View File

@@ -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()