From 1402626a4ad044f65b865fd2ee0aa93429a525c6 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 16 Aug 2026 21:20:25 +0800 Subject: [PATCH] =?UTF-8?q?refactor(tmdb):=20=E7=A9=BA=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E8=BF=87=E6=9C=9F=E6=97=B6=E9=97=B4=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E4=B8=BA=E8=AE=BE=E7=BD=AE=E9=A1=B9=20EMPTY=5FRESULT?= =?UTF-8?q?=5FCACHE=5FTTL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConfigModel TMDB 配置区新增 EMPTY_RESULT_CACHE_TTL(默认 30 分钟),支持环境变量覆盖 - request/async_request/discover/TmdbCache 统一改读 settings,移除模块级常量与跨模块导入 --- app/modules/themoviedb/tmdb_cache.py | 3 +-- app/modules/themoviedb/tmdbv3api/objs/discover.py | 11 ++++++----- app/modules/themoviedb/tmdbv3api/tmdb.py | 13 ++++--------- app/runtime/config.py | 2 ++ tests/test_tmdb_cache_type_guard.py | 5 ++--- tests/test_tmdb_empty_result_cache.py | 10 +++++----- 6 files changed, 20 insertions(+), 24 deletions(-) diff --git a/app/modules/themoviedb/tmdb_cache.py b/app/modules/themoviedb/tmdb_cache.py index eefd6d890..0c23515bd 100644 --- a/app/modules/themoviedb/tmdb_cache.py +++ b/app/modules/themoviedb/tmdb_cache.py @@ -11,7 +11,6 @@ from app.domain.meta.metabase import MetaBase from app.runtime.log import logger from app.schemas.types import MediaSource, MediaType from app.foundation.singleton import WeakSingleton -from app.modules.themoviedb.tmdbv3api.tmdb import EMPTY_RESULT_CACHE_TTL lock = RLock() PERSISTENCE_VERSION = 1 @@ -262,7 +261,7 @@ class TmdbCache(metaclass=WeakSingleton): # 负识别缓存使用独立的短 TTL:故障期间「合法 JSON 但结果为空」会被 # 记为未识别,若按完整有效期固化,故障自愈后同名仍会被判无法识别; # 短过期让恢复后可重新识别,真不存在的条目过期后重新确认一次即可 - self._set(key, {"id": 0}, ttl=EMPTY_RESULT_CACHE_TTL) + self._set(key, {"id": 0}, ttl=settings.EMPTY_RESULT_CACHE_TTL) def save(self, force: bool = False) -> None: """ diff --git a/app/modules/themoviedb/tmdbv3api/objs/discover.py b/app/modules/themoviedb/tmdbv3api/objs/discover.py index 68e9a1d70..f9d59da01 100644 --- a/app/modules/themoviedb/tmdbv3api/objs/discover.py +++ b/app/modules/themoviedb/tmdbv3api/objs/discover.py @@ -1,5 +1,6 @@ from app.runtime.cache import cached -from ..tmdb import TMDb, EMPTY_RESULT_CACHE_TTL +from app.runtime.config import settings +from ..tmdb import TMDb try: from urllib import urlencode @@ -13,7 +14,7 @@ class Discover(TMDb): "tv": "/discover/tv" } - @cached(maxsize=1, ttl=43200, empty_ttl=EMPTY_RESULT_CACHE_TTL) + @cached(maxsize=1, ttl=43200, empty_ttl=settings.EMPTY_RESULT_CACHE_TTL) def discover_movies(self, params_tuple): """ Discover movies by different types of data like average rating, number of votes, genres and certifications. @@ -23,7 +24,7 @@ class Discover(TMDb): params = dict(params_tuple) return self._request_obj(self._urls["movies"], urlencode(params), key="results", call_cached=False) - @cached(maxsize=1, ttl=43200, empty_ttl=EMPTY_RESULT_CACHE_TTL) + @cached(maxsize=1, ttl=43200, empty_ttl=settings.EMPTY_RESULT_CACHE_TTL) def discover_tv_shows(self, params_tuple): """ Discover TV shows by different types of data like average rating, number of votes, genres, @@ -33,7 +34,7 @@ class Discover(TMDb): """ return self._request_obj(self._urls["tv"], urlencode(params_tuple), key="results", call_cached=False) - @cached(maxsize=1, ttl=43200, empty_ttl=EMPTY_RESULT_CACHE_TTL) + @cached(maxsize=1, ttl=43200, empty_ttl=settings.EMPTY_RESULT_CACHE_TTL) async def async_discover_movies(self, params_tuple): """ Discover movies by different types of data like average rating, number of votes, genres and certifications.(异步版本) @@ -43,7 +44,7 @@ class Discover(TMDb): params = dict(params_tuple) return await self._async_request_obj(self._urls["movies"], urlencode(params), key="results", call_cached=False) - @cached(maxsize=1, ttl=43200, empty_ttl=EMPTY_RESULT_CACHE_TTL) + @cached(maxsize=1, ttl=43200, empty_ttl=settings.EMPTY_RESULT_CACHE_TTL) async def async_discover_tv_shows(self, params_tuple): """ Discover TV shows by different types of data like average rating, number of votes, genres, diff --git a/app/modules/themoviedb/tmdbv3api/tmdb.py b/app/modules/themoviedb/tmdbv3api/tmdb.py index 1a50d5546..1b7b39ed3 100644 --- a/app/modules/themoviedb/tmdbv3api/tmdb.py +++ b/app/modules/themoviedb/tmdbv3api/tmdb.py @@ -22,11 +22,6 @@ logger = logging.getLogger(__name__) # 故取区间内的经验值。 RETRY_BACKOFF_SECONDS = 2 -# 空结果缓存的独立过期时间(秒)。TMDB 代理故障期间「合法 JSON 但 results 为空」的 -# 响应会随默认 TTL(可达数十小时)固化,故障自愈后同 key 仍持续命中空结果;空结果 -# 改用 30 分钟短 TTL,既能拦住故障窗口内的重复回源,又能在故障恢复后较快自然失效。 -EMPTY_RESULT_CACHE_TTL = 30 * 60 - def _is_business_failure_snapshot(snapshot) -> bool: """ @@ -47,8 +42,8 @@ def _is_empty_result_snapshot(snapshot) -> bool: 判断响应快照是否为空结果(列表/搜索类接口的 results 为空列表)。 这类快照结构合法但无业务内容,常由代理瞬时故障产生;不能靠 skip_none/skip_empty - 识别(快照本身是非空字典),需单独谓词判定后按 EMPTY_RESULT_CACHE_TTL 短 TTL - 缓存。详情类接口无 results 字段,不属于空结果。 + 识别(快照本身是非空字典),需单独谓词判定后按 settings.EMPTY_RESULT_CACHE_TTL + 短 TTL 缓存。详情类接口无 results 字段,不属于空结果。 """ if not isinstance(snapshot, dict): return False @@ -180,7 +175,7 @@ class TMDb(object): @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True, skip_if=_is_business_failure_snapshot, - empty_ttl=EMPTY_RESULT_CACHE_TTL, empty_if=_is_empty_result_snapshot) + empty_ttl=settings.EMPTY_RESULT_CACHE_TTL, empty_if=_is_empty_result_snapshot) def request(self, method, url, data, json, **kwargs): req = self._request_once(method, url, data, json) if req is None and method == "GET" and self._owns_session: @@ -206,7 +201,7 @@ class TMDb(object): @cached(maxsize=settings.CONF.tmdb, ttl=settings.CONF.meta, skip_none=True, skip_if=_is_business_failure_snapshot, - empty_ttl=EMPTY_RESULT_CACHE_TTL, empty_if=_is_empty_result_snapshot) + empty_ttl=settings.EMPTY_RESULT_CACHE_TTL, empty_if=_is_empty_result_snapshot) async def async_request(self, method, url, data, json, **kwargs): req = await self._async_request_once(method, url, data, json) if req is None: diff --git a/app/runtime/config.py b/app/runtime/config.py index d1d0d928f..e0ce5070e 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -255,6 +255,8 @@ class ConfigModel(BaseModel): TMDB_API_DOMAIN: str = "api.themoviedb.org" # TMDB元数据语言 TMDB_LOCALE: str = "zh" + # TMDB空结果缓存独立过期时间(秒),故障期间产生的空响应快速过期,故障自愈后可自然恢复 + EMPTY_RESULT_CACHE_TTL: int = 30 * 60 # 刮削使用TMDB原始语种图片 TMDB_SCRAP_ORIGINAL_IMAGE: bool = False # TMDB API Key diff --git a/tests/test_tmdb_cache_type_guard.py b/tests/test_tmdb_cache_type_guard.py index ae234f5ba..e0d1cf01b 100644 --- a/tests/test_tmdb_cache_type_guard.py +++ b/tests/test_tmdb_cache_type_guard.py @@ -3,7 +3,6 @@ from types import SimpleNamespace from app.runtime.config import settings from app.modules.themoviedb.tmdb_cache import TmdbCache -from app.modules.themoviedb.tmdbv3api.tmdb import EMPTY_RESULT_CACHE_TTL from app.schemas.types import MediaSource, MediaType @@ -133,9 +132,9 @@ def test_update_negative_cache_uses_short_ttl(): key = _key("电视剧") assert cache._cache.data[key] == {"id": 0} - assert cache._cache.ttls[key] == EMPTY_RESULT_CACHE_TTL + assert cache._cache.ttls[key] == settings.EMPTY_RESULT_CACHE_TTL # 持久化用的过期时间也应随短 TTL 计算,不能沿用默认有效期 - assert cache._expires_at[key] <= time() + EMPTY_RESULT_CACHE_TTL + 5 + assert cache._expires_at[key] <= time() + settings.EMPTY_RESULT_CACHE_TTL + 5 def test_update_positive_cache_keeps_default_ttl(): diff --git a/tests/test_tmdb_empty_result_cache.py b/tests/test_tmdb_empty_result_cache.py index c1de9afb1..c99d94d99 100644 --- a/tests/test_tmdb_empty_result_cache.py +++ b/tests/test_tmdb_empty_result_cache.py @@ -9,11 +9,11 @@ import asyncio from unittest.mock import patch from app.modules.themoviedb.tmdbv3api.tmdb import ( - EMPTY_RESULT_CACHE_TTL, TMDb, _is_empty_result_snapshot, ) from app.runtime.cache import MemoryBackend +from app.runtime.config import settings from tests.test_tmdb_response_cache import _FakeResponse @@ -41,7 +41,7 @@ def _make_tmdb() -> TMDb: def test_empty_result_cache_ttl_is_thirty_minutes(): """空结果缓存的独立过期时间应为 30 分钟。""" - assert EMPTY_RESULT_CACHE_TTL == 30 * 60 + assert settings.EMPTY_RESULT_CACHE_TTL == 30 * 60 def test_empty_result_snapshot_predicate(): @@ -67,7 +67,7 @@ def test_empty_result_is_cached_but_expires_with_short_ttl(): region_cache = _request_region_cache() started_at = region_cache.timer() - region_cache.expire(time=started_at + EMPTY_RESULT_CACHE_TTL + 1) + region_cache.expire(time=started_at + settings.EMPTY_RESULT_CACHE_TTL + 1) with patch.object(TMDb, "_request_once", return_value=fake) as req: tmdb.request("GET", url, None, None) @@ -87,7 +87,7 @@ def test_non_empty_result_keeps_default_ttl(): region_cache = _request_region_cache() started_at = region_cache.timer() # 推进到短 TTL 之后:非空结果不应在此刻过期 - region_cache.expire(time=started_at + EMPTY_RESULT_CACHE_TTL + 1) + region_cache.expire(time=started_at + settings.EMPTY_RESULT_CACHE_TTL + 1) tmdb.request("GET", url, None, None) assert req.call_count == 1 @@ -106,7 +106,7 @@ def test_async_empty_result_is_cached_with_short_ttl(): region_cache = _request_region_cache() started_at = region_cache.timer() - region_cache.expire(time=started_at + EMPTY_RESULT_CACHE_TTL + 1) + region_cache.expire(time=started_at + settings.EMPTY_RESULT_CACHE_TTL + 1) with patch.object(TMDb, "_async_request_once", return_value=fake) as req: asyncio.run(tmdb.async_request("GET", url, None, None))