refactor(recommend): unify paired decision plans

This commit is contained in:
jxxghp
2026-08-29 20:12:47 +08:00
parent 3f7c89654d
commit fdb7c10a9e
4 changed files with 296 additions and 132 deletions

View File

@@ -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)

View File

@@ -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 未进入包级覆盖率门禁 |

View File

@@ -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": {

View File

@@ -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()