/
/
/
1"""Yandex Music provider implementation."""
2
3from __future__ import annotations
4
5import logging
6from typing import TYPE_CHECKING
7
8from music_assistant_models.enums import MediaType
9from music_assistant_models.errors import (
10 InvalidDataError,
11 LoginFailed,
12 MediaNotFoundError,
13 ProviderUnavailableError,
14 ResourceTemporarilyUnavailable,
15)
16from music_assistant_models.media_items import (
17 Album,
18 Artist,
19 ItemMapping,
20 MediaItemType,
21 Playlist,
22 SearchResults,
23 Track,
24)
25
26from music_assistant.controllers.cache import use_cache
27from music_assistant.models.music_provider import MusicProvider
28
29from .api_client import YandexMusicClient
30from .constants import CONF_TOKEN, PLAYLIST_ID_SPLITTER
31from .parsers import parse_album, parse_artist, parse_playlist, parse_track
32from .streaming import YandexMusicStreamingManager
33
34if TYPE_CHECKING:
35 from collections.abc import AsyncGenerator
36
37 from music_assistant_models.streamdetails import StreamDetails
38
39
40class YandexMusicProvider(MusicProvider):
41 """Implementation of a Yandex Music MusicProvider."""
42
43 _client: YandexMusicClient | None = None
44 _streaming: YandexMusicStreamingManager | None = None
45
46 @property
47 def client(self) -> YandexMusicClient:
48 """Return the Yandex Music client."""
49 if self._client is None:
50 raise ProviderUnavailableError("Provider not initialized")
51 return self._client
52
53 @property
54 def streaming(self) -> YandexMusicStreamingManager:
55 """Return the streaming manager."""
56 if self._streaming is None:
57 raise ProviderUnavailableError("Provider not initialized")
58 return self._streaming
59
60 async def handle_async_init(self) -> None:
61 """Handle async initialization of the provider."""
62 token = self.config.get_value(CONF_TOKEN)
63 if not token:
64 raise LoginFailed("No Yandex Music token provided")
65
66 self._client = YandexMusicClient(str(token))
67 await self._client.connect()
68 # Suppress yandex_music library DEBUG dumps (full API request/response JSON)
69 logging.getLogger("yandex_music").setLevel(self.logger.level + 10)
70 self._streaming = YandexMusicStreamingManager(self)
71 self.logger.info("Successfully connected to Yandex Music")
72
73 async def unload(self, is_removed: bool = False) -> None:
74 """Handle unload/close of the provider.
75
76 :param is_removed: Whether the provider is being removed.
77 """
78 if self._client:
79 await self._client.disconnect()
80 self._client = None
81 self._streaming = None
82 await super().unload(is_removed)
83
84 def get_item_mapping(self, media_type: MediaType | str, key: str, name: str) -> ItemMapping:
85 """Create a generic item mapping.
86
87 :param media_type: The media type.
88 :param key: The item ID.
89 :param name: The item name.
90 :return: An ItemMapping instance.
91 """
92 if isinstance(media_type, str):
93 media_type = MediaType(media_type)
94 return ItemMapping(
95 media_type=media_type,
96 item_id=key,
97 provider=self.instance_id,
98 name=name,
99 )
100
101 # Search
102
103 @use_cache(3600 * 24 * 14)
104 async def search(
105 self, search_query: str, media_types: list[MediaType], limit: int = 5
106 ) -> SearchResults:
107 """Perform search on Yandex Music.
108
109 :param search_query: The search query.
110 :param media_types: List of media types to search for.
111 :param limit: Maximum number of results per type.
112 :return: SearchResults with found items.
113 """
114 result = SearchResults()
115
116 # Determine search type based on requested media types
117 # Map MediaType to Yandex API search type
118 type_mapping = {
119 MediaType.TRACK: "track",
120 MediaType.ALBUM: "album",
121 MediaType.ARTIST: "artist",
122 MediaType.PLAYLIST: "playlist",
123 }
124 requested_types = [type_mapping[mt] for mt in media_types if mt in type_mapping]
125
126 # Use specific type if only one requested, otherwise search all
127 search_type = requested_types[0] if len(requested_types) == 1 else "all"
128
129 search_result = await self.client.search(search_query, search_type=search_type, limit=limit)
130 if not search_result:
131 return result
132
133 # Parse tracks
134 if MediaType.TRACK in media_types and search_result.tracks:
135 for track in search_result.tracks.results[:limit]:
136 try:
137 result.tracks = [*result.tracks, parse_track(self, track)]
138 except InvalidDataError as err:
139 self.logger.debug("Error parsing track: %s", err)
140
141 # Parse albums
142 if MediaType.ALBUM in media_types and search_result.albums:
143 for album in search_result.albums.results[:limit]:
144 try:
145 result.albums = [*result.albums, parse_album(self, album)]
146 except InvalidDataError as err:
147 self.logger.debug("Error parsing album: %s", err)
148
149 # Parse artists
150 if MediaType.ARTIST in media_types and search_result.artists:
151 for artist in search_result.artists.results[:limit]:
152 try:
153 result.artists = [*result.artists, parse_artist(self, artist)]
154 except InvalidDataError as err:
155 self.logger.debug("Error parsing artist: %s", err)
156
157 # Parse playlists
158 if MediaType.PLAYLIST in media_types and search_result.playlists:
159 for playlist in search_result.playlists.results[:limit]:
160 try:
161 result.playlists = [*result.playlists, parse_playlist(self, playlist)]
162 except InvalidDataError as err:
163 self.logger.debug("Error parsing playlist: %s", err)
164
165 return result
166
167 # Get single items
168
169 @use_cache(3600 * 24 * 30)
170 async def get_artist(self, prov_artist_id: str) -> Artist:
171 """Get artist details by ID.
172
173 :param prov_artist_id: The provider artist ID.
174 :return: Artist object.
175 :raises MediaNotFoundError: If artist not found.
176 """
177 artist = await self.client.get_artist(prov_artist_id)
178 if not artist:
179 raise MediaNotFoundError(f"Artist {prov_artist_id} not found")
180 return parse_artist(self, artist)
181
182 @use_cache(3600 * 24 * 30)
183 async def get_album(self, prov_album_id: str) -> Album:
184 """Get album details by ID.
185
186 :param prov_album_id: The provider album ID.
187 :return: Album object.
188 :raises MediaNotFoundError: If album not found.
189 """
190 album = await self.client.get_album(prov_album_id)
191 if not album:
192 raise MediaNotFoundError(f"Album {prov_album_id} not found")
193 return parse_album(self, album)
194
195 @use_cache(3600 * 24 * 30)
196 async def get_track(self, prov_track_id: str) -> Track:
197 """Get track details by ID.
198
199 :param prov_track_id: The provider track ID.
200 :return: Track object.
201 :raises MediaNotFoundError: If track not found.
202 """
203 yandex_track = await self.client.get_track(prov_track_id)
204 if not yandex_track:
205 raise MediaNotFoundError(f"Track {prov_track_id} not found")
206 return parse_track(self, yandex_track)
207
208 @use_cache(3600 * 24 * 30)
209 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
210 """Get playlist details by ID.
211
212 :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind").
213 :return: Playlist object.
214 :raises MediaNotFoundError: If playlist not found.
215 """
216 # Parse the playlist ID (format: owner_id:kind)
217 if PLAYLIST_ID_SPLITTER in prov_playlist_id:
218 owner_id, kind = prov_playlist_id.split(PLAYLIST_ID_SPLITTER, 1)
219 else:
220 owner_id = str(self.client.user_id)
221 kind = prov_playlist_id
222
223 playlist = await self.client.get_playlist(owner_id, kind)
224 if not playlist:
225 raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found")
226 return parse_playlist(self, playlist)
227
228 # Get related items
229
230 @use_cache(3600 * 24 * 30)
231 async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
232 """Get album tracks.
233
234 :param prov_album_id: The provider album ID.
235 :return: List of Track objects.
236 """
237 album = await self.client.get_album_with_tracks(prov_album_id)
238 if not album or not album.volumes:
239 return []
240
241 tracks = []
242 for volume_index, volume in enumerate(album.volumes):
243 for track_index, track in enumerate(volume):
244 try:
245 parsed_track = parse_track(self, track)
246 parsed_track.disc_number = volume_index + 1
247 parsed_track.track_number = track_index + 1
248 tracks.append(parsed_track)
249 except InvalidDataError as err:
250 self.logger.debug("Error parsing album track: %s", err)
251 return tracks
252
253 @use_cache(3600 * 3)
254 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
255 """Get playlist tracks.
256
257 :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind").
258 :param page: Page number for pagination.
259 :return: List of Track objects.
260 """
261 # Yandex Music API returns all playlist tracks in one call (no server-side pagination).
262 # Return empty list for page > 0 so the controller pagination loop terminates.
263 if page > 0:
264 return []
265
266 # Parse the playlist ID (format: owner_id:kind)
267 if PLAYLIST_ID_SPLITTER in prov_playlist_id:
268 owner_id, kind = prov_playlist_id.split(PLAYLIST_ID_SPLITTER, 1)
269 else:
270 owner_id = str(self.client.user_id)
271 kind = prov_playlist_id
272
273 playlist = await self.client.get_playlist(owner_id, kind)
274 if not playlist:
275 return []
276
277 # API sometimes returns playlist without tracks; fetch them explicitly if needed
278 tracks_list = playlist.tracks or []
279 track_count = getattr(playlist, "track_count", None) or 0
280 if not tracks_list and track_count > 0:
281 self.logger.debug(
282 "Playlist %s/%s: track_count=%s but no tracks in response, "
283 "calling fetch_tracks_async",
284 owner_id,
285 kind,
286 track_count,
287 )
288 try:
289 tracks_list = await playlist.fetch_tracks_async()
290 except Exception as err:
291 self.logger.warning("fetch_tracks_async failed for %s/%s: %s", owner_id, kind, err)
292 if not tracks_list:
293 raise ResourceTemporarilyUnavailable(
294 "Playlist tracks not available; try again later"
295 )
296
297 if not tracks_list:
298 return []
299
300 # Yandex returns TrackShort objects, we need to fetch full track info
301 track_ids = [
302 str(track.track_id) if hasattr(track, "track_id") else str(track.id)
303 for track in tracks_list
304 if track
305 ]
306 if not track_ids:
307 return []
308
309 # Fetch full track details in batches to avoid timeouts
310 batch_size = 50
311 full_tracks = []
312 for i in range(0, len(track_ids), batch_size):
313 batch = track_ids[i : i + batch_size]
314 batch_result = await self.client.get_tracks(batch)
315 if not batch_result:
316 self.logger.warning(
317 "Received empty result for playlist %s tracks batch %s-%s",
318 prov_playlist_id,
319 i,
320 i + len(batch) - 1,
321 )
322 raise ResourceTemporarilyUnavailable(
323 "Playlist tracks not fully available; try again later"
324 )
325 full_tracks.extend(batch_result)
326
327 if track_ids and not full_tracks:
328 raise ResourceTemporarilyUnavailable("Failed to load track details; try again later")
329
330 tracks = []
331 for track in full_tracks:
332 try:
333 tracks.append(parse_track(self, track))
334 except InvalidDataError as err:
335 self.logger.debug("Error parsing playlist track: %s", err)
336 return tracks
337
338 @use_cache(3600 * 24 * 7)
339 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
340 """Get artist's albums.
341
342 :param prov_artist_id: The provider artist ID.
343 :return: List of Album objects.
344 """
345 albums = await self.client.get_artist_albums(prov_artist_id)
346 result = []
347 for album in albums:
348 try:
349 result.append(parse_album(self, album))
350 except InvalidDataError as err:
351 self.logger.debug("Error parsing artist album: %s", err)
352 return result
353
354 @use_cache(3600 * 24 * 7)
355 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
356 """Get artist's top tracks.
357
358 :param prov_artist_id: The provider artist ID.
359 :return: List of Track objects.
360 """
361 tracks = await self.client.get_artist_tracks(prov_artist_id)
362 result = []
363 for track in tracks:
364 try:
365 result.append(parse_track(self, track))
366 except InvalidDataError as err:
367 self.logger.debug("Error parsing artist track: %s", err)
368 return result
369
370 # Library methods
371
372 async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
373 """Retrieve library artists from Yandex Music."""
374 artists = await self.client.get_liked_artists()
375 for artist in artists:
376 try:
377 yield parse_artist(self, artist)
378 except InvalidDataError as err:
379 self.logger.debug("Error parsing library artist: %s", err)
380
381 async def get_library_albums(self) -> AsyncGenerator[Album, None]:
382 """Retrieve library albums from Yandex Music."""
383 albums = await self.client.get_liked_albums()
384 for album in albums:
385 try:
386 yield parse_album(self, album)
387 except InvalidDataError as err:
388 self.logger.debug("Error parsing library album: %s", err)
389
390 async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
391 """Retrieve library tracks from Yandex Music."""
392 track_shorts = await self.client.get_liked_tracks()
393 if not track_shorts:
394 return
395
396 # Fetch full track details in batches
397 track_ids = [str(ts.track_id) for ts in track_shorts if ts.track_id]
398 batch_size = 50
399 for i in range(0, len(track_ids), batch_size):
400 batch_ids = track_ids[i : i + batch_size]
401 full_tracks = await self.client.get_tracks(batch_ids)
402 for track in full_tracks:
403 try:
404 yield parse_track(self, track)
405 except InvalidDataError as err:
406 self.logger.debug("Error parsing library track: %s", err)
407
408 async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
409 """Retrieve library playlists from Yandex Music."""
410 playlists = await self.client.get_user_playlists()
411 for playlist in playlists:
412 try:
413 yield parse_playlist(self, playlist)
414 except InvalidDataError as err:
415 self.logger.debug("Error parsing library playlist: %s", err)
416
417 # Library edit methods
418
419 async def library_add(self, item: MediaItemType) -> bool:
420 """Add item to library.
421
422 :param item: The media item to add.
423 :return: True if successful.
424 """
425 prov_item_id = self._get_provider_item_id(item)
426 if not prov_item_id:
427 return False
428
429 if item.media_type == MediaType.TRACK:
430 return await self.client.like_track(prov_item_id)
431 if item.media_type == MediaType.ALBUM:
432 return await self.client.like_album(prov_item_id)
433 if item.media_type == MediaType.ARTIST:
434 return await self.client.like_artist(prov_item_id)
435 return False
436
437 async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
438 """Remove item from library.
439
440 :param prov_item_id: The provider item ID.
441 :param media_type: The media type.
442 :return: True if successful.
443 """
444 if media_type == MediaType.TRACK:
445 return await self.client.unlike_track(prov_item_id)
446 if media_type == MediaType.ALBUM:
447 return await self.client.unlike_album(prov_item_id)
448 if media_type == MediaType.ARTIST:
449 return await self.client.unlike_artist(prov_item_id)
450 return False
451
452 def _get_provider_item_id(self, item: MediaItemType) -> str | None:
453 """Get provider item ID from media item."""
454 for mapping in item.provider_mappings:
455 if mapping.provider_instance == self.instance_id:
456 return mapping.item_id
457 return item.item_id if item.provider == self.instance_id else None
458
459 # Streaming
460
461 async def get_stream_details(
462 self, item_id: str, media_type: MediaType = MediaType.TRACK
463 ) -> StreamDetails:
464 """Get stream details for a track.
465
466 :param item_id: The track ID.
467 :param media_type: The media type (should be TRACK).
468 :return: StreamDetails for the track.
469 """
470 return await self.streaming.get_stream_details(item_id)
471