mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-15 19:14:01 +08:00
390 lines
14 KiB
Python
390 lines
14 KiB
Python
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
|