mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 12:36:55 +08:00
238 lines
8.3 KiB
Python
238 lines
8.3 KiB
Python
import pickle
|
|
import traceback
|
|
from math import ceil
|
|
from threading import RLock
|
|
from time import time
|
|
from typing import Optional
|
|
|
|
from app.runtime.cache import FileCache, TTLCache
|
|
from app.runtime.settings import RuntimeSettingsCompat
|
|
|
|
settings = RuntimeSettingsCompat()
|
|
from app.domain.context import MusicInfo
|
|
from app.domain.meta.metamusic import MetaMusic
|
|
from app.runtime.log import logger
|
|
from app.schemas.types import MUSIC_ENTITY_RECORDING
|
|
from app.foundation.singleton import WeakSingleton
|
|
|
|
lock = RLock()
|
|
PERSISTENCE_VERSION = 1
|
|
PERSISTENCE_REGION = "recognize"
|
|
PERSISTENCE_KEY = "musicbrainz"
|
|
|
|
|
|
class MusicBrainzCache(metaclass=WeakSingleton):
|
|
"""
|
|
MusicBrainz识别缓存数据
|
|
{
|
|
"source": '',
|
|
"media_id": '',
|
|
"title": '',
|
|
"artists": [],
|
|
"album": '',
|
|
"year": '',
|
|
"music_type": ''
|
|
}
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""初始化音乐识别缓存并恢复未过期的持久化数据。"""
|
|
self.maxsize = settings.CONF.musicbrainz
|
|
self.ttl = settings.CONF.meta
|
|
self.region = "__musicbrainz_cache__"
|
|
self._cache = TTLCache(region=self.region, maxsize=self.maxsize, ttl=self.ttl)
|
|
self._expires_at: dict[str, float] = {}
|
|
self._dirty = False
|
|
self._file_cache = None
|
|
if not self._cache.is_redis():
|
|
self._file_cache = FileCache(base=settings.CACHE_PATH, ttl=self.ttl)
|
|
self._restore()
|
|
|
|
def _restore(self) -> None:
|
|
"""从统一文件缓存恢复仍在有效期内的音乐识别数据。"""
|
|
try:
|
|
content = self._file_cache.get(PERSISTENCE_KEY, region=PERSISTENCE_REGION)
|
|
if not content:
|
|
return
|
|
payload = pickle.loads(content)
|
|
now = time()
|
|
if (
|
|
not isinstance(payload, dict)
|
|
or payload.get("version") != PERSISTENCE_VERSION
|
|
or not isinstance(payload.get("items"), dict)
|
|
):
|
|
return
|
|
|
|
for key, item in payload["items"].items():
|
|
if not isinstance(item, dict):
|
|
self._dirty = True
|
|
continue
|
|
value = item.get("value")
|
|
expires_at = item.get("expires_at")
|
|
if not isinstance(value, dict) or not isinstance(expires_at, (int, float)):
|
|
self._dirty = True
|
|
continue
|
|
remaining_ttl = expires_at - now
|
|
if remaining_ttl <= 0:
|
|
self._dirty = True
|
|
continue
|
|
self._cache.set(key, value, ttl=ceil(remaining_ttl))
|
|
self._expires_at[key] = expires_at
|
|
except Exception as err:
|
|
logger.error(f"加载音乐识别缓存失败:{str(err)} - {traceback.format_exc()}")
|
|
|
|
def _set(self, key: str, value: dict) -> None:
|
|
"""写入单条音乐识别缓存并记录其独立过期时间。"""
|
|
self._cache.set(key, value)
|
|
if not self._cache.is_redis():
|
|
self._expires_at[key] = time() + self.ttl
|
|
self._dirty = True
|
|
|
|
def clear(self):
|
|
"""
|
|
清空所有音乐识别缓存
|
|
"""
|
|
with lock:
|
|
self._cache.clear()
|
|
self._expires_at.clear()
|
|
self._dirty = True
|
|
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
|
|
cache_items.append({
|
|
"key": key,
|
|
"media_id": value.get("media_id") or "",
|
|
"title": value.get("title") or "",
|
|
"artists": value.get("artists") or [],
|
|
"album": value.get("album") or "",
|
|
"year": value.get("year") or "",
|
|
"music_type": value.get("music_type") or MUSIC_ENTITY_RECORDING,
|
|
"cover_url": value.get("cover_url") or "",
|
|
})
|
|
return sorted(cache_items, key=lambda item: item["key"])
|
|
|
|
@staticmethod
|
|
def __get_key(meta: MetaMusic) -> str:
|
|
"""
|
|
获取缓存KEY,携带数据源原生 ID 时以 ID 为准身份
|
|
"""
|
|
artists = "/".join(meta.artists or [])
|
|
return f"[音乐]{meta.media_id or meta.title}-{artists}-{meta.album}-{meta.year}"
|
|
|
|
def get(self, meta: MetaMusic) -> Optional[MusicInfo]:
|
|
"""
|
|
根据元数据获取缓存的音乐识别结果
|
|
@param meta: 音乐元数据
|
|
@return: 缓存命中的音乐信息,未命中返回 None
|
|
"""
|
|
key = self.__get_key(meta)
|
|
with lock:
|
|
cache_data = self._cache.get(key)
|
|
if not cache_data and self._expires_at.pop(key, None) is not None:
|
|
self._dirty = True
|
|
if not cache_data:
|
|
return None
|
|
try:
|
|
return MusicInfo.from_dict(cache_data)
|
|
except Exception as err:
|
|
logger.error(f"解析音乐识别缓存失败:{str(err)}")
|
|
return None
|
|
|
|
def delete(self, key: str) -> dict:
|
|
"""
|
|
删除缓存信息
|
|
@param key: 缓存key
|
|
@return: 被删除的缓存内容
|
|
"""
|
|
with lock:
|
|
cache_data = self._cache.get(key)
|
|
if cache_data:
|
|
self._cache.delete(key)
|
|
self._expires_at.pop(key, None)
|
|
self._dirty = True
|
|
self.save(force=True)
|
|
return cache_data
|
|
return {}
|
|
|
|
def update(self, meta: MetaMusic, info: Optional[MusicInfo]) -> None:
|
|
"""
|
|
新增或更新缓存条目,无远端身份的兜底结果也写入内存负缓存,
|
|
避免批量识别时反复请求 MusicBrainz 触发限流
|
|
"""
|
|
if not meta or not info:
|
|
return
|
|
key = self.__get_key(meta)
|
|
cache_data = info.to_dict()
|
|
# 上游原始响应体积大且不参与身份恢复,不入缓存
|
|
cache_data.pop("raw_data", None)
|
|
with lock:
|
|
self._set(key, cache_data)
|
|
|
|
def save(self, force: bool = False) -> None:
|
|
"""
|
|
使用统一文件缓存保存未过期的音乐识别数据。
|
|
"""
|
|
if self._cache.is_redis():
|
|
return
|
|
if not self._file_cache:
|
|
return
|
|
with lock:
|
|
now = time()
|
|
cache_items = dict(self._cache.items())
|
|
active_keys = set(cache_items)
|
|
stale_keys = set(self._expires_at) - active_keys
|
|
if stale_keys:
|
|
for key in stale_keys:
|
|
self._expires_at.pop(key, None)
|
|
self._dirty = True
|
|
|
|
persisted_items = {}
|
|
for key, value in cache_items.items():
|
|
expires_at = self._expires_at.get(key)
|
|
if expires_at is None:
|
|
expires_at = now + self.ttl
|
|
self._expires_at[key] = expires_at
|
|
self._dirty = True
|
|
# 负缓存只留在内存,重启后允许重新尝试识别
|
|
if expires_at <= now or not value.get("media_id"):
|
|
continue
|
|
persisted_items[key] = {
|
|
"value": value,
|
|
"expires_at": expires_at,
|
|
}
|
|
|
|
if not force and not self._dirty:
|
|
return
|
|
|
|
try:
|
|
if persisted_items:
|
|
payload = {
|
|
"version": PERSISTENCE_VERSION,
|
|
"items": persisted_items,
|
|
}
|
|
self._file_cache.set(
|
|
PERSISTENCE_KEY,
|
|
pickle.dumps(payload, pickle.HIGHEST_PROTOCOL),
|
|
region=PERSISTENCE_REGION,
|
|
)
|
|
else:
|
|
self._file_cache.delete(PERSISTENCE_KEY, region=PERSISTENCE_REGION)
|
|
self._dirty = False
|
|
except Exception as err:
|
|
logger.error(f"保存音乐识别缓存失败:{str(err)} - {traceback.format_exc()}")
|
|
|
|
def __del__(self):
|
|
"""实例释放前保存非 Redis 缓存。"""
|
|
try:
|
|
self.save()
|
|
except Exception:
|
|
pass
|