"""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"}