refactor(tmdb): unify api sync async decisions

This commit is contained in:
jxxghp
2026-08-29 20:16:35 +08:00
parent 793d4bed8b
commit 8ffe1f63ac
5 changed files with 1055 additions and 636 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -78,8 +78,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| Event Contract | 53 | 均已有 payload model但当前全部是 diagnostic enforcement |
| Python 源码量 | 约 298,700 行 | 60 个文件超过 1,000 行10 个超过 2,000 行 |
| 长方法 | 299 个超过 80 行 | 70 个超过 150 行23 个超过 250 行;大量是私有方法 |
| 全量 mypy 历史债务 | 10,190 / 592 文件 | 识别链 typed plan/result、TMDB、消息、Indexer、目录源与 Recommend 双 ABI 共用计划继续清除参数、返回值和泛型注解债strict frontier 当前覆盖 41 个文件 |
| Ruff 历史诊断 | 641 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 全量 mypy 历史债务 | 10,100 / 592 文件 | 识别链 typed plan/result、TMDB API、消息、Indexer、目录源与 Recommend 双 ABI 共用计划继续清除参数、返回值和泛型注解债strict frontier 当前覆盖 41 个文件 |
| Ruff 历史诊断 | 640 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 81.00%Domain 80.67% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
### 3.3 热点文件

View File

@@ -2569,7 +2569,7 @@
"override": 1
},
"app/modules/themoviedb/__init__.py": {
"arg-type": 15,
"arg-type": 9,
"assignment": 12,
"empty-body": 1,
"index": 2,
@@ -2603,17 +2603,14 @@
"union-attr": 5
},
"app/modules/themoviedb/tmdbapi.py": {
"arg-type": 13,
"assignment": 6,
"arg-type": 1,
"attr-defined": 11,
"no-any-return": 24,
"no-untyped-call": 74,
"no-untyped-def": 11,
"no-any-return": 5,
"no-untyped-call": 70,
"no-untyped-def": 7,
"operator": 24,
"return-value": 2,
"type-arg": 80,
"union-attr": 4,
"var-annotated": 2
"type-arg": 47,
"union-attr": 2
},
"app/modules/themoviedb/tmdbv3api/as_obj.py": {
"assignment": 1,

View File

@@ -580,9 +580,6 @@
"app/modules/themoviedb/tmdb_cache.py": {
"I001": 1
},
"app/modules/themoviedb/tmdbapi.py": {
"I001": 1
},
"app/modules/themoviedb/tmdbv3api/__init__.py": {
"F401": 25
},

View File

@@ -0,0 +1,368 @@
"""TmdbApi 同步与异步入口共享业务决策的回归测试。"""
import asyncio
from copy import deepcopy
from types import SimpleNamespace
from typing import Optional, Union
from unittest.mock import AsyncMock, Mock, call
import pytest
from app.modules.themoviedb.tmdbapi import TmdbApi
from app.modules.themoviedb.tmdbv3api.exceptions import (
TMDbConnectionError,
TMDbException,
)
from app.schemas.types import MediaType
def _api() -> TmdbApi:
"""构造不初始化真实 TMDB 客户端的最小 API 实例。"""
api = object.__new__(TmdbApi)
api.tmdb = SimpleNamespace(language="en")
api.search = SimpleNamespace(
total_results=0,
movies=Mock(),
async_movies=AsyncMock(),
tv_shows=Mock(),
async_tv_shows=AsyncMock(),
multi=Mock(),
async_multi=AsyncMock(),
collections=Mock(),
async_collections=AsyncMock(),
)
api.movie = SimpleNamespace(details=Mock(), async_details=AsyncMock())
api.tv = SimpleNamespace(details=Mock(), async_details=AsyncMock())
return api
def _movie_detail(tmdbid: int, title: str = "展示标题") -> dict:
"""构造包含别名、分级和多语言标题的电影详情。"""
return {
"id": tmdbid,
"title": title,
"original_title": "Original Title",
"original_language": "en",
"release_date": "2024-06-01",
"genres": [{"id": 18}, {"id": 35}],
"alternative_titles": {"titles": [{"iso_3166_1": "US", "title": "目标别名"}]},
"translations": {
"translations": [
{
"iso_3166_1": "HK",
"data": {"title": "香港标题"},
}
]
},
"release_dates": {
"results": [
{
"iso_3166_1": "US",
"release_dates": [{"certification": "PG-13"}],
}
]
},
}
def _tv_detail(tmdbid: int, name: str = "目标剧") -> dict:
"""构造包含季年份和译名的电视剧详情。"""
return {
"id": tmdbid,
"name": name,
"original_name": "Original Series",
"original_language": "en",
"first_air_date": "2018-01-01",
"genres": [{"id": 18}],
"seasons": [{"season_number": 3, "air_date": "2024-03-02"}],
"alternative_titles": {"results": [{"iso_3166_1": "US", "title": "剧集别名"}]},
"translations": {"translations": []},
"content_ratings": {"results": []},
}
@pytest.mark.parametrize(
("mtype", "sync_name", "async_name", "title_field", "year_kwargs"),
[
(
MediaType.MOVIE,
"search_movies",
"async_search_movies",
"title",
{"term": "目标", "year": "2024"},
),
(
MediaType.TV,
"search_tvs",
"async_search_tvs",
"name",
{"term": "目标", "release_year": "2024"},
),
],
)
def test_search_lists_share_query_and_projection(
mtype: MediaType,
sync_name: str,
async_name: str,
title_field: str,
year_kwargs: dict[str, str],
) -> None:
"""电影和剧集列表双入口应共享参数、标题过滤和类型投影。"""
api = _api()
sync_items = [
{"id": 1, title_field: "目标作品"},
{"id": 2, title_field: "其他作品"},
]
async_items = deepcopy(sync_items)
sync_client = api.search.movies if mtype == MediaType.MOVIE else api.search.tv_shows
async_client = api.search.async_movies if mtype == MediaType.MOVIE else api.search.async_tv_shows
sync_client.return_value = sync_items
async_client.return_value = async_items
sync_result = getattr(api, sync_name)("目标", "2024")
async_result = asyncio.run(getattr(api, async_name)("目标", "2024"))
assert sync_result == async_result == [{"id": 1, title_field: "目标作品", "media_type": mtype}]
sync_client.assert_called_once_with(**year_kwargs)
async_client.assert_awaited_once_with(**year_kwargs)
def test_match_movie_sync_async_share_year_fallback_and_original_title() -> None:
"""电影匹配双入口应按当前、下一年顺序回退并采信原标题。"""
api = _api()
sync_hit = {
"id": 10,
"title": "本地标题",
"original_title": "Target Movie",
"release_date": "2025-02-01",
}
async_hit = deepcopy(sync_hit)
api.search.movies.side_effect = [[], [sync_hit]]
api.search.async_movies.side_effect = [[], [async_hit]]
sync_result = api.match("Target Movie", MediaType.MOVIE, year="2024")
async_result = asyncio.run(api.async_match("Target Movie", MediaType.MOVIE, year="2024"))
assert sync_result == async_result
assert sync_result["media_type"] == MediaType.MOVIE
expected_calls = [
call(term="Target Movie", year="2024"),
call(term="Target Movie", year="2025"),
]
assert api.search.movies.call_args_list == expected_calls
assert api.search.async_movies.call_args_list == expected_calls
def test_match_movie_sync_async_share_alias_detail_fallback() -> None:
"""标题未命中时双入口应同序查询详情并使用别名。"""
api = _api()
sync_candidate = {
"id": 11,
"title": "展示标题",
"original_title": "Original Title",
"release_date": "2024-06-01",
}
async_candidate = deepcopy(sync_candidate)
sync_detail = _movie_detail(11)
async_detail = deepcopy(sync_detail)
api.search.movies.return_value = [sync_candidate]
api.search.async_movies.return_value = [async_candidate]
api.movie.details.return_value = sync_detail
api.movie.async_details.return_value = async_detail
sync_result = api.match("目标别名", MediaType.MOVIE, year="2024")
async_result = asyncio.run(api.async_match("目标别名", MediaType.MOVIE, year="2024"))
assert sync_result == async_result
assert sync_result["id"] == 11
assert "目标别名" in sync_result["names"]
api.movie.details.assert_called_once()
api.movie.async_details.assert_awaited_once()
@pytest.mark.parametrize(
"group_seasons",
[
None,
[{"order": 3, "episodes": [{"air_date": "2024-04-01"}]}],
],
)
def test_match_tv_season_sync_async_share_detail_year_decision(
group_seasons: Optional[list[dict]],
) -> None:
"""首播年份不同的剧集应由同一季年份规则在双入口命中。"""
api = _api()
sync_candidate = {
"id": 20,
"name": "目标剧",
"original_name": "Original Series",
"first_air_date": "2018-01-01",
}
async_candidate = deepcopy(sync_candidate)
sync_detail = _tv_detail(20)
async_detail = deepcopy(sync_detail)
api.search.tv_shows.return_value = [sync_candidate]
api.search.async_tv_shows.return_value = [async_candidate]
api.tv.details.return_value = sync_detail
api.tv.async_details.return_value = async_detail
sync_result = api.match(
"目标剧",
MediaType.TV,
year="2018",
season_year="2024",
season_number=3,
group_seasons=group_seasons,
)
async_result = asyncio.run(
api.async_match(
"目标剧",
MediaType.TV,
year="2018",
season_year="2024",
season_number=3,
group_seasons=group_seasons,
)
)
assert sync_result == async_result
assert sync_result["id"] == 20
assert api.search.tv_shows.call_args_list == [call(term="目标剧")]
assert api.search.async_tv_shows.call_args_list == [call(term="目标剧")]
api.tv.details.assert_called_once()
api.tv.async_details.assert_awaited_once()
def test_match_multi_sync_async_share_sorting_and_alias_type_lookup() -> None:
"""多类型别名匹配双入口应跳过无关类型并按候选类型查详情。"""
api = _api()
sync_multis = [
{"id": 1, "media_type": "person", "name": "目标别名"},
{
"id": 21,
"media_type": "tv",
"name": "展示剧名",
"original_name": "Original Series",
"first_air_date": "2024-01-01",
},
]
async_multis = deepcopy(sync_multis)
sync_detail = _tv_detail(21, name="展示剧名")
sync_detail["alternative_titles"]["results"][0]["title"] = "目标别名"
async_detail = deepcopy(sync_detail)
api.search.multi.return_value = sync_multis
api.search.async_multi.return_value = async_multis
api.tv.details.return_value = sync_detail
api.tv.async_details.return_value = async_detail
sync_result = api.match_multi("目标别名")
async_result = asyncio.run(api.async_match_multi("目标别名"))
assert sync_result == async_result
assert sync_result["id"] == 21
assert sync_result["media_type"] == MediaType.TV
api.movie.details.assert_not_called()
api.movie.async_details.assert_not_awaited()
api.tv.details.assert_called_once()
api.tv.async_details.assert_awaited_once()
@pytest.mark.parametrize(
("outcome", "expected"),
[([], {}), (TMDbException("查询失败"), None)],
)
def test_match_multi_sync_async_share_empty_and_exception_results(
outcome: Union[list, Exception], expected: Optional[dict]
) -> None:
"""多类型搜索的空结果和异常在双入口应保留相同三态语义。"""
api = _api()
if isinstance(outcome, Exception):
api.search.multi.side_effect = outcome
api.search.async_multi.side_effect = outcome
else:
api.search.multi.return_value = outcome
api.search.async_multi.return_value = deepcopy(outcome)
assert api.match_multi("目标") == expected
assert asyncio.run(api.async_match_multi("目标")) == expected
def test_get_info_sync_async_share_projection() -> None:
"""详情双入口应共享类型、别名、分级和多语言字段投影。"""
api = _api()
sync_detail = _movie_detail(30)
async_detail = deepcopy(sync_detail)
api.movie.details.return_value = sync_detail
api.movie.async_details.return_value = async_detail
sync_result = api.get_info(MediaType.MOVIE, 30)
async_result = asyncio.run(api.async_get_info(MediaType.MOVIE, 30))
assert sync_result == async_result
assert sync_result["media_type"] == MediaType.MOVIE
assert sync_result["genre_ids"] == [18, 35]
assert sync_result["content_rating"] == "PG-13"
assert sync_result["en_title"] == "Original Title"
assert "目标别名" in sync_result["names"]
@pytest.mark.parametrize(
("tv_exists", "movie_exists", "expected_type"),
[
(True, False, MediaType.TV),
(False, True, MediaType.MOVIE),
(True, True, None),
(False, False, None),
],
)
def test_get_info_unknown_type_sync_async_share_lookup_order(
tv_exists: bool,
movie_exists: bool,
expected_type: Optional[MediaType],
) -> None:
"""未知类型详情双入口应先查剧集再查电影并统一判断歧义。"""
api = _api()
sync_calls = []
async_calls = []
def sync_tv(*args, **kwargs):
sync_calls.append("tv")
return _tv_detail(40) if tv_exists else {}
def sync_movie(*args, **kwargs):
sync_calls.append("movie")
return _movie_detail(40) if movie_exists else {}
async def async_tv(*args, **kwargs):
async_calls.append("tv")
return _tv_detail(40) if tv_exists else {}
async def async_movie(*args, **kwargs):
async_calls.append("movie")
return _movie_detail(40) if movie_exists else {}
api.tv.details.side_effect = sync_tv
api.movie.details.side_effect = sync_movie
api.tv.async_details.side_effect = async_tv
api.movie.async_details.side_effect = async_movie
sync_result = api.get_info(None, 40)
async_result = asyncio.run(api.async_get_info(None, 40))
assert sync_calls == async_calls == ["tv", "movie"]
assert (sync_result or {}).get("media_type") == expected_type
assert (async_result or {}).get("media_type") == expected_type
assert bool(sync_result) == bool(async_result)
def test_get_info_sync_async_propagate_connection_error_at_same_boundary() -> None:
"""显式连接错误传播开关在同步与异步详情入口应保持一致。"""
api = _api()
error = TMDbConnectionError("无法连接 TMDB")
api.movie.details.side_effect = error
api.movie.async_details.side_effect = error
with pytest.raises(TMDbConnectionError):
api.get_info(MediaType.MOVIE, 50, raise_on_connection_error=True)
with pytest.raises(TMDbConnectionError):
asyncio.run(api.async_get_info(MediaType.MOVIE, 50, raise_on_connection_error=True))