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