mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
fix: restore tmdb trending recommendations
This commit is contained in:
@@ -157,7 +157,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [tv.to_dict() for tv in tvs] if tvs else []
|
return [tv.to_dict() for tv in tvs] if tvs else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
|
||||||
def tmdb_trending(self, page: Optional[int] = 1) -> List[dict]:
|
def tmdb_trending(self, page: Optional[int] = 1) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
TMDB流行趋势
|
TMDB流行趋势
|
||||||
@@ -312,7 +312,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
return [tv.to_dict() for tv in tvs] if tvs else []
|
return [tv.to_dict() for tv in tvs] if tvs else []
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
@log_execution_time(logger=logger)
|
||||||
@cached(ttl=recommend_ttl, region=recommend_cache_region)
|
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
|
||||||
async def async_tmdb_trending(self, page: Optional[int] = 1) -> List[dict]:
|
async def async_tmdb_trending(self, page: Optional[int] = 1) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
异步TMDB流行趋势
|
异步TMDB流行趋势
|
||||||
|
|||||||
@@ -1220,6 +1220,26 @@ class TmdbApi:
|
|||||||
logger.error(str(e))
|
logger.error(str(e))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_trending_infos(infos: Optional[List[dict]]) -> List[dict]:
|
||||||
|
"""
|
||||||
|
过滤流行趋势中的人物等非媒体项,并统一电影、电视剧的媒体类型。
|
||||||
|
"""
|
||||||
|
if not infos:
|
||||||
|
return []
|
||||||
|
|
||||||
|
ret_infos = []
|
||||||
|
for info in infos:
|
||||||
|
media_type = info.get("media_type")
|
||||||
|
if media_type == "movie":
|
||||||
|
info["media_type"] = MediaType.MOVIE
|
||||||
|
elif media_type == "tv":
|
||||||
|
info["media_type"] = MediaType.TV
|
||||||
|
elif media_type not in [MediaType.MOVIE, MediaType.TV]:
|
||||||
|
continue
|
||||||
|
ret_infos.append(info)
|
||||||
|
return ret_infos
|
||||||
|
|
||||||
def discover_trending(self, page: Optional[int] = 1) -> List[dict]:
|
def discover_trending(self, page: Optional[int] = 1) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
流行趋势
|
流行趋势
|
||||||
@@ -1228,7 +1248,8 @@ class TmdbApi:
|
|||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
logger.debug(f"正在获取流行趋势:page={page} ...")
|
logger.debug(f"正在获取流行趋势:page={page} ...")
|
||||||
return self.trending.all_week(page=page)
|
tmdbinfo = self.trending.all_week(page=page)
|
||||||
|
return self._normalize_trending_infos(tmdbinfo)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(str(e))
|
logger.error(str(e))
|
||||||
return []
|
return []
|
||||||
@@ -1988,7 +2009,8 @@ class TmdbApi:
|
|||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
logger.debug(f"正在获取流行趋势:page={page} ...")
|
logger.debug(f"正在获取流行趋势:page={page} ...")
|
||||||
return await self.trending.async_all_week(page=page)
|
tmdbinfo = await self.trending.async_all_week(page=page)
|
||||||
|
return self._normalize_trending_infos(tmdbinfo)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(str(e))
|
logger.error(str(e))
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ class TMDb(object):
|
|||||||
json_data = cls._decode_compressed_response_json(response)
|
json_data = cls._decode_compressed_response_json(response)
|
||||||
if json_data is not cls._JSON_DECODE_FAILED:
|
if json_data is not cls._JSON_DECODE_FAILED:
|
||||||
return json_data
|
return json_data
|
||||||
raise TMDbException(cls._build_invalid_json_message(response)) from err
|
raise TMDbException(cls._build_invalid_json_message(response, err)) from err
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _decode_compressed_response_json(cls, response):
|
def _decode_compressed_response_json(cls, response):
|
||||||
@@ -224,14 +224,18 @@ class TMDb(object):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_invalid_json_message(response):
|
def _build_invalid_json_message(response, parse_error: Exception = None):
|
||||||
"""
|
"""
|
||||||
生成非JSON响应的诊断信息,避免日志只保留JSONDecodeError文本。
|
生成非JSON响应的诊断信息,避免日志只保留JSONDecodeError文本。
|
||||||
"""
|
"""
|
||||||
status_code = getattr(response, "status_code", None)
|
status_code = getattr(response, "status_code", None)
|
||||||
headers = getattr(response, "headers", {}) or {}
|
headers = getattr(response, "headers", {}) or {}
|
||||||
content_type = TMDb._get_header_value(headers, "Content-Type")
|
content_type = TMDb._get_header_value(headers, "Content-Type")
|
||||||
|
is_encoding_error = isinstance(parse_error, UnicodeDecodeError)
|
||||||
|
|
||||||
|
# 编码错误时响应体通常是压缩字节或乱码,打印内容只会污染日志。
|
||||||
|
response_text = ""
|
||||||
|
if not is_encoding_error:
|
||||||
try:
|
try:
|
||||||
response_text = getattr(response, "text", "") or ""
|
response_text = getattr(response, "text", "") or ""
|
||||||
except Exception as err: # pragma: no cover - 防御异常响应对象
|
except Exception as err: # pragma: no cover - 防御异常响应对象
|
||||||
@@ -250,7 +254,9 @@ class TMDb(object):
|
|||||||
content_encoding = TMDb._get_header_value(headers, "Content-Encoding")
|
content_encoding = TMDb._get_header_value(headers, "Content-Encoding")
|
||||||
if content_encoding:
|
if content_encoding:
|
||||||
message_parts.append(f"Content-Encoding:{content_encoding}")
|
message_parts.append(f"Content-Encoding:{content_encoding}")
|
||||||
if response_text:
|
if is_encoding_error:
|
||||||
|
message_parts.append("响应内容因编码错误已省略")
|
||||||
|
elif response_text:
|
||||||
message_parts.append(f"响应内容:{response_text!r}")
|
message_parts.append(f"响应内容:{response_text!r}")
|
||||||
else:
|
else:
|
||||||
message_parts.append("响应内容为空")
|
message_parts.append("响应内容为空")
|
||||||
|
|||||||
@@ -107,6 +107,22 @@ class MediaRecognizeModulesTest(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(result, "zh,en,null,ja")
|
self.assertEqual(result, "zh,en,null,ja")
|
||||||
|
|
||||||
|
def test_tmdb_trending_filters_non_media_and_normalizes_media_type(self):
|
||||||
|
"""TMDB流行趋势应过滤人物项,并把字符串媒体类型转为内部枚举。"""
|
||||||
|
infos = [
|
||||||
|
{"id": 100, "media_type": "movie", "title": "测试电影"},
|
||||||
|
{"id": 101, "media_type": "tv", "name": "测试剧集"},
|
||||||
|
{"id": 102, "media_type": "person", "name": "测试人物"},
|
||||||
|
{"id": 103, "media_type": MediaType.MOVIE, "title": "枚举电影"},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = TmdbApi._normalize_trending_infos(infos)
|
||||||
|
|
||||||
|
self.assertEqual([info["id"] for info in result], [100, 101, 103])
|
||||||
|
self.assertEqual(result[0]["media_type"], MediaType.MOVIE)
|
||||||
|
self.assertEqual(result[1]["media_type"], MediaType.TV)
|
||||||
|
self.assertEqual(result[2]["media_type"], MediaType.MOVIE)
|
||||||
|
|
||||||
def test_tmdb_obtain_images_uses_language_fallback_and_picks_best(self):
|
def test_tmdb_obtain_images_uses_language_fallback_and_picks_best(self):
|
||||||
"""obtain_images 应从图片接口回填缺失的海报和背景图。"""
|
"""obtain_images 应从图片接口回填缺失的海报和背景图。"""
|
||||||
module = TheMovieDbModule()
|
module = TheMovieDbModule()
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import asyncio
|
||||||
|
from unittest import TestCase
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from app.chain.recommend import RecommendChain
|
||||||
|
from app.core.cache import TTLCache
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendChainTest(TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
"""
|
||||||
|
清理推荐缓存,避免缓存装饰器状态影响其他用例。
|
||||||
|
"""
|
||||||
|
RecommendChain.tmdb_trending.cache_clear()
|
||||||
|
asyncio.run(RecommendChain.async_tmdb_trending.cache_clear())
|
||||||
|
TTLCache(region=RecommendChain.recommend_cache_region).clear()
|
||||||
|
|
||||||
|
def test_tmdb_trending_does_not_cache_empty_result(self):
|
||||||
|
"""
|
||||||
|
TMDB流行趋势返回空列表时不应缓存,避免一次接口异常后长时间固定为空。
|
||||||
|
"""
|
||||||
|
chain = RecommendChain()
|
||||||
|
with patch("app.chain.recommend.TmdbChain") as tmdb_chain:
|
||||||
|
tmdb_chain.return_value.tmdb_trending.side_effect = [[], []]
|
||||||
|
|
||||||
|
self.assertEqual(chain.tmdb_trending(page=1), [])
|
||||||
|
self.assertEqual(chain.tmdb_trending(page=1), [])
|
||||||
|
|
||||||
|
self.assertEqual(tmdb_chain.return_value.tmdb_trending.call_count, 2)
|
||||||
|
|
||||||
|
def test_async_tmdb_trending_does_not_cache_empty_result(self):
|
||||||
|
"""
|
||||||
|
异步TMDB流行趋势返回空列表时也不应缓存。
|
||||||
|
"""
|
||||||
|
chain = RecommendChain()
|
||||||
|
with patch("app.chain.recommend.TmdbChain") as tmdb_chain:
|
||||||
|
tmdb_chain.return_value.async_run_module = AsyncMock(side_effect=[[], []])
|
||||||
|
|
||||||
|
self.assertEqual(asyncio.run(chain.async_tmdb_trending(page=1)), [])
|
||||||
|
self.assertEqual(asyncio.run(chain.async_tmdb_trending(page=1)), [])
|
||||||
|
|
||||||
|
self.assertEqual(tmdb_chain.return_value.async_run_module.call_count, 2)
|
||||||
@@ -146,13 +146,13 @@ class _UnicodeDecodeErrorResponse:
|
|||||||
模拟 httpx.Response.json() 直接抛 UnicodeDecodeError 的异常响应。
|
模拟 httpx.Response.json() 直接抛 UnicodeDecodeError 的异常响应。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, content: bytes = b"\x8b"):
|
def __init__(self, content: bytes = b"\x8b", text: str = ""):
|
||||||
"""
|
"""
|
||||||
初始化一个带有压缩响应特征的伪响应对象。
|
初始化一个带有压缩响应特征的伪响应对象。
|
||||||
"""
|
"""
|
||||||
self.headers = {"Content-Type": "application/json", "Content-Encoding": "gzip"}
|
self.headers = {"Content-Type": "application/json", "Content-Encoding": "gzip"}
|
||||||
self.status_code = 200
|
self.status_code = 200
|
||||||
self.text = ""
|
self.text = text
|
||||||
self.content = content
|
self.content = content
|
||||||
|
|
||||||
def json(self):
|
def json(self):
|
||||||
@@ -227,10 +227,16 @@ class TmdbResponseCacheTest(TestCase):
|
|||||||
错误编码的响应体也应转换为TMDbException,避免UnicodeDecodeError直接冒泡。
|
错误编码的响应体也应转换为TMDbException,避免UnicodeDecodeError直接冒泡。
|
||||||
"""
|
"""
|
||||||
tmdb = TMDb()
|
tmdb = TMDb()
|
||||||
tmdb._req.get_res = lambda *args, **kwargs: _UnicodeDecodeErrorResponse()
|
tmdb._req.get_res = lambda *args, **kwargs: _UnicodeDecodeErrorResponse(
|
||||||
|
text="乱码内容不应进入日志"
|
||||||
|
)
|
||||||
|
|
||||||
with self.assertRaisesRegex(TMDbException, "不是有效JSON.*Content-Encoding:gzip"):
|
with self.assertRaisesRegex(
|
||||||
|
TMDbException,
|
||||||
|
"不是有效JSON.*Content-Encoding:gzip.*响应内容因编码错误已省略",
|
||||||
|
) as cm:
|
||||||
TMDb.request.__wrapped__(tmdb, "GET", "https://example.com", None, None)
|
TMDb.request.__wrapped__(tmdb, "GET", "https://example.com", None, None)
|
||||||
|
self.assertNotIn("乱码内容", str(cm.exception))
|
||||||
|
|
||||||
def test_request_decodes_raw_gzip_json_response(self):
|
def test_request_decodes_raw_gzip_json_response(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user