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