/
/
/
1"""API client wrapper for Zvuk Music."""
2
3from __future__ import annotations
4
5import logging
6from collections.abc import Awaitable, Callable, Sequence
7from typing import Any, ParamSpec, TypeVar, cast
8
9from music_assistant_models.errors import (
10 LoginFailed,
11 ProviderUnavailableError,
12 RateLimited,
13 ResourceTemporarilyUnavailable,
14)
15from zvuk_music import Artist as ZvukArtist
16from zvuk_music import ClientAsync, Collection, StreamQuality
17from zvuk_music import CollectionItem as ZvukCollectionItem
18from zvuk_music import DirectStream as ZvukDirectStream
19from zvuk_music import Lyrics as ZvukLyrics
20from zvuk_music import Playlist as ZvukPlaylist
21from zvuk_music import Release as ZvukRelease
22from zvuk_music import Search as ZvukSearch
23from zvuk_music import SimplePlaylist as ZvukSimplePlaylist
24from zvuk_music import SimpleTrack as ZvukSimpleTrack
25from zvuk_music import Stream as ZvukStream
26from zvuk_music import Track as ZvukTrack
27from zvuk_music.exceptions import (
28 BadRequestError,
29 BotDetectedError,
30 GraphQLError,
31 NetworkError,
32 NotFoundError,
33 TimedOutError,
34 UnauthorizedError,
35)
36
37from music_assistant.helpers.throttle_retry import Throttler
38
39from .constants import DEFAULT_LIMIT
40
41LOGGER = logging.getLogger(__name__)
42
43_P = ParamSpec("_P")
44_R = TypeVar("_R")
45_NOT_FOUND_SENTINEL: Any = object()
46
47
48def handle_zvuk_errors(
49 not_found_return: Any = _NOT_FOUND_SENTINEL,
50) -> Callable[[Callable[_P, Awaitable[_R]]], Callable[_P, Awaitable[_R]]]:
51 """
52 Decorate async methods to map Zvuk API exceptions to MA errors.
53
54 :param not_found_return: Value to return on NotFoundError (e.g. None or []).
55 If not provided, NotFoundError is not caught.
56 """
57
58 def decorator(func: Callable[_P, Awaitable[_R]]) -> Callable[_P, Awaitable[_R]]:
59 async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
60 try:
61 return await func(*args, **kwargs)
62 except UnauthorizedError as err:
63 raise LoginFailed("Invalid Zvuk Music token") from err
64 except NetworkError as err:
65 msg = str(err).lower()
66 if "429" in msg or "too many requests" in msg or "rate limit" in msg:
67 raise RateLimited("Zvuk Music rate limit", backoff_time=60) from err
68 LOGGER.error("Zvuk API error: %s", err)
69 raise ResourceTemporarilyUnavailable("Zvuk Music request failed") from err
70 except TimedOutError as err:
71 LOGGER.error("Zvuk API error: %s", err)
72 raise ResourceTemporarilyUnavailable("Zvuk Music request failed") from err
73 except (BadRequestError, GraphQLError) as err:
74 LOGGER.error("Zvuk API error: %s", err)
75 raise ResourceTemporarilyUnavailable("Zvuk Music request failed") from err
76 except BotDetectedError as err:
77 raise ProviderUnavailableError("Bot detected by Zvuk") from err
78 except NotFoundError:
79 if not_found_return is _NOT_FOUND_SENTINEL:
80 raise
81 return cast("_R", not_found_return)
82
83 return wrapper
84
85 return decorator
86
87
88class ZvukMusicClient:
89 """Wrapper around zvuk-music ClientAsync."""
90
91 def __init__(self, token: str) -> None:
92 """
93 Initialize the Zvuk Music client.
94
95 :param token: Zvuk Music X-Auth-Token.
96 """
97 self._token = token
98 self._client: ClientAsync | None = None
99 self._user_id: str | None = None
100 self._throttler = Throttler(rate_limit=5, period=1.0)
101
102 @property
103 def user_id(self) -> str:
104 """Return the user ID."""
105 if self._user_id is None:
106 raise ProviderUnavailableError("Client not initialized, call connect() first")
107 return self._user_id
108
109 async def connect(self) -> None:
110 """
111 Initialize the client and verify token validity.
112
113 :raises LoginFailed: If the token is invalid.
114 :raises ResourceTemporarilyUnavailable: If there is a network error.
115 """
116 try:
117 self._client = await ClientAsync(token=self._token).init()
118 if not await self._client.is_authorized():
119 raise LoginFailed("Invalid Zvuk Music token")
120 profile = await self._client.get_profile()
121 if profile and profile.result:
122 self._user_id = str(profile.result.id)
123 LOGGER.debug("Connected to Zvuk Music as user %s", self._user_id)
124 except UnauthorizedError as err:
125 raise LoginFailed("Invalid Zvuk Music token") from err
126 except (NetworkError, TimedOutError) as err:
127 msg = "Network error connecting to Zvuk Music"
128 raise ResourceTemporarilyUnavailable(msg) from err
129
130 async def disconnect(self) -> None:
131 """Disconnect the client."""
132 self._client = None
133 self._user_id = None
134
135 @handle_zvuk_errors(not_found_return=None)
136 async def search(
137 self,
138 query: str,
139 limit: int = DEFAULT_LIMIT,
140 *,
141 search_tracks: bool = True,
142 search_artists: bool = True,
143 search_releases: bool = True,
144 search_playlists: bool = True,
145 ) -> ZvukSearch | None:
146 """
147 Search for tracks, albums, artists, or playlists.
148
149 :param query: Search query string.
150 :param limit: Maximum number of results per type.
151 :param search_tracks: Whether to search for tracks.
152 :param search_artists: Whether to search for artists.
153 :param search_releases: Whether to search for releases.
154 :param search_playlists: Whether to search for playlists.
155 :return: Search results object or None.
156 """
157 client = await self._get_client()
158 return await client.search(
159 query,
160 limit=limit,
161 tracks=search_tracks,
162 artists=search_artists,
163 releases=search_releases,
164 playlists=search_playlists,
165 podcasts=False,
166 episodes=False,
167 profiles=False,
168 books=False,
169 )
170
171 @handle_zvuk_errors(not_found_return=None)
172 async def get_track(self, track_id: str) -> ZvukTrack | None:
173 """
174 Get a single track by ID.
175
176 :param track_id: Track ID.
177 :return: Track object or None if not found.
178 """
179 client = await self._get_client()
180 return await client.get_track(track_id)
181
182 @handle_zvuk_errors(not_found_return=[])
183 async def get_tracks(self, track_ids: list[str]) -> list[ZvukTrack]:
184 """
185 Get multiple tracks by IDs.
186
187 :param track_ids: List of track IDs.
188 :return: List of track objects.
189 """
190 client = await self._get_client()
191 return await client.get_tracks(list(track_ids))
192
193 @handle_zvuk_errors(not_found_return=None)
194 async def get_release(self, release_id: str) -> ZvukRelease | None:
195 """
196 Get a single release (album) by ID.
197
198 :param release_id: Release ID.
199 :return: Release object or None if not found.
200 """
201 client = await self._get_client()
202 return await client.get_release(release_id)
203
204 @handle_zvuk_errors(not_found_return=[])
205 async def get_releases(self, release_ids: list[str]) -> list[ZvukRelease]:
206 """
207 Get multiple releases by IDs.
208
209 :param release_ids: List of release IDs.
210 :return: List of release objects.
211 """
212 client = await self._get_client()
213 return await client.get_releases(list(release_ids))
214
215 @handle_zvuk_errors(not_found_return=None)
216 async def get_artist(self, artist_id: str) -> ZvukArtist | None:
217 """
218 Get a single artist by ID.
219
220 :param artist_id: Artist ID.
221 :return: Artist object or None if not found.
222 """
223 client = await self._get_client()
224 return await client.get_artist(artist_id, with_description=True)
225
226 @handle_zvuk_errors(not_found_return=[])
227 async def get_artists(self, artist_ids: list[str]) -> list[ZvukArtist]:
228 """
229 Get multiple artists by IDs.
230
231 :param artist_ids: List of artist IDs.
232 :return: List of artist objects.
233 """
234 client = await self._get_client()
235 return await client.get_artists(list(artist_ids))
236
237 @handle_zvuk_errors(not_found_return=[])
238 async def get_artist_releases(
239 self, artist_id: str, limit: int = DEFAULT_LIMIT
240 ) -> list[ZvukArtist]:
241 """
242 Get artist's releases.
243
244 :param artist_id: Artist ID.
245 :param limit: Maximum number of releases.
246 :return: List of artist objects with populated releases.
247 """
248 client = await self._get_client()
249 return await client.get_artists([artist_id], with_releases=True, releases_limit=limit)
250
251 @handle_zvuk_errors(not_found_return=[])
252 async def get_artist_top_tracks(
253 self, artist_id: str, limit: int = DEFAULT_LIMIT
254 ) -> list[ZvukArtist]:
255 """
256 Get artist's top tracks.
257
258 :param artist_id: Artist ID.
259 :param limit: Maximum number of tracks.
260 :return: List of artist objects with populated popular_tracks.
261 """
262 client = await self._get_client()
263 return await client.get_artists([artist_id], with_popular_tracks=True, tracks_limit=limit)
264
265 @handle_zvuk_errors(not_found_return=None)
266 async def get_playlist(self, playlist_id: str) -> ZvukPlaylist | None:
267 """
268 Get a playlist by ID.
269
270 :param playlist_id: Playlist ID.
271 :return: Playlist object or None if not found.
272 """
273 client = await self._get_client()
274 return await client.get_playlist(playlist_id)
275
276 @handle_zvuk_errors(not_found_return=[])
277 async def get_playlists(self, playlist_ids: list[str]) -> list[ZvukPlaylist]:
278 """
279 Get multiple playlists by IDs.
280
281 :param playlist_ids: List of playlist IDs.
282 :return: List of playlist objects.
283 """
284 client = await self._get_client()
285 return await client.get_playlists(list(playlist_ids))
286
287 @handle_zvuk_errors(not_found_return=[])
288 async def get_playlist_tracks(
289 self, playlist_id: str, limit: int = 50, offset: int = 0
290 ) -> list[ZvukSimpleTrack]:
291 """
292 Get playlist tracks.
293
294 :param playlist_id: Playlist ID.
295 :param limit: Maximum number of tracks.
296 :param offset: Offset for pagination.
297 :return: List of SimpleTrack objects.
298 """
299 client = await self._get_client()
300 return await client.get_playlist_tracks(playlist_id, limit=limit, offset=offset)
301
302 @handle_zvuk_errors(not_found_return=[])
303 async def get_stream_urls(self, track_id: str) -> list[ZvukStream]:
304 """
305 Get stream URLs for a track.
306
307 :param track_id: Track ID.
308 :return: List of Stream objects.
309 """
310 client = await self._get_client()
311 return await client.get_stream_urls(track_id)
312
313 @handle_zvuk_errors(not_found_return=None)
314 async def get_direct_stream_url(self, track_id: str, quality: str) -> str | None:
315 """
316 Get a direct (non-DRM) stream URL for a track.
317
318 :param track_id: Track ID.
319 :param quality: Quality string â "flac", "high", or "mid".
320 :return: Stream URL string, or None if not found.
321 """
322 client = await self._get_client()
323 result: ZvukDirectStream | None = await client.get_direct_stream_url(
324 track_id, StreamQuality(quality)
325 )
326 if not result:
327 return None
328 return result.stream or None
329
330 @handle_zvuk_errors(not_found_return=None)
331 async def get_collection(self) -> Collection | None:
332 """
333 Get user's collection (liked items).
334
335 :return: Collection object or None.
336 """
337 client = await self._get_client()
338 return await client.get_collection()
339
340 @handle_zvuk_errors(not_found_return=[])
341 async def get_liked_tracks(self) -> list[ZvukTrack]:
342 """
343 Get user's liked tracks.
344
345 :return: List of full Track objects.
346 """
347 client = await self._get_client()
348 return await client.get_liked_tracks()
349
350 @handle_zvuk_errors(not_found_return=[])
351 async def get_user_playlists(self) -> list[ZvukCollectionItem]:
352 """
353 Get user's playlists.
354
355 :return: List of CollectionItem objects with playlist IDs.
356 """
357 client = await self._get_client()
358 return await client.get_user_playlists()
359
360 @handle_zvuk_errors(not_found_return=[])
361 async def get_short_playlists(
362 self, playlist_ids: Sequence[int | str]
363 ) -> list[ZvukSimplePlaylist]:
364 """
365 Get playlist metadata (title, image, description) by IDs without tracks.
366
367 Uses the lightweight getShortPlaylist GraphQL query which returns only metadata.
368 Works for both regular playlists and synthesis playlists (IDs 3,4,6,11,12,13,14,15).
369
370 :param playlist_ids: List of playlist IDs.
371 :return: List of SimplePlaylist objects.
372 """
373 client = await self._get_client()
374 return await client.get_short_playlist(list(playlist_ids))
375
376 @handle_zvuk_errors(not_found_return=[])
377 async def get_editorial_playlist_ids(self) -> list[str]:
378 """
379 Get editorial (curated) playlist IDs from Zvuk's grid content API.
380
381 Fetches «ÐодбоÑки» â genre-focused curated playlists shown on the home page.
382
383 :return: List of playlist IDs as strings.
384 """
385 client = await self._get_client()
386 return await client.get_editorial_playlist_ids()
387
388 @handle_zvuk_errors(not_found_return=None)
389 async def get_lyrics(self, track_id: str) -> ZvukLyrics | None:
390 """
391 Get lyrics for a track from Zvuk lyrics API.
392
393 Returns synced LRC text (``is_synced=True``) or plain text.
394 Returns ``None`` if the track has no lyrics.
395
396 :param track_id: Track ID.
397 :return: Lyrics object or None.
398 """
399 client = await self._get_client()
400 return await client.get_lyrics(track_id)
401
402 async def like_track(self, track_id: str) -> bool:
403 """
404 Add a track to liked tracks.
405
406 :param track_id: Track ID.
407 :return: True if successful.
408 """
409 client = await self._get_client()
410 try:
411 return await client.like_track(track_id)
412 except (BadRequestError, NetworkError, GraphQLError) as err:
413 LOGGER.error("Error liking track %s: %s", track_id, err)
414 return False
415
416 async def unlike_track(self, track_id: str) -> bool:
417 """
418 Remove a track from liked tracks.
419
420 :param track_id: Track ID.
421 :return: True if successful.
422 """
423 client = await self._get_client()
424 try:
425 return await client.unlike_track(track_id)
426 except (BadRequestError, NetworkError, GraphQLError) as err:
427 LOGGER.error("Error unliking track %s: %s", track_id, err)
428 return False
429
430 async def like_release(self, release_id: str) -> bool:
431 """
432 Add a release to liked releases.
433
434 :param release_id: Release ID.
435 :return: True if successful.
436 """
437 client = await self._get_client()
438 try:
439 return await client.like_release(release_id)
440 except (BadRequestError, NetworkError, GraphQLError) as err:
441 LOGGER.error("Error liking release %s: %s", release_id, err)
442 return False
443
444 async def unlike_release(self, release_id: str) -> bool:
445 """
446 Remove a release from liked releases.
447
448 :param release_id: Release ID.
449 :return: True if successful.
450 """
451 client = await self._get_client()
452 try:
453 return await client.unlike_release(release_id)
454 except (BadRequestError, NetworkError, GraphQLError) as err:
455 LOGGER.error("Error unliking release %s: %s", release_id, err)
456 return False
457
458 async def like_artist(self, artist_id: str) -> bool:
459 """
460 Add an artist to liked artists.
461
462 :param artist_id: Artist ID.
463 :return: True if successful.
464 """
465 client = await self._get_client()
466 try:
467 return await client.like_artist(artist_id)
468 except (BadRequestError, NetworkError, GraphQLError) as err:
469 LOGGER.error("Error liking artist %s: %s", artist_id, err)
470 return False
471
472 async def unlike_artist(self, artist_id: str) -> bool:
473 """
474 Remove an artist from liked artists.
475
476 :param artist_id: Artist ID.
477 :return: True if successful.
478 """
479 client = await self._get_client()
480 try:
481 return await client.unlike_artist(artist_id)
482 except (BadRequestError, NetworkError, GraphQLError) as err:
483 LOGGER.error("Error unliking artist %s: %s", artist_id, err)
484 return False
485
486 async def like_playlist(self, playlist_id: str) -> bool:
487 """
488 Add a playlist to liked playlists.
489
490 :param playlist_id: Playlist ID.
491 :return: True if successful.
492 """
493 client = await self._get_client()
494 try:
495 return await client.like_playlist(playlist_id)
496 except (BadRequestError, NetworkError, GraphQLError) as err:
497 LOGGER.error("Error liking playlist %s: %s", playlist_id, err)
498 return False
499
500 async def unlike_playlist(self, playlist_id: str) -> bool:
501 """
502 Remove a playlist from liked playlists.
503
504 :param playlist_id: Playlist ID.
505 :return: True if successful.
506 """
507 client = await self._get_client()
508 try:
509 return await client.unlike_playlist(playlist_id)
510 except (BadRequestError, NetworkError, GraphQLError) as err:
511 LOGGER.error("Error unliking playlist %s: %s", playlist_id, err)
512 return False
513
514 @handle_zvuk_errors()
515 async def create_playlist(self, name: str, track_ids: list[str] | None = None) -> str:
516 """
517 Create a new playlist.
518
519 :param name: Playlist name.
520 :param track_ids: Optional list of track IDs to add.
521 :return: New playlist ID.
522 """
523 client = await self._get_client()
524 return await client.create_playlist(name, track_ids=track_ids)
525
526 async def delete_playlist(self, playlist_id: str) -> bool:
527 """
528 Delete a playlist.
529
530 :param playlist_id: Playlist ID.
531 :return: True if successful.
532 """
533 client = await self._get_client()
534 try:
535 return await client.delete_playlist(playlist_id)
536 except (BadRequestError, NetworkError, GraphQLError) as err:
537 LOGGER.error("Error deleting playlist %s: %s", playlist_id, err)
538 return False
539
540 async def add_tracks_to_playlist(self, playlist_id: str, track_ids: list[str]) -> bool:
541 """
542 Add tracks to a playlist.
543
544 :param playlist_id: Playlist ID.
545 :param track_ids: List of track IDs to add.
546 :return: True if successful.
547 """
548 client = await self._get_client()
549 try:
550 return await client.add_tracks_to_playlist(playlist_id, track_ids)
551 except (BadRequestError, NetworkError, GraphQLError) as err:
552 LOGGER.error("Error adding tracks to playlist %s: %s", playlist_id, err)
553 return False
554
555 async def update_playlist(self, playlist_id: str, track_ids: list[str]) -> bool:
556 """
557 Update playlist tracks (used for removing tracks by providing remaining ones).
558
559 :param playlist_id: Playlist ID.
560 :param track_ids: Complete list of track IDs the playlist should contain.
561 :return: True if successful.
562 """
563 client = await self._get_client()
564 try:
565 return await client.update_playlist(playlist_id, track_ids)
566 except (BadRequestError, NetworkError, GraphQLError) as err:
567 LOGGER.error("Error updating playlist %s: %s", playlist_id, err)
568 return False
569
570 def _ensure_connected(self) -> ClientAsync:
571 """Ensure the client is connected and return it."""
572 if self._client is None:
573 raise ProviderUnavailableError("Client not connected, call connect() first")
574 return self._client
575
576 async def _get_client(self) -> ClientAsync:
577 """Acquire a throttle slot then return the connected client."""
578 await self._throttler.acquire()
579 return self._ensure_connected()
580