Files
MoviePilot/app/modules/acoustid/__init__.py

390 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.runtime.config import settings
from app.runtime.log import logger
from app.modules import _ModuleBase
from app.schemas.types import ModuleType, OtherModulesType
from app.adapters.network.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