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:
Broque Thomas 2026-05-03 22:30:19 -07:00
parent 529486a2d1
commit eab1297afc
3 changed files with 241 additions and 0 deletions

View file

@ -377,6 +377,120 @@ class Album:
external_urls={}, 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 @classmethod
def from_hydrabase_dict(cls, raw: Dict[str, Any]) -> 'Album': def from_hydrabase_dict(cls, raw: Dict[str, Any]) -> 'Album':
"""Hydrabase metadata service response shape.""" """Hydrabase metadata service response shape."""

View file

@ -49,6 +49,16 @@ Plus per-provider classmethod converters on `Album`:
- `Album.from_discogs_dict(raw)` - `Album.from_discogs_dict(raw)`
- `Album.from_musicbrainz_dict(raw)` - `Album.from_musicbrainz_dict(raw)`
- `Album.from_hydrabase_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 Each converter is the SINGLE place that knows that provider's wire
shape. Adding a new provider = adding one classmethod here and shape. Adding a new provider = adding one classmethod here and

View file

@ -342,6 +342,121 @@ def test_album_from_musicbrainz_dict_release_group_type_overrides_default():
assert Album.from_musicbrainz_dict(raw).album_type == 'single' 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 # Hydrabase
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -393,6 +508,7 @@ def test_album_from_hydrabase_dict_handles_string_artists():
('from_musicbrainz_dict', {'id': 'x', 'title': 'X', ('from_musicbrainz_dict', {'id': 'x', 'title': 'X',
'artist-credit': [{'artist': {'name': 'A'}}]}), 'artist-credit': [{'artist': {'name': 'A'}}]}),
('from_hydrabase_dict', {'id': 'x', 'name': 'X', 'artists': [{'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): def test_every_converter_produces_required_fields(factory, raw):
"""Every converter MUST populate the required fields with sensible """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', ('from_musicbrainz_dict', {'id': 'x', 'title': 'X',
'artist-credit': [{'artist': {'name': 'A'}}]}), 'artist-credit': [{'artist': {'name': 'A'}}]}),
('from_hydrabase_dict', {'id': 'x', 'name': 'X', 'artists': [{'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): def test_to_context_dict_shape_is_uniform_across_providers(factory, raw):
"""The bridge dict every consumer currently expects has the same """The bridge dict every consumer currently expects has the same