66from typing import Any , Literal , TextIO
77from 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+
4062class AppleMusicMetadata (_Forward ):
4163 artistName : str | None = None
4264 url : str | None = None
@@ -77,14 +99,14 @@ class NapsterMetadata(_Forward):
7799
78100
79101class 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
86108class 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
256285class 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
317346class 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
322356class 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
353394class 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
373419class 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
386432class 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
0 commit comments