mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 02:05:13 +08:00
442 lines
15 KiB
Python
442 lines
15 KiB
Python
"""MusicBrainz 音乐识别本地持久化缓存测试。
|
|
|
|
覆盖缓存键生成、读写回环、负缓存、持久化恢复与保存、管理端点
|
|
统计与权限,以及 MusicBrainzModule 识别流程对缓存的命中与回填。
|
|
"""
|
|
import asyncio
|
|
import inspect
|
|
import pickle
|
|
from unittest.mock import Mock
|
|
|
|
from app.api.endpoints import music as music_endpoint
|
|
from app.core.context import MusicInfo
|
|
from app.core.meta import MetaMusic
|
|
from app.db.user_oper import get_current_active_superuser_async
|
|
from app.modules.musicbrainz import music_cache as music_cache_module
|
|
from app.modules.musicbrainz import MusicBrainzModule
|
|
from app.modules.musicbrainz.music_cache import MusicBrainzCache
|
|
|
|
|
|
class _MemoryCacheStub:
|
|
"""提供音乐缓存管理测试所需的最小内存后端。"""
|
|
|
|
def __init__(self, data: dict):
|
|
"""使用给定字典初始化测试缓存。"""
|
|
self.data = data
|
|
|
|
@staticmethod
|
|
def is_redis() -> bool:
|
|
"""测试替身固定使用非 Redis 后端。"""
|
|
return False
|
|
|
|
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 set(self, key: str, value, ttl=None):
|
|
"""写入指定缓存条目。"""
|
|
self.data[key] = value
|
|
|
|
def clear(self):
|
|
"""清空全部缓存条目。"""
|
|
self.data.clear()
|
|
|
|
|
|
class _TTLCacheStub(_MemoryCacheStub):
|
|
"""记录每条数据恢复时剩余 TTL 的内存缓存替身。"""
|
|
|
|
def __init__(self):
|
|
"""初始化空缓存和 TTL 记录。"""
|
|
super().__init__({})
|
|
self.ttls = {}
|
|
|
|
def set(self, key: str, value, ttl=None):
|
|
"""写入缓存并记录本次设置的 TTL。"""
|
|
super().set(key, value, ttl=ttl)
|
|
self.ttls[key] = ttl
|
|
|
|
|
|
class _FileCacheStub:
|
|
"""提供音乐缓存持久化测试所需的统一文件缓存替身。"""
|
|
|
|
def __init__(self, content: bytes = None):
|
|
"""使用预置序列化内容初始化文件缓存。"""
|
|
self.content = content
|
|
self.set_calls = []
|
|
self.delete_calls = []
|
|
|
|
def get(self, key: str, region: str):
|
|
"""读取预置缓存内容。"""
|
|
return self.content
|
|
|
|
def set(self, key: str, value: bytes, region: str):
|
|
"""记录统一文件缓存写入。"""
|
|
self.content = value
|
|
self.set_calls.append((key, region))
|
|
|
|
def delete(self, key: str, region: str):
|
|
"""记录统一文件缓存删除。"""
|
|
self.content = None
|
|
self.delete_calls.append((key, region))
|
|
|
|
|
|
def _build_music_cache(data: dict) -> MusicBrainzCache:
|
|
"""构造绕过单例初始化的音乐识别缓存测试实例。"""
|
|
cache = object.__new__(MusicBrainzCache)
|
|
cache.maxsize = 256
|
|
cache.ttl = 3600
|
|
cache.region = "__musicbrainz_cache__"
|
|
cache._cache = _MemoryCacheStub(data)
|
|
cache._expires_at = {key: float("inf") for key in data}
|
|
cache._dirty = False
|
|
cache._file_cache = None
|
|
cache.save = lambda force=False: None
|
|
return cache
|
|
|
|
|
|
def _build_initialized_music_cache(monkeypatch, file_cache: _FileCacheStub,
|
|
runtime_cache: _TTLCacheStub,
|
|
now: float = 1000) -> MusicBrainzCache:
|
|
"""使用可控时间和缓存替身初始化完整音乐识别缓存实例。"""
|
|
monkeypatch.setattr(music_cache_module, "time", lambda: now)
|
|
monkeypatch.setattr(music_cache_module, "TTLCache", lambda **kwargs: runtime_cache)
|
|
monkeypatch.setattr(music_cache_module, "FileCache", lambda **kwargs: file_cache)
|
|
cache = object.__new__(MusicBrainzCache)
|
|
cache.__init__()
|
|
return cache
|
|
|
|
|
|
def _music_info(**kwargs) -> MusicInfo:
|
|
"""构造标准音乐识别结果,默认携带远端身份。"""
|
|
defaults = {
|
|
"media_source": "musicbrainz",
|
|
"media_id": "rec-1",
|
|
"title": "晴天",
|
|
"artists": ["周杰伦"],
|
|
"album": "叶惠美",
|
|
"year": 2003,
|
|
}
|
|
defaults.update(kwargs)
|
|
return MusicInfo(**defaults)
|
|
|
|
|
|
def test_music_cache_endpoints_require_superuser():
|
|
"""音乐识别缓存管理接口必须仅允许超级管理员访问。"""
|
|
endpoints = [
|
|
music_endpoint.music_recognition_cache,
|
|
music_endpoint.delete_music_recognition_cache,
|
|
music_endpoint.clear_music_recognition_cache,
|
|
]
|
|
|
|
for endpoint in endpoints:
|
|
dependency = inspect.signature(endpoint).parameters["_"].default.dependency
|
|
assert dependency is get_current_active_superuser_async
|
|
|
|
|
|
def test_music_cache_key_prefers_media_id():
|
|
"""携带数据源原生 ID 的元数据应以 ID 作为缓存键主身份。"""
|
|
cache = _build_music_cache({})
|
|
meta = MetaMusic(
|
|
title="晴天",
|
|
artists=["周杰伦"],
|
|
media_source="musicbrainz",
|
|
media_id="rec-1",
|
|
)
|
|
|
|
cache.update(meta, _music_info())
|
|
|
|
# 缓存键取自请求元数据,携带原生 ID 时以 ID 为主身份
|
|
assert list(cache._cache.data.keys()) == ["[音乐]rec-1-周杰伦-None-None"]
|
|
|
|
|
|
def test_music_cache_update_and_get_roundtrip():
|
|
"""识别结果入缓存后应能还原出标准音乐信息,且不保存上游原始响应。"""
|
|
cache = _build_music_cache({})
|
|
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003)
|
|
info = _music_info(raw_data={"payload": "large"})
|
|
|
|
cache.update(meta, info)
|
|
hit = cache.get(meta)
|
|
|
|
assert hit is not None
|
|
assert hit.media_id == "rec-1"
|
|
assert hit.title == "晴天"
|
|
assert hit.artists == ["周杰伦"]
|
|
stored = next(iter(cache._cache.data.values()))
|
|
assert "raw_data" not in stored
|
|
|
|
|
|
def test_music_cache_get_miss_returns_none():
|
|
"""未命中的缓存查询应返回 None 而不是抛错。"""
|
|
cache = _build_music_cache({})
|
|
meta = MetaMusic(title="未知曲目")
|
|
|
|
assert cache.get(meta) is None
|
|
|
|
|
|
def test_music_cache_list_items_normalizes_and_sorts():
|
|
"""管理列表应输出稳定顺序和前端展示所需字段。"""
|
|
cache = _build_music_cache({
|
|
"z-key": {
|
|
"media_id": "rec-2",
|
|
"title": "Zulu",
|
|
"artists": ["歌手B"],
|
|
"album": "专辑B",
|
|
"year": 2024,
|
|
"music_type": "recording",
|
|
},
|
|
"a-key": {
|
|
"media_id": "",
|
|
"title": "Alpha",
|
|
"year": 2023,
|
|
},
|
|
})
|
|
|
|
items = cache.list_items()
|
|
|
|
assert [item["key"] for item in items] == ["a-key", "z-key"]
|
|
assert items[0]["media_id"] == ""
|
|
assert items[0]["artists"] == []
|
|
assert items[0]["music_type"] == "recording"
|
|
assert items[1]["artists"] == ["歌手B"]
|
|
|
|
|
|
def test_music_cache_delete_and_clear_persist_immediately(monkeypatch):
|
|
"""管理操作应修改运行时缓存并立即触发本地持久化。"""
|
|
cache = _build_music_cache({"first": {"media_id": "rec-1"}, "second": {"media_id": "rec-2"}})
|
|
saved_forces = []
|
|
monkeypatch.setattr(cache, "save", lambda force=False: saved_forces.append(force))
|
|
|
|
assert cache.delete("first") == {"media_id": "rec-1"}
|
|
assert cache.delete("missing") == {}
|
|
cache.clear()
|
|
|
|
assert cache.list_items() == []
|
|
assert saved_forces == [True, True]
|
|
|
|
|
|
def test_music_cache_restores_only_unexpired_persisted_items(monkeypatch):
|
|
"""持久化恢复应保留每条数据原有期限并跳过已过期条目。"""
|
|
payload = {
|
|
"version": music_cache_module.PERSISTENCE_VERSION,
|
|
"items": {
|
|
"fresh": {
|
|
"value": {"media_id": "rec-1", "title": "有效"},
|
|
"expires_at": 1030,
|
|
},
|
|
"expired": {
|
|
"value": {"media_id": "rec-2", "title": "过期"},
|
|
"expires_at": 999,
|
|
},
|
|
},
|
|
}
|
|
file_cache = _FileCacheStub(pickle.dumps(payload))
|
|
runtime_cache = _TTLCacheStub()
|
|
|
|
cache = _build_initialized_music_cache(
|
|
monkeypatch=monkeypatch,
|
|
file_cache=file_cache,
|
|
runtime_cache=runtime_cache,
|
|
)
|
|
|
|
assert runtime_cache.data == {"fresh": {"media_id": "rec-1", "title": "有效"}}
|
|
assert runtime_cache.ttls == {"fresh": 30}
|
|
assert cache._expires_at == {"fresh": 1030}
|
|
|
|
|
|
def test_music_cache_persists_only_items_with_media_id(monkeypatch):
|
|
"""持久化应跳过未识别的负缓存条目,只保存携带远端身份的结果。"""
|
|
file_cache = _FileCacheStub()
|
|
runtime_cache = _TTLCacheStub()
|
|
cache = _build_initialized_music_cache(
|
|
monkeypatch=monkeypatch,
|
|
file_cache=file_cache,
|
|
runtime_cache=runtime_cache,
|
|
)
|
|
runtime_cache.data = {
|
|
"recognized": {"media_id": "rec-1", "title": "晴天"},
|
|
"negative": {"media_id": "", "title": "未知曲目"},
|
|
}
|
|
cache._expires_at = {
|
|
"recognized": 1060,
|
|
"negative": 1070,
|
|
}
|
|
cache._dirty = True
|
|
|
|
cache.save()
|
|
|
|
payload = pickle.loads(file_cache.content)
|
|
assert file_cache.set_calls == [(
|
|
music_cache_module.PERSISTENCE_KEY,
|
|
music_cache_module.PERSISTENCE_REGION,
|
|
)]
|
|
assert payload == {
|
|
"version": music_cache_module.PERSISTENCE_VERSION,
|
|
"items": {
|
|
"recognized": {
|
|
"value": {"media_id": "rec-1", "title": "晴天"},
|
|
"expires_at": 1060,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def test_music_cache_save_removes_file_when_empty(monkeypatch):
|
|
"""全部条目失效后保存应删除持久化文件。"""
|
|
file_cache = _FileCacheStub(pickle.dumps({"version": 1, "items": {}}))
|
|
runtime_cache = _TTLCacheStub()
|
|
cache = _build_initialized_music_cache(
|
|
monkeypatch=monkeypatch,
|
|
file_cache=file_cache,
|
|
runtime_cache=runtime_cache,
|
|
)
|
|
cache._dirty = True
|
|
|
|
cache.save()
|
|
|
|
assert file_cache.delete_calls == [(
|
|
music_cache_module.PERSISTENCE_KEY,
|
|
music_cache_module.PERSISTENCE_REGION,
|
|
)]
|
|
|
|
|
|
def test_music_cache_endpoint_returns_management_statistics(monkeypatch):
|
|
"""查询接口应返回识别成功和失败条目的统计。"""
|
|
cache = _build_music_cache({
|
|
"recognized": {"media_id": "rec-1", "title": "晴天"},
|
|
"unrecognized": {"media_id": "", "title": "未知曲目"},
|
|
})
|
|
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
|
|
|
|
response = asyncio.run(music_endpoint.music_recognition_cache(None))
|
|
|
|
assert response.success is True
|
|
assert response.data["count"] == 2
|
|
assert response.data["recognized"] == 1
|
|
assert response.data["unrecognized"] == 1
|
|
assert len(response.data["data"]) == 2
|
|
|
|
|
|
def test_music_cache_delete_endpoint_reports_missing_item(monkeypatch):
|
|
"""删除接口应区分成功删除与缓存不存在。"""
|
|
cache = _build_music_cache({"existing": {"media_id": "rec-1"}})
|
|
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
|
|
|
|
deleted_response = asyncio.run(
|
|
music_endpoint.delete_music_recognition_cache("existing", None)
|
|
)
|
|
missing_response = asyncio.run(
|
|
music_endpoint.delete_music_recognition_cache("missing", None)
|
|
)
|
|
|
|
assert deleted_response.success is True
|
|
assert missing_response.success is False
|
|
|
|
|
|
def test_music_cache_clear_endpoint_removes_all_items(monkeypatch):
|
|
"""清空接口应删除全部音乐识别缓存。"""
|
|
cache = _build_music_cache({"existing": {"media_id": "rec-1"}})
|
|
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
|
|
|
|
response = asyncio.run(music_endpoint.clear_music_recognition_cache(None))
|
|
|
|
assert response.success is True
|
|
assert cache.list_items() == []
|
|
|
|
|
|
def _build_module_with_cache(cache: MusicBrainzCache) -> MusicBrainzModule:
|
|
"""构造挂载测试缓存的 MusicBrainz 模块实例。"""
|
|
module = MusicBrainzModule()
|
|
module.cache = cache
|
|
return module
|
|
|
|
|
|
def test_module_recognize_media_hits_cache_without_search(monkeypatch):
|
|
"""识别缓存命中时应直接返回缓存结果,不再触发 MusicBrainz 搜索。"""
|
|
cache = _build_music_cache({})
|
|
module = _build_module_with_cache(cache)
|
|
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003)
|
|
cache.update(meta, _music_info())
|
|
search_mock = Mock()
|
|
monkeypatch.setattr(module, "_search_recordings", search_mock)
|
|
|
|
result = module.recognize_media(meta=meta)
|
|
|
|
assert result is not None
|
|
assert result.media_id == "rec-1"
|
|
assert getattr(result, "recognize_cache_hit") is True
|
|
search_mock.assert_not_called()
|
|
|
|
|
|
def test_module_recognize_media_bypasses_cache_when_disabled(monkeypatch):
|
|
"""cache=False 时不读取缓存,重新走搜索识别流程。"""
|
|
cache = _build_music_cache({})
|
|
module = _build_module_with_cache(cache)
|
|
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003)
|
|
cache.update(meta, _music_info())
|
|
fresh = _music_info(media_id="rec-2")
|
|
monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[fresh]))
|
|
monkeypatch.setattr(module, "_select_candidate", Mock(return_value=fresh))
|
|
|
|
result = module.recognize_media(meta=meta, cache=False)
|
|
|
|
assert result is fresh
|
|
assert getattr(result, "recognize_cache_hit", False) is False
|
|
|
|
|
|
def test_module_recognize_media_writes_search_result_to_cache(monkeypatch):
|
|
"""搜索识别成功后应回填本地识别缓存。"""
|
|
cache = _build_music_cache({})
|
|
module = _build_module_with_cache(cache)
|
|
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003)
|
|
matched = _music_info()
|
|
monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[matched]))
|
|
monkeypatch.setattr(module, "_select_candidate", Mock(return_value=matched))
|
|
|
|
result = module.recognize_media(meta=meta)
|
|
|
|
assert result is matched
|
|
hit = cache.get(meta)
|
|
assert hit is not None
|
|
assert hit.media_id == "rec-1"
|
|
|
|
|
|
def test_module_recognize_media_caches_offline_fallback(monkeypatch):
|
|
"""搜索无结果的兜底信息也应进入负缓存,避免重复请求。"""
|
|
cache = _build_music_cache({})
|
|
module = _build_module_with_cache(cache)
|
|
meta = MetaMusic(title="未知曲目", artists=["未知艺术家"])
|
|
monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[]))
|
|
monkeypatch.setattr(module, "_search_albums", Mock(return_value=[]))
|
|
|
|
result = module.recognize_media(meta=meta)
|
|
|
|
assert result is not None
|
|
assert result.media_id is None
|
|
cached = cache.get(meta)
|
|
assert cached is not None
|
|
assert cached.media_id is None
|
|
assert cached.title == "未知曲目"
|
|
|
|
|
|
def test_module_update_recognize_cache_only_for_musicbrainz_music():
|
|
"""共享识别回填仅处理本数据源的音乐结果。"""
|
|
cache = _build_music_cache({})
|
|
module = _build_module_with_cache(cache)
|
|
meta = MetaMusic(title="晴天", artists=["周杰伦"])
|
|
|
|
assert module.update_recognize_cache(meta=meta, mediainfo=_music_info()) is True
|
|
assert cache.get(meta) is not None
|
|
|
|
other_source = _music_info(media_source="theaudiodb", media_id="x-1")
|
|
assert module.update_recognize_cache(meta=meta, mediainfo=other_source) is None
|
|
assert module.update_recognize_cache(meta=None, mediainfo=_music_info()) is None
|