mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 13:28:32 +08:00
fix(douban): preserve recommendation lists and page model contracts
This commit is contained in:
@@ -2015,10 +2015,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
:return: 当前页或本次查询的标准对象列表;无结果时为空列表
|
||||
"""
|
||||
recommend = self.doubanapi.movie_recommendations(subject_id=doubanid)
|
||||
if recommend:
|
||||
items = recommend.get("items") or []
|
||||
return [MediaInfo(douban_info=info) for info in items if isinstance(info, dict)]
|
||||
return []
|
||||
return self._process_recommendations(recommend)
|
||||
|
||||
def douban_tv_recommend(self, doubanid: str) -> List[MediaInfo]:
|
||||
"""
|
||||
@@ -2028,10 +2025,20 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
:return: 当前页或本次查询的标准对象列表;无结果时为空列表
|
||||
"""
|
||||
recommend = self.doubanapi.tv_recommendations(subject_id=doubanid)
|
||||
if recommend:
|
||||
items = recommend.get("items") or []
|
||||
return [MediaInfo(douban_info=info) for info in items if isinstance(info, dict)]
|
||||
return []
|
||||
return self._process_recommendations(recommend)
|
||||
|
||||
@staticmethod
|
||||
def _process_recommendations(
|
||||
recommend: Optional[Union[dict[str, Any], list[dict[str, Any]]]],
|
||||
) -> List[MediaInfo]:
|
||||
"""
|
||||
将豆瓣推荐列表或携带 items 的响应投影为媒体列表。
|
||||
|
||||
:param recommend: 推荐接口原生列表或兼容的分页字典;无响应时为 None
|
||||
:return: 保持原顺序的媒体列表,跳过非字典条目且不修改缓存响应
|
||||
"""
|
||||
items = (recommend.get("items") or []) if isinstance(recommend, dict) else (recommend or [])
|
||||
return [MediaInfo(douban_info=info) for info in items if isinstance(info, dict)]
|
||||
|
||||
@staticmethod
|
||||
def _process_comment_author(doubaninfo: Optional[dict[str, Any]]) -> Optional[_SchemaMediaCommentAuthor]:
|
||||
@@ -2270,10 +2277,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
:return: 当前页或本次查询的标准对象列表;无结果时为空列表
|
||||
"""
|
||||
recommend = await self.doubanapi.async_movie_recommendations(subject_id=doubanid)
|
||||
if recommend:
|
||||
items = recommend.get("items") or []
|
||||
return [MediaInfo(douban_info=info) for info in items if isinstance(info, dict)]
|
||||
return []
|
||||
return self._process_recommendations(recommend)
|
||||
|
||||
async def async_douban_tv_recommend(self, doubanid: str) -> List[MediaInfo]:
|
||||
"""
|
||||
@@ -2283,10 +2287,7 @@ class DoubanModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
:return: 当前页或本次查询的标准对象列表;无结果时为空列表
|
||||
"""
|
||||
recommend = await self.doubanapi.async_tv_recommendations(subject_id=doubanid)
|
||||
if recommend:
|
||||
items = recommend.get("items") or []
|
||||
return [MediaInfo(douban_info=info) for info in items if isinstance(info, dict)]
|
||||
return []
|
||||
return self._process_recommendations(recommend)
|
||||
|
||||
async def async_douban_movie_interests(self, doubanid: str, start: int = 0,
|
||||
count: int = 20) -> _SchemaMediaCommentPage:
|
||||
|
||||
@@ -1389,9 +1389,9 @@ class DoubanApi(metaclass=WeakSingleton):
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20,
|
||||
ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[dict[str, Any]]:
|
||||
ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[Union[dict[str, Any], list[dict[str, Any]]]]:
|
||||
"""
|
||||
电影推荐。
|
||||
电影推荐,保留原生列表或携带 items 的字典响应。
|
||||
|
||||
:param subject_id: 豆瓣条目原生标识
|
||||
:param start: 从 0 开始的条目偏移量,不是页码
|
||||
@@ -1403,9 +1403,9 @@ class DoubanApi(metaclass=WeakSingleton):
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
async def async_movie_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20,
|
||||
ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[dict[str, Any]]:
|
||||
ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[Union[dict[str, Any], list[dict[str, Any]]]]:
|
||||
"""
|
||||
电影推荐(异步版本)。
|
||||
电影推荐(异步版本),保留原生列表或携带 items 的字典响应。
|
||||
|
||||
:param subject_id: 豆瓣条目原生标识
|
||||
:param start: 从 0 开始的条目偏移量,不是页码
|
||||
@@ -1417,9 +1417,9 @@ class DoubanApi(metaclass=WeakSingleton):
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20,
|
||||
ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[dict[str, Any]]:
|
||||
ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[Union[dict[str, Any], list[dict[str, Any]]]]:
|
||||
"""
|
||||
电视剧推荐。
|
||||
电视剧推荐,保留原生列表或携带 items 的字典响应。
|
||||
|
||||
:param subject_id: 豆瓣条目原生标识
|
||||
:param start: 从 0 开始的条目偏移量,不是页码
|
||||
@@ -1431,9 +1431,9 @@ class DoubanApi(metaclass=WeakSingleton):
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
async def async_tv_recommendations(self, subject_id: str, start: Optional[int] = 0, count: Optional[int] = 20,
|
||||
ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[dict[str, Any]]:
|
||||
ts: str = datetime.strftime(datetime.now(), '%Y%m%d')) -> Optional[Union[dict[str, Any], list[dict[str, Any]]]]:
|
||||
"""
|
||||
电视剧推荐(异步版本)。
|
||||
电视剧推荐(异步版本),保留原生列表或携带 items 的字典响应。
|
||||
|
||||
:param subject_id: 豆瓣条目原生标识
|
||||
:param start: 从 0 开始的条目偏移量,不是页码
|
||||
|
||||
@@ -10,6 +10,8 @@ from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ModuleResultAggregation(StrEnum):
|
||||
"""
|
||||
@@ -32,7 +34,9 @@ class ModuleResultAggregation(StrEnum):
|
||||
|
||||
class ModuleResultShape(StrEnum):
|
||||
"""
|
||||
描述模块 provider 返回值的基础 Python 形状。
|
||||
描述模块 provider 返回值的基础 Python 形状或模型对象。
|
||||
|
||||
MODEL 用于 Pydantic 分页等模型对象,MAPPING 仍只接受字典。
|
||||
"""
|
||||
|
||||
# 接受任意结果形状
|
||||
@@ -43,6 +47,7 @@ class ModuleResultShape(StrEnum):
|
||||
STRING = "string"
|
||||
# 结果应为键值映射
|
||||
MAPPING = "mapping"
|
||||
MODEL = "model"
|
||||
# 结果应为布尔值
|
||||
BOOLEAN = "boolean"
|
||||
# 结果应为字节序列
|
||||
@@ -699,7 +704,7 @@ _METHOD_CONTRACTS = {
|
||||
family="douban",
|
||||
input_contract="DoubanCommentRequest",
|
||||
result_contract="MediaCommentPage",
|
||||
result_shape=ModuleResultShape.MAPPING,
|
||||
result_shape=ModuleResultShape.MODEL,
|
||||
aggregation=ModuleResultAggregation.FIRST_NON_EMPTY,
|
||||
required_parameters=("doubanid", "start", "count"),
|
||||
),
|
||||
@@ -707,7 +712,7 @@ _METHOD_CONTRACTS = {
|
||||
family="douban",
|
||||
input_contract="DoubanCommentRequest",
|
||||
result_contract="MediaCommentPage",
|
||||
result_shape=ModuleResultShape.MAPPING,
|
||||
result_shape=ModuleResultShape.MODEL,
|
||||
aggregation=ModuleResultAggregation.FIRST_NON_EMPTY,
|
||||
required_parameters=("doubanid", "start", "count"),
|
||||
),
|
||||
@@ -715,7 +720,7 @@ _METHOD_CONTRACTS = {
|
||||
family="douban",
|
||||
input_contract="DoubanCommentRequest",
|
||||
result_contract="MediaReviewPage",
|
||||
result_shape=ModuleResultShape.MAPPING,
|
||||
result_shape=ModuleResultShape.MODEL,
|
||||
aggregation=ModuleResultAggregation.FIRST_NON_EMPTY,
|
||||
required_parameters=("doubanid", "start", "count"),
|
||||
),
|
||||
@@ -723,7 +728,7 @@ _METHOD_CONTRACTS = {
|
||||
family="douban",
|
||||
input_contract="DoubanCommentRequest",
|
||||
result_contract="MediaReviewPage",
|
||||
result_shape=ModuleResultShape.MAPPING,
|
||||
result_shape=ModuleResultShape.MODEL,
|
||||
aggregation=ModuleResultAggregation.FIRST_NON_EMPTY,
|
||||
required_parameters=("doubanid", "start", "count"),
|
||||
),
|
||||
@@ -1965,6 +1970,7 @@ def diagnose_module_result(method: str, result: Any) -> tuple[str, ...]:
|
||||
ModuleResultShape.LIST: isinstance(result, list),
|
||||
ModuleResultShape.STRING: isinstance(result, str),
|
||||
ModuleResultShape.MAPPING: isinstance(result, dict),
|
||||
ModuleResultShape.MODEL: isinstance(result, BaseModel),
|
||||
ModuleResultShape.BOOLEAN: isinstance(result, bool),
|
||||
ModuleResultShape.BYTES: isinstance(result, bytes),
|
||||
}
|
||||
|
||||
@@ -4408,7 +4408,7 @@
|
||||
"start"
|
||||
],
|
||||
"result_contract": "MediaCommentPage",
|
||||
"result_shape": "mapping",
|
||||
"result_shape": "model",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
"timeout_policy": "caller_budget",
|
||||
@@ -4448,7 +4448,7 @@
|
||||
"start"
|
||||
],
|
||||
"result_contract": "MediaReviewPage",
|
||||
"result_shape": "mapping",
|
||||
"result_shape": "model",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
"timeout_policy": "caller_budget",
|
||||
@@ -4527,7 +4527,7 @@
|
||||
"start"
|
||||
],
|
||||
"result_contract": "MediaCommentPage",
|
||||
"result_shape": "mapping",
|
||||
"result_shape": "model",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
"timeout_policy": "caller_budget",
|
||||
@@ -4567,7 +4567,7 @@
|
||||
"start"
|
||||
],
|
||||
"result_contract": "MediaReviewPage",
|
||||
"result_shape": "mapping",
|
||||
"result_shape": "model",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
"timeout_policy": "caller_budget",
|
||||
@@ -5615,7 +5615,7 @@
|
||||
"start"
|
||||
],
|
||||
"result_contract": "MediaCommentPage",
|
||||
"result_shape": "mapping",
|
||||
"result_shape": "model",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
"timeout_policy": "caller_budget",
|
||||
@@ -5655,7 +5655,7 @@
|
||||
"start"
|
||||
],
|
||||
"result_contract": "MediaReviewPage",
|
||||
"result_shape": "mapping",
|
||||
"result_shape": "model",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
"timeout_policy": "caller_budget",
|
||||
@@ -5734,7 +5734,7 @@
|
||||
"start"
|
||||
],
|
||||
"result_contract": "MediaCommentPage",
|
||||
"result_shape": "mapping",
|
||||
"result_shape": "model",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
"timeout_policy": "caller_budget",
|
||||
@@ -5774,7 +5774,7 @@
|
||||
"start"
|
||||
],
|
||||
"result_contract": "MediaReviewPage",
|
||||
"result_shape": "mapping",
|
||||
"result_shape": "model",
|
||||
"supports_async": true,
|
||||
"supports_sync": true,
|
||||
"timeout_policy": "caller_budget",
|
||||
|
||||
@@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
import pytest
|
||||
|
||||
from app.modules.douban import DoubanModule
|
||||
from app.runtime.extensions.module.contracts import diagnose_module_result
|
||||
|
||||
|
||||
def _module() -> DoubanModule:
|
||||
@@ -180,6 +181,10 @@ def test_sync_and_async_entries_share_projection(
|
||||
async_result = asyncio.run(getattr(module, async_method)("100", start=5, count=10))
|
||||
|
||||
assert sync_result == async_result
|
||||
assert diagnose_module_result(sync_method, sync_result) == ()
|
||||
assert diagnose_module_result(async_method, async_result) == ()
|
||||
assert diagnose_module_result(sync_method, {}) == ("unexpected-result:model:dict",)
|
||||
assert diagnose_module_result(async_method, []) == ("unexpected-result:model:list",)
|
||||
getattr(module.doubanapi, client_method).assert_called_once_with(
|
||||
subject_id="100", start=5, count=10
|
||||
)
|
||||
|
||||
61
tests/test_douban_recommendations.py
Normal file
61
tests/test_douban_recommendations.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
豆瓣推荐从 HTTP 响应到媒体投影的同步异步回归。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.douban import DoubanModule
|
||||
from app.modules.douban.apiv2 import DoubanApi
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["movie", "tv"])
|
||||
@pytest.mark.parametrize("async_mode", [False, True])
|
||||
@pytest.mark.parametrize("shape", ["list", "mapping", "empty_list", "empty_mapping", "missing"])
|
||||
def test_recommendations_preserve_native_lists_and_compatible_mappings(
|
||||
monkeypatch: pytest.MonkeyPatch, kind: str, async_mode: bool, shape: str,
|
||||
) -> None:
|
||||
"""
|
||||
实际客户端解码列表和分页字典后,四个模块入口均保留推荐顺序且不污染缓存。
|
||||
"""
|
||||
records = [
|
||||
{"id": "100", "type": kind, "title": "第一条推荐"},
|
||||
{"id": "101", "type": kind, "title": "第二条推荐"},
|
||||
]
|
||||
payloads = {
|
||||
"list": records,
|
||||
"mapping": {"items": records},
|
||||
"empty_list": [],
|
||||
"empty_mapping": {},
|
||||
"missing": None,
|
||||
}
|
||||
payload = payloads[shape]
|
||||
original = deepcopy(payload)
|
||||
response = None if payload is None else Mock(json=Mock(return_value=payload))
|
||||
api = object.__new__(DoubanApi)
|
||||
api._request = Mock(get_res=Mock(return_value=response))
|
||||
async_request = AsyncMock(return_value=response)
|
||||
monkeypatch.setattr(
|
||||
"app.modules.douban.apiv2.AsyncRequestUtils",
|
||||
Mock(return_value=Mock(get_res=async_request)),
|
||||
)
|
||||
module = object.__new__(DoubanModule)
|
||||
module.doubanapi = api
|
||||
method = f"{'async_' if async_mode else ''}douban_{kind}_recommend"
|
||||
api.clear_cache()
|
||||
try:
|
||||
result = getattr(module, method)("42")
|
||||
result = asyncio.run(result) if async_mode else result
|
||||
expected_ids = ["100", "101"] if shape in {"list", "mapping"} else []
|
||||
expected_titles = [record["title"] for record in records] if expected_ids else []
|
||||
assert [media.douban_id for media in result] == expected_ids
|
||||
assert [media.title for media in result] == expected_titles
|
||||
assert payload == original
|
||||
request = async_request if async_mode else api._request.get_res
|
||||
request.assert_called_once()
|
||||
assert request.call_args.kwargs["url"].endswith(f"/{kind}/42/recommendations")
|
||||
finally:
|
||||
api.clear_cache()
|
||||
Reference in New Issue
Block a user