add cover art support

This commit is contained in:
Alok Saboo
2026-06-14 14:24:51 -04:00
parent bf879d5e1e
commit 659ee7df0b
5 changed files with 234 additions and 14 deletions

View File

@@ -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:

View File

@@ -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]]

View File

@@ -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
~~~~~~~~~

View File

@@ -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

View File

@@ -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
)