/
/
/
1"""Yandex Music provider implementation."""
2
3from __future__ import annotations
4
5import logging
6from collections.abc import Sequence
7from typing import TYPE_CHECKING
8
9from music_assistant_models.enums import MediaType, ProviderFeature
10from music_assistant_models.errors import (
11 InvalidDataError,
12 LoginFailed,
13 MediaNotFoundError,
14 ProviderUnavailableError,
15 ResourceTemporarilyUnavailable,
16)
17from music_assistant_models.media_items import (
18 Album,
19 Artist,
20 BrowseFolder,
21 ItemMapping,
22 MediaItemType,
23 Playlist,
24 ProviderMapping,
25 RecommendationFolder,
26 SearchResults,
27 Track,
28 UniqueList,
29)
30
31from music_assistant.controllers.cache import use_cache
32from music_assistant.models.music_provider import MusicProvider
33
34from .api_client import YandexMusicClient
35from .constants import (
36 BROWSE_NAMES_EN,
37 BROWSE_NAMES_RU,
38 CONF_TOKEN,
39 MY_WAVE_PLAYLIST_ID,
40 PLAYLIST_ID_SPLITTER,
41 RADIO_TRACK_ID_SEP,
42 ROTOR_STATION_MY_WAVE,
43)
44from .parsers import parse_album, parse_artist, parse_playlist, parse_track
45from .streaming import YandexMusicStreamingManager
46
47if TYPE_CHECKING:
48 from collections.abc import AsyncGenerator
49
50 from music_assistant_models.streamdetails import StreamDetails
51
52
53def _parse_radio_item_id(item_id: str) -> tuple[str, str | None]:
54 """Extract track_id and optional station_id from provider item_id.
55
56 My Wave tracks use item_id format 'track_id@station_id'. Other tracks use
57 plain track_id.
58
59 :param item_id: Provider item_id (may contain RADIO_TRACK_ID_SEP).
60 :return: (track_id, station_id or None).
61 """
62 if RADIO_TRACK_ID_SEP in item_id:
63 parts = item_id.split(RADIO_TRACK_ID_SEP, 1)
64 return (parts[0], parts[1] if len(parts) > 1 else None)
65 return (item_id, None)
66
67
68class YandexMusicProvider(MusicProvider):
69 """Implementation of a Yandex Music MusicProvider."""
70
71 _client: YandexMusicClient | None = None
72 _streaming: YandexMusicStreamingManager | None = None
73 _my_wave_batch_id: str | None = None
74 _my_wave_last_track_id: str | None = None # last track id for "Load more" (API queue param)
75 _my_wave_playlist_next_cursor: str | None = None # first_track_id for next playlist page
76 _my_wave_radio_started_sent: bool = False
77
78 @property
79 def client(self) -> YandexMusicClient:
80 """Return the Yandex Music client."""
81 if self._client is None:
82 raise ProviderUnavailableError("Provider not initialized")
83 return self._client
84
85 @property
86 def streaming(self) -> YandexMusicStreamingManager:
87 """Return the streaming manager."""
88 if self._streaming is None:
89 raise ProviderUnavailableError("Provider not initialized")
90 return self._streaming
91
92 def _get_browse_names(self) -> dict[str, str]:
93 """Get locale-based browse folder names."""
94 try:
95 locale = (self.mass.metadata.locale or "en_US").lower()
96 use_russian = locale.startswith("ru")
97 except Exception:
98 use_russian = False
99 return BROWSE_NAMES_RU if use_russian else BROWSE_NAMES_EN
100
101 async def handle_async_init(self) -> None:
102 """Handle async initialization of the provider."""
103 token = self.config.get_value(CONF_TOKEN)
104 if not token:
105 raise LoginFailed("No Yandex Music token provided")
106
107 self._client = YandexMusicClient(str(token))
108 await self._client.connect()
109 # Suppress yandex_music library DEBUG dumps (full API request/response JSON)
110 logging.getLogger("yandex_music").setLevel(self.logger.level + 10)
111 self._streaming = YandexMusicStreamingManager(self)
112 self.logger.info("Successfully connected to Yandex Music")
113
114 async def unload(self, is_removed: bool = False) -> None:
115 """Handle unload/close of the provider.
116
117 :param is_removed: Whether the provider is being removed.
118 """
119 if self._client:
120 await self._client.disconnect()
121 self._client = None
122 self._streaming = None
123 await super().unload(is_removed)
124
125 def get_item_mapping(self, media_type: MediaType | str, key: str, name: str) -> ItemMapping:
126 """Create a generic item mapping.
127
128 :param media_type: The media type.
129 :param key: The item ID.
130 :param name: The item name.
131 :return: An ItemMapping instance.
132 """
133 if isinstance(media_type, str):
134 media_type = MediaType(media_type)
135 return ItemMapping(
136 media_type=media_type,
137 item_id=key,
138 provider=self.instance_id,
139 name=name,
140 )
141
142 async def browse( # noqa: PLR0915
143 self, path: str
144 ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
145 """Browse provider items with locale-based folder names and My Wave.
146
147 Root level shows My Wave, artists, albums, liked tracks, playlists. Names
148 are in Russian when MA locale is ru_*, otherwise in English. My Wave
149 tracks use item_id format track_id@station_id for rotor feedback.
150
151 :param path: The path to browse (e.g. provider_id:// or provider_id://artists).
152 """
153 if ProviderFeature.BROWSE not in self.supported_features:
154 raise NotImplementedError
155
156 path_parts = path.split("://")[1].split("/") if "://" in path else []
157 subpath = path_parts[0] if len(path_parts) > 0 else None
158 sub_subpath = path_parts[1] if len(path_parts) > 1 else None
159
160 if subpath == MY_WAVE_PLAYLIST_ID:
161 # Root my_wave: fetch up to 3 batches so Play adds more tracks.
162 # "Load more" uses single next batch.
163 max_batches = 3 if sub_subpath != "next" else 1
164 queue: str | int | None = None
165 if sub_subpath == "next":
166 queue = self._my_wave_last_track_id
167 elif sub_subpath:
168 queue = sub_subpath
169
170 all_tracks: list[Track | BrowseFolder] = []
171 last_batch_id: str | None = None
172 first_track_id_this_batch: str | None = None
173
174 for _ in range(max_batches):
175 yandex_tracks, batch_id = await self.client.get_my_wave_tracks(queue=queue)
176 if batch_id:
177 self._my_wave_batch_id = batch_id
178 last_batch_id = batch_id
179 if not self._my_wave_radio_started_sent and yandex_tracks:
180 self._my_wave_radio_started_sent = True
181 await self.client.send_rotor_station_feedback(
182 ROTOR_STATION_MY_WAVE,
183 "radioStarted",
184 batch_id=batch_id,
185 )
186 first_track_id_this_batch = None
187 for yt in yandex_tracks:
188 try:
189 t = parse_track(self, yt)
190 track_id = (
191 str(yt.id)
192 if hasattr(yt, "id") and yt.id
193 else getattr(yt, "track_id", None)
194 )
195 if track_id:
196 if first_track_id_this_batch is None:
197 first_track_id_this_batch = track_id
198 t.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_WAVE}"
199 for pm in t.provider_mappings:
200 if pm.provider_instance == self.instance_id:
201 pm.item_id = t.item_id
202 break
203 all_tracks.append(t)
204 except InvalidDataError as err:
205 self.logger.debug("Error parsing My Wave track: %s", err)
206 if first_track_id_this_batch is not None:
207 self._my_wave_last_track_id = first_track_id_this_batch
208 if not batch_id or not yandex_tracks:
209 break
210 queue = first_track_id_this_batch
211
212 if last_batch_id:
213 names = self._get_browse_names()
214 next_name = "ÐÑÑ" if names is BROWSE_NAMES_RU else "Load more"
215 all_tracks.append(
216 BrowseFolder(
217 item_id="next",
218 provider=self.instance_id,
219 path=f"{path.rstrip('/')}/next",
220 name=next_name,
221 is_playable=False,
222 )
223 )
224 return all_tracks
225
226 if subpath:
227 return await super().browse(path)
228
229 names = self._get_browse_names()
230
231 folders: list[BrowseFolder] = []
232 base = path if path.endswith("//") else path.rstrip("/") + "/"
233 folders.append(
234 BrowseFolder(
235 item_id=MY_WAVE_PLAYLIST_ID,
236 provider=self.instance_id,
237 path=f"{base}{MY_WAVE_PLAYLIST_ID}",
238 name=names[MY_WAVE_PLAYLIST_ID],
239 is_playable=True,
240 )
241 )
242 if ProviderFeature.LIBRARY_ARTISTS in self.supported_features:
243 folders.append(
244 BrowseFolder(
245 item_id="artists",
246 provider=self.instance_id,
247 path=f"{base}artists",
248 name=names["artists"],
249 is_playable=True,
250 )
251 )
252 if ProviderFeature.LIBRARY_ALBUMS in self.supported_features:
253 folders.append(
254 BrowseFolder(
255 item_id="albums",
256 provider=self.instance_id,
257 path=f"{base}albums",
258 name=names["albums"],
259 is_playable=True,
260 )
261 )
262 if ProviderFeature.LIBRARY_TRACKS in self.supported_features:
263 folders.append(
264 BrowseFolder(
265 item_id="tracks",
266 provider=self.instance_id,
267 path=f"{base}tracks",
268 name=names["tracks"],
269 is_playable=True,
270 )
271 )
272 if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features:
273 folders.append(
274 BrowseFolder(
275 item_id="playlists",
276 provider=self.instance_id,
277 path=f"{base}playlists",
278 name=names["playlists"],
279 is_playable=True,
280 )
281 )
282 if len(folders) == 1:
283 return await self.browse(folders[0].path)
284 return folders
285
286 # Search
287
288 @use_cache(3600 * 24 * 14)
289 async def search(
290 self, search_query: str, media_types: list[MediaType], limit: int = 5
291 ) -> SearchResults:
292 """Perform search on Yandex Music.
293
294 :param search_query: The search query.
295 :param media_types: List of media types to search for.
296 :param limit: Maximum number of results per type.
297 :return: SearchResults with found items.
298 """
299 result = SearchResults()
300
301 # Determine search type based on requested media types
302 # Map MediaType to Yandex API search type
303 type_mapping = {
304 MediaType.TRACK: "track",
305 MediaType.ALBUM: "album",
306 MediaType.ARTIST: "artist",
307 MediaType.PLAYLIST: "playlist",
308 }
309 requested_types = [type_mapping[mt] for mt in media_types if mt in type_mapping]
310
311 # Use specific type if only one requested, otherwise search all
312 search_type = requested_types[0] if len(requested_types) == 1 else "all"
313
314 search_result = await self.client.search(search_query, search_type=search_type, limit=limit)
315 if not search_result:
316 return result
317
318 # Parse tracks
319 if MediaType.TRACK in media_types and search_result.tracks:
320 for track in search_result.tracks.results[:limit]:
321 try:
322 result.tracks = [*result.tracks, parse_track(self, track)]
323 except InvalidDataError as err:
324 self.logger.debug("Error parsing track: %s", err)
325
326 # Parse albums
327 if MediaType.ALBUM in media_types and search_result.albums:
328 for album in search_result.albums.results[:limit]:
329 try:
330 result.albums = [*result.albums, parse_album(self, album)]
331 except InvalidDataError as err:
332 self.logger.debug("Error parsing album: %s", err)
333
334 # Parse artists
335 if MediaType.ARTIST in media_types and search_result.artists:
336 for artist in search_result.artists.results[:limit]:
337 try:
338 result.artists = [*result.artists, parse_artist(self, artist)]
339 except InvalidDataError as err:
340 self.logger.debug("Error parsing artist: %s", err)
341
342 # Parse playlists
343 if MediaType.PLAYLIST in media_types and search_result.playlists:
344 for playlist in search_result.playlists.results[:limit]:
345 try:
346 result.playlists = [*result.playlists, parse_playlist(self, playlist)]
347 except InvalidDataError as err:
348 self.logger.debug("Error parsing playlist: %s", err)
349
350 return result
351
352 # Get single items
353
354 @use_cache(3600 * 24 * 30)
355 async def get_artist(self, prov_artist_id: str) -> Artist:
356 """Get artist details by ID.
357
358 :param prov_artist_id: The provider artist ID.
359 :return: Artist object.
360 :raises MediaNotFoundError: If artist not found.
361 """
362 artist = await self.client.get_artist(prov_artist_id)
363 if not artist:
364 raise MediaNotFoundError(f"Artist {prov_artist_id} not found")
365 return parse_artist(self, artist)
366
367 @use_cache(3600 * 24 * 30)
368 async def get_album(self, prov_album_id: str) -> Album:
369 """Get album details by ID.
370
371 :param prov_album_id: The provider album ID.
372 :return: Album object.
373 :raises MediaNotFoundError: If album not found.
374 """
375 album = await self.client.get_album(prov_album_id)
376 if not album:
377 raise MediaNotFoundError(f"Album {prov_album_id} not found")
378 return parse_album(self, album)
379
380 @use_cache(3600 * 24 * 30)
381 async def get_track(self, prov_track_id: str) -> Track:
382 """Get track details by ID.
383
384 Supports composite item_id (track_id@station_id) for My Wave tracks;
385 only the track_id part is used for the API.
386
387 :param prov_track_id: The provider track ID (or track_id@station_id).
388 :return: Track object.
389 :raises MediaNotFoundError: If track not found.
390 """
391 track_id, _ = _parse_radio_item_id(prov_track_id)
392 yandex_track = await self.client.get_track(track_id)
393 if not yandex_track:
394 raise MediaNotFoundError(f"Track {prov_track_id} not found")
395 return parse_track(self, yandex_track)
396
397 @use_cache(3600 * 24 * 30)
398 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
399 """Get playlist details by ID.
400
401 Supports virtual playlist MY_WAVE_PLAYLIST_ID (My Wave). Real playlists
402 use format "owner_id:kind".
403
404 :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind" or my_wave).
405 :return: Playlist object.
406 :raises MediaNotFoundError: If playlist not found.
407 """
408 if prov_playlist_id == MY_WAVE_PLAYLIST_ID:
409 names = self._get_browse_names()
410 return Playlist(
411 item_id=MY_WAVE_PLAYLIST_ID,
412 provider=self.instance_id,
413 name=names[MY_WAVE_PLAYLIST_ID],
414 owner="Yandex Music",
415 provider_mappings={
416 ProviderMapping(
417 item_id=MY_WAVE_PLAYLIST_ID,
418 provider_domain=self.domain,
419 provider_instance=self.instance_id,
420 is_unique=True,
421 )
422 },
423 is_editable=False,
424 )
425
426 # Parse the playlist ID (format: owner_id:kind)
427 if PLAYLIST_ID_SPLITTER in prov_playlist_id:
428 owner_id, kind = prov_playlist_id.split(PLAYLIST_ID_SPLITTER, 1)
429 else:
430 owner_id = str(self.client.user_id)
431 kind = prov_playlist_id
432
433 playlist = await self.client.get_playlist(owner_id, kind)
434 if not playlist:
435 raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found")
436 return parse_playlist(self, playlist)
437
438 async def _get_my_wave_playlist_tracks(self, page: int) -> list[Track]:
439 """Get My Wave tracks for virtual playlist (uncached; uses cursor for page > 0).
440
441 :param page: Page number (0 = first batch, 1+ = next batches via queue cursor).
442 :return: List of Track objects for this page.
443 """
444 queue: str | int | None = None
445 if page > 0:
446 queue = self._my_wave_playlist_next_cursor
447 if not queue:
448 return []
449 yandex_tracks, batch_id = await self.client.get_my_wave_tracks(queue=queue)
450 if batch_id:
451 self._my_wave_batch_id = batch_id
452 if not self._my_wave_radio_started_sent and yandex_tracks:
453 self._my_wave_radio_started_sent = True
454 await self.client.send_rotor_station_feedback(
455 ROTOR_STATION_MY_WAVE,
456 "radioStarted",
457 batch_id=batch_id,
458 )
459 first_track_id_this_batch = None
460 tracks = []
461 for yt in yandex_tracks:
462 try:
463 t = parse_track(self, yt)
464 track_id = (
465 str(yt.id) if hasattr(yt, "id") and yt.id else getattr(yt, "track_id", None)
466 )
467 if track_id:
468 if first_track_id_this_batch is None:
469 first_track_id_this_batch = track_id
470 t.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_WAVE}"
471 for pm in t.provider_mappings:
472 if pm.provider_instance == self.instance_id:
473 pm.item_id = t.item_id
474 break
475 tracks.append(t)
476 except InvalidDataError as err:
477 self.logger.debug("Error parsing My Wave track: %s", err)
478 if first_track_id_this_batch is not None:
479 self._my_wave_playlist_next_cursor = first_track_id_this_batch
480 return tracks
481
482 # Get related items
483
484 @use_cache(3600 * 24 * 30)
485 async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
486 """Get album tracks.
487
488 :param prov_album_id: The provider album ID.
489 :return: List of Track objects.
490 """
491 album = await self.client.get_album_with_tracks(prov_album_id)
492 if not album or not album.volumes:
493 return []
494
495 tracks = []
496 for volume_index, volume in enumerate(album.volumes):
497 for track_index, track in enumerate(volume):
498 try:
499 parsed_track = parse_track(self, track)
500 parsed_track.disc_number = volume_index + 1
501 parsed_track.track_number = track_index + 1
502 tracks.append(parsed_track)
503 except InvalidDataError as err:
504 self.logger.debug("Error parsing album track: %s", err)
505 return tracks
506
507 @use_cache(3600 * 3)
508 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
509 """Get similar tracks using Yandex Rotor station for this track.
510
511 Uses rotor station track:{id} so MA radio mode gets Yandex recommendations.
512
513 :param prov_track_id: Provider track ID (plain or track_id@station_id).
514 :param limit: Maximum number of tracks to return.
515 :return: List of similar Track objects.
516 """
517 track_id, _ = _parse_radio_item_id(prov_track_id)
518 station_id = f"track:{track_id}"
519 yandex_tracks, _ = await self.client.get_rotor_station_tracks(station_id, queue=None)
520 tracks = []
521 for yt in yandex_tracks[:limit]:
522 try:
523 tracks.append(parse_track(self, yt))
524 except InvalidDataError as err:
525 self.logger.debug("Error parsing similar track: %s", err)
526 return tracks
527
528 @use_cache(3600 * 3)
529 async def recommendations(self) -> list[RecommendationFolder]:
530 """Get recommendations; includes My Wave (ÐÐ¾Ñ Ð²Ð¾Ð»Ð½Ð°) as first folder.
531
532 :return: List of recommendation folders (My Wave with first batch of tracks).
533 """
534 names = self._get_browse_names()
535 yandex_tracks, _ = await self.client.get_my_wave_tracks(queue=None)
536 items: list[Track] = []
537 for yt in yandex_tracks:
538 try:
539 t = parse_track(self, yt)
540 track_id = (
541 str(yt.id) if hasattr(yt, "id") and yt.id else getattr(yt, "track_id", None)
542 )
543 if track_id:
544 t.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_WAVE}"
545 for pm in t.provider_mappings:
546 if pm.provider_instance == self.instance_id:
547 pm.item_id = t.item_id
548 break
549 items.append(t)
550 except InvalidDataError as err:
551 self.logger.debug("Error parsing My Wave track for recommendations: %s", err)
552 return [
553 RecommendationFolder(
554 item_id=MY_WAVE_PLAYLIST_ID,
555 provider=self.instance_id,
556 name=names[MY_WAVE_PLAYLIST_ID],
557 items=UniqueList(items),
558 icon="mdi-waveform",
559 )
560 ]
561
562 @use_cache(3600 * 3)
563 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
564 """Get playlist tracks.
565
566 :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind" or my_wave).
567 :param page: Page number for pagination.
568 :return: List of Track objects.
569 """
570 if prov_playlist_id == MY_WAVE_PLAYLIST_ID:
571 return await self._get_my_wave_playlist_tracks(page)
572
573 # Yandex Music API returns all playlist tracks in one call (no server-side pagination).
574 # Return empty list for page > 0 so the controller pagination loop terminates.
575 if page > 0:
576 return []
577
578 # Parse the playlist ID (format: owner_id:kind)
579 if PLAYLIST_ID_SPLITTER in prov_playlist_id:
580 owner_id, kind = prov_playlist_id.split(PLAYLIST_ID_SPLITTER, 1)
581 else:
582 owner_id = str(self.client.user_id)
583 kind = prov_playlist_id
584
585 playlist = await self.client.get_playlist(owner_id, kind)
586 if not playlist:
587 return []
588
589 # API sometimes returns playlist without tracks; fetch them explicitly if needed
590 tracks_list = playlist.tracks or []
591 track_count = getattr(playlist, "track_count", None) or 0
592 if not tracks_list and track_count > 0:
593 self.logger.debug(
594 "Playlist %s/%s: track_count=%s but no tracks in response, "
595 "calling fetch_tracks_async",
596 owner_id,
597 kind,
598 track_count,
599 )
600 try:
601 tracks_list = await playlist.fetch_tracks_async()
602 except Exception as err:
603 self.logger.warning("fetch_tracks_async failed for %s/%s: %s", owner_id, kind, err)
604 if not tracks_list:
605 raise ResourceTemporarilyUnavailable(
606 "Playlist tracks not available; try again later"
607 )
608
609 if not tracks_list:
610 return []
611
612 # Yandex returns TrackShort objects, we need to fetch full track info
613 track_ids = [
614 str(track.track_id) if hasattr(track, "track_id") else str(track.id)
615 for track in tracks_list
616 if track
617 ]
618 if not track_ids:
619 return []
620
621 # Fetch full track details in batches to avoid timeouts
622 batch_size = 50
623 full_tracks = []
624 for i in range(0, len(track_ids), batch_size):
625 batch = track_ids[i : i + batch_size]
626 batch_result = await self.client.get_tracks(batch)
627 if not batch_result:
628 self.logger.warning(
629 "Received empty result for playlist %s tracks batch %s-%s",
630 prov_playlist_id,
631 i,
632 i + len(batch) - 1,
633 )
634 raise ResourceTemporarilyUnavailable(
635 "Playlist tracks not fully available; try again later"
636 )
637 full_tracks.extend(batch_result)
638
639 if track_ids and not full_tracks:
640 raise ResourceTemporarilyUnavailable("Failed to load track details; try again later")
641
642 tracks = []
643 for track in full_tracks:
644 try:
645 tracks.append(parse_track(self, track))
646 except InvalidDataError as err:
647 self.logger.debug("Error parsing playlist track: %s", err)
648 return tracks
649
650 @use_cache(3600 * 24 * 7)
651 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
652 """Get artist's albums.
653
654 :param prov_artist_id: The provider artist ID.
655 :return: List of Album objects.
656 """
657 albums = await self.client.get_artist_albums(prov_artist_id)
658 result = []
659 for album in albums:
660 try:
661 result.append(parse_album(self, album))
662 except InvalidDataError as err:
663 self.logger.debug("Error parsing artist album: %s", err)
664 return result
665
666 @use_cache(3600 * 24 * 7)
667 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
668 """Get artist's top tracks.
669
670 :param prov_artist_id: The provider artist ID.
671 :return: List of Track objects.
672 """
673 tracks = await self.client.get_artist_tracks(prov_artist_id)
674 result = []
675 for track in tracks:
676 try:
677 result.append(parse_track(self, track))
678 except InvalidDataError as err:
679 self.logger.debug("Error parsing artist track: %s", err)
680 return result
681
682 # Library methods
683
684 async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
685 """Retrieve library artists from Yandex Music."""
686 artists = await self.client.get_liked_artists()
687 for artist in artists:
688 try:
689 yield parse_artist(self, artist)
690 except InvalidDataError as err:
691 self.logger.debug("Error parsing library artist: %s", err)
692
693 async def get_library_albums(self) -> AsyncGenerator[Album, None]:
694 """Retrieve library albums from Yandex Music."""
695 albums = await self.client.get_liked_albums()
696 for album in albums:
697 try:
698 yield parse_album(self, album)
699 except InvalidDataError as err:
700 self.logger.debug("Error parsing library album: %s", err)
701
702 async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
703 """Retrieve library tracks from Yandex Music."""
704 track_shorts = await self.client.get_liked_tracks()
705 if not track_shorts:
706 return
707
708 # Fetch full track details in batches
709 track_ids = [str(ts.track_id) for ts in track_shorts if ts.track_id]
710 batch_size = 50
711 for i in range(0, len(track_ids), batch_size):
712 batch_ids = track_ids[i : i + batch_size]
713 full_tracks = await self.client.get_tracks(batch_ids)
714 for track in full_tracks:
715 try:
716 yield parse_track(self, track)
717 except InvalidDataError as err:
718 self.logger.debug("Error parsing library track: %s", err)
719
720 async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
721 """Retrieve library playlists from Yandex Music.
722
723 Includes the virtual My Wave playlist first, then user playlists.
724 """
725 yield await self.get_playlist(MY_WAVE_PLAYLIST_ID)
726 playlists = await self.client.get_user_playlists()
727 for playlist in playlists:
728 try:
729 yield parse_playlist(self, playlist)
730 except InvalidDataError as err:
731 self.logger.debug("Error parsing library playlist: %s", err)
732
733 # Library edit methods
734
735 async def library_add(self, item: MediaItemType) -> bool:
736 """Add item to library.
737
738 :param item: The media item to add.
739 :return: True if successful.
740 """
741 prov_item_id = self._get_provider_item_id(item)
742 if not prov_item_id:
743 return False
744 track_id, _ = _parse_radio_item_id(prov_item_id)
745
746 if item.media_type == MediaType.TRACK:
747 return await self.client.like_track(track_id)
748 if item.media_type == MediaType.ALBUM:
749 return await self.client.like_album(prov_item_id)
750 if item.media_type == MediaType.ARTIST:
751 return await self.client.like_artist(prov_item_id)
752 return False
753
754 async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
755 """Remove item from library.
756
757 :param prov_item_id: The provider item ID (may be track_id@station_id for tracks).
758 :param media_type: The media type.
759 :return: True if successful.
760 """
761 track_id, _ = _parse_radio_item_id(prov_item_id)
762 if media_type == MediaType.TRACK:
763 return await self.client.unlike_track(track_id)
764 if media_type == MediaType.ALBUM:
765 return await self.client.unlike_album(prov_item_id)
766 if media_type == MediaType.ARTIST:
767 return await self.client.unlike_artist(prov_item_id)
768 return False
769
770 def _get_provider_item_id(self, item: MediaItemType) -> str | None:
771 """Get provider item ID from media item."""
772 for mapping in item.provider_mappings:
773 if mapping.provider_instance == self.instance_id:
774 return mapping.item_id
775 return item.item_id if item.provider == self.instance_id else None
776
777 # Streaming
778
779 async def get_stream_details(
780 self, item_id: str, media_type: MediaType = MediaType.TRACK
781 ) -> StreamDetails:
782 """Get stream details for a track.
783
784 :param item_id: The track ID (or track_id@station_id for My Wave).
785 :param media_type: The media type (should be TRACK).
786 :return: StreamDetails for the track.
787 """
788 return await self.streaming.get_stream_details(item_id)
789
790 async def on_played(
791 self,
792 media_type: MediaType,
793 prov_item_id: str,
794 fully_played: bool,
795 position: int,
796 media_item: MediaItemType,
797 is_playing: bool = False,
798 ) -> None:
799 """Report playback for rotor feedback when the track is from My Wave.
800
801 Sends trackStarted when the track is currently playing (is_playing=True).
802 trackFinished/skip are sent from on_streamed to use accurate seconds_streamed.
803 """
804 if media_type != MediaType.TRACK:
805 return
806 track_id, station_id = _parse_radio_item_id(prov_item_id)
807 if not station_id:
808 return
809 if is_playing:
810 await self.client.send_rotor_station_feedback(
811 station_id,
812 "trackStarted",
813 track_id=track_id,
814 batch_id=self._my_wave_batch_id,
815 )
816
817 async def on_streamed(self, streamdetails: StreamDetails) -> None:
818 """Report stream completion for My Wave rotor feedback.
819
820 Sends trackFinished or skip with actual seconds_streamed so Yandex
821 can improve recommendations.
822 """
823 track_id, station_id = _parse_radio_item_id(streamdetails.item_id)
824 if not station_id:
825 return
826 seconds = int(streamdetails.seconds_streamed or 0)
827 duration = streamdetails.duration or 0
828 feedback_type = "trackFinished" if duration and seconds >= max(0, duration - 10) else "skip"
829 await self.client.send_rotor_station_feedback(
830 station_id,
831 feedback_type,
832 track_id=track_id,
833 total_played_seconds=seconds,
834 batch_id=self._my_wave_batch_id,
835 )
836