Skip to content
Open
33 changes: 30 additions & 3 deletions beetsplug/tidal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
ResourceIdentifier,
TidalAlbum,
TidalArtist,
TidalArtwork,
TidalTrack,
TrackAttributes,
)
Expand Down Expand Up @@ -295,7 +296,7 @@ def search_albums_by_ids(
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
Expand All @@ -312,11 +313,19 @@ def search_albums_by_ids(
for item in albums_doc.get("included", [])
if item["type"] == "artists"
}
artwork_by_id: dict[str, TidalArtwork] = {
item["id"]: item
for item in albums_doc.get("included", [])
if item["type"] == "artworks"
}

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,
artwork_by_id=artwork_by_id,
)
else:
yield None
Expand All @@ -332,6 +341,7 @@ def search_albums_by_ids(
album,
track_by_id=track_by_id,
artist_by_id=artist_by_id,
artwork_by_id=artwork_by_id,
)
else:
yield None
Expand All @@ -341,8 +351,8 @@ def _get_album_info(
album: TidalAlbum,
track_by_id: dict[str, TidalTrack],
artist_by_id: dict[str, TidalArtist],
artwork_by_id: dict[str, TidalArtwork],
) -> AlbumInfo:

track_infos: list[TrackInfo] = []
for i, track_rel in enumerate(
album["relationships"]["items"]["data"], start=1
Expand All @@ -363,6 +373,7 @@ def _get_album_info(
artists_ids=artist_ids,
data_url=self._parse_data_url(album["attributes"]),
barcode=album["attributes"]["barcodeId"],
cover_art_url=self._parse_artwork_url(album, artwork_by_id),
# Meta
album=self._parse_title(album["attributes"]),
tracks=track_infos,
Expand All @@ -381,6 +392,22 @@ def _get_album_info(
tidal_updated=time.time(),
)

@staticmethod
def _parse_artwork_url(
album: TidalAlbum, artwork_by_id: dict[str, TidalArtwork]
) -> str | None:
cover_rel = album["relationships"].get("coverArt")
if cover_rel is None:
return None
ids = [ri["id"] for ri in cover_rel["data"] if ri["type"] == "artworks"]
if not ids:
return None
if cover_art := artwork_by_id.get(ids[0]):
files = cover_art["attributes"]["files"]
if files:
return files[0]["href"]
return None

def _get_track_info(
self, track: TidalTrack, artist_by_id: dict[str, TidalArtist]
) -> TrackInfo:
Expand Down
26 changes: 25 additions & 1 deletion beetsplug/tidal/api_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,28 @@ class TidalTrack(TypedDict):
relationships: dict[str, RelationshipData]


class FileMeta(TypedDict):
width: int
height: int


class ArtworkFile(TypedDict):
href: str
meta: FileMeta


class ArtworkAttributes(TypedDict):
mediaType: Literal["IMAGE"]
files: list[ArtworkFile]
visualMetadata: NotRequired[dict[str, str]]


class TidalArtwork(TypedDict):
id: str
type: Literal["artworks"]
attributes: ArtworkAttributes


class TidalSearch(TypedDict):
id: str
type: Literal["searchResults"]
Expand All @@ -157,7 +179,9 @@ class TidalSearch(TypedDict):

class Document(TypedDict, Generic[T]):
data: T
included: NotRequired[list[TidalArtist | TidalAlbum | TidalTrack]]
included: NotRequired[
list[TidalArtist | TidalAlbum | TidalTrack | TidalArtwork]
]
links: NotRequired[dict[str, str]]


Expand Down
4 changes: 4 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ New features
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
~~~~~~~~~

Expand Down
7 changes: 7 additions & 0 deletions docs/plugins/tidal.rst
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ Default

The path to the file where the Tidal authentication token is stored.

Cover Art
---------

When the :doc:`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
Expand Down
169 changes: 156 additions & 13 deletions test/plugins/test_tidal.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,29 @@
if TYPE_CHECKING:
from beetsplug.tidal.api_types import (
AlbumAttributes,
RelationshipData,
ResourceIdentifier,
TidalAlbum,
TidalArtist,
TidalArtwork,
TidalTrack,
TrackAttributes,
)


def _make_artwork(id_: str, href: str = "") -> TidalArtwork:
return {
"id": id_,
"type": "artworks",
"attributes": {
"mediaType": "IMAGE",
"files": [{"href": href, "meta": {"width": 1280, "height": 1280}}]
if href
else [],
},
}


def _make_artist(id_: str, name: str) -> TidalArtist:
return {
"id": id_,
Expand All @@ -40,6 +55,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
Expand All @@ -61,20 +77,27 @@ def _make_album(
if version:
attrs["version"] = version

relationships: dict[str, RelationshipData] = {
"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": "artworks"}],
"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,
}
return album, track_lookup, artist_lookup

Expand Down Expand Up @@ -143,7 +166,9 @@ def test_parse_album(self):
"1", "My Album", [track], ["1001"]
)

info = self.tidal._get_album_info(album, track_lookup, artist_lookup)
info = self.tidal._get_album_info(
album, track_lookup, artist_lookup, {}
)

assert info.album == "My Album"
assert info.album_id == "1"
Expand All @@ -164,7 +189,9 @@ def test_parse_album_with_multiple_tracks(self):
"2", "Album Two", tracks, ["1001"]
)

info = self.tidal._get_album_info(album, track_lookup, artist_lookup)
info = self.tidal._get_album_info(
album, track_lookup, artist_lookup, {}
)

assert len(info.tracks) == 2
assert info.tracks[0].index == 1
Expand All @@ -178,11 +205,65 @@ def test_parse_album_with_version(self):
"3", "My Album", [track], ["1001"], version="Deluxe Edition"
)

info = self.tidal._get_album_info(album, track_lookup, artist_lookup)
info = self.tidal._get_album_info(
album, track_lookup, artist_lookup, {}
)

assert info.album == "My Album (Deluxe Edition)"


class TestArtworkParsing(TidalPluginTest):
"""Tests for artwork URL parsing."""

def test_artwork_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"
)
artwork_lookup = {
"ca1": _make_artwork(
"ca1", "https://resources.tidal.com/images/ca1/1280x1280.jpg"
)
}

info = self.tidal._get_album_info(
album, track_lookup, artist_lookup, artwork_lookup
)

assert (
info.cover_art_url
== "https://resources.tidal.com/images/ca1/1280x1280.jpg"
)

def test_artwork_without_files_returns_none(self):
"""Cover art returns None when files array is empty."""
track = _make_track("t1", "Song", "PT3M", "ISRC001", ["a1"])
album, track_lookup, artist_lookup = _make_album(
"al1", "Album", [track], ["a1"], cover_art_id="ca1"
)
artwork_lookup = {"ca1": _make_artwork("ca1")}

info = self.tidal._get_album_info(
album, track_lookup, artist_lookup, artwork_lookup
)

assert info.cover_art_url is None

def test_artwork_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."""

Expand Down Expand Up @@ -591,6 +672,68 @@ def test_popularity_with_float(self):
assert TidalPlugin._parse_popularity({"popularity": 1.0}) == 100
assert TidalPlugin._parse_popularity({"popularity": 0.0}) == 0

@pytest.mark.parametrize(
"artwork_data, artwork_by_id, expected",
[
(
[{"id": "ca1", "type": "artworks"}],
{
"ca1": {
"id": "ca1",
"type": "artworks",
"attributes": {
"mediaType": "IMAGE",
"files": [
{
"href": "https://example.com/cover.jpg",
"meta": {"width": 1280, "height": 1280},
}
],
},
}
},
"https://example.com/cover.jpg",
),
(
[{"id": "ca1", "type": "artworks"}],
{
"ca1": {
"id": "ca1",
"type": "artworks",
"attributes": {"mediaType": "IMAGE", "files": []},
}
},
None,
),
# No artworks in relationship data
([{"id": "ca1", "type": "coverArts"}], {}, None),
# No cover art lookup
([{"id": "ca1", "type": "artworks"}], {}, None),
# Empty relationships
({}, {}, None),
],
)
def test_parse_artwork_url(self, artwork_data, artwork_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": artwork_data, "links": {}}}
if artwork_data
else {},
}
assert TidalPlugin._parse_artwork_url(album, artwork_by_id) == expected


class TestTidalsync(TidalPluginTest):
"""Tests for the tidalsync command."""
Expand Down
Loading