mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 21:34:32 +08:00
fix(search): match AniList season entries with numbered torrents
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user