diff --git a/app/chain/recommend.py b/app/chain/recommend.py index d0b261259..5adf6485f 100644 --- a/app/chain/recommend.py +++ b/app/chain/recommend.py @@ -1,4 +1,4 @@ -from typing import Callable, List, Optional +from typing import Any, Callable, List, Optional, Sequence import pillow_avif # noqa: F401 # pylint: disable=unused-import # AVIF 注册副作用 @@ -35,6 +35,111 @@ class RecommendChain(ChainBase, metaclass=Singleton): # 推荐缓存区域 recommend_cache_region = "recommend" + @staticmethod + def _music_chart_request( + range_name: str, + page: int, + count: int, + entity: str, + ) -> dict[str, Any]: + """构造同步和异步榜单入口共用的 ListenBrainz 请求。""" + return { + "range_name": range_name, + "page": page, + "count": count, + "entity": entity, + } + + @staticmethod + def _music_discover_request( + page: int, + count: int, + entity: str, + mode: str, + tags: str, + sort: str, + ) -> dict[str, Any]: + """构造同步和异步豆瓣音乐发现入口共用的请求。""" + return { + "page": page, + "count": count, + "entity": entity, + "mode": mode, + "tags": tags, + "sort": sort, + } + + @staticmethod + def _tmdb_discover_request( + mtype: MediaType, + sort_by: Optional[str], + with_genres: Optional[str], + with_original_language: Optional[str], + with_keywords: Optional[str], + with_watch_providers: Optional[str], + vote_average: Optional[float], + vote_count: Optional[int], + release_date: Optional[str], + page: Optional[int], + ) -> dict[str, Any]: + """构造同步和异步 TMDB 发现入口共用的业务请求。""" + return { + "mtype": mtype, + "sort_by": sort_by, + "with_genres": with_genres, + "with_original_language": with_original_language, + "with_keywords": with_keywords, + "with_watch_providers": with_watch_providers, + "vote_average": vote_average, + "vote_count": vote_count, + "release_date": release_date, + "page": page, + } + + @staticmethod + def _douban_discover_request( + mtype: MediaType, + sort: Optional[str], + tags: Optional[str], + page: Optional[int], + count: Optional[int], + ) -> dict[str, Any]: + """构造同步和异步豆瓣影视发现入口共用的业务请求。""" + return { + "mtype": mtype, + "sort": sort, + "tags": tags, + "page": page, + "count": count, + } + + @staticmethod + def _supports_music_source(media_source: MediaSource) -> bool: + """判断推荐发现是否支持请求中的音乐数据源。""" + return normalize_media_source(media_source) == MediaSource.DoubanMusic + + @staticmethod + def _serialize_medias( + medias: Optional[Sequence[Any]], + ) -> list[dict[str, Any]]: + """把来源媒体对象统一投影为推荐缓存使用的字典。""" + return [media.to_dict() for media in medias] if medias else [] + + @classmethod + def _serialize_media_page( + cls, + medias: Optional[Sequence[Any]], + page: Optional[int], + count: Optional[int], + ) -> list[dict[str, Any]]: + """按规范化页码截取来源媒体,并统一投影为推荐字典。""" + if not medias: + return [] + normalized_page = page or 1 + normalized_count = count or 30 + start = (normalized_page - 1) * normalized_count + return cls._serialize_medias(medias[start:start + normalized_count]) + def music_chart( self, range_name: str, @@ -46,12 +151,8 @@ class RecommendChain(ChainBase, metaclass=Singleton): entity: str = MUSIC_ENTITY_RECORDING, ) -> list[MusicInfo]: """读取 ListenBrainz 音乐榜单并应用推荐筛选与排序。""" - results = ListenBrainzChain().music_chart( - range_name=range_name, - page=page, - count=count, - entity=entity, - ) + request = self._music_chart_request(range_name, page, count, entity) + results = ListenBrainzChain().music_chart(**request) return self._filter_music_candidates( results, count=count, @@ -71,12 +172,8 @@ class RecommendChain(ChainBase, metaclass=Singleton): entity: str = MUSIC_ENTITY_RECORDING, ) -> list[MusicInfo]: """异步读取 ListenBrainz 音乐榜单并应用推荐筛选与排序。""" - results = await ListenBrainzChain().async_music_chart( - range_name=range_name, - page=page, - count=count, - entity=entity, - ) + request = self._music_chart_request(range_name, page, count, entity) + results = await ListenBrainzChain().async_music_chart(**request) return self._filter_music_candidates( results, count=count, @@ -121,16 +218,10 @@ class RecommendChain(ChainBase, metaclass=Singleton): sort: str = "U", ) -> list[MusicInfo]: """按固定音乐来源读取发现内容,当前支持豆瓣音乐。""" - if normalize_media_source(media_source) != MediaSource.DoubanMusic: + if not self._supports_music_source(media_source): return [] - return DoubanChain().music_discover( - page=page, - count=count, - entity=entity, - mode=mode, - tags=tags, - sort=sort, - ) + request = self._music_discover_request(page, count, entity, mode, tags, sort) + return DoubanChain().music_discover(**request) async def async_music_discover( self, @@ -143,16 +234,10 @@ class RecommendChain(ChainBase, metaclass=Singleton): sort: str = "U", ) -> list[MusicInfo]: """异步按固定音乐来源读取发现内容,当前支持豆瓣音乐。""" - if normalize_media_source(media_source) != MediaSource.DoubanMusic: + if not self._supports_music_source(media_source): return [] - return await DoubanChain().async_music_discover( - page=page, - count=count, - entity=entity, - mode=mode, - tags=tags, - sort=sort, - ) + request = self._music_discover_request(page, count, entity, mode, tags, sort) + return await DoubanChain().async_music_discover(**request) @staticmethod def _filter_music_candidates( @@ -312,17 +397,12 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ TMDB热门电影 """ - movies = TmdbChain().tmdb_discover(mtype=MediaType.MOVIE, - sort_by=sort_by, - with_genres=with_genres, - with_original_language=with_original_language, - with_keywords=with_keywords, - with_watch_providers=with_watch_providers, - vote_average=vote_average, - vote_count=vote_count, - release_date=release_date, - page=page) - return [movie.to_dict() for movie in movies] if movies else [] + request = self._tmdb_discover_request( + MediaType.MOVIE, sort_by, with_genres, with_original_language, + with_keywords, with_watch_providers, vote_average, vote_count, + release_date, page, + ) + return self._serialize_medias(TmdbChain().tmdb_discover(**request)) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -334,7 +414,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): count=count or 30, entity=MUSIC_ENTITY_ALBUM, ) - return [media.to_dict() for media in medias] + return self._serialize_medias(medias) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -350,7 +430,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): count=count or 30, entity=MUSIC_ENTITY_ALBUM, ) - return [media.to_dict() for media in medias] + return self._serialize_medias(medias) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -366,17 +446,12 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ TMDB热门电视剧 """ - tvs = TmdbChain().tmdb_discover(mtype=MediaType.TV, - sort_by=sort_by, - with_genres=with_genres, - with_original_language=with_original_language, - with_keywords=with_keywords, - with_watch_providers=with_watch_providers, - vote_average=vote_average, - vote_count=vote_count, - release_date=release_date, - page=page) - return [tv.to_dict() for tv in tvs] if tvs else [] + request = self._tmdb_discover_request( + MediaType.TV, sort_by, with_genres, with_original_language, + with_keywords, with_watch_providers, vote_average, vote_count, + release_date, page, + ) + return self._serialize_medias(TmdbChain().tmdb_discover(**request)) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -384,8 +459,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ TMDB流行趋势 """ - infos = TmdbChain().tmdb_trending(page=page) - return [info.to_dict() for info in infos] if infos else [] + return self._serialize_medias(TmdbChain().tmdb_trending(page=page)) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -393,8 +467,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ Bangumi每日放送 """ - medias = BangumiChain().calendar() - return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else [] + return self._serialize_media_page(BangumiChain().calendar(), page, count) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -402,8 +475,9 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 豆瓣正在热映 """ - movies = DoubanChain().movie_showing(page=page, count=count) - return [media.to_dict() for media in movies] if movies else [] + return self._serialize_medias( + DoubanChain().movie_showing(page=page, count=count) + ) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -412,9 +486,10 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 豆瓣最新电影 """ - movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE, - sort=sort, tags=tags, page=page, count=count) - return [media.to_dict() for media in movies] if movies else [] + request = self._douban_discover_request( + MediaType.MOVIE, sort, tags, page, count + ) + return self._serialize_medias(DoubanChain().douban_discover(**request)) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -423,9 +498,8 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 豆瓣最新电视剧 """ - tvs = DoubanChain().douban_discover(mtype=MediaType.TV, - sort=sort, tags=tags, page=page, count=count) - return [media.to_dict() for media in tvs] if tvs else [] + request = self._douban_discover_request(MediaType.TV, sort, tags, page, count) + return self._serialize_medias(DoubanChain().douban_discover(**request)) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -433,8 +507,9 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 豆瓣电影TOP250 """ - movies = DoubanChain().movie_top250(page=page, count=count) - return [media.to_dict() for media in movies] if movies else [] + return self._serialize_medias( + DoubanChain().movie_top250(page=page, count=count) + ) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -442,8 +517,9 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 豆瓣国产剧集榜 """ - tvs = DoubanChain().tv_weekly_chinese(page=page, count=count) - return [media.to_dict() for media in tvs] if tvs else [] + return self._serialize_medias( + DoubanChain().tv_weekly_chinese(page=page, count=count) + ) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -451,8 +527,9 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 豆瓣全球剧集榜 """ - tvs = DoubanChain().tv_weekly_global(page=page, count=count) - return [media.to_dict() for media in tvs] if tvs else [] + return self._serialize_medias( + DoubanChain().tv_weekly_global(page=page, count=count) + ) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -460,8 +537,9 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 豆瓣热门动漫 """ - tvs = DoubanChain().tv_animation(page=page, count=count) - return [media.to_dict() for media in tvs] if tvs else [] + return self._serialize_medias( + DoubanChain().tv_animation(page=page, count=count) + ) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -469,8 +547,9 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 豆瓣热门电影 """ - movies = DoubanChain().movie_hot(page=page, count=count) - return [media.to_dict() for media in movies] if movies else [] + return self._serialize_medias( + DoubanChain().movie_hot(page=page, count=count) + ) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -478,8 +557,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 豆瓣热门电视剧 """ - tvs = DoubanChain().tv_hot(page=page, count=count) - return [media.to_dict() for media in tvs] if tvs else [] + return self._serialize_medias(DoubanChain().tv_hot(page=page, count=count)) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -496,18 +574,15 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 异步TMDB热门电影 """ - movies = await TmdbChain().async_run_module("async_tmdb_discover", mtype=MediaType.MOVIE, - sort_by=sort_by, - with_genres=with_genres, - with_original_language=with_original_language, - with_keywords=with_keywords, - with_watch_providers=with_watch_providers, - vote_average=vote_average, - vote_count=vote_count, - release_date=release_date, - page=page, - raise_exception=raise_exception) - return [movie.to_dict() for movie in movies] if movies else [] + request = self._tmdb_discover_request( + MediaType.MOVIE, sort_by, with_genres, with_original_language, + with_keywords, with_watch_providers, vote_average, vote_count, + release_date, page, + ) + movies = await TmdbChain().async_run_module( + "async_tmdb_discover", **request, raise_exception=raise_exception + ) + return self._serialize_medias(movies) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -524,18 +599,15 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 异步TMDB热门电视剧 """ - tvs = await TmdbChain().async_run_module("async_tmdb_discover", mtype=MediaType.TV, - sort_by=sort_by, - with_genres=with_genres, - with_original_language=with_original_language, - with_keywords=with_keywords, - with_watch_providers=with_watch_providers, - vote_average=vote_average, - vote_count=vote_count, - release_date=release_date, - page=page, - raise_exception=raise_exception) - return [tv.to_dict() for tv in tvs] if tvs else [] + request = self._tmdb_discover_request( + MediaType.TV, sort_by, with_genres, with_original_language, + with_keywords, with_watch_providers, vote_average, vote_count, + release_date, page, + ) + tvs = await TmdbChain().async_run_module( + "async_tmdb_discover", **request, raise_exception=raise_exception + ) + return self._serialize_medias(tvs) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -550,7 +622,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): page=page, raise_exception=raise_exception, ) - return [info.to_dict() for info in infos] if infos else [] + return self._serialize_medias(infos) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -559,7 +631,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): 异步Bangumi每日放送 """ medias = await BangumiChain().async_run_module("async_bangumi_calendar") - return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else [] + return self._serialize_media_page(medias, page, count) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -567,8 +639,10 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 异步豆瓣正在热映 """ - movies = await DoubanChain().async_run_module("async_movie_showing", page=page, count=count) - return [media.to_dict() for media in movies] if movies else [] + movies = await DoubanChain().async_run_module( + "async_movie_showing", page=page, count=count + ) + return self._serialize_medias(movies) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -580,7 +654,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): count=count or 30, entity=MUSIC_ENTITY_ALBUM, ) - return [media.to_dict() for media in medias] + return self._serialize_medias(medias) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -596,7 +670,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): count=count or 30, entity=MUSIC_ENTITY_ALBUM, ) - return [media.to_dict() for media in medias] + return self._serialize_medias(medias) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -605,9 +679,13 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 异步豆瓣最新电影 """ - movies = await DoubanChain().async_run_module("async_douban_discover", mtype=MediaType.MOVIE, - sort=sort, tags=tags, page=page, count=count) - return [media.to_dict() for media in movies] if movies else [] + request = self._douban_discover_request( + MediaType.MOVIE, sort, tags, page, count + ) + movies = await DoubanChain().async_run_module( + "async_douban_discover", **request + ) + return self._serialize_medias(movies) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -616,9 +694,11 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 异步豆瓣最新电视剧 """ - tvs = await DoubanChain().async_run_module("async_douban_discover", mtype=MediaType.TV, - sort=sort, tags=tags, page=page, count=count) - return [media.to_dict() for media in tvs] if tvs else [] + request = self._douban_discover_request(MediaType.TV, sort, tags, page, count) + tvs = await DoubanChain().async_run_module( + "async_douban_discover", **request + ) + return self._serialize_medias(tvs) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -626,8 +706,10 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 异步豆瓣电影TOP250 """ - movies = await DoubanChain().async_run_module("async_movie_top250", page=page, count=count) - return [media.to_dict() for media in movies] if movies else [] + movies = await DoubanChain().async_run_module( + "async_movie_top250", page=page, count=count + ) + return self._serialize_medias(movies) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -635,8 +717,10 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 异步豆瓣国产剧集榜 """ - tvs = await DoubanChain().async_run_module("async_tv_weekly_chinese", page=page, count=count) - return [media.to_dict() for media in tvs] if tvs else [] + tvs = await DoubanChain().async_run_module( + "async_tv_weekly_chinese", page=page, count=count + ) + return self._serialize_medias(tvs) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -644,8 +728,10 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 异步豆瓣全球剧集榜 """ - tvs = await DoubanChain().async_run_module("async_tv_weekly_global", page=page, count=count) - return [media.to_dict() for media in tvs] if tvs else [] + tvs = await DoubanChain().async_run_module( + "async_tv_weekly_global", page=page, count=count + ) + return self._serialize_medias(tvs) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -653,8 +739,10 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 异步豆瓣热门动漫 """ - tvs = await DoubanChain().async_run_module("async_tv_animation", page=page, count=count) - return [media.to_dict() for media in tvs] if tvs else [] + tvs = await DoubanChain().async_run_module( + "async_tv_animation", page=page, count=count + ) + return self._serialize_medias(tvs) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -662,8 +750,10 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 异步豆瓣热门电影 """ - movies = await DoubanChain().async_run_module("async_movie_hot", page=page, count=count) - return [media.to_dict() for media in movies] if movies else [] + movies = await DoubanChain().async_run_module( + "async_movie_hot", page=page, count=count + ) + return self._serialize_medias(movies) @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @@ -671,5 +761,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): """ 异步豆瓣热门电视剧 """ - tvs = await DoubanChain().async_run_module("async_tv_hot", page=page, count=count) - return [media.to_dict() for media in tvs] if tvs else [] + tvs = await DoubanChain().async_run_module( + "async_tv_hot", page=page, count=count + ) + return self._serialize_medias(tvs) diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index 34f61177b..7ad9a6284 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -78,7 +78,7 @@ 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,231 / 592 文件 | 识别链 typed plan/result、TMDB、消息、Indexer 与 AniList/Bangumi/IMDb 双 ABI 共用计划继续清除参数、返回值和泛型注解债;strict frontier 当前覆盖 41 个文件 | +| 全量 mypy 历史债务 | 10,190 / 592 文件 | 识别链 typed plan/result、TMDB、消息、Indexer、目录源与 Recommend 双 ABI 共用计划继续清除参数、返回值和泛型注解债;strict frontier 当前覆盖 41 个文件 | | Ruff 历史诊断 | 641 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率低水位 | Application 81.00%,Domain 80.67% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 0efdc4edb..6953008cd 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -1316,11 +1316,9 @@ "type-arg": 2 }, "app/chain/recommend.py": { - "arg-type": 20, "misc": 60, - "no-untyped-call": 14, + "no-untyped-call": 1, "no-untyped-def": 1, - "operator": 8, "type-arg": 31 }, "app/chain/scraping.py": { diff --git a/tests/test_recommend_chain.py b/tests/test_recommend_chain.py index 7e1d1bf24..c6dbb98b8 100644 --- a/tests/test_recommend_chain.py +++ b/tests/test_recommend_chain.py @@ -1,6 +1,6 @@ import asyncio from typing import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -96,6 +96,80 @@ def test_async_recommend_methods_do_not_cache_empty_result( assert backend_chain.return_value.async_run_module.call_count == 2 +def test_tmdb_discover_sync_async_share_request_and_projection() -> None: + """TMDB 推荐双入口应复用同一发现请求和结果投影。""" + chain = RecommendChain() + candidate = Mock() + candidate.to_dict.return_value = {"media_id": "tmdb-1"} + + with patch("app.chain.recommend.TmdbChain") as source_chain: + source_chain.return_value.tmdb_discover.return_value = [candidate] + source_chain.return_value.async_run_module = AsyncMock(return_value=[candidate]) + + sync_result = chain.tmdb_movies( + with_genres="18", vote_average=7.5, vote_count=100, page=2 + ) + async_result = asyncio.run(chain.async_tmdb_movies( + with_genres="18", vote_average=7.5, vote_count=100, page=2 + )) + + sync_request = source_chain.return_value.tmdb_discover.call_args.kwargs + async_request = dict(source_chain.return_value.async_run_module.call_args.kwargs) + assert async_request.pop("raise_exception") is False + assert source_chain.return_value.async_run_module.call_args.args == ( + "async_tmdb_discover", + ) + assert sync_request == async_request + assert sync_result == async_result == [{"media_id": "tmdb-1"}] + + +def test_bangumi_calendar_sync_async_share_page_projection() -> None: + """Bangumi 日历双入口应复用同一分页与字典投影规则。""" + chain = RecommendChain() + candidates = [] + for index in range(5): + candidate = Mock() + candidate.to_dict.return_value = {"media_id": str(index)} + candidates.append(candidate) + + with patch("app.chain.recommend.BangumiChain") as source_chain: + source_chain.return_value.calendar.return_value = candidates + source_chain.return_value.async_run_module = AsyncMock(return_value=candidates) + + sync_result = chain.bangumi_calendar(page=2, count=2) + async_result = asyncio.run(chain.async_bangumi_calendar(page=2, count=2)) + + assert sync_result == async_result == [ + {"media_id": "2"}, + {"media_id": "3"}, + ] + + +def test_douban_discover_sync_async_share_request_and_projection() -> None: + """豆瓣推荐双入口应复用媒体类型、分页和结果投影。""" + chain = RecommendChain() + candidate = Mock() + candidate.to_dict.return_value = {"media_id": "douban-1"} + + with patch("app.chain.recommend.DoubanChain") as source_chain: + source_chain.return_value.douban_discover.return_value = [candidate] + source_chain.return_value.async_run_module = AsyncMock(return_value=[candidate]) + + sync_result = chain.douban_movies(sort="U", tags="喜剧", page=3, count=12) + clear_recommend_cache() + async_result = asyncio.run(chain.async_douban_movies( + sort="U", tags="喜剧", page=3, count=12 + )) + + sync_request = source_chain.return_value.douban_discover.call_args.kwargs + async_request = source_chain.return_value.async_run_module.call_args.kwargs + assert source_chain.return_value.async_run_module.call_args.args == ( + "async_douban_discover", + ) + assert sync_request == async_request + assert sync_result == async_result == [{"media_id": "douban-1"}] + + def test_music_weekly_uses_music_chart(): """同步推荐缓存应从本周音乐榜单生成通用媒体字典。""" chain = RecommendChain()