mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 01:16:50 +08:00
feat: 支持豆瓣识别缓存管理
This commit is contained in:
@@ -6,11 +6,61 @@ from app import schemas
|
|||||||
from app.chain.douban import DoubanChain
|
from app.chain.douban import DoubanChain
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.core.security import verify_token
|
from app.core.security import verify_token
|
||||||
|
from app.db.models.user import User
|
||||||
|
from app.db.user_oper import get_current_active_superuser_async
|
||||||
|
from app.modules.douban.douban_cache import DoubanCache
|
||||||
from app.schemas import MediaType
|
from app.schemas import MediaType
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/cache", summary="查询豆瓣识别缓存", response_model=schemas.Response
|
||||||
|
)
|
||||||
|
async def douban_recognition_cache(
|
||||||
|
_: User = Depends(get_current_active_superuser_async),
|
||||||
|
) -> schemas.Response:
|
||||||
|
"""查询可管理的豆瓣识别缓存。"""
|
||||||
|
cache_items = DoubanCache().list_items()
|
||||||
|
recognized_count = sum(1 for item in cache_items if item["douban_id"])
|
||||||
|
return schemas.Response(
|
||||||
|
success=True,
|
||||||
|
data={
|
||||||
|
"count": len(cache_items),
|
||||||
|
"recognized": recognized_count,
|
||||||
|
"unrecognized": len(cache_items) - recognized_count,
|
||||||
|
"data": cache_items,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/cache/{cache_key:path}",
|
||||||
|
summary="删除指定豆瓣识别缓存",
|
||||||
|
response_model=schemas.Response,
|
||||||
|
)
|
||||||
|
async def delete_douban_recognition_cache(
|
||||||
|
cache_key: str,
|
||||||
|
_: User = Depends(get_current_active_superuser_async),
|
||||||
|
) -> schemas.Response:
|
||||||
|
"""按缓存键删除单条豆瓣识别缓存。"""
|
||||||
|
deleted_item = DoubanCache().delete(cache_key)
|
||||||
|
if not deleted_item:
|
||||||
|
return schemas.Response(success=False, message="豆瓣识别缓存不存在")
|
||||||
|
return schemas.Response(success=True, message="豆瓣识别缓存删除成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/cache", summary="清空豆瓣识别缓存", response_model=schemas.Response
|
||||||
|
)
|
||||||
|
async def clear_douban_recognition_cache(
|
||||||
|
_: User = Depends(get_current_active_superuser_async),
|
||||||
|
) -> schemas.Response:
|
||||||
|
"""清空全部豆瓣识别缓存。"""
|
||||||
|
DoubanCache().clear()
|
||||||
|
return schemas.Response(success=True, message="豆瓣识别缓存清理完成")
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson
|
"/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -179,6 +179,9 @@
|
|||||||
"TheMovieDb 识别缓存不存在": "TheMovieDb recognition cache does not exist",
|
"TheMovieDb 识别缓存不存在": "TheMovieDb recognition cache does not exist",
|
||||||
"TheMovieDb 识别缓存删除成功": "TheMovieDb recognition cache deleted successfully",
|
"TheMovieDb 识别缓存删除成功": "TheMovieDb recognition cache deleted successfully",
|
||||||
"TheMovieDb 识别缓存清理完成": "TheMovieDb recognition cache cleanup completed",
|
"TheMovieDb 识别缓存清理完成": "TheMovieDb recognition cache cleanup completed",
|
||||||
|
"豆瓣识别缓存不存在": "Douban recognition cache does not exist",
|
||||||
|
"豆瓣识别缓存删除成功": "Douban recognition cache deleted successfully",
|
||||||
|
"豆瓣识别缓存清理完成": "Douban recognition cache cleanup completed",
|
||||||
"重新识别完成": "Re-recognition completed",
|
"重新识别完成": "Re-recognition completed",
|
||||||
"未识别到新名称": "Unable to recognize new name",
|
"未识别到新名称": "Unable to recognize new name",
|
||||||
"缺少参数": "Missing parameters",
|
"缺少参数": "Missing parameters",
|
||||||
|
|||||||
@@ -108,7 +108,10 @@
|
|||||||
"Redis连接失败,请检查配置": "Redis连接失败,请检查配置",
|
"Redis连接失败,请检查配置": "Redis连接失败,请检查配置",
|
||||||
"TheMovieDb 识别缓存不存在": "TheMovieDb 识别缓存不存在",
|
"TheMovieDb 识别缓存不存在": "TheMovieDb 识别缓存不存在",
|
||||||
"TheMovieDb 识别缓存删除成功": "TheMovieDb 识别缓存删除成功",
|
"TheMovieDb 识别缓存删除成功": "TheMovieDb 识别缓存删除成功",
|
||||||
"TheMovieDb 识别缓存清理完成": "TheMovieDb 识别缓存清理完成"
|
"TheMovieDb 识别缓存清理完成": "TheMovieDb 识别缓存清理完成",
|
||||||
|
"豆瓣识别缓存不存在": "豆瓣识别缓存不存在",
|
||||||
|
"豆瓣识别缓存删除成功": "豆瓣识别缓存删除成功",
|
||||||
|
"豆瓣识别缓存清理完成": "豆瓣识别缓存清理完成"
|
||||||
},
|
},
|
||||||
"message_patterns": [
|
"message_patterns": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -179,6 +179,9 @@
|
|||||||
"TheMovieDb 识别缓存不存在": "TheMovieDb 識別快取不存在",
|
"TheMovieDb 识别缓存不存在": "TheMovieDb 識別快取不存在",
|
||||||
"TheMovieDb 识别缓存删除成功": "TheMovieDb 識別快取刪除成功",
|
"TheMovieDb 识别缓存删除成功": "TheMovieDb 識別快取刪除成功",
|
||||||
"TheMovieDb 识别缓存清理完成": "TheMovieDb 識別快取清理完成",
|
"TheMovieDb 识别缓存清理完成": "TheMovieDb 識別快取清理完成",
|
||||||
|
"豆瓣识别缓存不存在": "豆瓣識別快取不存在",
|
||||||
|
"豆瓣识别缓存删除成功": "豆瓣識別快取刪除成功",
|
||||||
|
"豆瓣识别缓存清理完成": "豆瓣識別快取清理完成",
|
||||||
"重新识别完成": "重新識別完成",
|
"重新识别完成": "重新識別完成",
|
||||||
"未识别到新名称": "未識別到新名稱",
|
"未识别到新名称": "未識別到新名稱",
|
||||||
"缺少参数": "缺少參數",
|
"缺少参数": "缺少參數",
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ class DoubanCache(metaclass=WeakSingleton):
|
|||||||
"type": MediaType
|
"type": MediaType
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
# TMDB缓存过期
|
# 豆瓣缓存过期
|
||||||
_douban_cache_expire: bool = True
|
_douban_cache_expire: bool = True
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
"""初始化豆瓣识别缓存并恢复本地持久化数据。"""
|
||||||
self.maxsize = settings.CONF.douban
|
self.maxsize = settings.CONF.douban
|
||||||
self.ttl = settings.CONF.meta
|
self.ttl = settings.CONF.meta
|
||||||
self.region = "__douban_cache__"
|
self.region = "__douban_cache__"
|
||||||
@@ -46,6 +47,30 @@ class DoubanCache(metaclass=WeakSingleton):
|
|||||||
"""
|
"""
|
||||||
with lock:
|
with lock:
|
||||||
self._cache.clear()
|
self._cache.clear()
|
||||||
|
self.save(force=True)
|
||||||
|
|
||||||
|
def list_items(self) -> list[dict]:
|
||||||
|
"""返回可供管理界面展示的豆瓣识别缓存列表。"""
|
||||||
|
with lock:
|
||||||
|
cache_items = []
|
||||||
|
for key, value in self._cache.items():
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
media_type = value.get("type")
|
||||||
|
if not isinstance(media_type, MediaType):
|
||||||
|
try:
|
||||||
|
media_type = MediaType(media_type)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
media_type = None
|
||||||
|
cache_items.append({
|
||||||
|
"key": key,
|
||||||
|
"douban_id": value.get("id") or 0,
|
||||||
|
"title": value.get("title") or "",
|
||||||
|
"year": value.get("year") or "",
|
||||||
|
"media_type": media_type.to_agent() if media_type else "unknown",
|
||||||
|
"poster_path": value.get("poster_path") or "",
|
||||||
|
})
|
||||||
|
return sorted(cache_items, key=lambda item: item["key"])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_key(meta: MetaBase) -> str:
|
def __get_key(meta: MetaBase) -> str:
|
||||||
@@ -73,6 +98,7 @@ class DoubanCache(metaclass=WeakSingleton):
|
|||||||
redis_data = self._cache.get(key)
|
redis_data = self._cache.get(key)
|
||||||
if redis_data:
|
if redis_data:
|
||||||
self._cache.delete(key)
|
self._cache.delete(key)
|
||||||
|
self.save(force=True)
|
||||||
return redis_data
|
return redis_data
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -169,4 +195,5 @@ class DoubanCache(metaclass=WeakSingleton):
|
|||||||
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # noqa
|
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL) # noqa
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
|
"""实例释放前保存非 Redis 缓存。"""
|
||||||
self.save()
|
self.save()
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from app.api.endpoints import douban as douban_endpoint
|
||||||
|
from app.db.user_oper import get_current_active_superuser_async
|
||||||
|
from app.modules.douban.douban_cache import DoubanCache
|
||||||
|
from app.schemas.types import MediaType
|
||||||
|
|
||||||
|
|
||||||
|
class _MemoryCacheStub:
|
||||||
|
"""提供豆瓣缓存管理测试所需的最小内存后端。"""
|
||||||
|
|
||||||
|
def __init__(self, data: dict):
|
||||||
|
"""使用给定字典初始化测试缓存。"""
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
def items(self):
|
||||||
|
"""返回全部缓存条目。"""
|
||||||
|
return self.data.items()
|
||||||
|
|
||||||
|
def get(self, key: str):
|
||||||
|
"""读取指定缓存条目。"""
|
||||||
|
return self.data.get(key)
|
||||||
|
|
||||||
|
def delete(self, key: str):
|
||||||
|
"""删除指定缓存条目。"""
|
||||||
|
self.data.pop(key, None)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""清空全部缓存条目。"""
|
||||||
|
self.data.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_douban_cache(data: dict) -> DoubanCache:
|
||||||
|
"""构造绕过单例初始化的豆瓣缓存测试实例。"""
|
||||||
|
cache = object.__new__(DoubanCache)
|
||||||
|
cache._cache = _MemoryCacheStub(data)
|
||||||
|
cache.save = lambda force=False: None
|
||||||
|
return cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_douban_cache_management_endpoints_require_superuser():
|
||||||
|
"""豆瓣识别缓存管理接口必须仅允许超级管理员访问。"""
|
||||||
|
endpoints = [
|
||||||
|
douban_endpoint.douban_recognition_cache,
|
||||||
|
douban_endpoint.delete_douban_recognition_cache,
|
||||||
|
douban_endpoint.clear_douban_recognition_cache,
|
||||||
|
]
|
||||||
|
|
||||||
|
for endpoint in endpoints:
|
||||||
|
dependency = inspect.signature(endpoint).parameters["_"].default.dependency
|
||||||
|
assert dependency is get_current_active_superuser_async
|
||||||
|
|
||||||
|
|
||||||
|
def test_douban_cache_list_items_normalizes_media_type_and_sorting():
|
||||||
|
"""豆瓣管理列表应输出稳定顺序和前端可识别的媒体类型。"""
|
||||||
|
cache = _build_douban_cache({
|
||||||
|
"[电视剧]Zulu-2024-1": {
|
||||||
|
"id": "2",
|
||||||
|
"title": "Zulu",
|
||||||
|
"type": MediaType.TV,
|
||||||
|
"year": "2024",
|
||||||
|
},
|
||||||
|
"[电影]Alpha-2023-None": {
|
||||||
|
"id": "1",
|
||||||
|
"title": "Alpha",
|
||||||
|
"type": "电影",
|
||||||
|
"year": "2023",
|
||||||
|
"poster_path": "https://example.com/alpha.jpg",
|
||||||
|
},
|
||||||
|
"[电影]Missing-2022-None": {"id": 0},
|
||||||
|
})
|
||||||
|
|
||||||
|
items = cache.list_items()
|
||||||
|
|
||||||
|
assert [item["title"] for item in items] == ["Alpha", "", "Zulu"]
|
||||||
|
assert [item["media_type"] for item in items] == ["movie", "unknown", "tv"]
|
||||||
|
assert items[0]["poster_path"] == "https://example.com/alpha.jpg"
|
||||||
|
assert items[1]["douban_id"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_douban_cache_delete_and_clear_persist_immediately(monkeypatch):
|
||||||
|
"""豆瓣管理操作应修改运行时缓存并立即触发本地持久化。"""
|
||||||
|
cache = _build_douban_cache({"first": {"id": "1"}, "second": {"id": "2"}})
|
||||||
|
saved_forces = []
|
||||||
|
monkeypatch.setattr(cache, "save", lambda force=False: saved_forces.append(force))
|
||||||
|
|
||||||
|
assert cache.delete("first") == {"id": "1"}
|
||||||
|
assert cache.delete("missing") == {}
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
assert cache.list_items() == []
|
||||||
|
assert saved_forces == [True, True]
|
||||||
|
|
||||||
|
|
||||||
|
def test_douban_cache_endpoint_returns_management_statistics(monkeypatch):
|
||||||
|
"""豆瓣查询接口应返回识别成功和失败条目的统计。"""
|
||||||
|
cache = _build_douban_cache({
|
||||||
|
"recognized": {"id": "1", "title": "Alpha", "type": MediaType.MOVIE},
|
||||||
|
"unrecognized": {"id": 0},
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(douban_endpoint, "DoubanCache", lambda: cache)
|
||||||
|
|
||||||
|
response = asyncio.run(douban_endpoint.douban_recognition_cache(None))
|
||||||
|
|
||||||
|
assert response.success is True
|
||||||
|
assert response.data["count"] == 2
|
||||||
|
assert response.data["recognized"] == 1
|
||||||
|
assert response.data["unrecognized"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_douban_cache_delete_endpoint_reports_missing_item(monkeypatch):
|
||||||
|
"""豆瓣删除接口应区分成功删除与缓存不存在。"""
|
||||||
|
cache = _build_douban_cache({"existing": {"id": "1"}})
|
||||||
|
monkeypatch.setattr(douban_endpoint, "DoubanCache", lambda: cache)
|
||||||
|
|
||||||
|
deleted_response = asyncio.run(
|
||||||
|
douban_endpoint.delete_douban_recognition_cache("existing", None)
|
||||||
|
)
|
||||||
|
missing_response = asyncio.run(
|
||||||
|
douban_endpoint.delete_douban_recognition_cache("missing", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert deleted_response.success is True
|
||||||
|
assert missing_response.success is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_douban_cache_clear_endpoint_removes_all_items(monkeypatch):
|
||||||
|
"""豆瓣清空接口应删除全部识别缓存。"""
|
||||||
|
cache = _build_douban_cache({"existing": {"id": "1"}})
|
||||||
|
monkeypatch.setattr(douban_endpoint, "DoubanCache", lambda: cache)
|
||||||
|
|
||||||
|
response = asyncio.run(douban_endpoint.clear_douban_recognition_cache(None))
|
||||||
|
|
||||||
|
assert response.success is True
|
||||||
|
assert cache.list_items() == []
|
||||||
Reference in New Issue
Block a user