import asyncio import json import shutil import subprocess import threading import time from collections import OrderedDict from pathlib import Path from typing import Any, Optional, Tuple, Union from uuid import UUID from app.core.config import settings from app.log import logger from app.modules import _ModuleBase from app.schemas.types import ModuleType, OtherModulesType from app.utils.http import AsyncRequestUtils, RequestUtils class AcoustIdModule(_ModuleBase): """通过 Chromaprint 本地指纹和 AcoustID API 识别 MusicBrainz Recording ID。""" _base_url = "https://api.acoustid.org/v2/lookup" _minimum_score = 0.9 _request_interval = 0.34 _fingerprint_timeout = 60 _cache_max = 1024 _request_lock = threading.Lock() _last_request_at = 0.0 def __init__(self) -> None: """初始化 fpcalc 路径和进程内文件指纹识别缓存。""" super().__init__() self._fpcalc_path: Optional[str] = None self._cache: OrderedDict[tuple[str, int, int], Optional[str]] = OrderedDict() self._cache_lock = threading.Lock() def init_module(self) -> None: """定位 fpcalc 工具并清空可能过期的文件识别缓存。""" self._fpcalc_path = shutil.which("fpcalc") with self._cache_lock: self._cache.clear() if not self._fpcalc_path: logger.warning("AcoustID 已配置,但未找到 fpcalc,音频指纹识别将跳过") def init_setting(self) -> Tuple[str, Union[str, bool]]: """仅在配置 AcoustID 应用 API Key 后启用模块。""" return "ACOUSTID_API_KEY", True def stop(self) -> None: """停止模块并释放进程内文件识别缓存。""" with self._cache_lock: self._cache.clear() def test(self) -> Tuple[bool, str]: """检查 API Key、fpcalc 和 AcoustID API 的基础连通性。""" if not str(settings.ACOUSTID_API_KEY or "").strip(): return False, "AcoustID API Key 未配置" if not self._fpcalc_path: return False, "未找到 fpcalc,请先安装 Chromaprint" response = RequestUtils( ua=settings.USER_AGENT, proxies=settings.PROXY, timeout=15, ).get_res( url=self._base_url, params={"client": settings.ACOUSTID_API_KEY, "format": "json"}, ) if response is None: return False, "AcoustID 网络连接失败" try: if response.status_code >= 500: return False, f"AcoustID 服务异常:{response.status_code}" return True, "" finally: response.close() @staticmethod def get_name() -> str: """返回模块展示名称。""" return "AcoustID" @staticmethod def get_type() -> ModuleType: """返回模块所属的其它音乐能力类型。""" return ModuleType.Other @staticmethod def get_subtype() -> OtherModulesType: """返回 AcoustID 模块子类型。""" return OtherModulesType.AcoustId @staticmethod def get_priority() -> int: """返回音频指纹识别模块执行优先级。""" return 0 def identify_music_by_fingerprint(self, path: Path) -> Optional[str]: """读取本地音频指纹并返回高置信匹配的 MusicBrainz Recording ID。""" file_path = Path(path) if not self._fpcalc_path or not file_path.is_file(): return None cache_key = self._file_cache_key(file_path) if cache_key: found, cached_id = self._get_cached(cache_key) if found: return cached_id fingerprint = self._generate_fingerprint(file_path) recording_id = ( self._lookup_recording_id(*fingerprint) if fingerprint else None ) if cache_key: self._set_cached(cache_key, recording_id) return recording_id async def async_identify_music_by_fingerprint( self, path: Path, ) -> Optional[str]: """异步读取音频指纹并返回高置信匹配的 MusicBrainz Recording ID。""" file_path = Path(path) if not self._fpcalc_path or not file_path.is_file(): return None cache_key = self._file_cache_key(file_path) if cache_key: found, cached_id = self._get_cached(cache_key) if found: return cached_id fingerprint = await self._async_generate_fingerprint(file_path) recording_id = ( await self._async_lookup_recording_id(*fingerprint) if fingerprint else None ) if cache_key: self._set_cached(cache_key, recording_id) return recording_id @staticmethod def _file_cache_key(path: Path) -> Optional[tuple[str, int, int]]: """按规范路径、文件大小和修改时间构造可自动失效的缓存键。""" try: stat = path.stat() return str(path.resolve()), stat.st_size, stat.st_mtime_ns except OSError: return None def _get_cached( self, cache_key: tuple[str, int, int], ) -> tuple[bool, Optional[str]]: """读取并触摸文件识别 LRU 缓存,区分未缓存与已缓存未命中。""" with self._cache_lock: if cache_key not in self._cache: return False, None value = self._cache.pop(cache_key) self._cache[cache_key] = value return True, value def _set_cached( self, cache_key: tuple[str, int, int], recording_id: Optional[str], ) -> None: """写入文件识别 LRU 缓存并淘汰最早使用的条目。""" with self._cache_lock: self._cache.pop(cache_key, None) self._cache[cache_key] = recording_id while len(self._cache) > self._cache_max: self._cache.popitem(last=False) def _generate_fingerprint(self, path: Path) -> Optional[tuple[int, str]]: """调用 fpcalc 生成 AcoustID 查询所需的完整时长和压缩指纹。""" try: result = subprocess.run( [self._fpcalc_path, "-json", str(path)], capture_output=True, text=True, check=False, timeout=self._fingerprint_timeout, ) except (OSError, subprocess.TimeoutExpired) as err: logger.warning(f"生成音频指纹失败:{path} - {err}") return None if result.returncode != 0: logger.warning( f"生成音频指纹失败:{path} - fpcalc 退出码 {result.returncode}" ) return None return self._parse_fingerprint_output(path, result.stdout) async def _async_generate_fingerprint( self, path: Path, ) -> Optional[tuple[int, str]]: """异步调用 fpcalc 生成 AcoustID 查询所需的指纹。""" process = None try: process = await asyncio.create_subprocess_exec( self._fpcalc_path, "-json", str(path), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, _ = await asyncio.wait_for( process.communicate(), timeout=self._fingerprint_timeout, ) except asyncio.TimeoutError: if process and process.returncode is None: process.kill() await process.communicate() logger.warning(f"生成音频指纹超时:{path}") return None except asyncio.CancelledError: if process and process.returncode is None: process.kill() await process.communicate() raise except OSError as err: logger.warning(f"生成音频指纹失败:{path} - {err}") return None if process.returncode != 0: logger.warning( f"生成音频指纹失败:{path} - fpcalc 退出码 {process.returncode}" ) return None return self._parse_fingerprint_output( path, stdout.decode("utf-8", errors="replace"), ) @staticmethod def _parse_fingerprint_output( path: Path, output: str, ) -> Optional[tuple[int, str]]: """解析 fpcalc JSON 输出中的音频时长和压缩指纹。""" try: payload = json.loads(output) duration = round(float(payload.get("duration") or 0)) fingerprint = str(payload.get("fingerprint") or "").strip() except (AttributeError, TypeError, ValueError) as err: logger.warning(f"fpcalc 输出解析失败:{path} - {err}") return None if duration <= 0 or not fingerprint: logger.warning(f"fpcalc 未返回有效音频指纹:{path}") return None return duration, fingerprint @classmethod def _reserve_request_delay(cls) -> float: """为同步和异步 AcoustID 请求统一预留下一个发送时间。""" with cls._request_lock: now = time.monotonic() request_at = max(now, cls._last_request_at + cls._request_interval) cls._last_request_at = request_at return max(0.0, request_at - now) @classmethod def _wait_for_rate_limit(cls) -> None: """同步等待 AcoustID 公共接口的已预留请求时间。""" if delay := cls._reserve_request_delay(): time.sleep(delay) @classmethod async def _async_wait_for_rate_limit(cls) -> None: """异步等待 AcoustID 公共接口的已预留请求时间。""" if delay := cls._reserve_request_delay(): await asyncio.sleep(delay) def _lookup_recording_id( self, duration: int, fingerprint: str, ) -> Optional[str]: """查询 AcoustID 指纹库并提取 MusicBrainz Recording ID。""" api_key = str(settings.ACOUSTID_API_KEY or "").strip() if not api_key: return None self._wait_for_rate_limit() response = RequestUtils( ua=settings.USER_AGENT, proxies=settings.PROXY, timeout=30, ).post_res( url=self._base_url, data={ "client": api_key, "duration": duration, "fingerprint": fingerprint, "meta": "recordingids", "format": "json", }, ) if response is None: logger.warning("AcoustID 指纹查询失败:无响应") return None try: if response.status_code != 200: logger.warning(f"AcoustID 指纹查询失败:HTTP {response.status_code}") return None payload = response.json() return self._select_recording_id(payload) except (TypeError, ValueError) as err: logger.warning(f"AcoustID 响应解析失败:{err}") return None finally: response.close() async def _async_lookup_recording_id( self, duration: int, fingerprint: str, ) -> Optional[str]: """异步查询 AcoustID 指纹库并提取 MusicBrainz Recording ID。""" api_key = str(settings.ACOUSTID_API_KEY or "").strip() if not api_key: return None await self._async_wait_for_rate_limit() response = await AsyncRequestUtils( ua=settings.USER_AGENT, proxies=settings.PROXY, timeout=30, ).post_res( url=self._base_url, data={ "client": api_key, "duration": duration, "fingerprint": fingerprint, "meta": "recordingids", "format": "json", }, ) if response is None: logger.warning("AcoustID 指纹查询失败:无响应") return None try: if response.status_code != 200: logger.warning(f"AcoustID 指纹查询失败:HTTP {response.status_code}") return None return self._select_recording_id(response.json()) except (TypeError, ValueError) as err: logger.warning(f"AcoustID 响应解析失败:{err}") return None finally: await response.aclose() @classmethod def _select_recording_id(cls, payload: Any) -> Optional[str]: """按匹配分从 AcoustID 响应中选择首个有效 MusicBrainz Recording ID。""" if not isinstance(payload, dict) or payload.get("status") != "ok": return None results = payload.get("results") or [] ranked = sorted( (item for item in results if isinstance(item, dict)), key=lambda item: cls._score(item.get("score")), reverse=True, ) for item in ranked: score = cls._score(item.get("score")) if score < cls._minimum_score: break for recording in item.get("recordings") or []: recording_id = cls._normalize_recording_id( recording.get("id") if isinstance(recording, dict) else None ) if recording_id: logger.info( f"AcoustID 指纹命中 MusicBrainz:{recording_id},匹配度 {score:.3f}" ) return recording_id return None @staticmethod def _score(value: Any) -> float: """将 AcoustID 匹配分安全转换为零到一之间的浮点数。""" try: return max(0.0, min(float(value), 1.0)) except (TypeError, ValueError): return 0.0 @staticmethod def _normalize_recording_id(value: Any) -> Optional[str]: """校验并规范化 MusicBrainz UUID,拒绝异常外部响应进入详情路径。""" try: return str(UUID(str(value))) except (AttributeError, TypeError, ValueError): return None