mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 18:24:42 +08:00
feat(music): 音乐订阅刷新与识别缓存
This commit is contained in:
@@ -165,6 +165,8 @@ class SiteSpider:
|
||||
# 种子搜索相对路径
|
||||
paths = self.search.get('paths', [])
|
||||
torrentspath = ""
|
||||
# 是否选中了媒体类型专用路径,浏览模式下专用路径优先于 browse 配置
|
||||
typed_path_selected = False
|
||||
if len(paths) == 1:
|
||||
torrentspath = paths[0].get('path', '')
|
||||
else:
|
||||
@@ -183,6 +185,7 @@ class SiteSpider:
|
||||
not expected_type and path_type == "all"
|
||||
):
|
||||
torrentspath = path.get('path', '')
|
||||
typed_path_selected = bool(expected_type and path_type == expected_type)
|
||||
break
|
||||
if not torrentspath:
|
||||
torrentspath = fallback_path
|
||||
@@ -282,16 +285,18 @@ class SiteSpider:
|
||||
"page": self.page or 0,
|
||||
"keyword": ""
|
||||
}
|
||||
# 有单独浏览路径
|
||||
if self.browse:
|
||||
# 有单独浏览路径;指定了媒体类型专用路径时不覆盖,确保音乐等专用入口可达
|
||||
if self.browse and not typed_path_selected:
|
||||
torrentspath = self.browse.get("path")
|
||||
if self.browse.get("start"):
|
||||
start_page = int(self.browse.get("start")) + int(self.page or 0)
|
||||
inputs_dict.update({
|
||||
"page": start_page
|
||||
})
|
||||
elif self.page:
|
||||
torrentspath = torrentspath + f"?page={self.page}"
|
||||
elif self.page and "{page}" not in str(torrentspath):
|
||||
# 按路径是否已带查询参数选择连接符,避免拼出两个问号的非法地址
|
||||
separator = "&" if "?" in str(torrentspath) else "?"
|
||||
torrentspath = torrentspath + f"{separator}page={self.page}"
|
||||
# 搜索Url
|
||||
searchurl = self.domain + str(torrentspath).format(**inputs_dict)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.core.context import (
|
||||
from app.core.meta import MetaBase, MetaMusic
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.musicbrainz.music_cache import MusicBrainzCache
|
||||
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.zhconv import convert as zhconv_convert
|
||||
@@ -36,6 +37,8 @@ class MusicBrainzModule(_ModuleBase):
|
||||
_request_interval = 1.0
|
||||
_request_lock = threading.Lock()
|
||||
_last_request_at = 0.0
|
||||
# 本地识别缓存,由模块管理器初始化时挂载
|
||||
cache: MusicBrainzCache = None
|
||||
# 全局复用 HTTP 会话:keep-alive 省去每次请求的 DNS+TLS 握手(约 6s → 0.4s)
|
||||
_session: Optional[Session] = None
|
||||
_session_lock = threading.Lock()
|
||||
@@ -72,14 +75,32 @@ class MusicBrainzModule(_ModuleBase):
|
||||
)
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""初始化无状态的 MusicBrainz 模块。"""
|
||||
"""初始化 MusicBrainz 模块并挂载本地识别缓存。"""
|
||||
self.cache = MusicBrainzCache()
|
||||
|
||||
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
|
||||
"""MusicBrainz 无需独立密钥或启用开关。"""
|
||||
return None
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止模块;当前实现没有需要释放的持久资源。"""
|
||||
"""停止模块,退出前持久化识别缓存。"""
|
||||
if self.cache:
|
||||
try:
|
||||
self.cache.save()
|
||||
except Exception as err:
|
||||
logger.error(f"保存音乐识别缓存失败:{str(err)}")
|
||||
|
||||
def scheduler_job(self) -> None:
|
||||
"""定时任务,每10分钟持久化一次音乐识别缓存。"""
|
||||
if self.cache:
|
||||
self.cache.save()
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""响应全局缓存清理事件,清空音乐识别缓存。"""
|
||||
logger.info("开始清除音乐识别缓存 ...")
|
||||
if self.cache:
|
||||
self.cache.clear()
|
||||
logger.info("音乐识别缓存清除完成")
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""测试 MusicBrainz 搜索接口连通性。"""
|
||||
@@ -610,11 +631,23 @@ class MusicBrainzModule(_ModuleBase):
|
||||
if source == self._source and mediaid:
|
||||
return self.recognize_music(source, str(mediaid))
|
||||
return None
|
||||
# 识别缓存命中直接响应,避免重复搜索占用 MusicBrainz 限流配额
|
||||
cache_enabled = bool(kwargs.get("cache", True))
|
||||
if cache_enabled and self.cache:
|
||||
cached_info = self.cache.get(meta)
|
||||
if cached_info:
|
||||
if cached_info.media_id:
|
||||
logger.info(f"{meta.title} 使用音乐识别缓存:{cached_info.title}")
|
||||
else:
|
||||
logger.info(f"{meta.title} 使用音乐识别缓存:无法识别")
|
||||
cached_info.recognize_cache_hit = True
|
||||
return cached_info
|
||||
# 携带数据源与原生 ID 的请求优先按详情识别
|
||||
resolved_source = source or meta.media_source
|
||||
if resolved_source and (mediaid or meta.media_id):
|
||||
info = self.recognize_music(resolved_source, str(mediaid or meta.media_id))
|
||||
if info:
|
||||
self._update_recognize_cache(meta, info)
|
||||
return info
|
||||
# 无身份时按标题搜索并挑选可信候选,检索不到时返回元数据兑底
|
||||
# 文件识别只能从 Recording 中挑选,专辑或艺术家同名结果不能成为音轨身份。
|
||||
@@ -626,7 +659,38 @@ class MusicBrainzModule(_ModuleBase):
|
||||
if not matched and meta.artists:
|
||||
albums = self._search_albums(meta, limit=10)
|
||||
matched = self._select_album_candidate(meta, albums)
|
||||
return matched or self._info_from_meta(meta)
|
||||
result = matched or self._info_from_meta(meta)
|
||||
# 无远端身份的兑底结果同样入缓存,避免批量识别时反复搜索同一文件
|
||||
self._update_recognize_cache(meta, result)
|
||||
return result
|
||||
|
||||
def _update_recognize_cache(self, meta: MetaMusic, info: Optional[MusicInfo]) -> None:
|
||||
"""识别完成后把结果写入本地识别缓存,未挂载缓存时静默跳过。"""
|
||||
if self.cache:
|
||||
self.cache.update(meta, info)
|
||||
|
||||
def update_recognize_cache(
|
||||
self,
|
||||
meta: MetaBase,
|
||||
mediainfo: MusicInfo,
|
||||
) -> Optional[bool]:
|
||||
"""回填音乐本地识别缓存,共享识别成功后避免重复回查。"""
|
||||
if not meta or not mediainfo:
|
||||
return None
|
||||
if not isinstance(meta, MetaMusic) or not isinstance(mediainfo, MusicInfo):
|
||||
return None
|
||||
if mediainfo.source != self._source:
|
||||
return None
|
||||
self._update_recognize_cache(meta, mediainfo)
|
||||
return True
|
||||
|
||||
async def async_update_recognize_cache(
|
||||
self,
|
||||
meta: MetaBase,
|
||||
mediainfo: MusicInfo,
|
||||
) -> Optional[bool]:
|
||||
"""异步回填音乐本地识别缓存。"""
|
||||
return self.update_recognize_cache(meta=meta, mediainfo=mediainfo)
|
||||
|
||||
async def async_recognize_media(
|
||||
self,
|
||||
|
||||
234
app/modules/musicbrainz/music_cache.py
Normal file
234
app/modules/musicbrainz/music_cache.py
Normal file
@@ -0,0 +1,234 @@
|
||||
import pickle
|
||||
import traceback
|
||||
from math import ceil
|
||||
from threading import RLock
|
||||
from time import time
|
||||
from typing import Optional
|
||||
|
||||
from app.core.cache import FileCache, TTLCache
|
||||
from app.core.config import settings
|
||||
from app.core.context import MusicInfo
|
||||
from app.core.meta import MetaMusic
|
||||
from app.log import logger
|
||||
from app.utils.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 "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
|
||||
Reference in New Issue
Block a user