feat(music): 音乐订阅刷新与识别缓存

This commit is contained in:
jxxghp
2026-08-11 11:39:08 +08:00
parent 778d185c9d
commit d0edcfa7bb
12 changed files with 1364 additions and 37 deletions

View File

@@ -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)

View File

@@ -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,

View 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