music-assistant-server

32.1 KBPY
provider.py
32.1 KB836 lines • python
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