From 659ee7df0bf1ecb2c8ccd7cce61a1145f6120239 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Sun, 14 Jun 2026 14:24:51 -0400 Subject: [PATCH] add cover art support --- beetsplug/tidal/__init__.py | 37 ++++++- beetsplug/tidal/api_types.py | 14 ++- docs/changelog.rst | 3 + docs/plugins/tidal.rst | 8 ++ test/plugins/test_tidal.py | 186 +++++++++++++++++++++++++++++++++-- 5 files changed, 234 insertions(+), 14 deletions(-) diff --git a/beetsplug/tidal/__init__.py b/beetsplug/tidal/__init__.py index 361f5b895..b4b120b6b 100644 --- a/beetsplug/tidal/__init__.py +++ b/beetsplug/tidal/__init__.py @@ -32,6 +32,7 @@ if TYPE_CHECKING: ResourceIdentifier, TidalAlbum, TidalArtist, + TidalCoverArt, TidalTrack, TrackAttributes, ) @@ -295,7 +296,7 @@ class TidalPlugin(MetadataSourcePlugin): albums_doc = self.api.get_albums( ids=list(filter(None, _ids)), barcode_ids=barcode_ids, - include=["items.artists", "artists"], + include=["items.artists", "artists", "coverArt"], ) album_by_id: dict[str, TidalAlbum] = { item["id"]: item @@ -312,11 +313,19 @@ class TidalPlugin(MetadataSourcePlugin): for item in albums_doc.get("included", []) if item["type"] == "artists" } + cover_art_by_id: dict[str, TidalCoverArt] = { + item["id"]: item + for item in albums_doc.get("included", []) + if item["type"] == "coverArts" + } for _id in _ids: if _id is not None and (album := album_by_id.get(_id)): yield self._get_album_info( - album, track_by_id=track_by_id, artist_by_id=artist_by_id + album, + track_by_id=track_by_id, + artist_by_id=artist_by_id, + cover_art_by_id=cover_art_by_id, ) else: yield None @@ -332,6 +341,7 @@ class TidalPlugin(MetadataSourcePlugin): album, track_by_id=track_by_id, artist_by_id=artist_by_id, + cover_art_by_id=cover_art_by_id, ) else: yield None @@ -341,8 +351,8 @@ class TidalPlugin(MetadataSourcePlugin): album: TidalAlbum, track_by_id: dict[str, TidalTrack], artist_by_id: dict[str, TidalArtist], + cover_art_by_id: dict[str, TidalCoverArt] | None = None, ) -> AlbumInfo: - track_infos: list[TrackInfo] = [] for i, track_rel in enumerate( album["relationships"]["items"]["data"], start=1 @@ -363,6 +373,7 @@ class TidalPlugin(MetadataSourcePlugin): artists_ids=artist_ids, data_url=self._parse_data_url(album["attributes"]), barcode=album["attributes"]["barcodeId"], + cover_art_url=self._parse_cover_art_url(album, cover_art_by_id), # Meta album=self._parse_title(album["attributes"]), tracks=track_infos, @@ -381,6 +392,26 @@ class TidalPlugin(MetadataSourcePlugin): tidal_updated=time.time(), ) + @staticmethod + def _parse_cover_art_url( + album: TidalAlbum, cover_art_by_id: dict[str, TidalCoverArt] | None + ) -> str | None: + if not cover_art_by_id: + return None + cover_art_data = ( + album.get("relationships", {}).get("coverArt", {}).get("data") + ) + if not cover_art_data: + return None + ids = [ri["id"] for ri in cover_art_data if ri["type"] == "coverArts"] + if not ids: + return None + if cover_art := cover_art_by_id.get(ids[0]): + if url := cover_art.get("attributes", {}).get("url"): + return url + return f"https://resources.tidal.com/images/{ids[0]}/1280x1280.jpg" + return None + def _get_track_info( self, track: TidalTrack, artist_by_id: dict[str, TidalArtist] ) -> TrackInfo: diff --git a/beetsplug/tidal/api_types.py b/beetsplug/tidal/api_types.py index 7e61e6b14..6273b8db8 100644 --- a/beetsplug/tidal/api_types.py +++ b/beetsplug/tidal/api_types.py @@ -145,6 +145,16 @@ class TidalTrack(TypedDict): relationships: dict[str, RelationshipData] +class CoverArtAttributes(TypedDict): + url: str + + +class TidalCoverArt(TypedDict): + id: str + type: Literal["coverArts"] + attributes: CoverArtAttributes + + class TidalSearch(TypedDict): id: str type: Literal["searchResults"] @@ -157,7 +167,9 @@ T = TypeVar("T") class Document(TypedDict, Generic[T]): data: T - included: NotRequired[list[TidalArtist | TidalAlbum | TidalTrack]] + included: NotRequired[ + list[TidalArtist | TidalAlbum | TidalTrack | TidalCoverArt] + ] links: NotRequired[dict[str, str]] diff --git a/docs/changelog.rst b/docs/changelog.rst index 2609ce8fa..765672828 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -77,6 +77,9 @@ New features the new flexible attributes with ``beet modify``: run ``beet modify data_source:tidal tidal_album_id='$mb_albumid' -a`` for albums and ``beet modify data_source:tidal tidal_track_id='$mb_trackid'`` for items. +- :doc:`plugins/tidal`: Add cover art support. Album metadata now includes + ``cover_art_url`` from Tidal's ``coverArt`` relationship, which the + :doc:`plugins/fetchart` plugin can retrieve. Bug fixes ~~~~~~~~~ diff --git a/docs/plugins/tidal.rst b/docs/plugins/tidal.rst index 689d8002d..6a9abc65e 100644 --- a/docs/plugins/tidal.rst +++ b/docs/plugins/tidal.rst @@ -102,6 +102,14 @@ Default The path to the file where the Tidal authentication token is stored. +Cover Art +--------- + +When the :doc:`plugins/fetchart` plugin is enabled, beets can retrieve album +cover art from Tidal. The Tidal plugin automatically includes the cover art URL +when looking up albums. No additional configuration is needed to enable this +feature. + .. include:: ./shared_metadata_source_config.rst Flexible Attributes diff --git a/test/plugins/test_tidal.py b/test/plugins/test_tidal.py index b76693e30..947f36cae 100644 --- a/test/plugins/test_tidal.py +++ b/test/plugins/test_tidal.py @@ -19,6 +19,7 @@ if TYPE_CHECKING: ResourceIdentifier, TidalAlbum, TidalArtist, + TidalCoverArt, TidalTrack, TrackAttributes, ) @@ -26,6 +27,14 @@ if TYPE_CHECKING: CURRENT_TS = 150000000 +def _make_cover_art(id_: str, url: str = "") -> TidalCoverArt: + return { + "id": id_, + "type": "coverArts", + "attributes": {"url": url} if url else {}, + } + + def _make_artist(id_: str, name: str) -> TidalArtist: return { "id": id_, @@ -41,6 +50,7 @@ def _make_album( artist_ids: list[str], release_date: str = "2024-01-15", version: str | None = None, + cover_art_id: str | None = None, ) -> tuple[TidalAlbum, dict[str, TidalTrack], dict[str, TidalArtist]]: artist_lookup = { aid: _make_artist(aid, f"Artist {aid}") for aid in artist_ids @@ -62,20 +72,27 @@ def _make_album( if version: attrs["version"] = version + relationships: dict[str, object] = { + "artists": { + "data": [{"id": aid, "type": "artists"} for aid in artist_ids], + "links": {}, + }, + "items": { + "data": [{"id": t["id"], "type": "tracks"} for t in tracks], + "links": {}, + }, + } + if cover_art_id: + relationships["coverArt"] = { + "data": [{"id": cover_art_id, "type": "coverArts"}], + "links": {}, + } + album: TidalAlbum = { "id": id_, "type": "albums", "attributes": attrs, - "relationships": { - "artists": { - "data": [{"id": aid, "type": "artists"} for aid in artist_ids], - "links": {}, - }, - "items": { - "data": [{"id": t["id"], "type": "tracks"} for t in tracks], - "links": {}, - }, - }, + "relationships": relationships, # type: ignore[typeddict-item] } return album, track_lookup, artist_lookup @@ -166,6 +183,7 @@ class TestParsing(TidalPluginTest): "barcode": "123456", "catalognum": None, "country": None, + "cover_art_url": None, "data_source": "Tidal", "data_url": None, "day": 15, @@ -289,6 +307,101 @@ class TestParsing(TidalPluginTest): "year": 2024, } + def test_parse_album_with_version(self): + """Album title should have version appended.""" + track = _make_track("101", "My Song", "PT3M", "ISRC001", ["1001"]) + album, track_lookup, artist_lookup = _make_album( + "3", "My Album", [track], ["1001"], version="Deluxe Edition" + ) + + info = self.tidal._get_album_info(album, track_lookup, artist_lookup) + + assert info.album == "My Album (Deluxe Edition)" + + +class TestCoverArtParsing(TidalPluginTest): + """Tests for cover art URL parsing.""" + + def test_cover_art_with_url(self): + """Cover art URL from included resources.""" + track = _make_track("t1", "Song", "PT3M", "ISRC001", ["a1"]) + album, track_lookup, artist_lookup = _make_album( + "al1", "Album", [track], ["a1"], cover_art_id="ca1" + ) + cover_art_lookup = { + "ca1": _make_cover_art( + "ca1", "https://resources.tidal.com/images/ca1/1280x1280.jpg" + ) + } + + info = self.tidal._get_album_info( + album, track_lookup, artist_lookup, cover_art_lookup + ) + + assert ( + info.cover_art_url + == "https://resources.tidal.com/images/ca1/1280x1280.jpg" + ) + + def test_cover_art_without_url_falls_back_to_constructed(self): + """Cover art URL constructed from ID when no URL in attributes.""" + track = _make_track("t1", "Song", "PT3M", "ISRC001", ["a1"]) + album, track_lookup, artist_lookup = _make_album( + "al1", "Album", [track], ["a1"], cover_art_id="ca1" + ) + cover_art_lookup = {"ca1": _make_cover_art("ca1")} + + info = self.tidal._get_album_info( + album, track_lookup, artist_lookup, cover_art_lookup + ) + + assert ( + info.cover_art_url + == "https://resources.tidal.com/images/ca1/1280x1280.jpg" + ) + + def test_cover_art_without_relationship_returns_none(self): + """No cover_art_url when album has no coverArt relationship.""" + track = _make_track("t1", "Song", "PT3M", "ISRC001", ["a1"]) + album, track_lookup, artist_lookup = _make_album( + "al1", "Album", [track], ["a1"] + ) + + info = self.tidal._get_album_info(album, track_lookup, artist_lookup) + + assert info.cover_art_url is None + + +class TestTrackParsing(TidalPluginTest): + """High-level tests for track parsing.""" + + def test_parse_track(self): + track = _make_track("101", "My Track", "PT4M", "ISRC456", ["1001"]) + artist_lookup = {"1001": _make_artist("1001", "My Artist")} + + info = self.tidal._get_track_info(track, artist_lookup) + + assert info.title == "My Track" + assert info.track_id == "101" + assert info.duration == 240 # PT4M = 240 seconds + assert info.isrc == "ISRC456" + assert info.artist == "My Artist" + assert info.tidal_track_id == "101" + assert info.tidal_artist_id == "1001" + assert info.tidal_track_popularity == 50 + + def test_parse_track_with_version(self): + """Track title should have version appended.""" + track = _make_track( + "102", "My Song", "PT3M", "ISRC002", ["1001"], version="Remastered" + ) + artist_lookup = {"1001": _make_artist("1001", "My Artist")} + + info = self.tidal._get_track_info(track, artist_lookup) + + assert info.title == "My Song (Remastered)" + assert info.tidal_track_id == "102" + class TestTrackForID(TidalPluginTest): """Tests for track_for_id with mocked API.""" @@ -843,3 +956,56 @@ class TestTidalsync(TidalPluginTest): ["albums"], write=False, force=False ) self.tidal.sync_item_popularity.assert_not_called() + + + @pytest.mark.parametrize( + "cover_art_data, cover_art_by_id, expected", + [ + ( + [{"id": "ca1", "type": "coverArts"}], + { + "ca1": { + "id": "ca1", + "type": "coverArts", + "attributes": {"url": "https://example.com/cover.jpg"}, + } + }, + "https://example.com/cover.jpg", + ), + ( + [{"id": "ca1", "type": "coverArts"}], + {"ca1": {"id": "ca1", "type": "coverArts", "attributes": {}}}, + "https://resources.tidal.com/images/ca1/1280x1280.jpg", + ), + # No coverArts in relationship data + ([{"id": "ca1", "type": "artworks"}], {}, None), + # No cover art lookup + ([{"id": "ca1", "type": "coverArts"}], None, None), + # Empty relationships + ({}, {}, None), + ], + ) + def test_parse_cover_art_url( + self, cover_art_data, cover_art_by_id, expected + ): + album: TidalAlbum = { + "id": "al1", + "type": "albums", + "attributes": { + "albumType": "ALBUM", + "barcodeId": "123", + "duration": "PT45M", + "explicit": False, + "mediaTags": [], + "numberOfItems": 1, + "numberOfVolumes": 1, + "popularity": 0.5, + "title": "Album", + }, + "relationships": {"coverArt": {"data": cover_art_data, "links": {}}} + if cover_art_data + else {}, + } + assert ( + TidalPlugin._parse_cover_art_url(album, cover_art_by_id) == expected + )