/
/
/
1"""Parsers for Zvuk Music API responses."""
2
3from __future__ import annotations
4
5from contextlib import suppress
6from datetime import datetime
7from typing import TYPE_CHECKING
8from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
9
10from music_assistant_models.enums import (
11 AlbumType,
12 ContentType,
13 ImageType,
14)
15from music_assistant_models.media_items import (
16 Album,
17 Artist,
18 AudioFormat,
19 MediaItemImage,
20 Playlist,
21 ProviderMapping,
22 Track,
23 UniqueList,
24)
25
26from music_assistant.helpers.util import parse_title_and_version
27
28from .constants import IMAGE_SIZE_LARGE, ZVUK_BASE_URL
29
30if TYPE_CHECKING:
31 from zvuk_music import Artist as ZvukArtist
32 from zvuk_music import Playlist as ZvukPlaylist
33 from zvuk_music import Release as ZvukRelease
34 from zvuk_music import Track as ZvukTrack
35 from zvuk_music.models.artist import SimpleArtist as ZvukSimpleArtist
36 from zvuk_music.models.common import Image as ZvukImage
37 from zvuk_music.models.playlist import SimplePlaylist as ZvukSimplePlaylist
38 from zvuk_music.models.release import SimpleRelease as ZvukSimpleRelease
39 from zvuk_music.models.track import SimpleTrack as ZvukSimpleTrack
40
41 from .provider import ZvukMusicProvider
42
43
44def _get_image_url(image: ZvukImage | None, size: int = IMAGE_SIZE_LARGE) -> str | None:
45 """
46 Convert Zvuk Image to full URL with guaranteed square dimensions.
47
48 :param image: Zvuk Image object.
49 :param size: Image size in pixels (applied as both width and height).
50 :return: Full image URL or None.
51
52 Note: The underlying ``Image.get_url()`` only updates ``?size=`` when it already
53 exists in the URL. Editorial playlist covers are served without that parameter,
54 so we ensure it is always present after calling ``get_url()``.
55 """
56 if not image or not image.src:
57 return None
58 url = image.get_url(size, size)
59 if not url:
60 return None
61 # Ensure ?size=WxH is always set (get_url skips it when absent from the original URL)
62 parsed = urlparse(url)
63 qs = parse_qs(parsed.query, keep_blank_values=True)
64 if "size" not in qs:
65 qs["size"] = [f"{size}x{size}"]
66 # Zvuk CDN rejects subtype=secondImage (only "cover" and "cover_background" are valid).
67 # The second_image field carries this subtype in its src; remap it to cover_background.
68 if qs.get("subtype") == ["secondImage"]:
69 qs["subtype"] = ["cover_background"]
70 return urlunparse(parsed._replace(query=urlencode(qs, doseq=True)))
71
72
73def parse_artist(provider: ZvukMusicProvider, artist_obj: ZvukArtist | ZvukSimpleArtist) -> Artist:
74 """
75 Parse Zvuk artist object to MA Artist model.
76
77 :param provider: The Zvuk Music provider instance.
78 :param artist_obj: Zvuk artist or SimpleArtist object.
79 :return: Music Assistant Artist model.
80 """
81 artist_id = str(artist_obj.id)
82 artist = Artist(
83 item_id=artist_id,
84 provider=provider.instance_id,
85 name=artist_obj.title or "Unknown Artist",
86 provider_mappings={
87 ProviderMapping(
88 item_id=artist_id,
89 provider_domain=provider.domain,
90 provider_instance=provider.instance_id,
91 url=f"{ZVUK_BASE_URL}/artist/{artist_id}",
92 )
93 },
94 )
95
96 if artist_obj.image:
97 image_url = _get_image_url(artist_obj.image)
98 if image_url:
99 artist.metadata.images = UniqueList(
100 [
101 MediaItemImage(
102 type=ImageType.THUMB,
103 path=image_url,
104 provider=provider.instance_id,
105 remotely_accessible=True,
106 )
107 ]
108 )
109
110 # Map artist biography (only available when fetched with with_description=True)
111 if hasattr(artist_obj, "description") and artist_obj.description:
112 artist.metadata.description = artist_obj.description
113
114 # Map artist background/fanart image (second_image, available on full Artist)
115 if hasattr(artist_obj, "second_image") and artist_obj.second_image:
116 fanart_url = _get_image_url(artist_obj.second_image)
117 if fanart_url:
118 if artist.metadata.images is None:
119 artist.metadata.images = UniqueList()
120 artist.metadata.images.append(
121 MediaItemImage(
122 type=ImageType.FANART,
123 path=fanart_url,
124 provider=provider.instance_id,
125 remotely_accessible=True,
126 )
127 )
128
129 return artist
130
131
132def parse_album(provider: ZvukMusicProvider, release_obj: ZvukRelease | ZvukSimpleRelease) -> Album:
133 """
134 Parse Zvuk release object to MA Album model.
135
136 :param provider: The Zvuk Music provider instance.
137 :param release_obj: Zvuk release or SimpleRelease object.
138 :return: Music Assistant Album model.
139 """
140 name, version = parse_title_and_version(
141 release_obj.title or "Unknown Album",
142 )
143 album_id = str(release_obj.id)
144
145 album = Album(
146 item_id=album_id,
147 provider=provider.instance_id,
148 name=name,
149 version=version,
150 provider_mappings={
151 ProviderMapping(
152 item_id=album_id,
153 provider_domain=provider.domain,
154 provider_instance=provider.instance_id,
155 audio_format=AudioFormat(
156 content_type=ContentType.UNKNOWN,
157 ),
158 url=f"{ZVUK_BASE_URL}/release/{album_id}",
159 )
160 },
161 )
162
163 # Parse artists
164 if release_obj.artists:
165 for artist in release_obj.artists:
166 album.artists.append(parse_artist(provider, artist))
167
168 # Determine album type from ReleaseType
169 if release_obj.type:
170 release_type_value = (
171 release_obj.type.value if hasattr(release_obj.type, "value") else str(release_obj.type)
172 )
173 if release_type_value == "compilation":
174 album.album_type = AlbumType.COMPILATION
175 elif release_type_value == "single":
176 album.album_type = AlbumType.SINGLE
177 elif release_type_value == "ep":
178 album.album_type = AlbumType.EP
179 else:
180 album.album_type = AlbumType.ALBUM
181 else:
182 album.album_type = AlbumType.ALBUM
183
184 # Parse date
185 if release_obj.date:
186 # get_year() is available on both Release and SimpleRelease
187 year = release_obj.get_year()
188 if year:
189 album.year = year
190 with suppress(ValueError):
191 album.metadata.release_date = datetime.fromisoformat(release_obj.date)
192
193 # Parse genres (only available on full Release, not SimpleRelease)
194 if hasattr(release_obj, "genres") and release_obj.genres:
195 album.metadata.genres = {genre.name for genre in release_obj.genres if genre.name}
196
197 # Parse record label (only available on full Release, not SimpleRelease)
198 if hasattr(release_obj, "label") and release_obj.label and release_obj.label.title:
199 album.metadata.label = release_obj.label.title
200
201 # Parse explicit flag
202 if release_obj.explicit:
203 album.metadata.explicit = True
204
205 # Add cover image
206 if release_obj.image:
207 image_url = _get_image_url(release_obj.image)
208 if image_url:
209 album.metadata.images = UniqueList(
210 [
211 MediaItemImage(
212 type=ImageType.THUMB,
213 path=image_url,
214 provider=provider.instance_id,
215 remotely_accessible=True,
216 )
217 ]
218 )
219
220 return album
221
222
223def parse_track(provider: ZvukMusicProvider, track_obj: ZvukTrack | ZvukSimpleTrack) -> Track:
224 """
225 Parse Zvuk track object to MA Track model.
226
227 :param provider: The Zvuk Music provider instance.
228 :param track_obj: Zvuk track or SimpleTrack object.
229 :return: Music Assistant Track model.
230 """
231 name, version = parse_title_and_version(
232 track_obj.title or "Unknown Track",
233 )
234 track_id = str(track_obj.id)
235
236 # Duration is already in seconds in Zvuk API
237 duration = track_obj.duration or 0
238
239 track = Track(
240 item_id=track_id,
241 provider=provider.instance_id,
242 name=name,
243 version=version,
244 duration=duration,
245 provider_mappings={
246 ProviderMapping(
247 item_id=track_id,
248 provider_domain=provider.domain,
249 provider_instance=provider.instance_id,
250 audio_format=AudioFormat(
251 content_type=ContentType.UNKNOWN,
252 ),
253 url=f"{ZVUK_BASE_URL}/track/{track_id}",
254 )
255 },
256 )
257
258 # Parse artists
259 if track_obj.artists:
260 track.artists = UniqueList()
261 for artist in track_obj.artists:
262 track.artists.append(parse_artist(provider, artist))
263
264 # Parse album from release (available on both Track and SimpleTrack)
265 if track_obj.release:
266 track.album = provider.get_item_mapping(
267 media_type="album",
268 key=str(track_obj.release.id),
269 name=track_obj.release.title or "Unknown Album",
270 )
271 # Get image from release
272 if track_obj.release.image:
273 image_url = _get_image_url(track_obj.release.image)
274 if image_url:
275 track.metadata.images = UniqueList(
276 [
277 MediaItemImage(
278 type=ImageType.THUMB,
279 path=image_url,
280 provider=provider.instance_id,
281 remotely_accessible=True,
282 )
283 ]
284 )
285
286 # Track number (position in release, only on full Track)
287 if hasattr(track_obj, "position") and track_obj.position is not None:
288 track.track_number = track_obj.position
289
290 # Genres (only on full Track, not SimpleTrack)
291 if hasattr(track_obj, "genres") and track_obj.genres:
292 track.metadata.genres = {g.name for g in track_obj.genres if g.name}
293
294 # Credits/performers (only on full Track)
295 if hasattr(track_obj, "credits") and track_obj.credits:
296 track.metadata.performers = {track_obj.credits}
297
298 # Explicit flag (boolean on both Track and SimpleTrack)
299 if track_obj.explicit:
300 track.metadata.explicit = True
301
302 return track
303
304
305def parse_playlist(
306 provider: ZvukMusicProvider, playlist_obj: ZvukPlaylist | ZvukSimplePlaylist
307) -> Playlist:
308 """
309 Parse Zvuk playlist object to MA Playlist model.
310
311 :param provider: The Zvuk Music provider instance.
312 :param playlist_obj: Zvuk playlist or SimplePlaylist object.
313 :return: Music Assistant Playlist model.
314 """
315 playlist_id = str(playlist_obj.id)
316
317 # Determine if editable (user owns the playlist)
318 # user_id is only available on full Playlist, not SimplePlaylist
319 is_editable = False
320 owner_name = "Zvuk Music"
321 user_id = getattr(playlist_obj, "user_id", None)
322 if user_id and provider.client.user_id:
323 is_editable = str(user_id) == str(provider.client.user_id)
324 if is_editable:
325 owner_name = "Me"
326
327 playlist = Playlist(
328 item_id=playlist_id,
329 provider=provider.instance_id,
330 name=playlist_obj.title or "Unknown Playlist",
331 owner=owner_name,
332 provider_mappings={
333 ProviderMapping(
334 item_id=playlist_id,
335 provider_domain=provider.domain,
336 provider_instance=provider.instance_id,
337 url=f"{ZVUK_BASE_URL}/playlist/{playlist_id}",
338 is_unique=is_editable,
339 )
340 },
341 is_editable=is_editable,
342 )
343
344 # Metadata
345 if playlist_obj.description:
346 playlist.metadata.description = playlist_obj.description
347
348 # Add cover image
349 if playlist_obj.image:
350 image_url = _get_image_url(playlist_obj.image)
351 if image_url:
352 # Static avatar images (/static/avatar/...) require Zvuk auth cookies and
353 # cannot be fetched publicly; set remotely_accessible=False so MA calls
354 # resolve_image() on the provider instead of trying to proxy the URL directly.
355 src = getattr(playlist_obj.image, "src", None)
356 remotely_accessible = isinstance(src, str) and "/static/" not in src
357 playlist.metadata.images = UniqueList(
358 [
359 MediaItemImage(
360 type=ImageType.THUMB,
361 path=image_url,
362 provider=provider.instance_id,
363 remotely_accessible=remotely_accessible,
364 )
365 ]
366 )
367
368 return playlist
369