/
/
/
1"""Parsers for Tidal API responses."""
2
3from __future__ import annotations
4
5from contextlib import suppress
6from datetime import datetime
7from typing import TYPE_CHECKING, Any
8
9from music_assistant_models.enums import (
10 AlbumType,
11 ContentType,
12 ExternalID,
13 ImageType,
14 MediaType,
15)
16from music_assistant_models.media_items import (
17 Album,
18 Artist,
19 AudioFormat,
20 MediaItemImage,
21 Playlist,
22 ProviderMapping,
23 Track,
24 UniqueList,
25)
26
27from music_assistant.helpers.util import infer_album_type, parse_title_and_version
28
29from .constants import BROWSE_URL, RESOURCES_URL
30
31if TYPE_CHECKING:
32 from .provider import TidalProvider
33
34
35def parse_artist(provider: TidalProvider, artist_obj: dict[str, Any]) -> Artist:
36 """Parse tidal artist object to generic layout."""
37 artist_id = str(artist_obj["id"])
38 artist = Artist(
39 item_id=artist_id,
40 provider=provider.instance_id,
41 name=artist_obj["name"],
42 provider_mappings={
43 ProviderMapping(
44 item_id=artist_id,
45 provider_domain=provider.domain,
46 provider_instance=provider.instance_id,
47 # NOTE: don't use the /browse endpoint as it's
48 # not working for musicbrainz lookups
49 url=f"https://tidal.com/artist/{artist_id}",
50 )
51 },
52 )
53 # metadata
54 if artist_obj["picture"]:
55 picture_id = artist_obj["picture"].replace("-", "/")
56 image_url = f"{RESOURCES_URL}/{picture_id}/750x750.jpg"
57 artist.metadata.images = UniqueList(
58 [
59 MediaItemImage(
60 type=ImageType.THUMB,
61 path=image_url,
62 provider=provider.instance_id,
63 remotely_accessible=True,
64 )
65 ]
66 )
67
68 return artist
69
70
71def parse_album(provider: TidalProvider, album_obj: dict[str, Any]) -> Album:
72 """Parse tidal album object to generic layout."""
73 name, version = parse_title_and_version(
74 album_obj.get("title", "Unknown Album"),
75 album_obj.get("version") or None,
76 )
77 album_id = str(album_obj.get("id", ""))
78
79 album = Album(
80 item_id=album_id,
81 provider=provider.instance_id,
82 name=name,
83 version=version,
84 provider_mappings={
85 ProviderMapping(
86 item_id=album_id,
87 provider_domain=provider.domain,
88 provider_instance=provider.instance_id,
89 audio_format=AudioFormat(
90 content_type=ContentType.FLAC,
91 ),
92 url=f"https://tidal.com/album/{album_id}",
93 available=album_obj.get("streamReady", True), # Default to available
94 )
95 },
96 )
97
98 # Safely handle artists array
99 various_artist_album: bool = False
100 for artist_obj in album_obj.get("artists", []):
101 try:
102 if artist_obj.get("name") == "Various Artists":
103 various_artist_album = True
104 album.artists.append(parse_artist(provider, artist_obj))
105 except (KeyError, TypeError) as err:
106 provider.logger.warning("Error parsing artist in album %s: %s", name, err)
107
108 # Safely determine album type
109 album_type = album_obj.get("type", "ALBUM")
110 if album_type == "COMPILATION" or various_artist_album:
111 album.album_type = AlbumType.COMPILATION
112 elif album_type == "ALBUM":
113 album.album_type = AlbumType.ALBUM
114 elif album_type == "EP":
115 album.album_type = AlbumType.EP
116 elif album_type == "SINGLE":
117 album.album_type = AlbumType.SINGLE
118
119 # Try inference - override if it finds something more specific
120 inferred_type = infer_album_type(name, version)
121 if inferred_type in (AlbumType.SOUNDTRACK, AlbumType.LIVE):
122 album.album_type = inferred_type
123
124 # Safely parse year
125 if release_date := album_obj.get("releaseDate", ""):
126 try:
127 album.year = int(release_date.split("-")[0])
128 except (ValueError, IndexError):
129 provider.logger.debug("Invalid release date format: %s", release_date)
130 with suppress(ValueError):
131 album.metadata.release_date = datetime.fromisoformat(release_date)
132
133 # Safely set metadata
134 upc = album_obj.get("upc")
135 if upc:
136 album.external_ids.add((ExternalID.BARCODE, upc))
137
138 album.metadata.copyright = album_obj.get("copyright", "")
139 album.metadata.explicit = album_obj.get("explicit", False)
140 album.metadata.popularity = album_obj.get("popularity", 0)
141
142 # Safely handle cover image
143 cover = album_obj.get("cover")
144 if cover:
145 picture_id = cover.replace("-", "/")
146 image_url = f"{RESOURCES_URL}/{picture_id}/750x750.jpg"
147 album.metadata.images = UniqueList(
148 [
149 MediaItemImage(
150 type=ImageType.THUMB,
151 path=image_url,
152 provider=provider.instance_id,
153 remotely_accessible=True,
154 )
155 ]
156 )
157
158 return album
159
160
161def parse_track(
162 provider: TidalProvider,
163 track_obj: dict[str, Any],
164 lyrics: dict[str, str] | None = None,
165) -> Track:
166 """Parse tidal track object to generic layout."""
167 name, version = parse_title_and_version(
168 track_obj.get("title", "Unknown"),
169 track_obj.get("version") or None,
170 )
171 track_id = str(track_obj.get("id", 0))
172 media_metadata = track_obj.get("mediaMetadata") or {}
173 tags = media_metadata.get("tags", [])
174 hi_res_lossless = any(tag in tags for tag in ["HIRES_LOSSLESS", "HI_RES_LOSSLESS"])
175 track = Track(
176 item_id=track_id,
177 provider=provider.instance_id,
178 name=name,
179 version=version,
180 duration=track_obj.get("duration", 0),
181 provider_mappings={
182 ProviderMapping(
183 item_id=str(track_id),
184 provider_domain=provider.domain,
185 provider_instance=provider.instance_id,
186 audio_format=AudioFormat(
187 content_type=ContentType.FLAC,
188 bit_depth=24 if hi_res_lossless else 16,
189 ),
190 url=f"https://tidal.com/track/{track_id}",
191 available=track_obj["streamReady"],
192 )
193 },
194 disc_number=track_obj.get("volumeNumber", 0) or 0,
195 track_number=track_obj.get("trackNumber", 0) or 0,
196 )
197 if "isrc" in track_obj:
198 track.external_ids.add((ExternalID.ISRC, track_obj["isrc"]))
199 track.artists = UniqueList()
200 for track_artist in track_obj["artists"]:
201 artist = parse_artist(provider, track_artist)
202 track.artists.append(artist)
203 # metadata
204 track.metadata.explicit = track_obj["explicit"]
205 track.metadata.popularity = track_obj["popularity"]
206 if "copyright" in track_obj:
207 track.metadata.copyright = track_obj["copyright"]
208 if lyrics and "lyrics" in lyrics:
209 track.metadata.lyrics = lyrics["lyrics"]
210 if lyrics and "subtitles" in lyrics:
211 track.metadata.lrc_lyrics = lyrics["subtitles"]
212 if track_obj["album"]:
213 # Here we use an ItemMapping as Tidal returns
214 # minimal data when getting an Album from a Track
215 track.album = provider.get_item_mapping(
216 media_type=MediaType.ALBUM,
217 key=str(track_obj["album"]["id"]),
218 name=track_obj["album"]["title"],
219 )
220 if track_obj["album"]["cover"]:
221 picture_id = track_obj["album"]["cover"].replace("-", "/")
222 image_url = f"{RESOURCES_URL}/{picture_id}/750x750.jpg"
223 track.metadata.images = UniqueList(
224 [
225 MediaItemImage(
226 type=ImageType.THUMB,
227 path=image_url,
228 provider=provider.instance_id,
229 remotely_accessible=True,
230 )
231 ]
232 )
233 return track
234
235
236def parse_playlist(
237 provider: TidalProvider, playlist_obj: dict[str, Any], is_mix: bool = False
238) -> Playlist:
239 """Parse tidal playlist object to generic layout."""
240 # Get ID based on playlist type
241 raw_id = str(playlist_obj.get("id" if is_mix else "uuid", ""))
242
243 # Add prefix for mixes to distinguish them
244 playlist_id = f"mix_{raw_id}" if is_mix else raw_id
245
246 # Owner logic differs between types
247 if is_mix:
248 owner_name = "Created by Tidal"
249 is_editable = False
250 else:
251 creator_id = None
252 creator = playlist_obj.get("creator", {})
253 if creator:
254 creator_id = creator.get("id")
255 is_editable = bool(creator_id and str(creator_id) == str(provider.auth.user_id))
256
257 owner_name = "Tidal"
258 if is_editable:
259 if provider.auth.user.profile_name:
260 owner_name = provider.auth.user.profile_name
261 elif provider.auth.user.user_name:
262 owner_name = provider.auth.user.user_name
263 elif provider.auth.user_id:
264 owner_name = str(provider.auth.user_id)
265
266 # URL path differs by type - use raw_id for URLs
267 url_path = "mix" if is_mix else "playlist"
268
269 playlist = Playlist(
270 item_id=playlist_id,
271 provider=provider.instance_id,
272 name=playlist_obj.get("title", "Unknown"),
273 owner=owner_name,
274 provider_mappings={
275 ProviderMapping(
276 item_id=playlist_id, # Use raw ID for provider mapping
277 provider_domain=provider.domain,
278 provider_instance=provider.instance_id,
279 url=f"{BROWSE_URL}/{url_path}/{raw_id}",
280 is_unique=is_editable, # user-owned playlists are unique
281 )
282 },
283 is_editable=is_editable,
284 )
285
286 # Metadata - different fields based on type
287
288 # Add the description from the subtitle for mixes
289 if is_mix:
290 subtitle = playlist_obj.get("subTitle")
291 if subtitle:
292 playlist.metadata.description = subtitle
293
294 # Handle images differently based on type
295 if is_mix:
296 if pictures := playlist_obj.get("images", {}).get("MEDIUM"):
297 image_url = pictures.get("url", "")
298 if image_url:
299 playlist.metadata.images = UniqueList(
300 [
301 MediaItemImage(
302 type=ImageType.THUMB,
303 path=image_url,
304 provider=provider.instance_id,
305 remotely_accessible=True,
306 )
307 ]
308 )
309 elif picture := (playlist_obj.get("squareImage") or playlist_obj.get("image")):
310 picture_id = picture.replace("-", "/")
311 image_url = f"{RESOURCES_URL}/{picture_id}/750x750.jpg"
312 playlist.metadata.images = UniqueList(
313 [
314 MediaItemImage(
315 type=ImageType.THUMB,
316 path=image_url,
317 provider=provider.instance_id,
318 remotely_accessible=True,
319 )
320 ]
321 )
322
323 return playlist
324