"""IMDb 原生媒体数据源 Module 测试。""" import asyncio from unittest.mock import AsyncMock, Mock, patch import pytest from app.domain.context import MediaInfo from app.domain.meta.metabase import MetaBase from app.modules.imdb import ImdbModule from app.modules.imdb.api import ( ImdbAka, ImdbApi, ImdbCredit, ImdbDate, ImdbEpisode, ImdbImage, ImdbPerson, ImdbSeason, ImdbTitle, ) from app.runtime.config import settings from app.runtime.tasks import TaskRegistry from app.schemas.types import MediaRecognizeType, MediaSource, MediaType @pytest.fixture def imdb_title() -> ImdbTitle: """构造一条包含完整基础字段的 IMDb 电视剧条目。""" return ImdbTitle( id="tt0903747", type="tvSeries", primaryTitle="Breaking Bad", originalTitle="Breaking Bad", primaryImage={"url": "https://image.example/poster.jpg", "type": "poster"}, startYear=2008, runtimeSeconds=2820, genres=["Crime", "Drama"], rating={"aggregateRating": 9.5, "voteCount": 2200000}, plot="A chemistry teacher becomes a methamphetamine producer.", originCountries=[{"code": "US", "name": "United States"}], spokenLanguages=[{"code": "eng", "name": "English"}], ) @pytest.fixture def imdb_api(imdb_title: ImdbTitle) -> Mock: """构造同步和异步方法行为一致的 IMDb API 替身。""" api = Mock(spec=ImdbApi) api.search_titles.return_value = [imdb_title] api.get_title.return_value = imdb_title api.list_akas.return_value = [ImdbAka(text="绝命毒师")] api.list_credits.return_value = [ ImdbCredit( name=ImdbPerson( id="nm0533713", displayName="Vince Gilligan", primaryImage=ImdbImage(url="https://image.example/director.jpg"), ), category="DIRECTOR", ), ImdbCredit( name=ImdbPerson( id="nm0186505", displayName="Bryan Cranston", primaryImage=ImdbImage(url="https://image.example/actor.jpg"), ), category="ACTOR", characters=["Walter White"], ), ] api.list_images.return_value = [ ImdbImage(url="https://image.example/backdrop.jpg", type="still_frame") ] api.list_episodes.return_value = [ ImdbEpisode( id="tt0959621", season="1", episodeNumber=1, releaseDate=ImdbDate(year=2008, month=1, day=20), ) ] api.list_seasons.return_value = [ImdbSeason(season="1", episodeCount=7)] api.async_search_titles = AsyncMock(return_value=[imdb_title]) api.async_get_title = AsyncMock(return_value=imdb_title) api.async_list_akas = AsyncMock(return_value=api.list_akas.return_value) api.async_list_credits = AsyncMock(return_value=api.list_credits.return_value) api.async_list_images = AsyncMock(return_value=api.list_images.return_value) api.async_list_episodes = AsyncMock(return_value=api.list_episodes.return_value) api.async_list_seasons = AsyncMock(return_value=api.list_seasons.return_value) return api @pytest.fixture def imdb_module(imdb_api: Mock) -> ImdbModule: """构造注入离线 API 替身的 IMDb Module。""" module = ImdbModule() module.imdb_api = imdb_api module.scraper = Mock() return module def test_imdb_module_declares_independent_media_recognizer() -> None: """IMDb 应作为独立宿主媒体识别 Module 暴露稳定元数据。""" assert ImdbModule.get_name() == "IMDb" assert ImdbModule.get_subtype() == MediaRecognizeType.IMDb assert ImdbModule.get_priority() == 4 def test_imdb_api_search_uses_keyless_imdb_suggestion_endpoint() -> None: """IMDb 标题搜索应直接调用 IMDb 免 Key 建议接口并缓存响应。""" api = ImdbApi() api.clear_cache() api._request = Mock() api._request.get_json.return_value = { "d": [ { "id": "tt0111161", "qid": "movie", "l": "The Shawshank Redemption", "y": 1994, } ] } results = api.search_titles("The Shawshank Redemption", limit=1) cached_results = api.search_titles("The Shawshank Redemption", limit=1) assert [item.id for item in results] == ["tt0111161"] assert [item.id for item in cached_results] == ["tt0111161"] api._request.get_json.assert_called_once_with( "https://v2.sg.media-imdb.com/suggestion/x/" "The%20Shawshank%20Redemption.json", params={}, ) api.clear_cache() api.search_titles("The Shawshank Redemption", limit=1) assert api._request.get_json.call_count == 2 def test_imdb_graphql_error_is_not_cached() -> None: """GraphQL 错误响应不应污染缓存,后续成功详情仍应正常写入缓存。""" api = ImdbApi() api.clear_cache() api._request = Mock() api._request.post_json.side_effect = [ {"errors": [{"message": "temporary failure"}]}, { "data": { "titles": [ { "id": "tt0111161", "titleText": {"text": "The Shawshank Redemption"}, "titleType": {"id": "movie"}, "releaseYear": {"year": 1994}, } ] } }, ] assert api.get_title("tt0111161") is None detail = api.get_title("tt0111161") cached_detail = api.get_title("tt0111161") assert detail and detail.primary_title == "The Shawshank Redemption" assert cached_detail and cached_detail.id == "tt0111161" assert api._request.post_json.call_count == 2 def test_imdb_clear_cache_registers_async_cleanup_in_running_loop() -> None: """同步清缓存入口在异步宿主中应登记任务,并保留原同步调用约定。""" async def scenario() -> None: """验证异步缓存清理直到完成前都由宿主登记器持有。""" api = ImdbApi() registry = TaskRegistry() release = asyncio.Event() async def clear_async_cache() -> None: """等待测试释放,确保可以观察登记中的任务。""" await release.wait() with ( patch("app.modules.imdb.api.get_task_registry", return_value=registry), patch.object(api, "async_clear_cache", side_effect=clear_async_cache), ): assert api.clear_cache() is None assert [record.owner for record in registry.records] == [ "module.imdb.cache_clear" ] release.set() await registry.records[0].task await asyncio.sleep(0) assert registry.records == () api.close() asyncio.run(scenario()) def test_async_imdb_api_merges_paginated_episodes() -> None: """IMDb 异步剧集查询应使用 GraphQL 游标合并并缓存所有分页。""" api = ImdbApi() asyncio.run(api.async_clear_cache()) api._async_request = Mock() api._async_request.post_json = AsyncMock( side_effect=[ { "data": { "titles": [ { "episodes": { "episodes": { "edges": [ { "node": { "id": "tt-first", "series": { "episodeNumber": { "seasonNumber": 1, "episodeNumber": 1, } }, } } ], "pageInfo": { "hasNextPage": True, "endCursor": "next-page", }, } } } ] } }, { "data": { "titles": [ { "episodes": { "episodes": { "edges": [ { "node": { "id": "tt-second", "series": { "episodeNumber": { "seasonNumber": 1, "episodeNumber": 2, } }, } } ], "pageInfo": { "hasNextPage": False, "endCursor": None, }, } } } ] } }, { "data": { "titles": [ { "episodes": { "episodes": { "edges": [ { "node": { "id": "tt-first", "series": { "episodeNumber": { "seasonNumber": 1, "episodeNumber": 1, } }, } } ], "pageInfo": { "hasNextPage": True, "endCursor": "next-page", }, } } } ] } }, { "data": { "titles": [ { "episodes": { "episodes": { "edges": [ { "node": { "id": "tt-second", "series": { "episodeNumber": { "seasonNumber": 1, "episodeNumber": 2, } }, } } ], "pageInfo": { "hasNextPage": False, "endCursor": None, }, } } } ] } }, ] ) results = asyncio.run(api.async_list_episodes("tt-series")) cached_results = asyncio.run(api.async_list_episodes("tt-series")) assert [item.id for item in results] == ["tt-first", "tt-second"] assert [item.id for item in cached_results] == ["tt-first", "tt-second"] assert api._async_request.post_json.await_count == 2 assert api._async_request.post_json.await_args_list[0].kwargs["json"][ "variables" ] == {"after": None, "first": 100, "titles": ("tt-series",)} assert api._async_request.post_json.await_args_list[1].kwargs["json"][ "variables" ] == {"after": "next-page", "first": 100, "titles": ("tt-series",)} asyncio.run(api.async_clear_cache()) refreshed_results = asyncio.run(api.async_list_episodes("tt-series")) assert [item.id for item in refreshed_results] == ["tt-first", "tt-second"] assert api._async_request.post_json.await_count == 4 def test_imdb_proxy_snapshot_reloads_client_and_closes_old_session( monkeypatch: pytest.MonkeyPatch, ) -> None: """代理变更应关闭旧 IMDb 客户端并按新代理快照重建。""" module = ImdbModule() old_client = Mock() new_client = Mock() monkeypatch.setattr(settings, "PROXY_HOST", "http://old-proxy:7890") with patch("app.modules.imdb.ImdbApi", side_effect=[old_client, new_client]) as api_type: module.init_module() old_proxy = settings.PROXY monkeypatch.setattr(settings, "PROXY_HOST", "http://new-proxy:7890") new_proxy = settings.PROXY module.on_config_changed() assert api_type.call_args_list[0].kwargs["proxies"] == old_proxy assert api_type.call_args_list[1].kwargs["proxies"] == new_proxy old_client.close.assert_called_once_with() assert module.imdb_api is new_client def test_imdb_module_clear_cache_delegates_to_api( imdb_module: ImdbModule, ) -> None: """系统统一清缓存动作应清理 IMDb HTTP 缓存区。""" imdb_module.clear_cache() imdb_module.imdb_api.clear_cache.assert_called_once_with() def test_imdb_search_respects_source_and_returns_generic_identity( imdb_module: ImdbModule, ) -> None: """IMDb 搜索只响应选中的来源,并返回来源与原生 ID 对。""" meta = MetaBase("Breaking Bad") meta.name = "Breaking Bad" meta.type = MediaType.TV assert imdb_module.search_medias(meta, media_source=MediaSource.Douban) is None results = imdb_module.search_medias(meta, media_source=MediaSource.IMDb) assert results and len(results) == 1 assert results[0].media_source == MediaSource.IMDb assert results[0].media_id == "tt0903747" assert results[0].imdb_id == "tt0903747" assert results[0].type == MediaType.TV def test_imdb_explicit_identity_recognition_enriches_media_info( imdb_module: ImdbModule, ) -> None: """显式 IMDb ID 识别应补齐别名、演职员、剧集和图片。""" result = imdb_module.recognize_media( media_source=MediaSource.IMDb, media_id="TT0903747", ) assert isinstance(result, MediaInfo) assert result.media_source == MediaSource.IMDb assert result.media_id == "tt0903747" assert result.title == "Breaking Bad" assert result.names == ["Breaking Bad", "绝命毒师"] assert result.backdrop_path == "https://image.example/backdrop.jpg" assert result.directors[0]["name"] == "Vince Gilligan" assert result.actors[0]["character"] == "Walter White" assert result.seasons == {1: [1]} assert result.season_years == {1: "2008"} assert result.number_of_episodes == 1 assert result.runtime == 47 assert result.category == "欧美剧" def test_imdb_name_recognition_requires_imdb_selection( imdb_module: ImdbModule, monkeypatch: pytest.MonkeyPatch ) -> None: """名称识别仅在请求级或全局来源选中 IMDb 时执行。""" meta = MetaBase("绝命毒师") meta.name = "绝命毒师" meta.cn_name = "绝命毒师" meta.type = MediaType.TV monkeypatch.setattr(settings, "RECOGNIZE_SOURCE", MediaSource.TMDB.value) assert imdb_module.recognize_media(meta=meta) is None result = imdb_module.recognize_media( meta=meta, media_source=MediaSource.IMDb, ) assert result and result.media_id == "tt0903747" imdb_module.imdb_api.search_titles.assert_called_with("绝命毒师") def test_imdb_recognition_rejects_invalid_or_music_identity( imdb_module: ImdbModule, ) -> None: """IMDb Module 不应接管非法 ID 或音乐识别请求。""" assert ( imdb_module.recognize_media( media_source=MediaSource.IMDb, media_id="0903747", ) is None ) assert ( imdb_module.recognize_media( mtype=MediaType.MUSIC, media_source=MediaSource.IMDb, media_id="tt0903747", ) is None ) def test_async_imdb_recognition_uses_async_detail_pipeline( imdb_module: ImdbModule, ) -> None: """异步显式识别应使用异步详情管线并保持统一身份。""" result = asyncio.run( imdb_module.async_recognize_media( media_source=MediaSource.IMDb, media_id="tt0903747", ) ) assert result and result.media_source == MediaSource.IMDb assert result.media_id == "tt0903747" assert result.backdrop_path == "https://image.example/backdrop.jpg" imdb_module.imdb_api.async_get_title.assert_awaited() imdb_module.imdb_api.async_list_credits.assert_awaited_once_with("tt0903747") def test_imdb_scraping_only_handles_imdb_source( imdb_module: ImdbModule, ) -> None: """IMDb NFO 和图片刮削只响应显式 IMDb 刮削来源。""" media_info = MediaInfo() media_info.scrape_source = MediaSource.Douban.value assert imdb_module.metadata_nfo(media_info) is None assert imdb_module.metadata_img(media_info) is None media_info.scrape_source = MediaSource.IMDb.value imdb_module.scraper.get_metadata_nfo.return_value = "" imdb_module.scraper.get_metadata_img.return_value = {"poster.jpg": "url"} assert imdb_module.metadata_nfo(media_info) == "" assert imdb_module.metadata_img(media_info) == {"poster.jpg": "url"}