Files
MoviePilot/app/modules/acoustid/__init__.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

645 lines
24 KiB
Python
Raw Permalink 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.
"""
AcoustID 音频指纹识别,管理本地计算、请求限速与文件结果缓存。
"""
import asyncio
import json
import math
import shutil
import subprocess
import threading
import time
from collections import OrderedDict
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional, Tuple, Union
from uuid import UUID
from app.adapters.network.http import AsyncRequestUtils, RequestUtils
from app.application.music.observation import (
capture_music_recognition,
music_recognition_diagnostics,
music_request_timeout,
music_wait_allowed,
report_music_fingerprints,
report_music_recognition,
)
from app.modules import _ModuleBase
from app.runtime.execution import run_in_threadpool
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.types import ModuleType, OtherModulesType
@dataclass(frozen=True, slots=True)
class _AcoustIdLookupPlan:
"""
同步与异步 AcoustID 查询共用的请求计划。
:param url: 指纹查询接口地址
:param data: 表单参数,包含应用密钥、音频时长、压缩指纹与响应格式
"""
url: str
data: dict[str, Union[str, int]]
@dataclass(frozen=True, slots=True)
class _FingerprintCacheEntry:
"""
文件指纹识别缓存,保留候选与诊断以区分未命中和服务失败。
:param recording_id: 首个高置信录音标识,未匹配时为 None
:param candidates: 已筛选候选的快照,空元组表示无候选
:param expires_at: 基于 time.monotonic 的过期时刻,单位为秒
:param status: 本次识别的诊断状态,如 not_found 或 service_error
:param message: 可读的诊断说明,无补充信息时为空字符串
"""
recording_id: Optional[str]
candidates: tuple[dict[str, Any], ...]
expires_at: float
status: str
message: str
class AcoustIdModule(_ModuleBase):
"""
通过 Chromaprint 本地指纹和 AcoustID API 识别 MusicBrainz Recording ID。
"""
_base_url = "https://api.acoustid.org/v2/lookup"
# 退出码 3 表示解码期间出现非致命错误,结果仍须通过 JSON 内容校验。
_usable_fpcalc_returncodes = frozenset({0, 3})
# 仅接受置信度达到九成的录音候选
_minimum_score = 0.9
# 两次请求至少间隔 0.34 秒,使请求频率低于每秒三次
_request_interval = 0.34
# 本地指纹计算最多等待 60 秒,同时受整体识别预算约束
_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], _FingerprintCacheEntry] = OrderedDict()
self._cache_lock = threading.Lock()
@staticmethod
def _resolve_fpcalc() -> Optional[str]:
"""
在 PATH 中定位可执行的 fpcalc,返回其绝对路径,缺失则返回 None。
shutil.which 在 POSIX 上已校验文件可执行,足以判定本地依赖是否就绪。
"""
return shutil.which("fpcalc") or None
def init_module(self) -> None:
"""
定位 fpcalc 工具并清空可能过期的文件识别缓存。
"""
self._fpcalc_path = self._resolve_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 的基础连通性。
本地依赖检测独立于 init_module 的缓存快照,重新定位 fpcalc,确保即便
模块初始化早于 fpcalc 安装,或运行期依赖被移除,测试也能如实反映本地
依赖状态,而不是只校验网络连通性。
"""
if not str(get_runtime_setting('ACOUSTID_API_KEY') or "").strip():
return False, "AcoustID API Key 未配置"
fpcalc_path = self._resolve_fpcalc()
if not fpcalc_path:
return False, "未找到 fpcalc,请先安装 Chromaprint"
self._fpcalc_path = fpcalc_path
response = RequestUtils(
ua=get_runtime_setting('USER_AGENT'),
proxies=get_runtime_setting('PROXY'),
timeout=15,
).get_res(
url=self._base_url,
params={"client": get_runtime_setting('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 录音标识。
在 90 秒识别预算内运行,并恢复或记录候选与故障诊断。
文件规范路径、大小和修改时间共同决定缓存身份;命中缓存时不重新计算指纹。
:param path: 待识别的本地音频文件路径
:return: 最高分候选的录音标识;依赖缺失、文件不可用、预算耗尽、失败或无匹配时返回 None
"""
with capture_music_recognition(seconds=90) as observed:
report_music_fingerprints([])
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 fingerprint and observed.fingerprint_candidates:
report_music_fingerprints([{**item, "duration": fingerprint[0]} for item in observed.fingerprint_candidates])
if cache_key:
self._set_cached(cache_key, recording_id, observed.fingerprint_candidates)
return recording_id
async def async_identify_music_by_fingerprint(
self,
path: Path,
) -> Optional[str]:
"""
异步读取本地音频指纹,查询高置信的 MusicBrainz 录音标识。
在 90 秒识别预算内运行,并恢复或记录候选与故障诊断。
文件规范路径、大小和修改时间共同决定缓存身份;命中缓存时不重新计算指纹。
:param path: 待识别的本地音频文件路径
:return: 最高分候选的录音标识;依赖缺失、文件不可用、预算耗尽、失败或无匹配时返回 None
"""
with capture_music_recognition(seconds=90) as observed:
report_music_fingerprints([])
file_path = Path(path)
if not self._fpcalc_path or not await run_in_threadpool(
Path.is_file,
file_path,
):
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 fingerprint and observed.fingerprint_candidates:
report_music_fingerprints([{**item, "duration": fingerprint[0]} for item in observed.fingerprint_candidates])
if cache_key:
self._set_cached(cache_key, recording_id, observed.fingerprint_candidates)
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)
if value.expires_at <= time.monotonic():
return False, None
self._cache[cache_key] = value
report_music_fingerprints(list(value.candidates))
if value.status in {"service_error", "budget_exhausted"}:
report_music_recognition(value.status, value.message)
return True, value.recording_id
def _set_cached(
self,
cache_key: tuple[str, int, int],
recording_id: Optional[str],
candidates: Optional[list[dict[str, Any]]] = None,
) -> None:
"""
写入文件识别 LRU 缓存并淘汰最早使用的条目。
"""
diagnostics = music_recognition_diagnostics()
status = str(diagnostics.get("status") or "not_found")
ttl = 3600 if recording_id else 15 if status in {"service_error", "budget_exhausted"} else 300
entry = _FingerprintCacheEntry(recording_id, tuple(dict(item) for item in candidates or []),
time.monotonic() + ttl, status, str(diagnostics.get("message") or ""))
with self._cache_lock:
self._cache.pop(cache_key, None)
self._cache[cache_key] = entry
while len(self._cache) > self._cache_max:
self._cache.popitem(last=False)
def _generate_fingerprint(self, path: Path) -> Optional[tuple[int, str]]:
"""
调用 fpcalc 生成 AcoustID 查询所需的完整时长和压缩指纹。
"""
timeout = music_request_timeout(claim=False, maximum=self._fingerprint_timeout)
if timeout is None:
return None
fpcalc_path = self._fpcalc_path
if not fpcalc_path:
return None
try:
result = subprocess.run(
[fpcalc_path, "-json", str(path)],
capture_output=True,
text=True,
check=False,
timeout=timeout,
)
except (OSError, subprocess.TimeoutExpired) as err:
logger.warning(f"生成音频指纹失败:{path} - {err}")
report_music_recognition("service_error", "音频指纹暂时无法生成,请检查音频解码支持")
return None
if result.returncode not in self._usable_fpcalc_returncodes:
logger.warning(
f"生成音频指纹失败:{path} - fpcalc 退出码 {result.returncode}"
)
report_music_recognition("service_error", "音频指纹解码失败")
return None
return self._parse_fingerprint_output(path, result.stdout)
async def _async_generate_fingerprint(
self,
path: Path,
) -> Optional[tuple[int, str]]:
"""
异步调用 fpcalc 生成 AcoustID 查询所需的指纹。
"""
timeout = music_request_timeout(claim=False, maximum=self._fingerprint_timeout)
if timeout is None:
return None
fpcalc_path = self._fpcalc_path
if not fpcalc_path:
return None
process = None
try:
process = await asyncio.create_subprocess_exec(
fpcalc_path,
"-json",
str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(
process.communicate(),
timeout=timeout,
)
except asyncio.TimeoutError:
if process and process.returncode is None:
process.kill()
await process.communicate()
logger.warning(f"生成音频指纹超时:{path}")
report_music_recognition("service_error", "音频指纹暂时无法生成,请检查音频解码支持")
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}")
report_music_recognition("service_error", "音频指纹暂时无法生成,请检查音频解码支持")
return None
if process.returncode not in self._usable_fpcalc_returncodes:
logger.warning(
f"生成音频指纹失败:{path} - fpcalc 退出码 {process.returncode}"
)
report_music_recognition("service_error", "音频指纹解码失败")
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, OverflowError, TypeError, ValueError) as err:
logger.warning(f"fpcalc 输出解析失败:{path} - {err}")
report_music_recognition("service_error", "音频指纹暂时无法生成,请检查音频解码支持")
return None
if duration <= 0 or not fingerprint:
logger.warning(f"fpcalc 未返回有效音频指纹:{path}")
report_music_recognition("service_error", "音频指纹暂时无法生成,请检查音频解码支持")
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)
if not music_wait_allowed(request_at - now):
return -1
cls._last_request_at = request_at
return max(0.0, request_at - now)
@classmethod
def _wait_for_rate_limit(cls) -> bool:
"""
同步等待 AcoustID 公共接口的已预留请求时间。
"""
delay = cls._reserve_request_delay()
if delay < 0:
return False
if delay:
time.sleep(delay)
return True
@classmethod
async def _async_wait_for_rate_limit(cls) -> bool:
"""
异步等待 AcoustID 公共接口的已预留请求时间。
"""
delay = cls._reserve_request_delay()
if delay < 0:
return False
if delay:
await asyncio.sleep(delay)
return True
@classmethod
def _lookup_plan(
cls,
api_key: str,
duration: int,
fingerprint: str,
) -> Optional[_AcoustIdLookupPlan]:
"""
校验 API Key 并构造同步与异步共用的指纹查询计划。
"""
normalized_key = str(api_key or "").strip()
if not normalized_key:
return None
return _AcoustIdLookupPlan(
url=cls._base_url,
data={
"client": normalized_key,
"duration": duration,
"fingerprint": fingerprint,
"meta": "recordingids",
"format": "json",
},
)
@classmethod
def _project_lookup_response(
cls,
status_code: Optional[int],
payload: Any,
) -> Optional[str]:
"""
按统一 HTTP 状态和 AcoustID 规则投影 Recording ID。
"""
if (status_code != 200 or not isinstance(payload, dict) or payload.get("status") != "ok"
or not isinstance(payload.get("results", []), list)):
report_music_recognition("service_error", "音频指纹服务暂时不可用")
return None
candidates = cls._select_recording_candidates(payload)
report_music_fingerprints(candidates)
return str(candidates[0]["recording_id"]) if candidates else None
def _lookup_recording_id(
self,
duration: int,
fingerprint: str,
) -> Optional[str]:
"""
查询 AcoustID 指纹库并提取 MusicBrainz Recording ID。
"""
plan = self._lookup_plan(
get_runtime_setting('ACOUSTID_API_KEY'),
duration,
fingerprint,
)
if not plan:
return None
if music_request_timeout(maximum=30) is None or self._wait_for_rate_limit() is False:
return None
timeout = music_request_timeout(claim=False, maximum=30)
if timeout is None:
return None
response = RequestUtils(
ua=get_runtime_setting('USER_AGENT'),
proxies=get_runtime_setting('PROXY'),
timeout=30,
).post_res(
url=plan.url,
data=plan.data,
timeout=timeout,
)
if response is None:
logger.warning("AcoustID 指纹查询失败:无响应")
report_music_recognition("service_error", "音频指纹服务连接失败")
return None
try:
if response.status_code != 200:
logger.warning(f"AcoustID 指纹查询失败:HTTP {response.status_code}")
report_music_recognition("service_error", "音频指纹服务暂时不可用")
return None
return self._project_lookup_response(
response.status_code, response.json()
)
except (TypeError, ValueError) as err:
logger.warning(f"AcoustID 响应解析失败:{err}")
report_music_recognition("service_error", "音频指纹服务返回了无效响应")
return None
finally:
response.close()
async def _async_lookup_recording_id(
self,
duration: int,
fingerprint: str,
) -> Optional[str]:
"""
异步查询 AcoustID 指纹库并提取 MusicBrainz Recording ID。
"""
plan = self._lookup_plan(
get_runtime_setting('ACOUSTID_API_KEY'),
duration,
fingerprint,
)
if not plan:
return None
if music_request_timeout(maximum=30) is None or await self._async_wait_for_rate_limit() is False:
return None
timeout = music_request_timeout(claim=False, maximum=30)
if timeout is None:
return None
response = await AsyncRequestUtils(
ua=get_runtime_setting('USER_AGENT'),
proxies=get_runtime_setting('PROXY'),
timeout=30,
).post_res(
url=plan.url,
data=plan.data,
timeout=timeout,
)
if response is None:
logger.warning("AcoustID 指纹查询失败:无响应")
report_music_recognition("service_error", "音频指纹服务连接失败")
return None
try:
if response.status_code != 200:
logger.warning(f"AcoustID 指纹查询失败:HTTP {response.status_code}")
report_music_recognition("service_error", "音频指纹服务暂时不可用")
return None
return self._project_lookup_response(
response.status_code, response.json()
)
except (TypeError, ValueError) as err:
logger.warning(f"AcoustID 响应解析失败:{err}")
report_music_recognition("service_error", "音频指纹服务返回了无效响应")
return None
finally:
await response.aclose()
@classmethod
def _select_recording_id(cls, payload: Any) -> Optional[str]:
"""
兼容旧调用方的单ID视图,内部识别使用完整有界候选集合。
"""
candidates = cls._select_recording_candidates(payload)
return str(candidates[0]["recording_id"]) if candidates else None
@classmethod
def _select_recording_candidates(cls, payload: Any) -> list[dict[str, Any]]:
"""
按实际匹配分保留最多五个不同Recording ID,不用数组中的首个ID消除歧义。
"""
if not isinstance(payload, dict) or payload.get("status") != "ok":
return []
candidates: list[dict[str, Any]] = []
seen: set[str] = set()
results = payload.get("results") or []
if not isinstance(results, list):
return []
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
recordings = item.get("recordings") or []
if not isinstance(recordings, list):
continue
for recording in recordings:
recording_id = cls._normalize_recording_id(
recording.get("id") if isinstance(recording, dict) else None
)
if recording_id and recording_id not in seen:
seen.add(recording_id)
candidates.append({"recording_id": recording_id, "score": score})
if len(candidates) == 6:
return [{**candidate, "truncated": True} for candidate in candidates[:5]]
return candidates
@staticmethod
def _score(value: Any) -> float:
"""
将 AcoustID 匹配分安全转换为零到一之间的浮点数。
"""
try:
score = float(value)
return score if math.isfinite(score) and 0 <= score <= 1 else 0.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