Skip to content

Commit 4a92975

Browse files
author
Mikhail Samin's Claude
committed
Release v1.5.11
Make response parsing lenient: never throw because a field is missing or a different type than expected. The enterprise endpoint can return songs with no score (also missing isrc/upc/label, and some fields on YouTube-sourced entries). Those fields are now optional/nullable and degrade instead of raising. Kept legitimate failures: API status=error, undecodable JSON, transport errors, and caller-input errors (unknown provider, missing token).
1 parent 84c096c commit 4a92975

7 files changed

Lines changed: 230 additions & 31 deletions

File tree

src/audd/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Single source of truth for the package version. Hatch reads this."""
22

3-
__version__ = "1.5.10"
3+
__version__ = "1.5.11"

src/audd/advanced.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
AudDSerializationError,
1616
raise_from_error_response,
1717
)
18-
from audd.models import LyricsResult
18+
from audd.models import LyricsResult, _coerce_model_list
1919

2020
API_BASE = "https://api.audd.io"
2121

@@ -29,7 +29,7 @@ def find_lyrics(self, query: str) -> list[LyricsResult]:
2929
body = self.raw_request("findLyrics", {"q": query})
3030
if body.get("status") == "error":
3131
raise_from_error_response(body, http_status=200, request_id=None)
32-
return [LyricsResult.model_validate(r) for r in (body.get("result") or [])]
32+
return _coerce_model_list(body.get("result"), LyricsResult)
3333

3434
def raw_request(
3535
self,
@@ -64,7 +64,7 @@ async def find_lyrics(self, query: str) -> list[LyricsResult]:
6464
body = await self.raw_request("findLyrics", {"q": query})
6565
if body.get("status") == "error":
6666
raise_from_error_response(body, http_status=200, request_id=None)
67-
return [LyricsResult.model_validate(r) for r in (body.get("result") or [])]
67+
return _coerce_model_list(body.get("result"), LyricsResult)
6868

6969
async def raw_request(
7070
self,

src/audd/client.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,17 +188,26 @@ def _decode_or_raise(resp: HTTPResponse) -> dict[str, Any]:
188188
def _decode_recognize(resp: HTTPResponse) -> RecognitionResult | None:
189189
body = _decode_or_raise(resp)
190190
result = body.get("result")
191-
if result is None:
191+
# No-match responses carry result: null (or, rarely, a non-object falsy
192+
# value). Treat anything that isn't a dict as "no match" rather than raising.
193+
if not isinstance(result, dict):
192194
return None
193195
return RecognitionResult.model_validate(result)
194196

195197

196198
def _decode_enterprise(resp: HTTPResponse) -> list[EnterpriseMatch]:
197199
body = _decode_or_raise(resp)
198-
chunks_raw = body.get("result") or []
200+
chunks_raw = body.get("result")
199201
out: list[EnterpriseMatch] = []
202+
# A successful response must never raise on a missing/odd-typed result.
203+
# Skip anything that isn't a chunk object instead of aborting the parse.
204+
if not isinstance(chunks_raw, list):
205+
return out
200206
for chunk_dict in chunks_raw:
201-
chunk = EnterpriseChunkResult.model_validate(chunk_dict)
207+
try:
208+
chunk = EnterpriseChunkResult.model_validate(chunk_dict)
209+
except Exception: # noqa: BLE001 — degrade, never raise on response parse
210+
continue
202211
out.extend(chunk.songs)
203212
return out
204213

src/audd/models.py

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Any, Literal, TextIO
77
from urllib.parse import urlparse
88

9-
from pydantic import BaseModel, ConfigDict
9+
from pydantic import BaseModel, ConfigDict, field_validator
1010

1111
# Streaming providers reachable via the lis.tn `?<provider>` redirect helper.
1212
_STREAMING_PROVIDERS: tuple[str, ...] = (
@@ -37,6 +37,28 @@ class _Forward(BaseModel):
3737
model_config = ConfigDict(extra="allow", populate_by_name=True, str_strip_whitespace=False)
3838

3939

40+
def _coerce_model_list(value: Any, model: type[BaseModel]) -> list[Any]:
41+
"""Best-effort list-of-model coercion that never raises.
42+
43+
A successful API response must parse even when a list field is missing,
44+
is not a list, or carries a malformed element. Missing/None/non-list →
45+
empty list; each element that fails validation is skipped rather than
46+
aborting the whole parse.
47+
"""
48+
if not isinstance(value, list):
49+
return []
50+
out: list[Any] = []
51+
for item in value:
52+
if isinstance(item, model):
53+
out.append(item)
54+
continue
55+
try:
56+
out.append(model.model_validate(item))
57+
except Exception: # noqa: BLE001 — degrade, never raise on response parse
58+
continue
59+
return out
60+
61+
4062
class AppleMusicMetadata(_Forward):
4163
artistName: str | None = None
4264
url: str | None = None
@@ -77,14 +99,14 @@ class NapsterMetadata(_Forward):
7799

78100

79101
class MusicBrainzEntry(_Forward):
80-
id: str
102+
id: str | None = None
81103
score: int | str | None = None
82104
title: str | None = None
83105
length: int | None = None
84106

85107

86108
class RecognitionResult(_Forward):
87-
timecode: str
109+
timecode: str | None = None
88110
audio_id: int | None = None
89111
artist: str | None = None
90112
title: str | None = None
@@ -100,6 +122,13 @@ class RecognitionResult(_Forward):
100122
napster: NapsterMetadata | None = None
101123
musicbrainz: list[MusicBrainzEntry] | None = None
102124

125+
@field_validator("musicbrainz", mode="before")
126+
@classmethod
127+
def _coerce_musicbrainz(cls, v: Any) -> Any:
128+
if v is None:
129+
return None
130+
return _coerce_model_list(v, MusicBrainzEntry)
131+
103132
def __repr__(self) -> str:
104133
parts: list[str] = []
105134
if self.artist:
@@ -254,8 +283,8 @@ def preview_url(self) -> str | None:
254283

255284

256285
class EnterpriseMatch(_Forward):
257-
score: int
258-
timecode: str
286+
score: int | None = None
287+
timecode: str | None = None
259288
artist: str | None = None
260289
title: str | None = None
261290
album: str | None = None
@@ -315,14 +344,19 @@ def streaming_urls(self) -> dict[str, str]:
315344

316345

317346
class EnterpriseChunkResult(_Forward):
318-
songs: list[EnterpriseMatch]
319-
offset: str
347+
songs: list[EnterpriseMatch] = []
348+
offset: str | None = None
349+
350+
@field_validator("songs", mode="before")
351+
@classmethod
352+
def _coerce_songs(cls, v: Any) -> Any:
353+
return _coerce_model_list(v, EnterpriseMatch)
320354

321355

322356
class Stream(_Forward):
323-
radio_id: int
324-
url: str
325-
stream_running: bool
357+
radio_id: int | None = None
358+
url: str | None = None
359+
stream_running: bool | None = None
326360
longpoll_category: str | None = None
327361

328362

@@ -334,9 +368,9 @@ class StreamCallbackSong(_Forward):
334368
(variant releases, different masters, regional editions).
335369
"""
336370

337-
artist: str
338-
title: str
339-
score: int
371+
artist: str | None = None
372+
title: str | None = None
373+
score: int | None = None
340374
album: str | None = None
341375
release_date: str | None = None
342376
label: str | None = None
@@ -349,6 +383,13 @@ class StreamCallbackSong(_Forward):
349383
napster: NapsterMetadata | None = None
350384
musicbrainz: list[MusicBrainzEntry] | None = None
351385

386+
@field_validator("musicbrainz", mode="before")
387+
@classmethod
388+
def _coerce_musicbrainz(cls, v: Any) -> Any:
389+
if v is None:
390+
return None
391+
return _coerce_model_list(v, MusicBrainzEntry)
392+
352393

353394
class StreamCallbackMatch(_Forward):
354395
"""One recognition event from a stream callback or longpoll.
@@ -362,30 +403,35 @@ class StreamCallbackMatch(_Forward):
362403
:py:attr:`pydantic.BaseModel.model_extra`.
363404
"""
364405

365-
radio_id: int
406+
radio_id: int | None = None
366407
timestamp: str | None = None
367408
play_length: int | None = None
368-
song: StreamCallbackSong
409+
song: StreamCallbackSong | None = None
369410
alternatives: list[StreamCallbackSong] = []
370411
raw_response: dict[str, Any] | None = None
371412

413+
@field_validator("alternatives", mode="before")
414+
@classmethod
415+
def _coerce_alternatives(cls, v: Any) -> Any:
416+
return _coerce_model_list(v, StreamCallbackSong)
417+
372418

373419
class StreamCallbackNotification(_Forward):
374420
"""Stream lifecycle event (e.g. ``stream stopped``, ``can't connect``)."""
375421

376-
radio_id: int
422+
radio_id: int | None = None
377423
stream_running: bool | None = None
378-
notification_code: int
379-
notification_message: str
424+
notification_code: int | None = None
425+
notification_message: str | None = None
380426
# Outer ``time`` field on the callback envelope (epoch seconds).
381427
# Sits next to the ``notification`` block in the JSON, not inside it.
382428
time: int | None = None
383429
raw_response: dict[str, Any] | None = None
384430

385431

386432
class LyricsResult(_Forward):
387-
artist: str
388-
title: str
433+
artist: str | None = None
434+
title: str | None = None
389435
lyrics: str | None = None
390436
song_id: int | None = None
391437
media: str | None = None

src/audd/streams.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
Stream,
3030
StreamCallbackMatch,
3131
StreamCallbackNotification,
32+
_coerce_model_list,
3233
)
3334

3435
API_BASE = "https://api.audd.io"
@@ -577,8 +578,8 @@ def delete(self, radio_id: int) -> None:
577578
self._post("deleteStream", {"radio_id": str(radio_id)}, self._mutating)
578579

579580
def list(self) -> list[Stream]:
580-
result = self._post("getStreams", {}, self._read) or []
581-
return [Stream.model_validate(s) for s in result]
581+
result = self._post("getStreams", {}, self._read)
582+
return _coerce_model_list(result, Stream)
582583

583584
def handle_callback(
584585
self, request: Any,
@@ -718,8 +719,8 @@ async def delete(self, radio_id: int) -> None:
718719
await self._post("deleteStream", {"radio_id": str(radio_id)}, self._mutating)
719720

720721
async def list(self) -> list[Stream]:
721-
result = await self._post("getStreams", {}, self._read) or []
722-
return [Stream.model_validate(s) for s in result]
722+
result = await self._post("getStreams", {}, self._read)
723+
return _coerce_model_list(result, Stream)
723724

724725
async def handle_callback(
725726
self, request: Any,

tests/unit/test_client.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,45 @@ def test_recognize_enterprise_returns_list_of_matches() -> None:
140140
assert matches[0].artist == "X"
141141

142142

143+
@respx.mock
144+
def test_recognize_enterprise_song_without_score_parses() -> None:
145+
"""Enterprise endpoint legitimately returns songs with no score (and no
146+
isrc/upc/label) — e.g. YouTube-sourced entries. The decode path must not
147+
raise; the match parses with score is None."""
148+
respx.post("https://enterprise.audd.io/").mock(
149+
return_value=httpx.Response(200, json={
150+
"status": "success",
151+
"result": [{
152+
"songs": [{
153+
"timecode": "00:11", "artist": "X", "title": "Y",
154+
"song_link": "https://youtu.be/abc",
155+
}],
156+
"offset": "00:00",
157+
}],
158+
}),
159+
)
160+
matches = AudD(api_token="t-test").recognize_enterprise("https://example.mp3", limit=1)
161+
assert len(matches) == 1
162+
assert matches[0].score is None
163+
assert matches[0].isrc is None
164+
assert matches[0].artist == "X"
165+
166+
167+
@respx.mock
168+
def test_recognize_missing_timecode_parses() -> None:
169+
"""A recognition result missing timecode must parse, not raise."""
170+
respx.post("https://api.audd.io/").mock(
171+
return_value=httpx.Response(200, json={
172+
"status": "success",
173+
"result": {"artist": "X", "title": "Y"},
174+
}),
175+
)
176+
result = AudD(api_token="t-test").recognize("https://example.mp3")
177+
assert isinstance(result, RecognitionResult)
178+
assert result.timecode is None
179+
assert result.artist == "X"
180+
181+
143182
@respx.mock
144183
def test_recognize_enterprise_unauthorized_raises_subscription_error() -> None:
145184
respx.post("https://enterprise.audd.io/").mock(

0 commit comments

Comments
 (0)