AcoustID: report errors honestly instead of masking them as 'Skipped'

An invalid API key (and rate limits / missing chromaprint / fingerprint
failures) all collapsed into the same None as a genuine no-match, so:
  - every download showed a benign 'AcoustID: Skipped', and
  - the 'Test API key' button reported a dead key as VALID
because test_api_key trusted 'no exception = valid' but fingerprint_and_lookup
swallows the error and returns None. A broken AcoustID setup looked completely
normal — which cost a real debugging session to untangle.

- New AcoustIDClient.lookup_with_status() returns a structured result that
  distinguishes ok / no_match / error / no_backend / fingerprint_error /
  unavailable. fingerprint_and_lookup() stays dict-or-None (library scanner /
  auto-import / their tests unchanged) as a thin wrapper over it.
- verify_audio_file() uses it: a real error -> new VerificationResult.ERROR
  (-> _acoustid_result='error' -> the existing red 'Error' history badge),
  a genuine no-match -> SKIP 'No match in AcoustID database'. ERROR never
  quarantines (an outage/bad key must not punish good files).
- test_api_key() now validates via the authoritative direct API call (error
  code 4 = invalid key) instead of the swallowed-exception path.

Tests: structured-status distinction, legacy-wrapper contract, verify ERROR vs
SKIP, and test_api_key invalid-vs-accepted. Existing verify tests updated to
stub lookup_with_status (a stub returning just recordings is inferred as ok).
This commit is contained in:
BoulderBadgeDad 2026-05-31 20:10:31 -07:00
parent a703c5fdc2
commit ba6c39bae3
6 changed files with 281 additions and 89 deletions

View file

@ -282,8 +282,9 @@ class AcoustIDClient:
def test_api_key(self) -> Tuple[bool, str]:
"""
Validate the API key by fingerprinting a real audio file and looking it up.
Falls back to a direct API call if no audio files are available.
Validate the API key with a direct AcoustID lookup call. An invalid key
is reported as invalid (error code 4); any other error means the key was
accepted.
Returns:
Tuple of (success, message)
@ -294,24 +295,12 @@ class AcoustIDClient:
import requests
try:
# Try to find a real audio file to fingerprint for an end-to-end test
test_file = self._find_test_audio_file()
if test_file and CHROMAPRINT_AVAILABLE:
logger.info(f"Testing API key with real audio file: {test_file}")
try:
result = self.fingerprint_and_lookup(test_file)
# If we get here without exception, the API key is valid
# (invalid keys raise or return error before results)
return True, "AcoustID API key is valid"
except Exception as e:
error_str = str(e).lower()
if 'invalid' in error_str and 'api' in error_str:
return False, "Invalid AcoustID API key - get one from https://acoustid.org/new-application"
# Fingerprint/lookup failed for non-key reasons, fall through to direct test
logger.warning(f"Real file test failed ({e}), trying direct API call")
# Fallback: direct API call with minimal fingerprint
# Authoritative key check: a direct API lookup with a dummy
# fingerprint. AcoustID validates the client key first, so an
# invalid key returns error code 4 regardless of the fingerprint.
# (The previous real-file path trusted "no exception = valid", but
# fingerprint_and_lookup swallows the invalid-key error and returns
# None — so it reported broken keys as valid. #756-adjacent.)
url = 'https://api.acoustid.org/v2/lookup'
params = {
'client': self.api_key,
@ -326,7 +315,6 @@ class AcoustIDClient:
if data.get('status') == 'error':
error = data.get('error', {})
error_code = error.get('code', 0)
error_msg = error.get('message', 'Unknown error')
# Error code 4 is specifically "invalid API key"
if error_code == 4:
@ -346,33 +334,33 @@ class AcoustIDClient:
logger.error(f"Error testing AcoustID API key: {e}")
return False, f"Error: {str(e)}"
def fingerprint_and_lookup(self, audio_file: str) -> Optional[Dict[str, Any]]:
"""
Generate fingerprint and look up recording in AcoustID.
def lookup_with_status(self, audio_file: str) -> Dict[str, Any]:
"""Fingerprint + AcoustID lookup returning a STRUCTURED result.
This is the main method - combines fingerprinting and lookup in one call.
Unlike fingerprint_and_lookup() (which collapses every outcome into
dict-or-None), this distinguishes a genuine no-match from an actual
error an invalid API key, rate limit, missing chromaprint, or a
fingerprint failure. That distinction is what lets the UI show "AcoustID
Error" (something is broken — fix it) instead of a benign-looking
"Skipped" that silently hides a dead key.
Args:
audio_file: Path to the audio file
Returns:
Dict with:
'recordings': list of dicts with 'mbid', 'title', 'artist', 'score'
'best_score': float (highest score across all results)
'recording_mbids': list of unique MBIDs (for backward compat)
Or None on error.
Returns dict with:
'status': 'ok' | 'no_match' | 'error' | 'no_backend'
| 'fingerprint_error' | 'unsupported' | 'unavailable'
| 'not_found'
'recordings': list (meaningful only for 'ok')
'best_score': float
'recording_mbids': list
'error': human-readable detail for any non-'ok' status
'invalid_key': bool (True when the API specifically rejected the key)
"""
if not ACOUSTID_AVAILABLE:
logger.debug("Cannot lookup: pyacoustid not available")
return None
return {'status': 'unavailable', 'recordings': [], 'error': 'pyacoustid library not installed'}
if not self.api_key:
logger.debug("Cannot lookup: no API key")
return None
return {'status': 'unavailable', 'recordings': [], 'error': 'No AcoustID API key configured'}
if not os.path.isfile(audio_file):
logger.warning(f"Cannot lookup: file not found: {audio_file}")
return None
return {'status': 'not_found', 'recordings': [], 'error': f'File not found: {audio_file}'}
# Check channel count — chromaprint crashes (SIGABRT) on >2 channel files (e.g. 5.1 surround)
try:
@ -382,7 +370,8 @@ class AcoustIDClient:
channels = getattr(mf.info, 'channels', 2)
if channels and channels > 2:
logger.warning(f"Skipping AcoustID: file has {channels} channels (surround audio): {audio_file}")
return None
return {'status': 'unsupported', 'recordings': [],
'error': f'{channels}-channel (surround) audio not supported by chromaprint'}
except Exception as e:
logger.debug(f"Could not check channel count, proceeding anyway: {e}")
@ -392,17 +381,12 @@ class AcoustIDClient:
api_key_preview = f"{self.api_key[:8]}..." if self.api_key and len(self.api_key) > 8 else "NOT SET"
logger.info(f"Fingerprinting and looking up: {audio_file} (API key: {api_key_preview})")
# Use match() which handles fingerprinting + lookup + parsing
logger.debug("Running acoustid.match()...")
recordings = []
seen_mbids = set()
best_score = 0.0
for result in acoustid.match(
self.api_key,
audio_file,
parse=True
):
for result in acoustid.match(self.api_key, audio_file, parse=True):
# match() with parse=True returns (score, recording_id, title, artist)
if not isinstance(result, tuple) or len(result) < 2:
logger.warning(f"Unexpected result format: {result}")
@ -420,45 +404,57 @@ class AcoustIDClient:
if recording_id and recording_id not in seen_mbids:
seen_mbids.add(recording_id)
recordings.append({
'mbid': recording_id,
'title': title,
'artist': artist,
'score': score,
})
recordings.append({'mbid': recording_id, 'title': title, 'artist': artist, 'score': score})
logger.debug(f"Found match: {title} by {artist} (MBID: {recording_id}, score: {score})")
if not recordings:
logger.info(f"No AcoustID matches found for: {audio_file}")
return None
return {'status': 'no_match', 'recordings': [], 'best_score': best_score,
'recording_mbids': [], 'error': 'Track not found in AcoustID database'}
logger.info(f"AcoustID found {len(recordings)} recording(s) (best score: {best_score:.2f})")
return {
'recordings': recordings,
'best_score': best_score,
'recording_mbids': list(seen_mbids),
}
return {'status': 'ok', 'recordings': recordings, 'best_score': best_score,
'recording_mbids': list(seen_mbids)}
except acoustid.NoBackendError:
logger.error("Chromaprint library not found and fpcalc not available")
return None
return {'status': 'no_backend', 'recordings': [],
'error': 'Chromaprint/fpcalc not installed (install libchromaprint1)'}
except acoustid.FingerprintGenerationError as e:
logger.warning(f"Failed to fingerprint {audio_file}: {e}")
return None
return {'status': 'fingerprint_error', 'recordings': [], 'error': f'Could not fingerprint file: {e}'}
except acoustid.WebServiceError as e:
# Log more details about the API error
api_key_preview = f"{self.api_key[:8]}..." if self.api_key and len(self.api_key) > 8 else "???"
logger.warning(f"AcoustID API error (key: {api_key_preview}): {e}")
# Check for common errors
error_str = str(e).lower()
if 'invalid' in error_str or 'unknown' in error_str:
logger.error("API key appears to be invalid - check your AcoustID settings")
# Old pyacoustid reports an invalid key as the bare "status: error"
# (it drops the detail), so treat that as an invalid-key signal too.
invalid = ('invalid' in error_str or 'unknown' in error_str or 'status: error' in error_str)
if invalid:
logger.error("AcoustID API key appears to be invalid — check your AcoustID settings")
elif 'rate' in error_str or 'limit' in error_str:
logger.warning("Rate limited by AcoustID - will retry later")
return None
logger.warning("Rate limited by AcoustID — will retry later")
return {'status': 'error', 'recordings': [], 'invalid_key': invalid,
'error': f'AcoustID API error: {e}'}
except Exception as e:
logger.error(f"Unexpected error in AcoustID lookup: {e}", exc_info=True)
return None
return {'status': 'error', 'recordings': [], 'error': f'Unexpected error: {e}'}
def fingerprint_and_lookup(self, audio_file: str) -> Optional[Dict[str, Any]]:
"""Legacy dict-or-None lookup. Returns the recordings dict on a confirmed
match, else None. Kept for callers that only need "did we identify it"
(library scanner, auto-import). Callers that must report WHY a lookup
didn't match (verification badge, key test) should use
``lookup_with_status`` so an error isn't mistaken for a no-match.
"""
res = self.lookup_with_status(audio_file)
if res.get('status') == 'ok':
return {
'recordings': res['recordings'],
'best_score': res.get('best_score', 0.0),
'recording_mbids': res.get('recording_mbids', []),
}
return None
def refresh_config(self):
"""Refresh cached config values (call after settings change)."""

View file

@ -50,8 +50,9 @@ class VerificationResult(Enum):
"""Possible outcomes of audio verification."""
PASS = "pass" # Title/artist match - file is correct
FAIL = "fail" # Title/artist mismatch - wrong file downloaded
SKIP = "skip" # Could not verify (error or unavailable) - continue normally
SKIP = "skip" # Genuinely couldn't verify (no match in DB) - continue normally
DISABLED = "disabled" # Verification not enabled
ERROR = "error" # Lookup errored (invalid key / rate limit / no backend) - continue, but flag it
def _normalize(text: str) -> str:
@ -399,18 +400,33 @@ class AcoustIDVerification:
logger.debug(f"AcoustID verification skipped: {reason}")
return VerificationResult.SKIP, reason
# Step 2: Fingerprint and lookup in AcoustID
# Step 2: Fingerprint and lookup in AcoustID (structured so an
# actual error — invalid key / rate limit / no chromaprint — is
# reported distinctly from a genuine no-match, instead of both
# silently surfacing as "Skipped").
logger.info(f"Fingerprinting and looking up: {audio_file_path}")
acoustid_result = self.acoustid_client.fingerprint_and_lookup(audio_file_path)
lookup = self.acoustid_client.lookup_with_status(audio_file_path) or {}
status = lookup.get('status')
# Infer status by content when absent (a caller/stub that returned
# just recordings): recordings => matched, none => no match.
if status is None:
status = 'ok' if lookup.get('recordings') else 'no_match'
if not acoustid_result:
return VerificationResult.SKIP, "Track not found in AcoustID database"
if status in ('error', 'no_backend', 'fingerprint_error', 'unavailable'):
# Something is broken (not the track's fault) — never quarantine
# on this; surface it so the user can fix it.
return VerificationResult.ERROR, lookup.get('error', 'AcoustID lookup failed')
if status != 'ok':
# no_match / unsupported / not_found — genuinely could not verify.
return VerificationResult.SKIP, lookup.get('error', 'No match in AcoustID database')
acoustid_result = lookup
recordings = acoustid_result.get('recordings', [])
best_score = acoustid_result.get('best_score', 0)
if not recordings:
return VerificationResult.SKIP, "AcoustID returned no recordings"
return VerificationResult.SKIP, "No match in AcoustID database"
logger.debug(
f"AcoustID returned {len(recordings)} recording(s) "

View file

@ -140,7 +140,7 @@ class TestIssue442Regression:
verifier, fake_service = stubbed_verifier
# AcoustID returns the recording with kanji artist
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'YAMANAIAME', 'artist': '澤野弘之', 'mbid': 'rec-x'},
@ -165,7 +165,7 @@ class TestIssue442Regression:
"""Reporter's case 2 — Sergey Lazarev / Сергей Лазарев."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'On the Other Side', 'artist': 'Sergey Lazarev', 'mbid': 'rec-y'},
@ -196,7 +196,7 @@ class TestBackwardCompat:
verifier, fake_service = stubbed_verifier
# Wrong artist entirely — Latin script both sides, sim ~0
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'Some Track', 'artist': 'Khalil Turk & Friends'},
@ -214,7 +214,7 @@ class TestBackwardCompat:
"""Exact title + artist match → PASS regardless of aliases."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
@ -233,7 +233,7 @@ class TestBackwardCompat:
verifier, fake_service = stubbed_verifier
fake_service.lookup_artist_aliases.side_effect = Exception("MB down")
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
@ -261,7 +261,7 @@ class TestAliasLookupCalledOncePerVerify:
fire 60+ MB lookups (cached or not, that's wasteful)."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'X', 'artist': '澤野弘之'},
@ -295,7 +295,7 @@ class TestLazyAliasResolution:
this PR."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
@ -317,7 +317,7 @@ class TestLazyAliasResolution:
as expected."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'YAMANAIAME', 'artist': '澤野弘之'},
@ -343,7 +343,7 @@ class TestLazyAliasResolution:
# Force a code path that hits multiple sites: title matches
# several recordings but the best-match's artist sim is below
# threshold (forces secondary scan path).
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'X', 'artist': 'Different Latin Artist'}, # 0 alias hit

View file

@ -0,0 +1,180 @@
"""AcoustID error-vs-no-match reporting.
Regression for the masking bug: an invalid API key (and other lookup errors)
used to collapse into the same `None` as a genuine no-match, so the UI showed a
benign "Skipped" and the "Test API key" button reported a dead key as valid.
These tests pin the distinction end to end:
- lookup_with_status separates ok / no_match / error / no_backend / unavailable
- fingerprint_and_lookup (legacy) stays dict-or-None
- verify_audio_file -> ERROR for a real error, SKIP for a genuine no-match
- test_api_key reports an invalid key (API error code 4) as invalid
"""
from __future__ import annotations
import sys
import types
from unittest.mock import MagicMock, patch
import pytest
import core.acoustid_client as acc
from core.acoustid_client import AcoustIDClient
from core.acoustid_verification import AcoustIDVerification, VerificationResult
# ── lookup_with_status: structured status distinction ──────────────────────
def _client_with_fake_acoustid(monkeypatch, *, match=None, raises=None):
"""An AcoustIDClient wired to a fake `acoustid` module so we can drive
match() without network or chromaprint."""
fake = types.ModuleType("acoustid")
class WebServiceError(Exception):
pass
class NoBackendError(Exception):
pass
class FingerprintGenerationError(Exception):
pass
fake.WebServiceError = WebServiceError
fake.NoBackendError = NoBackendError
fake.FingerprintGenerationError = FingerprintGenerationError
def _match(api_key, audio_file, parse=True):
if raises is not None:
raise raises
return match or []
fake.match = _match
monkeypatch.setitem(sys.modules, "acoustid", fake)
monkeypatch.setattr(acc, "ACOUSTID_AVAILABLE", True)
c = AcoustIDClient()
c._api_key = "testkey123" # bypass config
return c, fake
def test_lookup_status_ok(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio") # mutagen -> None, channel check skipped
c, _ = _client_with_fake_acoustid(monkeypatch, match=[(0.95, "mbid-1", "Title", "Artist")])
res = c.lookup_with_status(str(f))
assert res["status"] == "ok"
assert res["recordings"] and res["recordings"][0]["mbid"] == "mbid-1"
def test_lookup_status_no_match(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, _ = _client_with_fake_acoustid(monkeypatch, match=[])
res = c.lookup_with_status(str(f))
assert res["status"] == "no_match"
assert res["recordings"] == []
def test_lookup_status_error_on_webservice(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, fake = _client_with_fake_acoustid(monkeypatch)
# invalid key surfaces (old pyacoustid) as the bare "status: error"
monkeypatch.setattr(c, "_api_key", "testkey123")
def _raise(*a, **k):
raise fake.WebServiceError("status: error")
fake.match = _raise
res = c.lookup_with_status(str(f))
assert res["status"] == "error"
assert res["invalid_key"] is True
def test_lookup_status_no_backend(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, fake = _client_with_fake_acoustid(monkeypatch)
def _raise(*a, **k):
raise fake.NoBackendError()
fake.match = _raise
assert c.lookup_with_status(str(f))["status"] == "no_backend"
def test_lookup_status_unavailable_without_key(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"x")
monkeypatch.setattr(acc, "ACOUSTID_AVAILABLE", True)
c = AcoustIDClient()
c._api_key = "" # no key
assert c.lookup_with_status(str(f))["status"] == "unavailable"
# ── fingerprint_and_lookup keeps its dict-or-None contract ─────────────────
def test_legacy_wrapper_returns_dict_on_match(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, _ = _client_with_fake_acoustid(monkeypatch, match=[(0.9, "mbid-1", "T", "A")])
out = c.fingerprint_and_lookup(str(f))
assert out is not None and out["recordings"][0]["mbid"] == "mbid-1"
def test_legacy_wrapper_returns_none_on_error(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, fake = _client_with_fake_acoustid(monkeypatch)
def _raise(*a, **k):
raise fake.WebServiceError("status: error")
fake.match = _raise
assert c.fingerprint_and_lookup(str(f)) is None
# ── verify_audio_file: ERROR vs SKIP ───────────────────────────────────────
def _verifier_with_lookup(result):
v = AcoustIDVerification()
client = MagicMock()
client.is_available.return_value = (True, "ready")
client.lookup_with_status.return_value = result
v.acoustid_client = client
return v
def test_verify_reports_error_for_api_error():
v = _verifier_with_lookup({"status": "error", "recordings": [], "error": "AcoustID API error: invalid"})
result, msg = v.verify_audio_file("/x.flac", "Song", "Artist")
assert result == VerificationResult.ERROR
assert "error" in msg.lower() or "invalid" in msg.lower()
def test_verify_reports_skip_for_no_match():
v = _verifier_with_lookup({"status": "no_match", "recordings": [], "error": "Track not found in AcoustID database"})
result, msg = v.verify_audio_file("/x.flac", "Song", "Artist")
assert result == VerificationResult.SKIP
assert "no match" in msg.lower() or "not found" in msg.lower()
# ── test_api_key: invalid key reported as invalid ──────────────────────────
def _api_response(payload):
r = MagicMock()
r.json.return_value = payload
return r
def test_test_api_key_invalid_when_code_4(monkeypatch):
c = AcoustIDClient()
c._api_key = "badkey"
with patch("requests.get",
return_value=_api_response({"status": "error", "error": {"code": 4, "message": "invalid API key"}})):
ok, msg = c.test_api_key()
assert ok is False
assert "invalid" in msg.lower()
def test_test_api_key_valid_when_accepted(monkeypatch):
c = AcoustIDClient()
c._api_key = "goodkey"
# A non-key error (e.g. bad dummy fingerprint) means the key was accepted.
with patch("requests.get",
return_value=_api_response({"status": "error", "error": {"code": 3, "message": "invalid fingerprint"}})):
ok, msg = c.test_api_key()
assert ok is True
assert "valid" in msg.lower()

View file

@ -52,7 +52,7 @@ def verifier(monkeypatch):
def is_available(self):
return True, 'available'
def fingerprint_and_lookup(self, path):
def lookup_with_status(self, path):
# Each test injects its own desired return value via
# monkeypatch on this method; default is empty.
return None
@ -62,8 +62,8 @@ def verifier(monkeypatch):
def _stub_lookup(verifier, *, recordings, best_score):
"""Make `fingerprint_and_lookup` return a fabricated AcoustID result."""
verifier.acoustid_client.fingerprint_and_lookup = lambda path: {
"""Make `lookup_with_status` return a fabricated AcoustID result."""
verifier.acoustid_client.lookup_with_status = lambda path: {
'recordings': recordings,
'best_score': best_score,
'recording_mbids': [r.get('id') for r in recordings if r.get('id')],

View file

@ -38,7 +38,7 @@ def verifier():
def is_available(self):
return True, "available"
def fingerprint_and_lookup(self, path):
def lookup_with_status(self, path):
return None # tests inject via _stub_lookup below
v.acoustid_client = _StubClient()
@ -46,7 +46,7 @@ def verifier():
def _stub_lookup(verifier, *, recordings, best_score=0.95):
verifier.acoustid_client.fingerprint_and_lookup = lambda path: {
verifier.acoustid_client.lookup_with_status = lambda path: {
"recordings": recordings,
"best_score": best_score,
"recording_mbids": [r.get("id") for r in recordings if r.get("id")],