/
/
/
1"""Parsing utilities to convert Spotify API responses into Music Assistant model objects."""
2
3from __future__ import annotations
4
5import contextlib
6from datetime import datetime
7from typing import TYPE_CHECKING, Any
8
9from music_assistant_models.enums import AlbumType, ContentType, ExternalID, ImageType
10from music_assistant_models.media_items import (
11 Album,
12 Artist,
13 Audiobook,
14 AudioFormat,
15 MediaItemImage,
16 Playlist,
17 Podcast,
18 PodcastEpisode,
19 ProviderMapping,
20 Track,
21)
22from music_assistant_models.unique_list import UniqueList
23
24from music_assistant.helpers.util import infer_album_type, parse_title_and_version
25
26if TYPE_CHECKING:
27 from .provider import SpotifyProvider
28
29
30def parse_images(
31 images_list: list[dict[str, Any]], instance_id: str, exclude_generic: bool = False
32) -> UniqueList[MediaItemImage]:
33 """Parse images list into MediaItemImage objects."""
34 if not images_list:
35 return UniqueList([])
36
37 # Filter out generic images if requested (for artists)
38 filtered_images = []
39 for img in images_list:
40 img_url = img["url"]
41 if exclude_generic and "2a96cbd8b46e442fc41c2b86b821562f" in img_url:
42 continue
43 filtered_images.append(img)
44
45 if not filtered_images:
46 return UniqueList([])
47
48 # Spotify images come in various sizes (typically 640x640, 300x300, 64x64)
49 # Find the largest image available
50 best_image = max(
51 filtered_images, key=lambda img: img.get("height", 0), default=filtered_images[0]
52 )
53
54 return UniqueList(
55 [
56 MediaItemImage(
57 type=ImageType.THUMB,
58 path=best_image["url"],
59 provider=instance_id,
60 remotely_accessible=True,
61 )
62 ]
63 )
64
65
66def parse_artist(artist_obj: dict[str, Any], provider: SpotifyProvider) -> Artist:
67 """Parse spotify artist object to generic layout."""
68 artist = Artist(
69 item_id=artist_obj["id"],
70 provider=provider.instance_id,
71 name=artist_obj["name"] or artist_obj["id"],
72 provider_mappings={
73 ProviderMapping(
74 item_id=artist_obj["id"],
75 provider_domain=provider.domain,
76 provider_instance=provider.instance_id,
77 url=artist_obj["external_urls"]["spotify"],
78 )
79 },
80 )
81 if "genres" in artist_obj:
82 artist.metadata.genres = set(artist_obj["genres"])
83
84 # Use unified image parsing with generic exclusion
85 artist.metadata.images = parse_images(
86 artist_obj.get("images", []), provider.instance_id, exclude_generic=True
87 )
88 return artist
89
90
91def parse_album(album_obj: dict[str, Any], provider: SpotifyProvider) -> Album:
92 """Parse spotify album object to generic layout."""
93 name, version = parse_title_and_version(album_obj["name"])
94 album = Album(
95 item_id=album_obj["id"],
96 provider=provider.instance_id,
97 name=name,
98 version=version,
99 provider_mappings={
100 ProviderMapping(
101 item_id=album_obj["id"],
102 provider_domain=provider.domain,
103 provider_instance=provider.instance_id,
104 audio_format=AudioFormat(content_type=ContentType.OGG, bit_rate=320),
105 url=album_obj["external_urls"]["spotify"],
106 )
107 },
108 )
109 if "external_ids" in album_obj and album_obj["external_ids"].get("upc"):
110 album.external_ids.add((ExternalID.BARCODE, album_obj["external_ids"]["upc"]))
111 if "external_ids" in album_obj and album_obj["external_ids"].get("ean"):
112 album.external_ids.add((ExternalID.BARCODE, album_obj["external_ids"]["ean"]))
113
114 for artist_obj in album_obj["artists"]:
115 if not artist_obj.get("name") or not artist_obj.get("id"):
116 continue
117 album.artists.append(parse_artist(artist_obj, provider))
118
119 with contextlib.suppress(ValueError):
120 album.album_type = AlbumType(album_obj["album_type"])
121
122 # Override with inferred type if version indicates it
123 inferred_type = infer_album_type(album.name, album.version)
124 if inferred_type in (AlbumType.LIVE, AlbumType.SOUNDTRACK):
125 album.album_type = inferred_type
126
127 if "genres" in album_obj:
128 album.metadata.genres = set(album_obj["genres"])
129
130 album.metadata.images = parse_images(album_obj.get("images", []), provider.instance_id)
131
132 if "label" in album_obj:
133 album.metadata.label = album_obj["label"]
134 if album_obj.get("release_date"):
135 album.year = int(album_obj["release_date"].split("-")[0])
136 if album_obj.get("copyrights"):
137 album.metadata.copyright = album_obj["copyrights"][0]["text"]
138 if album_obj.get("explicit"):
139 album.metadata.explicit = album_obj["explicit"]
140 return album
141
142
143def parse_track(
144 track_obj: dict[str, Any],
145 provider: SpotifyProvider,
146 artist: Artist | None = None,
147) -> Track:
148 """Parse spotify track object to generic layout."""
149 name, version = parse_title_and_version(track_obj["name"])
150 track = Track(
151 item_id=track_obj["id"],
152 provider=provider.instance_id,
153 name=name,
154 version=version,
155 duration=int(track_obj["duration_ms"] / 1000),
156 provider_mappings={
157 ProviderMapping(
158 item_id=track_obj["id"],
159 provider_domain=provider.domain,
160 provider_instance=provider.instance_id,
161 audio_format=AudioFormat(content_type=ContentType.OGG, bit_rate=320),
162 url=track_obj["external_urls"]["spotify"],
163 available=not track_obj["is_local"] and track_obj["is_playable"],
164 )
165 },
166 disc_number=track_obj.get("disc_number", 0),
167 track_number=track_obj.get("track_number", 0),
168 )
169 if isrc := track_obj.get("external_ids", {}).get("isrc"):
170 track.external_ids.add((ExternalID.ISRC, isrc))
171
172 if artist:
173 track.artists.append(artist)
174 for track_artist in track_obj.get("artists", []):
175 if not track_artist.get("name") or not track_artist.get("id"):
176 continue
177 artist_parsed = parse_artist(track_artist, provider)
178 if artist_parsed and artist_parsed.item_id not in {x.item_id for x in track.artists}:
179 track.artists.append(artist_parsed)
180
181 track.metadata.explicit = track_obj["explicit"]
182 if "preview_url" in track_obj:
183 track.metadata.preview = track_obj["preview_url"]
184 if "album" in track_obj:
185 track.album = parse_album(track_obj["album"], provider)
186 track.metadata.images = parse_images(
187 track_obj["album"].get("images", []), provider.instance_id
188 )
189 if track_obj.get("copyright"):
190 track.metadata.copyright = track_obj["copyright"]
191 if track_obj.get("explicit"):
192 track.metadata.explicit = True
193 if track_obj.get("popularity"):
194 track.metadata.popularity = track_obj["popularity"]
195 return track
196
197
198def parse_playlist(playlist_obj: dict[str, Any], provider: SpotifyProvider) -> Playlist:
199 """Parse spotify playlist object to generic layout."""
200 owner_id = playlist_obj["owner"].get("id", "")
201 is_editable = (
202 provider._sp_user is not None and owner_id == provider._sp_user["id"]
203 ) or playlist_obj["collaborative"]
204
205 # Spotify-owned playlists (Daily Mix, Discover Weekly, etc.) are personalized per user
206 is_spotify_owned = owner_id.lower() == "spotify"
207
208 # Get owner name with fallback
209 owner_name = playlist_obj["owner"].get("display_name")
210 if owner_name is None and provider._sp_user is not None:
211 owner_name = provider._sp_user["display_name"]
212
213 # Mark as unique if user-owned/editable OR if it's a Spotify personalized playlist
214 is_unique = is_editable or is_spotify_owned
215
216 playlist = Playlist(
217 item_id=playlist_obj["id"],
218 provider=provider.instance_id,
219 name=playlist_obj["name"],
220 owner=owner_name,
221 provider_mappings={
222 ProviderMapping(
223 item_id=playlist_obj["id"],
224 provider_domain=provider.domain,
225 provider_instance=provider.instance_id,
226 url=playlist_obj["external_urls"]["spotify"],
227 is_unique=is_unique,
228 )
229 },
230 is_editable=is_editable,
231 )
232
233 playlist.metadata.images = parse_images(playlist_obj.get("images", []), provider.instance_id)
234 return playlist
235
236
237def parse_podcast(podcast_obj: dict[str, Any], provider: SpotifyProvider) -> Podcast:
238 """Parse spotify podcast (show) object to generic layout."""
239 podcast = Podcast(
240 item_id=podcast_obj["id"],
241 provider=provider.instance_id,
242 name=podcast_obj["name"],
243 provider_mappings={
244 ProviderMapping(
245 item_id=podcast_obj["id"],
246 provider_domain=provider.domain,
247 provider_instance=provider.instance_id,
248 url=podcast_obj["external_urls"]["spotify"],
249 )
250 },
251 publisher=podcast_obj.get("publisher"),
252 total_episodes=podcast_obj.get("total_episodes"),
253 )
254
255 # Set metadata
256 if podcast_obj.get("description"):
257 podcast.metadata.description = podcast_obj["description"]
258
259 podcast.metadata.images = parse_images(podcast_obj.get("images", []), provider.instance_id)
260
261 if "explicit" in podcast_obj:
262 podcast.metadata.explicit = podcast_obj["explicit"]
263
264 if podcast_obj.get("languages"):
265 podcast.metadata.languages = UniqueList(podcast_obj["languages"])
266
267 podcast.metadata.genres = {"Spoken Word"}
268
269 return podcast
270
271
272def parse_podcast_episode(
273 episode_obj: dict[str, Any], provider: SpotifyProvider, podcast: Podcast | None = None
274) -> PodcastEpisode:
275 """Parse spotify podcast episode object to generic layout."""
276 # The show object embedded in an episode carries the show's description,
277 # artwork and publisher, so parse it in full rather than as a name-only stub.
278 if podcast is None and "show" in episode_obj:
279 podcast = parse_podcast(episode_obj["show"], provider)
280 elif podcast is None:
281 # Create a minimal podcast reference if none available
282 podcast = Podcast(
283 item_id="unknown",
284 provider=provider.instance_id,
285 name="Unknown Podcast",
286 translation_key="unknown_podcast",
287 provider_mappings=set(),
288 )
289
290 episode = PodcastEpisode(
291 item_id=episode_obj["id"],
292 provider=provider.instance_id,
293 name=episode_obj["name"],
294 duration=episode_obj["duration_ms"] // 1000 if episode_obj.get("duration_ms") else 0,
295 podcast=podcast,
296 position=0,
297 provider_mappings={
298 ProviderMapping(
299 item_id=episode_obj["id"],
300 provider_domain=provider.domain,
301 provider_instance=provider.instance_id,
302 audio_format=AudioFormat(content_type=ContentType.OGG, bit_rate=160),
303 url=episode_obj["external_urls"]["spotify"],
304 )
305 },
306 )
307
308 # Set description in metadata
309 if episode_obj.get("description"):
310 episode.metadata.description = episode_obj["description"]
311
312 # Add release date to metadata
313 if episode_obj.get("release_date"):
314 with contextlib.suppress(ValueError, TypeError):
315 date_str = episode_obj["release_date"].strip()
316
317 if len(date_str) == 4:
318 # Year only: "2023" -> "2023-01-01T00:00:00+00:00"
319 date_str = f"{date_str}-01-01T00:00:00+00:00"
320 elif len(date_str) == 10:
321 # Date only: "2023-12-25" -> "2023-12-25T00:00:00+00:00"
322 date_str = f"{date_str}T00:00:00+00:00"
323
324 episode.metadata.release_date = datetime.fromisoformat(date_str)
325
326 episode.metadata.images = parse_images(episode_obj.get("images", []), provider.instance_id)
327
328 # Use podcast artwork if episode has none
329 if not episode.metadata.images and isinstance(podcast, Podcast) and podcast.metadata.images:
330 episode.metadata.images = podcast.metadata.images
331
332 if "explicit" in episode_obj:
333 episode.metadata.explicit = episode_obj["explicit"]
334
335 if "audio_preview_url" in episode_obj:
336 episode.metadata.preview = episode_obj["audio_preview_url"]
337
338 return episode
339
340
341def parse_audiobook(audiobook_obj: dict[str, Any], provider: SpotifyProvider) -> Audiobook:
342 """Parse spotify audiobook object to generic layout."""
343 audiobook = Audiobook(
344 item_id=audiobook_obj["id"],
345 provider=provider.instance_id,
346 name=audiobook_obj["name"],
347 provider_mappings={
348 ProviderMapping(
349 item_id=audiobook_obj["id"],
350 provider_domain=provider.domain,
351 provider_instance=provider.instance_id,
352 audio_format=AudioFormat(content_type=ContentType.OGG, bit_rate=320),
353 url=audiobook_obj["external_urls"]["spotify"],
354 )
355 },
356 )
357
358 if "duration_ms" in audiobook_obj:
359 provider.logger.debug(
360 f"Found duration_ms in audiobook object: {audiobook_obj['duration_ms']}"
361 )
362 audiobook.duration = audiobook_obj["duration_ms"] // 1000
363 else:
364 provider.logger.debug(
365 "No duration_ms found in main audiobook object - will calculate from chapters"
366 )
367 # Don't set duration here - let get_audiobook calculate it from chapters
368 audiobook.duration = 0
369
370 # Set authors
371 if "authors" in audiobook_obj:
372 for author_obj in audiobook_obj["authors"]:
373 if author_obj.get("name"):
374 audiobook.authors.append(author_obj["name"])
375
376 # Set narrators
377 if "narrators" in audiobook_obj:
378 for narrator_obj in audiobook_obj["narrators"]:
379 if narrator_obj.get("name"):
380 audiobook.narrators.append(narrator_obj["name"])
381
382 # Set metadata
383 if audiobook_obj.get("description"):
384 audiobook.metadata.description = audiobook_obj["description"]
385
386 if audiobook_obj.get("publisher"):
387 audiobook.publisher = audiobook_obj["publisher"]
388
389 audiobook.metadata.images = parse_images(audiobook_obj.get("images", []), provider.instance_id)
390
391 if audiobook_obj.get("explicit"):
392 audiobook.metadata.explicit = audiobook_obj["explicit"]
393
394 if audiobook_obj.get("languages"):
395 audiobook.metadata.languages = UniqueList(audiobook_obj["languages"])
396
397 audiobook.metadata.genres = {"Spoken Word"}
398
399 # Set publication date if available
400 if audiobook_obj.get("publication_date"):
401 with contextlib.suppress(ValueError, TypeError):
402 date_str = audiobook_obj["publication_date"].strip()
403 if len(date_str) == 4:
404 # Year only: "2023" -> "2023-01-01T00:00:00+00:00"
405 date_str = f"{date_str}-01-01T00:00:00+00:00"
406 elif len(date_str) == 10:
407 # Date only: "2023-12-25" -> "2023-12-25T00:00:00+00:00"
408 date_str = f"{date_str}T00:00:00+00:00"
409 audiobook.metadata.release_date = datetime.fromisoformat(date_str)
410
411 return audiobook
412