/
/
/
1"""API client wrapper for KION Music."""
2
3from __future__ import annotations
4
5import asyncio
6import base64
7import hashlib
8import hmac
9import logging
10import re
11import time
12from collections.abc import Awaitable, Callable
13from datetime import UTC, datetime
14from typing import TYPE_CHECKING, Any, TypeVar, cast
15
16from music_assistant_models.errors import (
17 LoginFailed,
18 ProviderUnavailableError,
19 RateLimited,
20 ResourceTemporarilyUnavailable,
21)
22from yandex_music import Album as KionAlbum
23from yandex_music import Artist as KionArtist
24from yandex_music import ClientAsync, MixLink, Search, TrackShort
25from yandex_music import Playlist as KionPlaylist
26from yandex_music import Track as KionTrack
27from yandex_music.exceptions import BadRequestError, NetworkError, UnauthorizedError
28from yandex_music.utils.sign_request import DEFAULT_SIGN_KEY
29
30from music_assistant.helpers.datetime import utc
31from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER, Throttler
32
33if TYPE_CHECKING:
34 from yandex_music import DownloadInfo
35 from yandex_music.feed.feed import Feed
36 from yandex_music.landing.chart_info import ChartInfo
37 from yandex_music.landing.landing import Landing
38 from yandex_music.landing.landing_list import LandingList
39 from yandex_music.rotor.dashboard import Dashboard
40 from yandex_music.rotor.station_result import StationResult
41
42from .constants import DEFAULT_LIMIT, ROTOR_STATION_MY_MIX
43
44# get-file-info with quality=lossless returns FLAC; default /tracks/.../download-info often does not
45# Prefer flac-mp4/aac-mp4 (Kion API moved to these formats around 2025)
46GET_FILE_INFO_CODECS = "flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4"
47
48LOGGER = logging.getLogger(__name__)
49
50_T = TypeVar("_T")
51
52
53class KionMusicClient:
54 """Wrapper around kion-music-api ClientAsync."""
55
56 def __init__(self, token: str, base_url: str | None = None) -> None:
57 """
58 Initialize the KION Music client.
59
60 :param token: KION Music OAuth token.
61 :param base_url: Optional API base URL (defaults to KION Music API).
62 """
63 self._token = token
64 self._base_url = base_url
65 self._client: ClientAsync | None = None
66 self._user_id: int | None = None
67 self._last_reconnect_at: float = -30.0 # allow first reconnect immediately
68 self._reconnect_lock = asyncio.Lock()
69 self._throttler = Throttler(rate_limit=5, period=1.0)
70
71 @property
72 def user_id(self) -> int:
73 """Return the user ID."""
74 if self._user_id is None:
75 raise ProviderUnavailableError("Client not initialized, call connect() first")
76 return self._user_id
77
78 async def connect(self) -> bool:
79 """
80 Initialize the client and verify token validity.
81
82 :return: True if connection was successful.
83 :raises LoginFailed: If the token is invalid.
84 """
85 try:
86 self._client = await ClientAsync(self._token, base_url=self._base_url).init()
87 if self._client.me is None or self._client.me.account is None:
88 raise LoginFailed("Failed to get account info")
89 self._user_id = self._client.me.account.uid
90 LOGGER.debug("Connected to KION Music as user %s", self._user_id)
91 return True
92 except (UnauthorizedError, BadRequestError) as err:
93 raise LoginFailed("Invalid KION Music token") from err
94 except NetworkError as err:
95 msg = "Network error connecting to KION Music"
96 raise ResourceTemporarilyUnavailable(msg) from err
97
98 async def disconnect(self) -> None:
99 """Disconnect the client."""
100 self._client = None
101 self._user_id = None
102
103 async def _ensure_connected(self) -> ClientAsync:
104 """Ensure the client is connected, attempting reconnect if needed."""
105 if self._client is not None:
106 return self._client
107 async with self._reconnect_lock:
108 # Re-check after acquiring lock â another task may have connected already
109 if self._client is not None:
110 return self._client # type: ignore[unreachable]
111 LOGGER.info("Client disconnected, attempting to reconnect...")
112 try:
113 await self.connect()
114 except LoginFailed:
115 raise
116 except Exception as err:
117 raise ProviderUnavailableError("Client not connected and reconnect failed") from err
118 return cast("ClientAsync", self._client)
119
120 def _is_connection_error(self, err: Exception) -> bool:
121 """Return True if the exception indicates a connection or server drop."""
122 if isinstance(err, NetworkError) and not self._is_rate_limit_error(err):
123 return True
124 msg = str(err).lower()
125 return "disconnect" in msg or "connection" in msg or "timeout" in msg
126
127 def _is_rate_limit_error(self, err: Exception) -> bool:
128 """Return True if the exception indicates a rate-limit response from Kion."""
129 if not isinstance(err, NetworkError):
130 return False
131 msg = str(err).lower()
132 return "429" in msg or "too many requests" in msg or "rate limit" in msg
133
134 async def _reconnect(self) -> None:
135 """
136 Disconnect and connect again to recover from Server disconnected / connection errors.
137
138 Enforces a 30-second cooldown between reconnect attempts to avoid hammering Kion
139 and triggering rate limiting. A lock ensures concurrent callers don't bypass the cooldown.
140 """
141 async with self._reconnect_lock:
142 now = time.monotonic()
143 if now - self._last_reconnect_at < 30.0:
144 raise ProviderUnavailableError("Reconnect cooldown active, skipping")
145 self._last_reconnect_at = now
146 await self.disconnect()
147 await self.connect()
148
149 async def _call_with_retry(self, func: Callable[[ClientAsync], Awaitable[_T]]) -> _T:
150 """
151 Execute an async API call with throttling and one reconnect attempt on connection error.
152
153 :param func: Async callable that takes a ClientAsync and returns a result.
154 :return: The result of the API call.
155 """
156 if not BYPASS_THROTTLER.get():
157 await self._throttler.acquire()
158 client = await self._ensure_connected()
159 try:
160 return await func(client)
161 except Exception as err:
162 if self._is_rate_limit_error(err):
163 raise RateLimited("KION Music rate limit", backoff_time=60) from err
164 if not self._is_connection_error(err):
165 raise
166 LOGGER.warning("Connection error, reconnecting and retrying: %s", err)
167 try:
168 await self._reconnect()
169 except Exception as recon_err:
170 raise ProviderUnavailableError("Reconnect failed") from recon_err
171 client = cast("ClientAsync", self._client)
172 return await func(client)
173
174 async def _call_no_retry(self, func: Callable[[ClientAsync], Awaitable[_T]]) -> _T:
175 """
176 Execute an async API call without reconnect retry on call failure.
177
178 Used for fire-and-forget calls (e.g. rotor feedback) where a failed request
179 should be silently dropped rather than triggering a reconnect cycle that
180 could cause rate limiting. Note: _ensure_connected() is still called to
181 establish the initial connection if needed; only the reconnect-on-error
182 path is skipped.
183
184 :param func: Async callable that takes a ClientAsync and returns a result.
185 :return: The result of the API call.
186 """
187 if not BYPASS_THROTTLER.get():
188 await self._throttler.acquire()
189 client = await self._ensure_connected()
190 return await func(client)
191
192 # Rotor (radio station) methods
193
194 async def get_rotor_station_tracks(
195 self,
196 station_id: str,
197 queue: str | int | None = None,
198 ) -> tuple[list[KionTrack], str | None]:
199 """
200 Get tracks from a rotor station (e.g. user:onyourwave or track:1234).
201
202 :param station_id: Station ID (e.g. ROTOR_STATION_MY_MIX or "track:1234" for similar).
203 :param queue: Optional track ID for pagination (first track of previous batch).
204 :return: Tuple of (list of track objects, batch_id for feedback or None).
205 """
206 try:
207 result = await self._call_with_retry(
208 lambda c: c.rotor_station_tracks(station_id, settings2=True, queue=queue)
209 )
210 except BadRequestError as err:
211 LOGGER.warning("Error fetching rotor station %s tracks: %s", station_id, err)
212 return ([], None)
213 except (NetworkError, ProviderUnavailableError) as err:
214 LOGGER.warning("Error fetching rotor station tracks: %s", err)
215 return ([], None)
216
217 if not result or not result.sequence:
218 return ([], result.batch_id if result else None)
219 track_ids = []
220 for seq in result.sequence:
221 if seq.track is None:
222 continue
223 tid = getattr(seq.track, "id", None) or getattr(seq.track, "track_id", None)
224 if tid is not None:
225 track_ids.append(str(tid))
226 if not track_ids:
227 return ([], result.batch_id if result else None)
228 try:
229 full_tracks = await self.get_tracks(track_ids)
230 except ResourceTemporarilyUnavailable as err:
231 LOGGER.warning("Error fetching rotor station track details: %s", err)
232 return ([], result.batch_id if result else None)
233 order_map = {str(t.id): t for t in full_tracks if hasattr(t, "id") and t.id}
234 ordered = [order_map[tid] for tid in track_ids if tid in order_map]
235 return (ordered, result.batch_id if result else None)
236
237 async def get_my_wave_tracks(
238 self, queue: str | int | None = None
239 ) -> tuple[list[KionTrack], str | None]:
240 """
241 Get tracks from the My Mix radio station.
242
243 :param queue: Optional track ID of the last track from the previous batch (API uses it for
244 pagination; do not pass batch_id).
245 :return: Tuple of (list of track objects, batch_id for feedback).
246 """
247 return await self.get_rotor_station_tracks(ROTOR_STATION_MY_MIX, queue=queue)
248
249 async def send_rotor_station_feedback(
250 self,
251 station_id: str,
252 feedback_type: str,
253 *,
254 batch_id: str | None = None,
255 track_id: str | None = None,
256 total_played_seconds: int | None = None,
257 ) -> bool:
258 """
259 Send rotor station feedback for My Mix recommendations.
260
261 Used to report radioStarted, trackStarted, trackFinished, skip so that
262 Kion can improve subsequent recommendations.
263
264 :param station_id: Station ID (e.g. ROTOR_STATION_MY_MIX).
265 :param feedback_type: One of 'radioStarted', 'trackStarted', 'trackFinished', 'skip'.
266 :param batch_id: Optional batch ID from the last get_my_wave_tracks response.
267 :param track_id: Track ID (required for trackStarted, trackFinished, skip).
268 :param total_played_seconds: Seconds played (for trackFinished, skip).
269 :return: True if the request succeeded.
270 """
271 timestamp = utc().isoformat().replace("+00:00", "Z")
272
273 async def _send(c: ClientAsync) -> bool:
274 if feedback_type == "radioStarted":
275 return bool(
276 await c.rotor_station_feedback_radio_started(
277 station_id,
278 from_="KionMusicDesktopAppWindows",
279 batch_id=batch_id,
280 timestamp=timestamp,
281 )
282 )
283 if feedback_type == "trackStarted":
284 if track_id is None:
285 return False
286 return bool(
287 await c.rotor_station_feedback_track_started(
288 station_id,
289 track_id=track_id,
290 batch_id=batch_id,
291 timestamp=timestamp,
292 )
293 )
294 if feedback_type == "trackFinished":
295 if track_id is None:
296 return False
297 return bool(
298 await c.rotor_station_feedback_track_finished(
299 station_id,
300 track_id=track_id,
301 total_played_seconds=float(total_played_seconds or 0),
302 batch_id=batch_id,
303 timestamp=timestamp,
304 )
305 )
306 if feedback_type == "skip":
307 if track_id is None:
308 return False
309 return bool(
310 await c.rotor_station_feedback_skip(
311 station_id,
312 track_id=track_id,
313 total_played_seconds=float(total_played_seconds or 0),
314 batch_id=batch_id,
315 timestamp=timestamp,
316 )
317 )
318 return bool(
319 await c.rotor_station_feedback(
320 station_id,
321 type_=feedback_type,
322 timestamp=timestamp,
323 track_id=track_id,
324 total_played_seconds=total_played_seconds,
325 batch_id=batch_id,
326 )
327 )
328
329 try:
330 result = await self._call_no_retry(_send)
331 LOGGER.debug(
332 "Rotor feedback %s track_id=%s total_played_seconds=%s",
333 feedback_type,
334 track_id,
335 total_played_seconds,
336 )
337 return result
338 except BadRequestError as err:
339 LOGGER.warning("Rotor feedback %s failed: %s", feedback_type, err)
340 return False
341 except (NetworkError, ProviderUnavailableError) as err:
342 LOGGER.warning("Rotor feedback %s failed: %s", feedback_type, err)
343 return False
344
345 # Library methods
346
347 async def get_liked_tracks(self) -> list[TrackShort]:
348 """
349 Get user's liked tracks sorted by timestamp (most recent first).
350
351 :return: List of liked track objects sorted in reverse chronological order.
352 """
353 try:
354 result = await self._call_with_retry(lambda c: c.users_likes_tracks())
355 if result is None:
356 return []
357 tracks = result.tracks or []
358 # Sort by timestamp in descending order (most recently liked first)
359 # TrackShort objects have a timestamp field containing the date the track was liked
360 return sorted(
361 tracks,
362 key=lambda t: getattr(t, "timestamp", datetime.min.replace(tzinfo=UTC)),
363 reverse=True,
364 )
365 except BadRequestError as err:
366 LOGGER.error("Error fetching liked tracks: %s", err)
367 raise ResourceTemporarilyUnavailable("Failed to fetch liked tracks") from err
368 except (NetworkError, ProviderUnavailableError) as err:
369 LOGGER.error("Error fetching liked tracks: %s", err)
370 raise ResourceTemporarilyUnavailable("Failed to fetch liked tracks") from err
371
372 async def get_liked_albums(self, batch_size: int = 50) -> list[KionAlbum]:
373 """
374 Get user's liked albums with full details (including cover art).
375
376 The users_likes_albums endpoint returns minimal album data without
377 cover_uri, so we fetch full album details in batches afterwards.
378
379 :return: List of liked album objects with full details.
380 """
381 try:
382 result = await self._call_with_retry(lambda c: c.users_likes_albums())
383 except BadRequestError as err:
384 LOGGER.error("Error fetching liked albums: %s", err)
385 raise ResourceTemporarilyUnavailable("Failed to fetch liked albums") from err
386 except (NetworkError, ProviderUnavailableError) as err:
387 LOGGER.error("Error fetching liked albums: %s", err)
388 raise ResourceTemporarilyUnavailable("Failed to fetch liked albums") from err
389
390 if result is None:
391 return []
392 album_ids = [
393 str(like.album.id) for like in result if like.album is not None and like.album.id
394 ]
395 if not album_ids:
396 return []
397 # Fetch full album details in batches to get cover_uri and other metadata
398 full_albums: list[KionAlbum] = []
399 for i in range(0, len(album_ids), batch_size):
400 batch = album_ids[i : i + batch_size]
401 try:
402 batch_result = await self._call_with_retry(
403 lambda c, _b=batch: c.albums(_b) # type: ignore[misc]
404 )
405 if batch_result:
406 full_albums.extend(batch_result)
407 except (BadRequestError, NetworkError, ProviderUnavailableError) as batch_err:
408 LOGGER.warning("Error fetching album details batch: %s", batch_err)
409 # Fall back to minimal data for this batch
410 batch_set = set(batch)
411 for like in result:
412 if like.album is not None and like.album.id and str(like.album.id) in batch_set:
413 full_albums.append(like.album)
414 return full_albums
415
416 async def get_liked_artists(self) -> list[KionArtist]:
417 """
418 Get user's liked artists.
419
420 :return: List of liked artist objects.
421 """
422 try:
423 result = await self._call_with_retry(lambda c: c.users_likes_artists())
424 if result is None:
425 return []
426 return [like.artist for like in result if like.artist is not None]
427 except BadRequestError as err:
428 LOGGER.error("Error fetching liked artists: %s", err)
429 raise ResourceTemporarilyUnavailable("Failed to fetch liked artists") from err
430 except (NetworkError, ProviderUnavailableError) as err:
431 LOGGER.error("Error fetching liked artists: %s", err)
432 raise ResourceTemporarilyUnavailable("Failed to fetch liked artists") from err
433
434 async def get_user_playlists(self) -> list[KionPlaylist]:
435 """
436 Get user's playlists.
437
438 :return: List of playlist objects.
439 """
440 try:
441 result = await self._call_with_retry(lambda c: c.users_playlists_list())
442 if result is None:
443 return []
444 return list(result)
445 except BadRequestError as err:
446 LOGGER.error("Error fetching playlists: %s", err)
447 raise ResourceTemporarilyUnavailable("Failed to fetch playlists") from err
448 except (NetworkError, ProviderUnavailableError) as err:
449 LOGGER.error("Error fetching playlists: %s", err)
450 raise ResourceTemporarilyUnavailable("Failed to fetch playlists") from err
451
452 async def get_liked_playlists(self) -> list[KionPlaylist]:
453 """
454 Get user's liked/saved editorial playlists.
455
456 :return: List of liked playlist objects.
457 """
458 try:
459 result = await self._call_with_retry(lambda c: c.users_likes_playlists())
460 if result is None:
461 return []
462 playlists = []
463 for like in result:
464 if like.playlist is not None:
465 playlists.append(like.playlist)
466 return playlists
467 except BadRequestError as err:
468 LOGGER.error("Error fetching liked playlists: %s", err)
469 raise ResourceTemporarilyUnavailable("Failed to fetch liked playlists") from err
470 except (NetworkError, ProviderUnavailableError) as err:
471 LOGGER.error("Error fetching liked playlists: %s", err)
472 raise ResourceTemporarilyUnavailable("Failed to fetch liked playlists") from err
473
474 # Search
475
476 async def search(
477 self,
478 query: str,
479 search_type: str = "all",
480 limit: int = DEFAULT_LIMIT,
481 ) -> Search | None:
482 """
483 Search for tracks, albums, artists, or playlists.
484
485 :param query: Search query string.
486 :param search_type: Type of search ('all', 'track', 'album', 'artist', 'playlist').
487 :param limit: Maximum number of results per type.
488 :return: Search results object.
489 """
490 try:
491 return await self._call_with_retry(
492 lambda c: c.search(query, type_=search_type, page=0, nocorrect=False)
493 )
494 except BadRequestError as err:
495 LOGGER.error("Search error: %s", err)
496 raise ResourceTemporarilyUnavailable("Search failed") from err
497 except (NetworkError, ProviderUnavailableError) as err:
498 LOGGER.error("Search error: %s", err)
499 raise ResourceTemporarilyUnavailable("Search failed") from err
500
501 # Get single items
502
503 async def get_track(self, track_id: str) -> KionTrack | None:
504 """
505 Get a single track by ID.
506
507 :param track_id: Track ID.
508 :return: Track object or None if not found.
509 """
510 try:
511 tracks = await self._call_with_retry(lambda c: c.tracks([track_id]))
512 return tracks[0] if tracks else None
513 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
514 LOGGER.error("Error fetching track %s: %s", track_id, err)
515 return None
516
517 async def get_track_lyrics(self, track_id: str) -> tuple[str | None, bool]:
518 """
519 Get lyrics for a track.
520
521 Fetches lyrics from KION Music API. Returns the lyrics text and whether
522 it's in synced LRC format (with timestamps) or plain text.
523
524 Note: This method fetches the track first to check lyrics_available. If you
525 already have the KionTrack object, use get_track_lyrics_from_track() to
526 avoid a redundant API call.
527
528 :param track_id: Track ID.
529 :return: Tuple of (lyrics_text, is_synced). Returns (None, False) if unavailable.
530 """
531 try:
532 tracks = await self._call_with_retry(lambda c: c.tracks([track_id]))
533 if not tracks:
534 return None, False
535
536 return await self.get_track_lyrics_from_track(tracks[0])
537
538 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
539 LOGGER.debug("Error fetching lyrics for track %s: %s", track_id, err)
540 return None, False
541 except Exception as err:
542 # Catch any other errors (e.g., geo-restrictions, API changes)
543 LOGGER.debug("Unexpected error fetching lyrics for track %s: %s", track_id, err)
544 return None, False
545
546 async def get_track_lyrics_from_track(self, track: KionTrack) -> tuple[str | None, bool]:
547 """
548 Get lyrics for an already-fetched track.
549
550 Avoids the extra tracks([track_id]) API call when the KionTrack object
551 is already available.
552
553 :param track: KionTrack object (already fetched).
554 :return: Tuple of (lyrics_text, is_synced). Returns (None, False) if unavailable.
555 """
556 track_id = getattr(track, "id", None) or getattr(track, "track_id", "unknown")
557 try:
558 if not getattr(track, "lyrics_available", False):
559 LOGGER.debug("Lyrics not available for track %s", track_id)
560 return None, False
561
562 track_lyrics = await track.get_lyrics_async()
563 if not track_lyrics:
564 LOGGER.debug("Failed to get lyrics metadata for track %s", track_id)
565 return None, False
566
567 lyrics_text = await track_lyrics.fetch_lyrics_async()
568 if not lyrics_text:
569 return None, False
570
571 # Check if it's LRC format (synced lyrics have timestamps like [00:12.34])
572 # Use re.search without ^ so metadata lines like [ar:Artist] don't prevent detection
573 is_synced = bool(re.search(r"\[\d{1,2}:\d{1,2}(?:\.\d{2,3})?\]", lyrics_text))
574 return lyrics_text, is_synced
575
576 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
577 LOGGER.debug("Error fetching lyrics for track %s: %s", track_id, err)
578 return None, False
579 except Exception as err:
580 # Catch any other errors (e.g., geo-restrictions, API changes)
581 LOGGER.debug("Unexpected error fetching lyrics for track %s: %s", track_id, err)
582 return None, False
583
584 async def get_tracks(self, track_ids: list[str]) -> list[KionTrack]:
585 """
586 Get multiple tracks by IDs.
587
588 :param track_ids: List of track IDs.
589 :return: List of track objects.
590 :raises ResourceTemporarilyUnavailable: On network errors after retry.
591 """
592 try:
593 result = await self._call_with_retry(lambda c: c.tracks(track_ids))
594 return result or []
595 except BadRequestError as err:
596 LOGGER.error("Error fetching tracks: %s", err)
597 return []
598 except (NetworkError, ProviderUnavailableError) as err:
599 LOGGER.error("Error fetching tracks (retry failed): %s", err)
600 raise ResourceTemporarilyUnavailable("Failed to fetch tracks") from err
601
602 async def get_album(self, album_id: str) -> KionAlbum | None:
603 """
604 Get a single album by ID.
605
606 :param album_id: Album ID.
607 :return: Album object or None if not found.
608 """
609 try:
610 albums = await self._call_with_retry(lambda c: c.albums([album_id]))
611 return albums[0] if albums else None
612 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
613 LOGGER.error("Error fetching album %s: %s", album_id, err)
614 return None
615
616 async def get_album_with_tracks(self, album_id: str) -> KionAlbum | None:
617 """
618 Get an album with its tracks.
619
620 Uses the same semantics as the web client: albums/{id}/with-tracks
621 with resumeStream, richTracks, withListeningFinished.
622
623 :param album_id: Album ID.
624 :return: Album object with tracks or None if not found.
625 """
626 try:
627 return await self._call_with_retry(
628 lambda c: c.albums_with_tracks(
629 album_id,
630 resumeStream=True,
631 richTracks=True,
632 withListeningFinished=True,
633 )
634 )
635 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
636 LOGGER.error("Error fetching album with tracks %s: %s", album_id, err)
637 return None
638
639 async def get_artist(self, artist_id: str) -> KionArtist | None:
640 """
641 Get a single artist by ID.
642
643 :param artist_id: Artist ID.
644 :return: Artist object or None if not found.
645 """
646 try:
647 artists = await self._call_with_retry(lambda c: c.artists([artist_id]))
648 return artists[0] if artists else None
649 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
650 LOGGER.error("Error fetching artist %s: %s", artist_id, err)
651 return None
652
653 async def get_artist_albums(
654 self, artist_id: str, limit: int = DEFAULT_LIMIT
655 ) -> list[KionAlbum]:
656 """
657 Get artist's albums.
658
659 :param artist_id: Artist ID.
660 :param limit: Maximum number of albums.
661 :return: List of album objects.
662 """
663 try:
664 result = await self._call_with_retry(
665 lambda c: c.artists_direct_albums(artist_id, page=0, page_size=limit)
666 )
667 if result is None:
668 return []
669 return result.albums or []
670 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
671 LOGGER.error("Error fetching artist albums %s: %s", artist_id, err)
672 return []
673
674 async def get_pins(self) -> Any | None:
675 """
676 Get the user's pinned items (artists/albums/playlists/waves).
677
678 :return: PinsList object or None on error.
679 """
680 try:
681 return await self._call_with_retry(lambda c: c.pins())
682 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
683 LOGGER.error("Error fetching pins: %s", err)
684 return None
685
686 async def get_music_history(self) -> Any | None:
687 """
688 Get the user's listening history (grouped by day).
689
690 :return: MusicHistory object or None on error.
691 """
692 try:
693 return await self._call_with_retry(lambda c: c.music_history())
694 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
695 LOGGER.error("Error fetching music history: %s", err)
696 return None
697
698 async def get_artist_about(self, artist_id: str) -> Any | None:
699 """
700 Get artist enrichment info: description, monthly listeners, links.
701
702 :param artist_id: Artist ID.
703 :return: ArtistAbout object or None on error/missing.
704 """
705 try:
706 return await self._call_with_retry(lambda c: c.artists_about(artist_id))
707 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
708 LOGGER.error("Error fetching artist about %s: %s", artist_id, err)
709 return None
710
711 async def get_similar_artists(
712 self, artist_id: str, limit: int = DEFAULT_LIMIT
713 ) -> list[KionArtist]:
714 """
715 Get artists similar to the given one.
716
717 :param artist_id: Artist ID.
718 :param limit: Maximum number of artists.
719 :return: List of similar artist objects.
720 """
721 try:
722 result = await self._call_with_retry(lambda c: c.artists_similar(artist_id))
723 if result is None or not result.similar_artists:
724 return []
725 similar: list[KionArtist] = result.similar_artists
726 return similar[:limit]
727 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
728 LOGGER.error("Error fetching similar artists %s: %s", artist_id, err)
729 return []
730
731 async def get_artist_tracks(
732 self, artist_id: str, limit: int = DEFAULT_LIMIT
733 ) -> list[KionTrack]:
734 """
735 Get artist's top tracks.
736
737 :param artist_id: Artist ID.
738 :param limit: Maximum number of tracks.
739 :return: List of track objects.
740 """
741 try:
742 result = await self._call_with_retry(
743 lambda c: c.artists_tracks(artist_id, page=0, page_size=limit)
744 )
745 if result is None:
746 return []
747 return result.tracks or []
748 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
749 LOGGER.error("Error fetching artist tracks %s: %s", artist_id, err)
750 return []
751
752 async def get_playlist(self, user_id: str, playlist_id: str) -> KionPlaylist | None:
753 """
754 Get a playlist by ID.
755
756 :param user_id: User ID (owner of the playlist).
757 :param playlist_id: Playlist ID (kind).
758 :return: Playlist object or None if not found.
759 :raises ResourceTemporarilyUnavailable: On network errors.
760 """
761 try:
762 result = await self._call_with_retry(
763 lambda c: c.users_playlists(kind=int(playlist_id), user_id=user_id)
764 )
765 if isinstance(result, list):
766 return result[0] if result else None
767 return result
768 except BadRequestError as err:
769 LOGGER.error("Error fetching playlist %s/%s: %s", user_id, playlist_id, err)
770 return None
771 except (NetworkError, ProviderUnavailableError) as err:
772 LOGGER.warning("Network error fetching playlist %s/%s: %s", user_id, playlist_id, err)
773 raise ResourceTemporarilyUnavailable("Failed to fetch playlist") from err
774
775 # Streaming
776
777 async def get_track_download_info(
778 self, track_id: str, get_direct_links: bool = True
779 ) -> list[DownloadInfo]:
780 """
781 Get download info for a track.
782
783 :param track_id: Track ID.
784 :param get_direct_links: Whether to get direct download links.
785 :return: List of download info objects.
786 """
787 try:
788 result = await self._call_with_retry(
789 lambda c: c.tracks_download_info(track_id, get_direct_links=get_direct_links)
790 )
791 return result or []
792 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
793 LOGGER.error("Error fetching download info for track %s: %s", track_id, err)
794 return []
795
796 async def get_track_file_info(
797 self,
798 track_id: str,
799 quality: str = "lossless",
800 codecs: str = GET_FILE_INFO_CODECS,
801 transport: str = "raw",
802 ) -> dict[str, Any] | None:
803 """
804 Request stream via get-file-info for any quality tier.
805
806 The /get-file-info endpoint supports all quality tiers (lossless, nq, lq)
807 and returns the best available codec based on the codecs parameter order.
808
809 With transport="raw", returns a direct unencrypted URL.
810 With transport="encraw", returns an AES-CTR encrypted URL with decryption key.
811
812 Uses _call_with_retry for automatic reconnection on transient failures.
813
814 :param track_id: Track ID.
815 :param quality: Quality tier ("lossless", "nq", "lq").
816 :param codecs: Comma-separated codec preference list.
817 :param transport: Transport mode ("raw" or "encraw").
818 :return: Parsed downloadInfo dict (url, codec, key?, ...) or None on error.
819 """
820 # Normalize codecs: strip whitespace from each token to prevent HMAC mismatches
821 codecs = ",".join(c.strip() for c in codecs.split(",") if c.strip())
822
823 def _build_signed_params(client: ClientAsync) -> tuple[str, dict[str, Any]]:
824 """
825 Build URL and signed params using current client and timestamp.
826
827 Called on each attempt by _call_with_retry, so the HMAC signature
828 is recomputed with a fresh timestamp on every retry.
829 """
830 timestamp = int(time.time())
831 params = {
832 "ts": timestamp,
833 "trackId": track_id,
834 "quality": quality,
835 "codecs": codecs,
836 "transports": transport,
837 }
838 # Build sign string: ts + trackId + quality + codecs (commas stripped) + transports.
839 codecs_for_sign = codecs.replace(",", "")
840 param_string = f"{timestamp}{track_id}{quality}{codecs_for_sign}{transport}"
841 hmac_sign = hmac.new(
842 DEFAULT_SIGN_KEY.encode(),
843 param_string.encode(),
844 hashlib.sha256,
845 )
846 # SHA-256 (32 bytes) â base64 yields 44 chars with one "=" padding char,
847 # but Kion API expects the unpadded form. Use rstrip("=") rather than
848 # a fixed [:-1] slice so unexpected padding never produces a bad sign.
849 params["sign"] = base64.b64encode(hmac_sign.digest()).decode().rstrip("=")
850 url = f"{client.base_url}/get-file-info"
851 return url, params
852
853 def _parse_file_info_result(raw: dict[str, Any] | None) -> dict[str, Any] | None:
854 if not raw or not isinstance(raw, dict):
855 return None
856 # yandex-music v3 no longer normalises camelCase keys inside
857 # Response.result, so /get-file-info returns "downloadInfo" as-is.
858 download_info = raw.get("download_info") or raw.get("downloadInfo")
859 if not download_info or not download_info.get("url"):
860 return None
861
862 result = cast("dict[str, Any]", download_info)
863
864 if "key" in download_info:
865 result["needs_decryption"] = True
866 LOGGER.debug(
867 "Encrypted URL received for track %s, will require decryption",
868 track_id,
869 )
870 else:
871 result["needs_decryption"] = False
872
873 return result
874
875 async def _do_request(c: ClientAsync) -> dict[str, Any] | None:
876 url, params = _build_signed_params(c)
877 return await c._request.get(url, params=params) # type: ignore[no-any-return]
878
879 try:
880 result = await self._call_with_retry(_do_request)
881 parsed = _parse_file_info_result(result)
882 if parsed:
883 LOGGER.debug(
884 "get-file-info for track %s: Success, codec=%s, transport=%s",
885 track_id,
886 parsed.get("codec"),
887 transport,
888 )
889 return parsed
890 except (BadRequestError, NetworkError) as err:
891 LOGGER.debug(
892 "get-file-info for track %s: %s %s",
893 track_id,
894 type(err).__name__,
895 getattr(err, "message", str(err)) or repr(err),
896 )
897 except UnauthorizedError as err:
898 LOGGER.debug(
899 "get-file-info for track %s: UnauthorizedError %s",
900 track_id,
901 getattr(err, "message", str(err)) or repr(err),
902 )
903 except asyncio.CancelledError:
904 raise
905 except Exception as err:
906 LOGGER.warning(
907 "get-file-info for track %s: Unexpected %s: %s",
908 track_id,
909 type(err).__name__,
910 err,
911 )
912
913 return None
914
915 # Discovery / recommendations
916
917 async def get_feed(self) -> Feed | None:
918 """
919 Get personalized feed with generated playlists (Playlist of the Day, etc.).
920
921 :return: Feed object with generated_playlists, or None on error.
922 """
923 try:
924 return await self._call_with_retry(lambda c: c.feed())
925 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
926 LOGGER.debug("Error fetching feed: %s", err)
927 return None
928
929 async def get_chart(self, chart_option: str = "") -> ChartInfo | None:
930 """
931 Get chart data.
932
933 :param chart_option: Optional chart variant (e.g. 'world', 'russia').
934 :return: ChartInfo object or None on error.
935 """
936 try:
937 return await self._call_with_retry(lambda c: c.chart(chart_option))
938 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
939 LOGGER.debug("Error fetching chart: %s", err)
940 return None
941
942 async def get_new_releases(self) -> LandingList | None:
943 """
944 Get new album releases.
945
946 :return: LandingList with new_releases (list of album IDs) or None on error.
947 """
948 try:
949 return await self._call_with_retry(lambda c: c.new_releases())
950 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
951 LOGGER.debug("Error fetching new releases: %s", err)
952 return None
953
954 async def get_new_playlists(self) -> LandingList | None:
955 """
956 Get new editorial playlists.
957
958 :return: LandingList with new_playlists (list of PlaylistId) or None on error.
959 """
960 try:
961 return await self._call_with_retry(lambda c: c.new_playlists())
962 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
963 LOGGER.debug("Error fetching new playlists: %s", err)
964 return None
965
966 async def get_albums(self, album_ids: list[str]) -> list[KionAlbum]:
967 """
968 Get multiple albums by IDs.
969
970 :param album_ids: List of album IDs.
971 :return: List of album objects.
972 """
973 try:
974 result = await self._call_with_retry(lambda c: c.albums(album_ids))
975 return result or []
976 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
977 LOGGER.debug("Error fetching albums: %s", err)
978 return []
979
980 async def get_playlists(self, playlist_ids: list[str]) -> list[KionPlaylist]:
981 """
982 Get multiple playlists by IDs (format: 'uid:kind').
983
984 :param playlist_ids: List of playlist IDs in 'uid:kind' format.
985 :return: List of playlist objects.
986 """
987 try:
988 result = await self._call_with_retry(lambda c: c.playlists_list(playlist_ids))
989 return result or []
990 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
991 LOGGER.debug("Error fetching playlists: %s", err)
992 return []
993
994 async def get_tag_playlists(self, tag_id: str) -> list[KionPlaylist]:
995 """
996 Get playlists for a specific tag (mood, era, activity, genre, etc.).
997
998 Tags are used for curated collections like 'chill', '80s', 'workout', 'rock', etc.
999 The API returns playlist IDs which are then fetched in full.
1000
1001 :param tag_id: Tag identifier (e.g. 'chill', '80s', 'workout', 'rock').
1002 :return: List of playlist objects with full details.
1003 """
1004 try:
1005 tag_result = await self._call_with_retry(lambda c: c.tags(tag_id))
1006 if not tag_result or not tag_result.ids:
1007 LOGGER.debug("No playlists found for tag: %s", tag_id)
1008 return []
1009
1010 # Convert PlaylistId objects to 'uid:kind' format
1011 playlist_ids = [f"{pid.uid}:{pid.kind}" for pid in tag_result.ids]
1012
1013 # Fetch full playlist details
1014 return await self.get_playlists(playlist_ids)
1015 except BadRequestError as err:
1016 LOGGER.debug("Tag %s not found: %s", tag_id, err)
1017 return []
1018 except (NetworkError, ProviderUnavailableError) as err:
1019 LOGGER.debug("Error fetching tag %s playlists: %s", tag_id, err)
1020 return []
1021
1022 async def get_landing_tags(self) -> list[tuple[str, str]]:
1023 """
1024 Discover available tag slugs from the landing mixes block.
1025
1026 Uses the landing("mixes") API which returns MixLink entities
1027 containing tag URLs (e.g., /tag/chill/) and display titles.
1028 Filters out editorial post entries (/post/ URLs) which have no playlists.
1029
1030 :return: List of (tag_slug, title) tuples for real tag entries only.
1031 """
1032 try:
1033 landing: Landing | None = await self._call_with_retry(lambda c: c.landing("mixes"))
1034 if not landing or not landing.blocks:
1035 return []
1036 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
1037 LOGGER.debug("Error fetching landing tags: %s", err)
1038 return []
1039
1040 tags: list[tuple[str, str]] = []
1041 for block in landing.blocks:
1042 if not block.entities:
1043 continue
1044 for entity in block.entities:
1045 if entity.type == "mix-link" and isinstance(entity.data, MixLink):
1046 url = entity.data.url # e.g., "/tag/chill/" or "/post/..."
1047 # Filter out editorial posts â only include /tag/ URLs
1048 if not url.startswith("/tag/"):
1049 continue
1050 slug = url.strip("/").split("/")[-1]
1051 if slug:
1052 tags.append((slug, entity.data.title))
1053 return tags
1054
1055 async def get_mixes_waves(self) -> list[dict[str, Any]] | None:
1056 """
1057 Get AI Wave Set stations from /landing-blocks/mixes-waves endpoint.
1058
1059 Returns structured mix data with categories and station items, each
1060 containing station_id, title, seeds, and visual metadata.
1061
1062 :return: List of mix category dicts, or None on error.
1063 """
1064 return await self._get_landing_waves("mixes-waves")
1065
1066 async def get_waves_landing(self) -> list[dict[str, Any]] | None:
1067 """
1068 Get featured wave stations from /landing-blocks/waves endpoint.
1069
1070 Returns Kion-curated wave categories with station items â the "ÐолнÑ"
1071 landing page content, separate from the full rotor/stations/list and from
1072 the AI mixes-waves sets.
1073
1074 :return: List of wave category dicts, or None on error.
1075 """
1076 return await self._get_landing_waves("waves")
1077
1078 async def _get_landing_waves(self, block: str) -> list[dict[str, Any]] | None:
1079 """
1080 Fetch wave categories from a /landing-blocks/<block> endpoint.
1081
1082 Note: Response keys are auto-converted from camelCase to snake_case
1083 by the kion-music library's JSON parser.
1084
1085 :param block: Block name, e.g. 'waves' or 'mixes-waves'.
1086 :return: List of wave category dicts, or None on error.
1087 """
1088
1089 async def _get(c: ClientAsync) -> dict[str, Any]:
1090 url = f"{c.base_url}/landing-blocks/{block}"
1091 return await c._request.get(url) # type: ignore[no-any-return]
1092
1093 try:
1094 result = await self._call_with_retry(_get)
1095 if result and isinstance(result, dict):
1096 waves = result.get("waves", [])
1097 LOGGER.debug(
1098 "landing-blocks/%s returned %d categories",
1099 block,
1100 len(waves) if isinstance(waves, list) else -1,
1101 )
1102 return waves if isinstance(waves, list) else []
1103 return None
1104 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
1105 LOGGER.debug("Error fetching landing-blocks/%s: %s", block, err)
1106 return None
1107
1108 async def get_wave_stations(
1109 self, language: str | None = None
1110 ) -> list[tuple[str, str, str, str | None]]:
1111 """
1112 Get available rotor wave stations grouped by category.
1113
1114 Calls rotor_stations_list() â equivalent to the rotor/stations/list API endpoint.
1115 Filters out personal stations (type 'user') since My Mix is handled separately.
1116
1117 :param language: Language for station names (e.g. 'ru', 'en'). Defaults to API default.
1118 :return: List of (station_id, category, name, image_url) tuples,
1119 e.g. ('genre:rock', 'genre', 'Рок', 'https://...').
1120 """
1121 try:
1122 results: list[StationResult] = await self._call_with_retry(
1123 lambda c: c.rotor_stations_list(language)
1124 )
1125 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
1126 LOGGER.warning("Error fetching wave stations: %s", err)
1127 return []
1128
1129 stations: list[tuple[str, str, str, str | None]] = []
1130 for result in results or []:
1131 station = result.station
1132 if station is None or station.id is None:
1133 continue
1134 category = station.id.type
1135 tag = station.id.tag
1136 if not category or not tag:
1137 continue
1138 if category in ("user", "local-language"):
1139 # Skip personal stations (My Mix is handled separately)
1140 # and local-language stations (Kion returns overlapping tracks across them)
1141 continue
1142 station_id = f"{category}:{tag}"
1143 name = station.name or result.rup_title or tag
1144 image_url: str | None = None
1145 raw_url = station.full_image_url or (station.icon.image_url if station.icon else None)
1146 if raw_url:
1147 # Kion avatar URIs use '%%' as a size placeholder; replace it with
1148 # the desired size. If no placeholder, append the size as a suffix
1149 # since these URLs return HTTP 400 without a size component.
1150 if not raw_url.startswith("http"):
1151 raw_url = f"https://{raw_url}"
1152 if "%%" in raw_url:
1153 image_url = raw_url.replace("%%", "400x400")
1154 else:
1155 image_url = f"{raw_url}/400x400"
1156 stations.append((station_id, category, name, image_url))
1157 return stations
1158
1159 async def get_dashboard_stations(self) -> list[tuple[str, str, str | None]]:
1160 """
1161 Get personalized recommended stations for the current user.
1162
1163 Calls rotor_stations_dashboard() â returns user-specific stations based
1164 on listening history, unlike rotor_stations_list() which is non-personalized.
1165
1166 :return: List of (station_id, name, image_url) tuples,
1167 e.g. ('genre:rock', 'Рок', 'https://...').
1168 """
1169 try:
1170 dashboard: Dashboard | None = await self._call_with_retry(
1171 lambda c: c.rotor_stations_dashboard()
1172 )
1173 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
1174 LOGGER.warning("Error fetching dashboard stations: %s", err)
1175 return []
1176
1177 if not dashboard or not dashboard.stations:
1178 return []
1179
1180 stations: list[tuple[str, str, str | None]] = []
1181 for result in dashboard.stations:
1182 station = result.station
1183 if station is None or station.id is None:
1184 continue
1185 category = station.id.type
1186 tag = station.id.tag
1187 if not category or not tag:
1188 continue
1189 if category == "user":
1190 continue
1191 station_id = f"{category}:{tag}"
1192 name = station.name or result.rup_title or tag
1193 image_url: str | None = None
1194 raw_url = station.full_image_url or (station.icon.image_url if station.icon else None)
1195 if raw_url:
1196 if not raw_url.startswith("http"):
1197 raw_url = f"https://{raw_url}"
1198 if "%%" in raw_url:
1199 image_url = raw_url.replace("%%", "400x400")
1200 else:
1201 image_url = f"{raw_url}/400x400"
1202 stations.append((station_id, name, image_url))
1203 return stations
1204
1205 # Library modifications
1206
1207 async def like_track(self, track_id: str) -> bool:
1208 """
1209 Add a track to liked tracks.
1210
1211 :param track_id: Track ID to like.
1212 :return: True if successful.
1213 """
1214 try:
1215 result = await self._call_with_retry(lambda c: c.users_likes_tracks_add(track_id))
1216 return result is not None
1217 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
1218 LOGGER.error("Error liking track %s: %s", track_id, err)
1219 return False
1220
1221 async def unlike_track(self, track_id: str) -> bool:
1222 """
1223 Remove a track from liked tracks.
1224
1225 :param track_id: Track ID to unlike.
1226 :return: True if successful.
1227 """
1228 try:
1229 result = await self._call_with_retry(lambda c: c.users_likes_tracks_remove(track_id))
1230 return result is not None
1231 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
1232 LOGGER.error("Error unliking track %s: %s", track_id, err)
1233 return False
1234
1235 async def like_album(self, album_id: str) -> bool:
1236 """
1237 Add an album to liked albums.
1238
1239 :param album_id: Album ID to like.
1240 :return: True if successful.
1241 """
1242 try:
1243 result = await self._call_with_retry(lambda c: c.users_likes_albums_add(album_id))
1244 return result is not None
1245 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
1246 LOGGER.error("Error liking album %s: %s", album_id, err)
1247 return False
1248
1249 async def unlike_album(self, album_id: str) -> bool:
1250 """
1251 Remove an album from liked albums.
1252
1253 :param album_id: Album ID to unlike.
1254 :return: True if successful.
1255 """
1256 try:
1257 result = await self._call_with_retry(lambda c: c.users_likes_albums_remove(album_id))
1258 return result is not None
1259 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
1260 LOGGER.error("Error unliking album %s: %s", album_id, err)
1261 return False
1262
1263 async def like_artist(self, artist_id: str) -> bool:
1264 """
1265 Add an artist to liked artists.
1266
1267 :param artist_id: Artist ID to like.
1268 :return: True if successful.
1269 """
1270 try:
1271 result = await self._call_with_retry(lambda c: c.users_likes_artists_add(artist_id))
1272 return result is not None
1273 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
1274 LOGGER.error("Error liking artist %s: %s", artist_id, err)
1275 return False
1276
1277 async def unlike_artist(self, artist_id: str) -> bool:
1278 """
1279 Remove an artist from liked artists.
1280
1281 :param artist_id: Artist ID to unlike.
1282 :return: True if successful.
1283 """
1284 try:
1285 result = await self._call_with_retry(lambda c: c.users_likes_artists_remove(artist_id))
1286 return result is not None
1287 except (BadRequestError, NetworkError, ProviderUnavailableError) as err:
1288 LOGGER.error("Error unliking artist %s: %s", artist_id, err)
1289 return False
1290