From a5745af4843eb8a48b432d237aa0422d6063dc47 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 20 May 2026 10:55:01 +0800 Subject: [PATCH] fix: restore tmdb trending recommendations --- app/chain/recommend.py | 4 +-- app/modules/themoviedb/tmdbapi.py | 26 +++++++++++++-- app/modules/themoviedb/tmdbv3api/tmdb.py | 30 ++++++++++------- tests/test_media_recognize_modules.py | 16 +++++++++ tests/test_recommend_chain.py | 42 ++++++++++++++++++++++++ tests/test_tmdb_response_cache.py | 14 +++++--- 6 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 tests/test_recommend_chain.py diff --git a/app/chain/recommend.py b/app/chain/recommend.py index 7747f3c8..89d3ae86 100644 --- a/app/chain/recommend.py +++ b/app/chain/recommend.py @@ -157,7 +157,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [tv.to_dict() for tv in tvs] if tvs else [] @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]: """ TMDB流行趋势 @@ -312,7 +312,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): return [tv.to_dict() for tv in tvs] if tvs else [] @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]: """ 异步TMDB流行趋势 diff --git a/app/modules/themoviedb/tmdbapi.py b/app/modules/themoviedb/tmdbapi.py index 0a408d94..fa1da865 100644 --- a/app/modules/themoviedb/tmdbapi.py +++ b/app/modules/themoviedb/tmdbapi.py @@ -1220,6 +1220,26 @@ class TmdbApi: logger.error(str(e)) 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]: """ 流行趋势 @@ -1228,7 +1248,8 @@ class TmdbApi: return [] try: 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: logger.error(str(e)) return [] @@ -1988,7 +2009,8 @@ class TmdbApi: return [] try: 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: logger.error(str(e)) return [] diff --git a/app/modules/themoviedb/tmdbv3api/tmdb.py b/app/modules/themoviedb/tmdbv3api/tmdb.py index 10534118..3e93de4f 100644 --- a/app/modules/themoviedb/tmdbv3api/tmdb.py +++ b/app/modules/themoviedb/tmdbv3api/tmdb.py @@ -171,7 +171,7 @@ class TMDb(object): json_data = cls._decode_compressed_response_json(response) if json_data is not cls._JSON_DECODE_FAILED: 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 def _decode_compressed_response_json(cls, response): @@ -224,23 +224,27 @@ class TMDb(object): return None @staticmethod - def _build_invalid_json_message(response): + def _build_invalid_json_message(response, parse_error: Exception = None): """ 生成非JSON响应的诊断信息,避免日志只保留JSONDecodeError文本。 """ status_code = getattr(response, "status_code", None) headers = getattr(response, "headers", {}) or {} content_type = TMDb._get_header_value(headers, "Content-Type") + is_encoding_error = isinstance(parse_error, UnicodeDecodeError) - try: - response_text = getattr(response, "text", "") or "" - except Exception as err: # pragma: no cover - 防御异常响应对象 - response_text = f"<读取响应内容失败:{err!r}>" - if not isinstance(response_text, str): - response_text = repr(response_text) - response_text = response_text.strip() - if len(response_text) > 200: - response_text = f"{response_text[:200]}..." + # 编码错误时响应体通常是压缩字节或乱码,打印内容只会污染日志。 + response_text = "" + if not is_encoding_error: + try: + response_text = getattr(response, "text", "") or "" + except Exception as err: # pragma: no cover - 防御异常响应对象 + response_text = f"<读取响应内容失败:{err!r}>" + if not isinstance(response_text, str): + response_text = repr(response_text) + response_text = response_text.strip() + if len(response_text) > 200: + response_text = f"{response_text[:200]}..." message_parts = ["TheMovieDb 返回数据不是有效JSON"] if status_code is not None: @@ -250,7 +254,9 @@ class TMDb(object): content_encoding = TMDb._get_header_value(headers, "Content-Encoding") if 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}") else: message_parts.append("响应内容为空") diff --git a/tests/test_media_recognize_modules.py b/tests/test_media_recognize_modules.py index 8e9fd3fd..4aa7c2bc 100644 --- a/tests/test_media_recognize_modules.py +++ b/tests/test_media_recognize_modules.py @@ -107,6 +107,22 @@ class MediaRecognizeModulesTest(TestCase): 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): """obtain_images 应从图片接口回填缺失的海报和背景图。""" module = TheMovieDbModule() diff --git a/tests/test_recommend_chain.py b/tests/test_recommend_chain.py new file mode 100644 index 00000000..b5a81f6d --- /dev/null +++ b/tests/test_recommend_chain.py @@ -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) diff --git a/tests/test_tmdb_response_cache.py b/tests/test_tmdb_response_cache.py index 0a36343e..3421be74 100644 --- a/tests/test_tmdb_response_cache.py +++ b/tests/test_tmdb_response_cache.py @@ -146,13 +146,13 @@ class _UnicodeDecodeErrorResponse: 模拟 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.status_code = 200 - self.text = "" + self.text = text self.content = content def json(self): @@ -227,10 +227,16 @@ class TmdbResponseCacheTest(TestCase): 错误编码的响应体也应转换为TMDbException,避免UnicodeDecodeError直接冒泡。 """ 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) + self.assertNotIn("乱码内容", str(cm.exception)) def test_request_decodes_raw_gzip_json_response(self): """