diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index faa001a17..587a11851 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -366,6 +366,9 @@ async def seasons( ) if mediainfo: return _build_media_seasons(mediainfo, season) + # 明确来源的查询不能按标题切换到默认识别源,避免辅助 TMDB 信息替换主身份。 + if media_source and source_media_id: + return [] if title: meta = MetaInfo(title) if year: diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index fa707e4cb..aff590c02 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -904,6 +904,16 @@ class SubscribeChain(ChainBase): metainfo.begin_season = season if not media_source and not media_id and mediaid: media_source, media_id = parse_media_key(mediaid) + resolved_source, resolved_media_id = resolve_media_identity( + source=media_source, + media_id=media_id, + tmdbid=tmdbid, + doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + ) + if resolved_source and resolved_media_id: + media_source, media_id = resolved_source, resolved_media_id if any((media_id, tmdbid, doubanid, bangumiid, anilistid)): mediainfo = self.recognize_media( meta=metainfo, @@ -926,10 +936,11 @@ class SubscribeChain(ChainBase): if season is None: season = meta.begin_season - # 使用名称识别兜底 + # 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源。 if not mediainfo: mediainfo = MediaChain().recognize_by_meta( metainfo, + source=media_source, episode_group=episode_group, obtain_images=False, ) @@ -1099,6 +1110,16 @@ class SubscribeChain(ChainBase): metainfo.begin_season = season if not media_source and not media_id and mediaid: media_source, media_id = parse_media_key(mediaid) + resolved_source, resolved_media_id = resolve_media_identity( + source=media_source, + media_id=media_id, + tmdbid=tmdbid, + doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + ) + if resolved_source and resolved_media_id: + media_source, media_id = resolved_source, resolved_media_id if any((media_id, tmdbid, doubanid, bangumiid, anilistid)): mediainfo = await self.async_recognize_media( meta=metainfo, @@ -1121,10 +1142,11 @@ class SubscribeChain(ChainBase): if season is None: season = meta.begin_season - # 使用名称识别兜底 + # 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源。 if not mediainfo: mediainfo = await MediaChain().async_recognize_by_meta( metainfo, + source=media_source, episode_group=episode_group, obtain_images=False, ) diff --git a/tests/test_search_media_sources.py b/tests/test_search_media_sources.py index c5a042491..4db81050c 100644 --- a/tests/test_search_media_sources.py +++ b/tests/test_search_media_sources.py @@ -1,7 +1,12 @@ import asyncio +from unittest.mock import AsyncMock, Mock, patch + +import pytest from app.api.endpoints import media as media_endpoint from app.api.endpoints import search as search_endpoint +from app.chain import subscribe as subscribe_module +from app.chain.subscribe import SubscribeChain from app.core.context import MediaInfo from app.schemas.types import MediaType @@ -150,3 +155,146 @@ def test_media_seasons_builds_anilist_season_response(monkeypatch) -> None: assert result[0].poster_path == media.poster_path assert captured["source"] == "anilist" assert captured["mediaid"] == "154587" + + +@pytest.mark.parametrize( + ("mediaid", "media_kwargs", "episode_count"), + [ + ( + "douban:db-7301", + { + "douban_info": { + "episodes_count": 12, + "id": "db-7301", + "subtype": "tv", + "title": "豆瓣剧集", + } + }, + 12, + ), + ( + "bangumi:7302", + { + "bangumi_info": { + "id": 7302, + "name_cn": "Bangumi 剧集", + "platform": "TV", + "total_episodes": 13, + } + }, + 13, + ), + ( + "anilist:7303", + { + "anilist_info": { + "episodes": 14, + "format": "TV", + "id": 7303, + "title": {"native": "AniList 剧集"}, + } + }, + 14, + ), + ], +) +def test_media_seasons_uses_source_episode_count_and_defaults_to_first_season( + monkeypatch, mediaid: str, media_kwargs: dict, episode_count: int, +) -> None: + """非 TMDB 来源应使用自身总集数构造第 1 季,不依赖 TMDB。""" + media = MediaInfo(**media_kwargs) + media_chain = Mock() + media_chain.async_recognize_media = AsyncMock(return_value=media) + monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain)) + + result = asyncio.run( + media_endpoint.seasons(mediaid=mediaid, season=None, _=None) + ) + + assert media.season is None + assert media.seasons[1] == list(range(1, episode_count + 1)) + assert len(result) == 1 + assert result[0].season_number == 1 + assert result[0].episode_count == episode_count + + +@pytest.mark.parametrize( + "mediaid", + ["douban:db-7401", "bangumi:7402", "anilist:7403"], +) +def test_media_seasons_does_not_fallback_to_default_source_for_explicit_identity( + monkeypatch, mediaid: str, +) -> None: + """明确来源查询失败时应直接返回空列表,不能按标题切换到默认源。""" + media_chain = Mock() + media_chain.async_recognize_media = AsyncMock(return_value=None) + media_chain.async_recognize_by_meta = AsyncMock( + side_effect=AssertionError("不应按标题切换识别源") + ) + monkeypatch.setattr(media_endpoint, "MediaChain", Mock(return_value=media_chain)) + + result = asyncio.run( + media_endpoint.seasons( + mediaid=mediaid, + title="来源查询失败剧集", + year="2026", + _=None, + ) + ) + + assert result == [] + media_chain.async_recognize_media.assert_awaited_once() + media_chain.async_recognize_by_meta.assert_not_awaited() + + +def test_subscribe_add_keeps_inferred_anilist_source_during_title_fallback() -> None: + """同步新增订阅按兼容 ID 推导来源后,标题兜底仍应限定 AniList。""" + media_chain = Mock() + media_chain.recognize_by_meta.return_value = None + chain = object.__new__(SubscribeChain) + + with patch.object(SubscribeChain, "recognize_media", return_value=None) as recognize, \ + patch.object(subscribe_module, "MediaChain", return_value=media_chain): + sid, message = chain.add( + title="AniList 同步订阅", + year="2026", + mtype=MediaType.TV, + anilistid=154587, + tmdbid=209867, + media_source="anilist", + ) + + assert sid is None + assert message == "未识别到媒体信息" + assert recognize.call_args.kwargs["source"] == "anilist" + assert recognize.call_args.kwargs["mediaid"] == "154587" + assert media_chain.recognize_by_meta.call_args.kwargs["source"] == "anilist" + + +def test_subscribe_async_add_keeps_inferred_anilist_source_during_title_fallback() -> None: + """异步新增订阅按兼容 ID 推导来源后,标题兜底仍应限定 AniList。""" + media_chain = Mock() + media_chain.async_recognize_by_meta = AsyncMock(return_value=None) + chain = object.__new__(SubscribeChain) + + with patch.object( + SubscribeChain, "async_recognize_media", new=AsyncMock(return_value=None) + ) as recognize, patch.object( + subscribe_module, "MediaChain", return_value=media_chain + ): + sid, message = asyncio.run( + chain.async_add( + title="AniList 异步订阅", + year="2026", + mtype=MediaType.TV, + anilistid=154587, + tmdbid=209867, + media_source="anilist", + ) + ) + + assert sid is None + assert message == "未识别到媒体信息" + assert recognize.await_args.kwargs["source"] == "anilist" + assert recognize.await_args.kwargs["mediaid"] == "154587" + assert media_chain.async_recognize_by_meta.await_args.kwargs["source"] == "anilist"