mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
498 lines
18 KiB
Python
498 lines
18 KiB
Python
"""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 = "<movie />"
|
|
imdb_module.scraper.get_metadata_img.return_value = {"poster.jpg": "url"}
|
|
|
|
assert imdb_module.metadata_nfo(media_info) == "<movie />"
|
|
assert imdb_module.metadata_img(media_info) == {"poster.jpg": "url"}
|