From eab1297afc376dc57deae863c258bb9d9c0cee56 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 3 May 2026 22:30:19 -0700 Subject: [PATCH] Add Qobuz + Tidal album converters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/metadata/types.py | 114 +++++++++++++++++++ docs/metadata-types-migration.md | 10 ++ tests/metadata/test_typed_metadata_types.py | 117 ++++++++++++++++++++ 3 files changed, 241 insertions(+) diff --git a/core/metadata/types.py b/core/metadata/types.py index f94e2a60..4ed01ff4 100644 --- a/core/metadata/types.py +++ b/core/metadata/types.py @@ -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.""" diff --git a/docs/metadata-types-migration.md b/docs/metadata-types-migration.md index 2d1f3e83..393e6c09 100644 --- a/docs/metadata-types-migration.md +++ b/docs/metadata-types-migration.md @@ -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 diff --git a/tests/metadata/test_typed_metadata_types.py b/tests/metadata/test_typed_metadata_types.py index cc20f876..20478f75 100644 --- a/tests/metadata/test_typed_metadata_types.py +++ b/tests/metadata/test_typed_metadata_types.py @@ -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