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.
pull/490/head
Broque Thomas 3 weeks ago
parent 529486a2d1
commit eab1297afc

@ -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…
Cancel
Save