Add Qobuz + Tidal album converters
Audit caught two missing providers from the foundation pr. Both return album-shaped data via their clients (search + download flows). Tidal uses tidalapi objects rather than dicts so the converter is from_tidal_object, not _dict. Enrichment-only providers (lastfm/genius/acoustid/listenbrainz/ audiodb) intentionally have no album converter — they enrich existing rows, never return album shapes. Tests: +8 cases. 40 total now.
This commit is contained in:
parent
529486a2d1
commit
eab1297afc
3 changed files with 241 additions and 0 deletions
|
|
@ -377,6 +377,120 @@ class Album:
|
|||
external_urls={},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_qobuz_dict(cls, raw: Dict[str, Any]) -> 'Album':
|
||||
"""Qobuz API ``album/get`` response shape."""
|
||||
artist = raw.get('artist') or {}
|
||||
artist_name = _str(artist.get('name'), default='Unknown Artist') if isinstance(artist, dict) else _str(artist) or 'Unknown Artist'
|
||||
artist_id = _str(artist.get('id')) if isinstance(artist, dict) else ''
|
||||
|
||||
# Qobuz `image` is a dict with small/large/thumbnail variants.
|
||||
image = raw.get('image') or {}
|
||||
image_url = None
|
||||
if isinstance(image, dict):
|
||||
image_url = (
|
||||
_str(image.get('large'))
|
||||
or _str(image.get('small'))
|
||||
or _str(image.get('thumbnail'))
|
||||
or None
|
||||
)
|
||||
|
||||
external_ids = {}
|
||||
if raw.get('id'):
|
||||
external_ids['qobuz'] = _str(raw['id'])
|
||||
if raw.get('upc'):
|
||||
external_ids['upc'] = _str(raw['upc'])
|
||||
|
||||
external_urls = {}
|
||||
if raw.get('url'):
|
||||
external_urls['qobuz'] = _str(raw['url'])
|
||||
|
||||
# Qobuz exposes both `release_date_original` (vinyl/original
|
||||
# press date) and `released_at` (digital release timestamp).
|
||||
# Prefer the original date for cross-provider matching.
|
||||
release_date = _str(raw.get('release_date_original') or raw.get('released_at'))
|
||||
if release_date and 'T' in release_date:
|
||||
release_date = release_date.split('T', 1)[0]
|
||||
|
||||
genre = raw.get('genre') or {}
|
||||
genre_name = _str(genre.get('name')) if isinstance(genre, dict) else _str(genre)
|
||||
|
||||
label = raw.get('label') or {}
|
||||
label_name = _str(label.get('name')) if isinstance(label, dict) else _str(label)
|
||||
|
||||
return cls(
|
||||
id=_str(raw.get('id')),
|
||||
name=_str(raw.get('title')),
|
||||
artists=[artist_name],
|
||||
release_date=release_date,
|
||||
total_tracks=_int(raw.get('tracks_count')),
|
||||
album_type='album', # Qobuz doesn't tag this consistently
|
||||
image_url=image_url,
|
||||
artist_id=artist_id or None,
|
||||
genres=[genre_name] if genre_name else [],
|
||||
label=label_name or None,
|
||||
barcode=external_ids.get('upc'),
|
||||
source='qobuz',
|
||||
external_ids=external_ids,
|
||||
external_urls=external_urls,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_tidal_object(cls, obj: Any) -> 'Album':
|
||||
"""tidalapi ``Album`` object shape.
|
||||
|
||||
Tidal goes through the ``tidalapi`` library which returns
|
||||
Python objects, not raw dicts — so this converter is named
|
||||
``from_tidal_object`` to make the input contract explicit.
|
||||
Duck-types attribute access so unit tests can pass simple
|
||||
SimpleNamespace stand-ins."""
|
||||
artist = getattr(obj, 'artist', None)
|
||||
artist_name = _str(getattr(artist, 'name', None), default='Unknown Artist')
|
||||
artist_id = _str(getattr(artist, 'id', '')) if artist else ''
|
||||
|
||||
# tidalapi exposes `image()` as a method that returns a URL at
|
||||
# a given size. Try a sensible default size; fall back to the
|
||||
# `picture` field (the raw image id) if the method's missing.
|
||||
image_url = None
|
||||
try:
|
||||
if hasattr(obj, 'image') and callable(obj.image):
|
||||
image_url = obj.image(640) or None
|
||||
except Exception:
|
||||
image_url = None
|
||||
if not image_url:
|
||||
picture = _str(getattr(obj, 'picture', ''))
|
||||
if picture:
|
||||
# Tidal CDN URL format
|
||||
pic_path = picture.replace('-', '/')
|
||||
image_url = f"https://resources.tidal.com/images/{pic_path}/640x640.jpg"
|
||||
|
||||
release_date = ''
|
||||
rd = getattr(obj, 'release_date', None)
|
||||
if rd is not None:
|
||||
release_date = _str(rd).split('T')[0] if 'T' in _str(rd) else _str(rd)
|
||||
|
||||
external_ids = {}
|
||||
if getattr(obj, 'id', None):
|
||||
external_ids['tidal'] = _str(obj.id)
|
||||
if getattr(obj, 'universal_product_number', None):
|
||||
external_ids['upc'] = _str(obj.universal_product_number)
|
||||
|
||||
return cls(
|
||||
id=_str(getattr(obj, 'id', '')),
|
||||
name=_str(getattr(obj, 'name', '')),
|
||||
artists=[artist_name],
|
||||
release_date=release_date,
|
||||
total_tracks=_int(getattr(obj, 'num_tracks', 0)),
|
||||
album_type=_str(getattr(obj, 'type', None), default='album').lower() or 'album',
|
||||
image_url=image_url,
|
||||
artist_id=artist_id or None,
|
||||
genres=[], # tidalapi doesn't expose genres on Album
|
||||
barcode=external_ids.get('upc'),
|
||||
source='tidal',
|
||||
external_ids=external_ids,
|
||||
external_urls={},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_hydrabase_dict(cls, raw: Dict[str, Any]) -> 'Album':
|
||||
"""Hydrabase metadata service response shape."""
|
||||
|
|
|
|||
|
|
@ -49,6 +49,16 @@ Plus per-provider classmethod converters on `Album`:
|
|||
- `Album.from_discogs_dict(raw)`
|
||||
- `Album.from_musicbrainz_dict(raw)`
|
||||
- `Album.from_hydrabase_dict(raw)`
|
||||
- `Album.from_qobuz_dict(raw)`
|
||||
- `Album.from_tidal_object(obj)` — note: Tidal goes through the
|
||||
``tidalapi`` library which returns Python objects rather than
|
||||
raw dicts, so this converter is named ``_object`` not ``_dict``
|
||||
to make the input contract explicit.
|
||||
|
||||
Enrichment-only providers (Last.fm, Genius, AcoustID, ListenBrainz,
|
||||
AudioDB) don't return Album-shaped responses — they enrich
|
||||
existing rows with tags, lyrics URLs, fingerprint matches, etc.
|
||||
No Album converter needed for those.
|
||||
|
||||
Each converter is the SINGLE place that knows that provider's wire
|
||||
shape. Adding a new provider = adding one classmethod here and
|
||||
|
|
|
|||
|
|
@ -342,6 +342,121 @@ def test_album_from_musicbrainz_dict_release_group_type_overrides_default():
|
|||
assert Album.from_musicbrainz_dict(raw).album_type == 'single'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Qobuz
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_album_from_qobuz_dict_full_response():
|
||||
raw = {
|
||||
'id': 12345,
|
||||
'title': 'GNX',
|
||||
'artist': {'id': 67890, 'name': 'Kendrick Lamar'},
|
||||
'release_date_original': '2024-11-22',
|
||||
'released_at': '2024-11-22T08:00:00',
|
||||
'tracks_count': 12,
|
||||
'image': {
|
||||
'small': 'https://qobuz/small.jpg',
|
||||
'large': 'https://qobuz/large.jpg',
|
||||
'thumbnail': 'https://qobuz/thumb.jpg',
|
||||
},
|
||||
'genre': {'id': 116, 'name': 'Hip-Hop/Rap'},
|
||||
'label': {'id': 999, 'name': 'pgLang'},
|
||||
'upc': '00602465123456',
|
||||
'url': 'https://www.qobuz.com/album/gnx/12345',
|
||||
}
|
||||
album = Album.from_qobuz_dict(raw)
|
||||
assert album.id == '12345'
|
||||
assert album.name == 'GNX'
|
||||
assert album.artists == ['Kendrick Lamar']
|
||||
assert album.artist_id == '67890'
|
||||
assert album.release_date == '2024-11-22'
|
||||
assert album.total_tracks == 12
|
||||
assert album.image_url == 'https://qobuz/large.jpg'
|
||||
assert album.genres == ['Hip-Hop/Rap']
|
||||
assert album.label == 'pgLang'
|
||||
assert album.barcode == '00602465123456'
|
||||
assert album.source == 'qobuz'
|
||||
|
||||
|
||||
def test_album_from_qobuz_dict_falls_back_through_image_sizes():
|
||||
base = {'id': 1, 'title': 'X', 'artist': {'name': 'A'}}
|
||||
a = Album.from_qobuz_dict({**base, 'image': {'small': 'S'}})
|
||||
assert a.image_url == 'S'
|
||||
b = Album.from_qobuz_dict({**base, 'image': {}})
|
||||
assert b.image_url is None
|
||||
|
||||
|
||||
def test_album_from_qobuz_dict_strips_iso_timestamp_to_date():
|
||||
raw = {'id': 1, 'title': 'X', 'artist': {'name': 'A'},
|
||||
'released_at': '2024-11-22T08:00:00'}
|
||||
assert Album.from_qobuz_dict(raw).release_date == '2024-11-22'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tidal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_album_from_tidal_object_full_shape():
|
||||
"""tidalapi returns objects, not dicts. Use SimpleNamespace stand-ins
|
||||
to mirror the tidalapi.Album shape."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
artist_obj = SimpleNamespace(id=67890, name='Kendrick Lamar')
|
||||
album_obj = SimpleNamespace(
|
||||
id=12345,
|
||||
name='GNX',
|
||||
artist=artist_obj,
|
||||
release_date='2024-11-22',
|
||||
num_tracks=12,
|
||||
type='ALBUM',
|
||||
picture='abc-123-def',
|
||||
universal_product_number='00602465123456',
|
||||
image=lambda size=640: f'https://resources.tidal.com/images/abc/123/def/{size}x{size}.jpg',
|
||||
)
|
||||
|
||||
album = Album.from_tidal_object(album_obj)
|
||||
assert album.id == '12345'
|
||||
assert album.name == 'GNX'
|
||||
assert album.artists == ['Kendrick Lamar']
|
||||
assert album.artist_id == '67890'
|
||||
assert album.release_date == '2024-11-22'
|
||||
assert album.total_tracks == 12
|
||||
assert album.album_type == 'album' # lowercased
|
||||
assert album.image_url and 'tidal.com' in album.image_url
|
||||
assert album.barcode == '00602465123456'
|
||||
assert album.source == 'tidal'
|
||||
assert album.external_ids['tidal'] == '12345'
|
||||
|
||||
|
||||
def test_album_from_tidal_object_falls_back_to_picture_url_when_image_method_missing():
|
||||
from types import SimpleNamespace
|
||||
album_obj = SimpleNamespace(
|
||||
id=1, name='X',
|
||||
artist=SimpleNamespace(name='A', id=2),
|
||||
release_date='2024',
|
||||
num_tracks=10,
|
||||
picture='aa-bb-cc',
|
||||
)
|
||||
album = Album.from_tidal_object(album_obj)
|
||||
assert album.image_url and 'aa/bb/cc' in album.image_url
|
||||
|
||||
|
||||
def test_album_from_tidal_object_handles_missing_attrs():
|
||||
"""Bare-minimum tidalapi-shaped object — should still produce a
|
||||
valid Album with sensible defaults."""
|
||||
from types import SimpleNamespace
|
||||
album_obj = SimpleNamespace(id=1, name='X', artist=None)
|
||||
album = Album.from_tidal_object(album_obj)
|
||||
assert album.id == '1'
|
||||
assert album.name == 'X'
|
||||
assert album.artists == ['Unknown Artist']
|
||||
assert album.total_tracks == 0
|
||||
assert album.album_type == 'album'
|
||||
assert album.image_url is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hydrabase
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -393,6 +508,7 @@ def test_album_from_hydrabase_dict_handles_string_artists():
|
|||
('from_musicbrainz_dict', {'id': 'x', 'title': 'X',
|
||||
'artist-credit': [{'artist': {'name': 'A'}}]}),
|
||||
('from_hydrabase_dict', {'id': 'x', 'name': 'X', 'artists': [{'name': 'A'}]}),
|
||||
('from_qobuz_dict', {'id': 1, 'title': 'X', 'artist': {'name': 'A'}}),
|
||||
])
|
||||
def test_every_converter_produces_required_fields(factory, raw):
|
||||
"""Every converter MUST populate the required fields with sensible
|
||||
|
|
@ -419,6 +535,7 @@ def test_every_converter_produces_required_fields(factory, raw):
|
|||
('from_musicbrainz_dict', {'id': 'x', 'title': 'X',
|
||||
'artist-credit': [{'artist': {'name': 'A'}}]}),
|
||||
('from_hydrabase_dict', {'id': 'x', 'name': 'X', 'artists': [{'name': 'A'}]}),
|
||||
('from_qobuz_dict', {'id': 1, 'title': 'X', 'artist': {'name': 'A'}}),
|
||||
])
|
||||
def test_to_context_dict_shape_is_uniform_across_providers(factory, raw):
|
||||
"""The bridge dict every consumer currently expects has the same
|
||||
|
|
|
|||
Loading…
Reference in a new issue