music-assistant-server

13.9 KBPY
api_client.py
13.9 KB391 lines • python
1"""API client wrapper for Yandex Music."""
2
3from __future__ import annotations
4
5import logging
6from typing import TYPE_CHECKING
7
8from music_assistant_models.errors import (
9    LoginFailed,
10    ProviderUnavailableError,
11    ResourceTemporarilyUnavailable,
12)
13from yandex_music import Album as YandexAlbum
14from yandex_music import Artist as YandexArtist
15from yandex_music import ClientAsync, Search, TrackShort
16from yandex_music import Playlist as YandexPlaylist
17from yandex_music import Track as YandexTrack
18from yandex_music.exceptions import BadRequestError, NetworkError, UnauthorizedError
19
20if TYPE_CHECKING:
21    from yandex_music import DownloadInfo
22
23from .constants import DEFAULT_LIMIT
24
25LOGGER = logging.getLogger(__name__)
26
27
28class YandexMusicClient:
29    """Wrapper around yandex-music-api ClientAsync."""
30
31    def __init__(self, token: str) -> None:
32        """Initialize the Yandex Music client.
33
34        :param token: Yandex Music OAuth token.
35        """
36        self._token = token
37        self._client: ClientAsync | None = None
38        self._user_id: int | None = None
39
40    @property
41    def user_id(self) -> int:
42        """Return the user ID."""
43        if self._user_id is None:
44            raise ProviderUnavailableError("Client not initialized, call connect() first")
45        return self._user_id
46
47    async def connect(self) -> bool:
48        """Initialize the client and verify token validity.
49
50        :return: True if connection was successful.
51        :raises LoginFailed: If the token is invalid.
52        """
53        try:
54            self._client = await ClientAsync(self._token).init()
55            if self._client.me is None or self._client.me.account is None:
56                raise LoginFailed("Failed to get account info")
57            self._user_id = self._client.me.account.uid
58            LOGGER.debug("Connected to Yandex Music as user %s", self._user_id)
59            return True
60        except UnauthorizedError as err:
61            raise LoginFailed("Invalid Yandex Music token") from err
62        except NetworkError as err:
63            msg = "Network error connecting to Yandex Music"
64            raise ResourceTemporarilyUnavailable(msg) from err
65
66    async def disconnect(self) -> None:
67        """Disconnect the client."""
68        self._client = None
69        self._user_id = None
70
71    def _ensure_connected(self) -> ClientAsync:
72        """Ensure the client is connected and return it."""
73        if self._client is None:
74            raise ProviderUnavailableError("Client not connected, call connect() first")
75        return self._client
76
77    # Library methods
78
79    async def get_liked_tracks(self) -> list[TrackShort]:
80        """Get user's liked tracks.
81
82        :return: List of liked track objects.
83        """
84        client = self._ensure_connected()
85        try:
86            result = await client.users_likes_tracks()
87            if result is None:
88                return []
89            return result.tracks or []
90        except (BadRequestError, NetworkError) as err:
91            LOGGER.error("Error fetching liked tracks: %s", err)
92            raise ResourceTemporarilyUnavailable("Failed to fetch liked tracks") from err
93
94    async def get_liked_albums(self) -> list[YandexAlbum]:
95        """Get user's liked albums.
96
97        :return: List of liked album objects.
98        """
99        client = self._ensure_connected()
100        try:
101            result = await client.users_likes_albums()
102            if result is None:
103                return []
104            return [like.album for like in result if like.album is not None]
105        except (BadRequestError, NetworkError) as err:
106            LOGGER.error("Error fetching liked albums: %s", err)
107            raise ResourceTemporarilyUnavailable("Failed to fetch liked albums") from err
108
109    async def get_liked_artists(self) -> list[YandexArtist]:
110        """Get user's liked artists.
111
112        :return: List of liked artist objects.
113        """
114        client = self._ensure_connected()
115        try:
116            result = await client.users_likes_artists()
117            if result is None:
118                return []
119            return [like.artist for like in result if like.artist is not None]
120        except (BadRequestError, NetworkError) as err:
121            LOGGER.error("Error fetching liked artists: %s", err)
122            raise ResourceTemporarilyUnavailable("Failed to fetch liked artists") from err
123
124    async def get_user_playlists(self) -> list[YandexPlaylist]:
125        """Get user's playlists.
126
127        :return: List of playlist objects.
128        """
129        client = self._ensure_connected()
130        try:
131            result = await client.users_playlists_list()
132            if result is None:
133                return []
134            return list(result)
135        except (BadRequestError, NetworkError) as err:
136            LOGGER.error("Error fetching playlists: %s", err)
137            raise ResourceTemporarilyUnavailable("Failed to fetch playlists") from err
138
139    # Search
140
141    async def search(
142        self,
143        query: str,
144        search_type: str = "all",
145        limit: int = DEFAULT_LIMIT,
146    ) -> Search | None:
147        """Search for tracks, albums, artists, or playlists.
148
149        :param query: Search query string.
150        :param search_type: Type of search ('all', 'track', 'album', 'artist', 'playlist').
151        :param limit: Maximum number of results per type.
152        :return: Search results object.
153        """
154        client = self._ensure_connected()
155        try:
156            return await client.search(query, type_=search_type, page=0, nocorrect=False)
157        except (BadRequestError, NetworkError) as err:
158            LOGGER.error("Search error: %s", err)
159            raise ResourceTemporarilyUnavailable("Search failed") from err
160
161    # Get single items
162
163    async def get_track(self, track_id: str) -> YandexTrack | None:
164        """Get a single track by ID.
165
166        :param track_id: Track ID.
167        :return: Track object or None if not found.
168        """
169        client = self._ensure_connected()
170        try:
171            tracks = await client.tracks([track_id])
172            return tracks[0] if tracks else None
173        except (BadRequestError, NetworkError) as err:
174            LOGGER.error("Error fetching track %s: %s", track_id, err)
175            return None
176
177    async def get_tracks(self, track_ids: list[str]) -> list[YandexTrack]:
178        """Get multiple tracks by IDs.
179
180        :param track_ids: List of track IDs.
181        :return: List of track objects.
182        """
183        client = self._ensure_connected()
184        try:
185            result = await client.tracks(track_ids)
186            return result or []
187        except (BadRequestError, NetworkError) as err:
188            LOGGER.error("Error fetching tracks: %s", err)
189            return []
190
191    async def get_album(self, album_id: str) -> YandexAlbum | None:
192        """Get a single album by ID.
193
194        :param album_id: Album ID.
195        :return: Album object or None if not found.
196        """
197        client = self._ensure_connected()
198        try:
199            albums = await client.albums([album_id])
200            return albums[0] if albums else None
201        except (BadRequestError, NetworkError) as err:
202            LOGGER.error("Error fetching album %s: %s", album_id, err)
203            return None
204
205    async def get_album_with_tracks(self, album_id: str) -> YandexAlbum | None:
206        """Get an album with its tracks.
207
208        :param album_id: Album ID.
209        :return: Album object with tracks or None if not found.
210        """
211        client = self._ensure_connected()
212        try:
213            return await client.albums_with_tracks(album_id)
214        except (BadRequestError, NetworkError) as err:
215            LOGGER.error("Error fetching album with tracks %s: %s", album_id, err)
216            return None
217
218    async def get_artist(self, artist_id: str) -> YandexArtist | None:
219        """Get a single artist by ID.
220
221        :param artist_id: Artist ID.
222        :return: Artist object or None if not found.
223        """
224        client = self._ensure_connected()
225        try:
226            artists = await client.artists([artist_id])
227            return artists[0] if artists else None
228        except (BadRequestError, NetworkError) as err:
229            LOGGER.error("Error fetching artist %s: %s", artist_id, err)
230            return None
231
232    async def get_artist_albums(
233        self, artist_id: str, limit: int = DEFAULT_LIMIT
234    ) -> list[YandexAlbum]:
235        """Get artist's albums.
236
237        :param artist_id: Artist ID.
238        :param limit: Maximum number of albums.
239        :return: List of album objects.
240        """
241        client = self._ensure_connected()
242        try:
243            result = await client.artists_direct_albums(artist_id, page=0, page_size=limit)
244            if result is None:
245                return []
246            return result.albums or []
247        except (BadRequestError, NetworkError) as err:
248            LOGGER.error("Error fetching artist albums %s: %s", artist_id, err)
249            return []
250
251    async def get_artist_tracks(
252        self, artist_id: str, limit: int = DEFAULT_LIMIT
253    ) -> list[YandexTrack]:
254        """Get artist's top tracks.
255
256        :param artist_id: Artist ID.
257        :param limit: Maximum number of tracks.
258        :return: List of track objects.
259        """
260        client = self._ensure_connected()
261        try:
262            result = await client.artists_tracks(artist_id, page=0, page_size=limit)
263            if result is None:
264                return []
265            return result.tracks or []
266        except (BadRequestError, NetworkError) as err:
267            LOGGER.error("Error fetching artist tracks %s: %s", artist_id, err)
268            return []
269
270    async def get_playlist(self, user_id: str, playlist_id: str) -> YandexPlaylist | None:
271        """Get a playlist by ID.
272
273        :param user_id: User ID (owner of the playlist).
274        :param playlist_id: Playlist ID (kind).
275        :return: Playlist object or None if not found.
276        """
277        client = self._ensure_connected()
278        try:
279            result = await client.users_playlists(kind=int(playlist_id), user_id=user_id)
280            if isinstance(result, list):
281                return result[0] if result else None
282            return result
283        except (BadRequestError, NetworkError) as err:
284            LOGGER.error("Error fetching playlist %s/%s: %s", user_id, playlist_id, err)
285            return None
286
287    # Streaming
288
289    async def get_track_download_info(
290        self, track_id: str, get_direct_links: bool = True
291    ) -> list[DownloadInfo]:
292        """Get download info for a track.
293
294        :param track_id: Track ID.
295        :param get_direct_links: Whether to get direct download links.
296        :return: List of download info objects.
297        """
298        client = self._ensure_connected()
299        try:
300            result = await client.tracks_download_info(track_id, get_direct_links=get_direct_links)
301            return result or []
302        except (BadRequestError, NetworkError) as err:
303            LOGGER.error("Error fetching download info for track %s: %s", track_id, err)
304            return []
305
306    # Library modifications
307
308    async def like_track(self, track_id: str) -> bool:
309        """Add a track to liked tracks.
310
311        :param track_id: Track ID to like.
312        :return: True if successful.
313        """
314        client = self._ensure_connected()
315        try:
316            result = await client.users_likes_tracks_add(track_id)
317            return result is not None
318        except (BadRequestError, NetworkError) as err:
319            LOGGER.error("Error liking track %s: %s", track_id, err)
320            return False
321
322    async def unlike_track(self, track_id: str) -> bool:
323        """Remove a track from liked tracks.
324
325        :param track_id: Track ID to unlike.
326        :return: True if successful.
327        """
328        client = self._ensure_connected()
329        try:
330            result = await client.users_likes_tracks_remove(track_id)
331            return result is not None
332        except (BadRequestError, NetworkError) as err:
333            LOGGER.error("Error unliking track %s: %s", track_id, err)
334            return False
335
336    async def like_album(self, album_id: str) -> bool:
337        """Add an album to liked albums.
338
339        :param album_id: Album ID to like.
340        :return: True if successful.
341        """
342        client = self._ensure_connected()
343        try:
344            result = await client.users_likes_albums_add(album_id)
345            return result is not None
346        except (BadRequestError, NetworkError) as err:
347            LOGGER.error("Error liking album %s: %s", album_id, err)
348            return False
349
350    async def unlike_album(self, album_id: str) -> bool:
351        """Remove an album from liked albums.
352
353        :param album_id: Album ID to unlike.
354        :return: True if successful.
355        """
356        client = self._ensure_connected()
357        try:
358            result = await client.users_likes_albums_remove(album_id)
359            return result is not None
360        except (BadRequestError, NetworkError) as err:
361            LOGGER.error("Error unliking album %s: %s", album_id, err)
362            return False
363
364    async def like_artist(self, artist_id: str) -> bool:
365        """Add an artist to liked artists.
366
367        :param artist_id: Artist ID to like.
368        :return: True if successful.
369        """
370        client = self._ensure_connected()
371        try:
372            result = await client.users_likes_artists_add(artist_id)
373            return result is not None
374        except (BadRequestError, NetworkError) as err:
375            LOGGER.error("Error liking artist %s: %s", artist_id, err)
376            return False
377
378    async def unlike_artist(self, artist_id: str) -> bool:
379        """Remove an artist from liked artists.
380
381        :param artist_id: Artist ID to unlike.
382        :return: True if successful.
383        """
384        client = self._ensure_connected()
385        try:
386            result = await client.users_likes_artists_remove(artist_id)
387            return result is not None
388        except (BadRequestError, NetworkError) as err:
389            LOGGER.error("Error unliking artist %s: %s", artist_id, err)
390            return False
391