/
/
/
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 def _ensure_connected(self) -> ClientAsync:
136 """Ensure the client is connected and return it."""
137 if self._client is None:
138 raise ProviderUnavailableError("Client not connected, call connect() first")
139 return self._client
140
141 async def _get_client(self) -> ClientAsync:
142 """Acquire a throttle slot then return the connected client."""
143 await self._throttler.acquire()
144 return self._ensure_connected()
145
146 # Search
147
148 @handle_zvuk_errors(not_found_return=None)
149 async def search(
150 self,
151 query: str,
152 limit: int = DEFAULT_LIMIT,
153 *,
154 search_tracks: bool = True,
155 search_artists: bool = True,
156 search_releases: bool = True,
157 search_playlists: bool = True,
158 ) -> ZvukSearch | None:
159 """
160 Search for tracks, albums, artists, or playlists.
161
162 :param query: Search query string.
163 :param limit: Maximum number of results per type.
164 :param search_tracks: Whether to search for tracks.
165 :param search_artists: Whether to search for artists.
166 :param search_releases: Whether to search for releases.
167 :param search_playlists: Whether to search for playlists.
168 :return: Search results object or None.
169 """
170 client = await self._get_client()
171 return await client.search(
172 query,
173 limit=limit,
174 tracks=search_tracks,
175 artists=search_artists,
176 releases=search_releases,
177 playlists=search_playlists,
178 podcasts=False,
179 episodes=False,
180 profiles=False,
181 books=False,
182 )
183
184 # Get single items
185
186 @handle_zvuk_errors(not_found_return=None)
187 async def get_track(self, track_id: str) -> ZvukTrack | None:
188 """
189 Get a single track by ID.
190
191 :param track_id: Track ID.
192 :return: Track object or None if not found.
193 """
194 client = await self._get_client()
195 return await client.get_track(track_id)
196
197 @handle_zvuk_errors(not_found_return=[])
198 async def get_tracks(self, track_ids: list[str]) -> list[ZvukTrack]:
199 """
200 Get multiple tracks by IDs.
201
202 :param track_ids: List of track IDs.
203 :return: List of track objects.
204 """
205 client = await self._get_client()
206 return await client.get_tracks(list(track_ids))
207
208 @handle_zvuk_errors(not_found_return=None)
209 async def get_release(self, release_id: str) -> ZvukRelease | None:
210 """
211 Get a single release (album) by ID.
212
213 :param release_id: Release ID.
214 :return: Release object or None if not found.
215 """
216 client = await self._get_client()
217 return await client.get_release(release_id)
218
219 @handle_zvuk_errors(not_found_return=[])
220 async def get_releases(self, release_ids: list[str]) -> list[ZvukRelease]:
221 """
222 Get multiple releases by IDs.
223
224 :param release_ids: List of release IDs.
225 :return: List of release objects.
226 """
227 client = await self._get_client()
228 return await client.get_releases(list(release_ids))
229
230 @handle_zvuk_errors(not_found_return=None)
231 async def get_artist(self, artist_id: str) -> ZvukArtist | None:
232 """
233 Get a single artist by ID.
234
235 :param artist_id: Artist ID.
236 :return: Artist object or None if not found.
237 """
238 client = await self._get_client()
239 return await client.get_artist(artist_id, with_description=True)
240
241 @handle_zvuk_errors(not_found_return=[])
242 async def get_artists(self, artist_ids: list[str]) -> list[ZvukArtist]:
243 """
244 Get multiple artists by IDs.
245
246 :param artist_ids: List of artist IDs.
247 :return: List of artist objects.
248 """
249 client = await self._get_client()
250 return await client.get_artists(list(artist_ids))
251
252 @handle_zvuk_errors(not_found_return=[])
253 async def get_artist_releases(
254 self, artist_id: str, limit: int = DEFAULT_LIMIT
255 ) -> list[ZvukArtist]:
256 """
257 Get artist's releases.
258
259 :param artist_id: Artist ID.
260 :param limit: Maximum number of releases.
261 :return: List of artist objects with populated releases.
262 """
263 client = await self._get_client()
264 return await client.get_artists([artist_id], with_releases=True, releases_limit=limit)
265
266 @handle_zvuk_errors(not_found_return=[])
267 async def get_artist_top_tracks(
268 self, artist_id: str, limit: int = DEFAULT_LIMIT
269 ) -> list[ZvukArtist]:
270 """
271 Get artist's top tracks.
272
273 :param artist_id: Artist ID.
274 :param limit: Maximum number of tracks.
275 :return: List of artist objects with populated popular_tracks.
276 """
277 client = await self._get_client()
278 return await client.get_artists([artist_id], with_popular_tracks=True, tracks_limit=limit)
279
280 # Playlists
281
282 @handle_zvuk_errors(not_found_return=None)
283 async def get_playlist(self, playlist_id: str) -> ZvukPlaylist | None:
284 """
285 Get a playlist by ID.
286
287 :param playlist_id: Playlist ID.
288 :return: Playlist object or None if not found.
289 """
290 client = await self._get_client()
291 return await client.get_playlist(playlist_id)
292
293 @handle_zvuk_errors(not_found_return=[])
294 async def get_playlists(self, playlist_ids: list[str]) -> list[ZvukPlaylist]:
295 """
296 Get multiple playlists by IDs.
297
298 :param playlist_ids: List of playlist IDs.
299 :return: List of playlist objects.
300 """
301 client = await self._get_client()
302 return await client.get_playlists(list(playlist_ids))
303
304 @handle_zvuk_errors(not_found_return=[])
305 async def get_playlist_tracks(
306 self, playlist_id: str, limit: int = 50, offset: int = 0
307 ) -> list[ZvukSimpleTrack]:
308 """
309 Get playlist tracks.
310
311 :param playlist_id: Playlist ID.
312 :param limit: Maximum number of tracks.
313 :param offset: Offset for pagination.
314 :return: List of SimpleTrack objects.
315 """
316 client = await self._get_client()
317 return await client.get_playlist_tracks(playlist_id, limit=limit, offset=offset)
318
319 # Streaming
320
321 @handle_zvuk_errors(not_found_return=[])
322 async def get_stream_urls(self, track_id: str) -> list[ZvukStream]:
323 """
324 Get stream URLs for a track.
325
326 :param track_id: Track ID.
327 :return: List of Stream objects.
328 """
329 client = await self._get_client()
330 return await client.get_stream_urls(track_id)
331
332 @handle_zvuk_errors(not_found_return=None)
333 async def get_direct_stream_url(self, track_id: str, quality: str) -> str | None:
334 """
335 Get a direct (non-DRM) stream URL for a track.
336
337 :param track_id: Track ID.
338 :param quality: Quality string â "flac", "high", or "mid".
339 :return: Stream URL string, or None if not found.
340 """
341 client = await self._get_client()
342 result: ZvukDirectStream | None = await client.get_direct_stream_url(
343 track_id, StreamQuality(quality)
344 )
345 if not result:
346 return None
347 return result.stream or None
348
349 # Collection (Library)
350
351 @handle_zvuk_errors(not_found_return=None)
352 async def get_collection(self) -> Collection | None:
353 """
354 Get user's collection (liked items).
355
356 :return: Collection object or None.
357 """
358 client = await self._get_client()
359 return await client.get_collection()
360
361 @handle_zvuk_errors(not_found_return=[])
362 async def get_liked_tracks(self) -> list[ZvukTrack]:
363 """
364 Get user's liked tracks.
365
366 :return: List of full Track objects.
367 """
368 client = await self._get_client()
369 return await client.get_liked_tracks()
370
371 @handle_zvuk_errors(not_found_return=[])
372 async def get_user_playlists(self) -> list[ZvukCollectionItem]:
373 """
374 Get user's playlists.
375
376 :return: List of CollectionItem objects with playlist IDs.
377 """
378 client = await self._get_client()
379 return await client.get_user_playlists()
380
381 @handle_zvuk_errors(not_found_return=[])
382 async def get_short_playlists(
383 self, playlist_ids: Sequence[int | str]
384 ) -> list[ZvukSimplePlaylist]:
385 """
386 Get playlist metadata (title, image, description) by IDs without tracks.
387
388 Uses the lightweight getShortPlaylist GraphQL query which returns only metadata.
389 Works for both regular playlists and synthesis playlists (IDs 3,4,6,11,12,13,14,15).
390
391 :param playlist_ids: List of playlist IDs.
392 :return: List of SimplePlaylist objects.
393 """
394 client = await self._get_client()
395 return await client.get_short_playlist(list(playlist_ids))
396
397 @handle_zvuk_errors(not_found_return=[])
398 async def get_editorial_playlist_ids(self) -> list[str]:
399 """
400 Get editorial (curated) playlist IDs from Zvuk's grid content API.
401
402 Fetches «ÐодбоÑки» â genre-focused curated playlists shown on the home page.
403
404 :return: List of playlist IDs as strings.
405 """
406 client = await self._get_client()
407 return await client.get_editorial_playlist_ids()
408
409 @handle_zvuk_errors(not_found_return=None)
410 async def get_lyrics(self, track_id: str) -> ZvukLyrics | None:
411 """
412 Get lyrics for a track from Zvuk lyrics API.
413
414 Returns synced LRC text (``is_synced=True``) or plain text.
415 Returns ``None`` if the track has no lyrics.
416
417 :param track_id: Track ID.
418 :return: Lyrics object or None.
419 """
420 client = await self._get_client()
421 return await client.get_lyrics(track_id)
422
423 # Library modifications
424
425 async def like_track(self, track_id: str) -> bool:
426 """
427 Add a track to liked tracks.
428
429 :param track_id: Track ID.
430 :return: True if successful.
431 """
432 client = await self._get_client()
433 try:
434 return await client.like_track(track_id)
435 except (BadRequestError, NetworkError, GraphQLError) as err:
436 LOGGER.error("Error liking track %s: %s", track_id, err)
437 return False
438
439 async def unlike_track(self, track_id: str) -> bool:
440 """
441 Remove a track from liked tracks.
442
443 :param track_id: Track ID.
444 :return: True if successful.
445 """
446 client = await self._get_client()
447 try:
448 return await client.unlike_track(track_id)
449 except (BadRequestError, NetworkError, GraphQLError) as err:
450 LOGGER.error("Error unliking track %s: %s", track_id, err)
451 return False
452
453 async def like_release(self, release_id: str) -> bool:
454 """
455 Add a release to liked releases.
456
457 :param release_id: Release ID.
458 :return: True if successful.
459 """
460 client = await self._get_client()
461 try:
462 return await client.like_release(release_id)
463 except (BadRequestError, NetworkError, GraphQLError) as err:
464 LOGGER.error("Error liking release %s: %s", release_id, err)
465 return False
466
467 async def unlike_release(self, release_id: str) -> bool:
468 """
469 Remove a release from liked releases.
470
471 :param release_id: Release ID.
472 :return: True if successful.
473 """
474 client = await self._get_client()
475 try:
476 return await client.unlike_release(release_id)
477 except (BadRequestError, NetworkError, GraphQLError) as err:
478 LOGGER.error("Error unliking release %s: %s", release_id, err)
479 return False
480
481 async def like_artist(self, artist_id: str) -> bool:
482 """
483 Add an artist to liked artists.
484
485 :param artist_id: Artist ID.
486 :return: True if successful.
487 """
488 client = await self._get_client()
489 try:
490 return await client.like_artist(artist_id)
491 except (BadRequestError, NetworkError, GraphQLError) as err:
492 LOGGER.error("Error liking artist %s: %s", artist_id, err)
493 return False
494
495 async def unlike_artist(self, artist_id: str) -> bool:
496 """
497 Remove an artist from liked artists.
498
499 :param artist_id: Artist ID.
500 :return: True if successful.
501 """
502 client = await self._get_client()
503 try:
504 return await client.unlike_artist(artist_id)
505 except (BadRequestError, NetworkError, GraphQLError) as err:
506 LOGGER.error("Error unliking artist %s: %s", artist_id, err)
507 return False
508
509 async def like_playlist(self, playlist_id: str) -> bool:
510 """
511 Add a playlist to liked playlists.
512
513 :param playlist_id: Playlist ID.
514 :return: True if successful.
515 """
516 client = await self._get_client()
517 try:
518 return await client.like_playlist(playlist_id)
519 except (BadRequestError, NetworkError, GraphQLError) as err:
520 LOGGER.error("Error liking playlist %s: %s", playlist_id, err)
521 return False
522
523 async def unlike_playlist(self, playlist_id: str) -> bool:
524 """
525 Remove a playlist from liked playlists.
526
527 :param playlist_id: Playlist ID.
528 :return: True if successful.
529 """
530 client = await self._get_client()
531 try:
532 return await client.unlike_playlist(playlist_id)
533 except (BadRequestError, NetworkError, GraphQLError) as err:
534 LOGGER.error("Error unliking playlist %s: %s", playlist_id, err)
535 return False
536
537 # Playlist management
538
539 @handle_zvuk_errors()
540 async def create_playlist(self, name: str, track_ids: list[str] | None = None) -> str:
541 """
542 Create a new playlist.
543
544 :param name: Playlist name.
545 :param track_ids: Optional list of track IDs to add.
546 :return: New playlist ID.
547 """
548 client = await self._get_client()
549 return await client.create_playlist(name, track_ids=track_ids)
550
551 async def delete_playlist(self, playlist_id: str) -> bool:
552 """
553 Delete a playlist.
554
555 :param playlist_id: Playlist ID.
556 :return: True if successful.
557 """
558 client = await self._get_client()
559 try:
560 return await client.delete_playlist(playlist_id)
561 except (BadRequestError, NetworkError, GraphQLError) as err:
562 LOGGER.error("Error deleting playlist %s: %s", playlist_id, err)
563 return False
564
565 async def add_tracks_to_playlist(self, playlist_id: str, track_ids: list[str]) -> bool:
566 """
567 Add tracks to a playlist.
568
569 :param playlist_id: Playlist ID.
570 :param track_ids: List of track IDs to add.
571 :return: True if successful.
572 """
573 client = await self._get_client()
574 try:
575 return await client.add_tracks_to_playlist(playlist_id, track_ids)
576 except (BadRequestError, NetworkError, GraphQLError) as err:
577 LOGGER.error("Error adding tracks to playlist %s: %s", playlist_id, err)
578 return False
579
580 async def update_playlist(self, playlist_id: str, track_ids: list[str]) -> bool:
581 """
582 Update playlist tracks (used for removing tracks by providing remaining ones).
583
584 :param playlist_id: Playlist ID.
585 :param track_ids: Complete list of track IDs the playlist should contain.
586 :return: True if successful.
587 """
588 client = await self._get_client()
589 try:
590 return await client.update_playlist(playlist_id, track_ids)
591 except (BadRequestError, NetworkError, GraphQLError) as err:
592 LOGGER.error("Error updating playlist %s: %s", playlist_id, err)
593 return False
594