Yandex Music: My Wave Browse folder and locale-based names (#3122)
authorMikhail Nevskiy <139659391+trudenboy@users.noreply.github.com>
Tue, 10 Feb 2026 17:31:03 +0000 (20:31 +0300)
committerGitHub <noreply@github.com>
Tue, 10 Feb 2026 17:31:03 +0000 (18:31 +0100)
* Yandex Music: add My Wave (Моя волна) Browse folder

- Add ROTOR_STATION_MY_WAVE constant (user:onyourwave)
- Add get_my_wave_tracks() to api_client using rotor_station_tracks API
- Add My Wave folder to Browse root (first in list)
- When opening My Wave, return tracks from Rotor API as Track items
- Order: Моя волна, Мои исполнители, Мои альбомы, Мне нравится, Мои плейлисты
- On API errors, log and return [] to keep Browse working

Co-authored-by: Cursor <cursoragent@cursor.com>
* Yandex Music: locale-based Browse folder names (EN/RU)

- Add BROWSE_NAMES_RU and BROWSE_NAMES_EN in constants (My Favorites for tracks)
- In browse(), use Russian names when locale starts with ru, else English
- Fallback to English if metadata.locale unavailable
- Test: assert first root folder name is from locale mapping

Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: sort imports in yandex_music provider (I001)

Co-authored-by: Cursor <cursoragent@cursor.com>
* Yandex Music: My Wave — browse, recommendations, similar tracks, virtual playlist, rotor feedback

- My Wave in Browse: root folder (up to 3 batches) and Load more pagination
- Recommendations (Discover): My Wave section with first batch
- Similar tracks (radio mode): Rotor station track:{id} for radio queue
- Virtual playlist My Wave in library (get_playlist / get_playlist_tracks with page)
- My Wave listed first in get_library_playlists
- Rotor feedback: radioStarted, trackStarted, trackFinished, skip
- Reconnect and retry on Server disconnected in api_client

Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(yandex_music): resolve ruff/mypy — duplicate methods, line length, type for browse tracks

Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(yandex_music): use constants instead of string literals, extract locale helper

Replace all "my_wave" string literals with MY_WAVE_PLAYLIST_ID constant
and extract _get_browse_names() helper to deduplicate locale detection
logic across browse(), get_playlist(), and recommendations().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------

Co-authored-by: Михаил Невский <renso@MacBook-Pro-Mihail.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
music_assistant/providers/yandex_music/__init__.py
music_assistant/providers/yandex_music/api_client.py
music_assistant/providers/yandex_music/constants.py
music_assistant/providers/yandex_music/provider.py
music_assistant/providers/yandex_music/streaming.py
tests/providers/yandex_music/test_api_client.py
tests/providers/yandex_music/test_integration.py
tests/providers/yandex_music/test_my_wave.py [new file with mode: 0644]

index b179e9927b440088e20930231563e8a795ab06a0..1dbbb6472ef0813b28355edfdfa14405d0514593 100644 (file)
@@ -36,6 +36,8 @@ SUPPORTED_FEATURES = {
     ProviderFeature.LIBRARY_ALBUMS_EDIT,
     ProviderFeature.LIBRARY_TRACKS_EDIT,
     ProviderFeature.BROWSE,
+    ProviderFeature.SIMILAR_TRACKS,
+    ProviderFeature.RECOMMENDATIONS,
 }
 
 
index 5293068a564e9f40585a0fe57335c2639ac351e9..daf737049ddc9192311f4cd910bd43f1e57501bc 100644 (file)
@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import logging
+from datetime import UTC, datetime
 from typing import TYPE_CHECKING, Any, cast
 
 from music_assistant_models.errors import (
@@ -21,7 +22,7 @@ from yandex_music.utils.sign_request import get_sign_request
 if TYPE_CHECKING:
     from yandex_music import DownloadInfo
 
-from .constants import DEFAULT_LIMIT
+from .constants import DEFAULT_LIMIT, ROTOR_STATION_MY_WAVE
 
 # get-file-info with quality=lossless returns FLAC; default /tracks/.../download-info often does not
 # Prefer flac-mp4/aac-mp4 (Yandex API moved to these formats around 2025)
@@ -81,6 +82,141 @@ class YandexMusicClient:
             raise ProviderUnavailableError("Client not connected, call connect() first")
         return self._client
 
+    def _is_connection_error(self, err: Exception) -> bool:
+        """Return True if the exception indicates a connection or server drop."""
+        if isinstance(err, NetworkError):
+            return True
+        msg = str(err).lower()
+        return "disconnect" in msg or "connection" in msg or "timeout" in msg
+
+    async def _reconnect(self) -> None:
+        """Disconnect and connect again to recover from Server disconnected / connection errors."""
+        await self.disconnect()
+        await self.connect()
+
+    # Rotor (radio station) methods
+
+    async def get_rotor_station_tracks(
+        self,
+        station_id: str,
+        queue: str | int | None = None,
+    ) -> tuple[list[YandexTrack], str | None]:
+        """Get tracks from a rotor station (e.g. user:onyourwave or track:1234).
+
+        :param station_id: Station ID (e.g. ROTOR_STATION_MY_WAVE or "track:1234" for similar).
+        :param queue: Optional track ID for pagination (first track of previous batch).
+        :return: Tuple of (list of track objects, batch_id for feedback or None).
+        """
+        for attempt in range(2):
+            client = self._ensure_connected()
+            try:
+                result = await client.rotor_station_tracks(station_id, settings2=True, queue=queue)
+                if not result or not result.sequence:
+                    return ([], result.batch_id if result else None)
+                track_ids = []
+                for seq in result.sequence:
+                    if seq.track is None:
+                        continue
+                    tid = getattr(seq.track, "id", None) or getattr(seq.track, "track_id", None)
+                    if tid is not None:
+                        track_ids.append(str(tid))
+                if not track_ids:
+                    return ([], result.batch_id if result else None)
+                full_tracks = await self.get_tracks(track_ids)
+                order_map = {str(t.id): t for t in full_tracks if hasattr(t, "id") and t.id}
+                ordered = [order_map[tid] for tid in track_ids if tid in order_map]
+                return (ordered, result.batch_id if result else None)
+            except BadRequestError as err:
+                LOGGER.warning("Error fetching rotor station %s tracks: %s", station_id, err)
+                return ([], None)
+            except (NetworkError, Exception) as err:
+                if attempt == 0 and self._is_connection_error(err):
+                    LOGGER.warning(
+                        "Connection error fetching rotor tracks, reconnecting: %s",
+                        err,
+                    )
+                    try:
+                        await self._reconnect()
+                    except Exception as recon_err:
+                        LOGGER.warning("Reconnect failed: %s", recon_err)
+                        return ([], None)
+                else:
+                    LOGGER.warning("Error fetching rotor station tracks: %s", err)
+                    return ([], None)
+        return ([], None)
+
+    async def get_my_wave_tracks(
+        self, queue: str | int | None = None
+    ) -> tuple[list[YandexTrack], str | None]:
+        """Get tracks from the My Wave (Моя волна) radio station.
+
+        :param queue: Optional track ID of the last track from the previous batch (API uses it for
+            pagination; do not pass batch_id).
+        :return: Tuple of (list of track objects, batch_id for feedback).
+        """
+        return await self.get_rotor_station_tracks(ROTOR_STATION_MY_WAVE, queue=queue)
+
+    async def send_rotor_station_feedback(
+        self,
+        station_id: str,
+        feedback_type: str,
+        *,
+        batch_id: str | None = None,
+        track_id: str | None = None,
+        total_played_seconds: int | None = None,
+    ) -> bool:
+        """Send rotor station feedback for My Wave recommendations.
+
+        Used to report radioStarted, trackStarted, trackFinished, skip so that
+        Yandex can improve subsequent recommendations.
+
+        :param station_id: Station ID (e.g. ROTOR_STATION_MY_WAVE).
+        :param feedback_type: One of 'radioStarted', 'trackStarted', 'trackFinished', 'skip'.
+        :param batch_id: Optional batch ID from the last get_my_wave_tracks response.
+        :param track_id: Track ID (required for trackStarted, trackFinished, skip).
+        :param total_played_seconds: Seconds played (for trackFinished, skip).
+        :return: True if the request succeeded.
+        """
+        client = self._ensure_connected()
+        payload: dict[str, Any] = {
+            "type": feedback_type,
+            "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
+        }
+        if feedback_type == "radioStarted":
+            payload["from"] = "YandexMusicDesktopAppWindows"
+        if track_id is not None:
+            payload["trackId"] = track_id
+        if total_played_seconds is not None:
+            payload["totalPlayedSeconds"] = total_played_seconds
+        if batch_id is not None:
+            payload["batchId"] = batch_id
+
+        url = f"{client.base_url}/rotor/station/{station_id}/feedback"
+        for attempt in range(2):
+            client = self._ensure_connected()
+            try:
+                await client._request.post(url, payload)
+                return True
+            except BadRequestError as err:
+                LOGGER.debug("Rotor feedback %s failed: %s", feedback_type, err)
+                return False
+            except (NetworkError, Exception) as err:
+                if attempt == 0 and self._is_connection_error(err):
+                    LOGGER.warning(
+                        "Connection error on rotor feedback %s, reconnecting: %s",
+                        feedback_type,
+                        err,
+                    )
+                    try:
+                        await self._reconnect()
+                    except Exception as recon_err:
+                        LOGGER.debug("Reconnect failed: %s", recon_err)
+                        return False
+                else:
+                    LOGGER.debug("Rotor feedback %s failed: %s", feedback_type, err)
+                    return False
+        return False
+
     # Library methods
 
     async def get_liked_tracks(self) -> list[TrackShort]:
index b82796c2828b8e65f540a06cc4790d251b614850..1790442f2209689b889f6d2ad45ce809fdaeaf19 100644 (file)
@@ -30,3 +30,28 @@ IMAGE_SIZE_LARGE = "1000x1000"
 
 # ID separators
 PLAYLIST_ID_SPLITTER: Final[str] = ":"
+
+# Rotor (radio) station identifiers
+ROTOR_STATION_MY_WAVE: Final[str] = "user:onyourwave"
+
+# Virtual playlist ID for My Wave (used in get_playlist / get_playlist_tracks; not owner_id:kind)
+MY_WAVE_PLAYLIST_ID: Final[str] = "my_wave"
+
+# Composite item_id for My Wave tracks: track_id + separator + station_id (for rotor feedback)
+RADIO_TRACK_ID_SEP: Final[str] = "@"
+
+# Browse folder names by locale (item_id -> display name)
+BROWSE_NAMES_RU: Final[dict[str, str]] = {
+    "my_wave": "Моя волна",
+    "artists": "Мои исполнители",
+    "albums": "Мои альбомы",
+    "tracks": "Мне нравится",
+    "playlists": "Мои плейлисты",
+}
+BROWSE_NAMES_EN: Final[dict[str, str]] = {
+    "my_wave": "My Wave",
+    "artists": "My Artists",
+    "albums": "My Albums",
+    "tracks": "My Favorites",
+    "playlists": "My Playlists",
+}
index 21bc07a6e003d41985c65a27fa0136bd5be0c14b..c58f8cb470d171d6724b74cd85c9e80ad5b0c768 100644 (file)
@@ -3,9 +3,10 @@
 from __future__ import annotations
 
 import logging
+from collections.abc import Sequence
 from typing import TYPE_CHECKING
 
-from music_assistant_models.enums import MediaType
+from music_assistant_models.enums import MediaType, ProviderFeature
 from music_assistant_models.errors import (
     InvalidDataError,
     LoginFailed,
@@ -16,18 +17,30 @@ from music_assistant_models.errors import (
 from music_assistant_models.media_items import (
     Album,
     Artist,
+    BrowseFolder,
     ItemMapping,
     MediaItemType,
     Playlist,
+    ProviderMapping,
+    RecommendationFolder,
     SearchResults,
     Track,
+    UniqueList,
 )
 
 from music_assistant.controllers.cache import use_cache
 from music_assistant.models.music_provider import MusicProvider
 
 from .api_client import YandexMusicClient
-from .constants import CONF_TOKEN, PLAYLIST_ID_SPLITTER
+from .constants import (
+    BROWSE_NAMES_EN,
+    BROWSE_NAMES_RU,
+    CONF_TOKEN,
+    MY_WAVE_PLAYLIST_ID,
+    PLAYLIST_ID_SPLITTER,
+    RADIO_TRACK_ID_SEP,
+    ROTOR_STATION_MY_WAVE,
+)
 from .parsers import parse_album, parse_artist, parse_playlist, parse_track
 from .streaming import YandexMusicStreamingManager
 
@@ -37,11 +50,30 @@ if TYPE_CHECKING:
     from music_assistant_models.streamdetails import StreamDetails
 
 
+def _parse_radio_item_id(item_id: str) -> tuple[str, str | None]:
+    """Extract track_id and optional station_id from provider item_id.
+
+    My Wave tracks use item_id format 'track_id@station_id'. Other tracks use
+    plain track_id.
+
+    :param item_id: Provider item_id (may contain RADIO_TRACK_ID_SEP).
+    :return: (track_id, station_id or None).
+    """
+    if RADIO_TRACK_ID_SEP in item_id:
+        parts = item_id.split(RADIO_TRACK_ID_SEP, 1)
+        return (parts[0], parts[1] if len(parts) > 1 else None)
+    return (item_id, None)
+
+
 class YandexMusicProvider(MusicProvider):
     """Implementation of a Yandex Music MusicProvider."""
 
     _client: YandexMusicClient | None = None
     _streaming: YandexMusicStreamingManager | None = None
+    _my_wave_batch_id: str | None = None
+    _my_wave_last_track_id: str | None = None  # last track id for "Load more" (API queue param)
+    _my_wave_playlist_next_cursor: str | None = None  # first_track_id for next playlist page
+    _my_wave_radio_started_sent: bool = False
 
     @property
     def client(self) -> YandexMusicClient:
@@ -57,6 +89,15 @@ class YandexMusicProvider(MusicProvider):
             raise ProviderUnavailableError("Provider not initialized")
         return self._streaming
 
+    def _get_browse_names(self) -> dict[str, str]:
+        """Get locale-based browse folder names."""
+        try:
+            locale = (self.mass.metadata.locale or "en_US").lower()
+            use_russian = locale.startswith("ru")
+        except Exception:
+            use_russian = False
+        return BROWSE_NAMES_RU if use_russian else BROWSE_NAMES_EN
+
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         token = self.config.get_value(CONF_TOKEN)
@@ -98,6 +139,150 @@ class YandexMusicProvider(MusicProvider):
             name=name,
         )
 
+    async def browse(  # noqa: PLR0915
+        self, path: str
+    ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:
+        """Browse provider items with locale-based folder names and My Wave.
+
+        Root level shows My Wave, artists, albums, liked tracks, playlists. Names
+        are in Russian when MA locale is ru_*, otherwise in English. My Wave
+        tracks use item_id format track_id@station_id for rotor feedback.
+
+        :param path: The path to browse (e.g. provider_id:// or provider_id://artists).
+        """
+        if ProviderFeature.BROWSE not in self.supported_features:
+            raise NotImplementedError
+
+        path_parts = path.split("://")[1].split("/") if "://" in path else []
+        subpath = path_parts[0] if len(path_parts) > 0 else None
+        sub_subpath = path_parts[1] if len(path_parts) > 1 else None
+
+        if subpath == MY_WAVE_PLAYLIST_ID:
+            # Root my_wave: fetch up to 3 batches so Play adds more tracks.
+            # "Load more" uses single next batch.
+            max_batches = 3 if sub_subpath != "next" else 1
+            queue: str | int | None = None
+            if sub_subpath == "next":
+                queue = self._my_wave_last_track_id
+            elif sub_subpath:
+                queue = sub_subpath
+
+            all_tracks: list[Track | BrowseFolder] = []
+            last_batch_id: str | None = None
+            first_track_id_this_batch: str | None = None
+
+            for _ in range(max_batches):
+                yandex_tracks, batch_id = await self.client.get_my_wave_tracks(queue=queue)
+                if batch_id:
+                    self._my_wave_batch_id = batch_id
+                    last_batch_id = batch_id
+                if not self._my_wave_radio_started_sent and yandex_tracks:
+                    self._my_wave_radio_started_sent = True
+                    await self.client.send_rotor_station_feedback(
+                        ROTOR_STATION_MY_WAVE,
+                        "radioStarted",
+                        batch_id=batch_id,
+                    )
+                first_track_id_this_batch = None
+                for yt in yandex_tracks:
+                    try:
+                        t = parse_track(self, yt)
+                        track_id = (
+                            str(yt.id)
+                            if hasattr(yt, "id") and yt.id
+                            else getattr(yt, "track_id", None)
+                        )
+                        if track_id:
+                            if first_track_id_this_batch is None:
+                                first_track_id_this_batch = track_id
+                            t.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_WAVE}"
+                            for pm in t.provider_mappings:
+                                if pm.provider_instance == self.instance_id:
+                                    pm.item_id = t.item_id
+                                    break
+                        all_tracks.append(t)
+                    except InvalidDataError as err:
+                        self.logger.debug("Error parsing My Wave track: %s", err)
+                if first_track_id_this_batch is not None:
+                    self._my_wave_last_track_id = first_track_id_this_batch
+                if not batch_id or not yandex_tracks:
+                    break
+                queue = first_track_id_this_batch
+
+            if last_batch_id:
+                names = self._get_browse_names()
+                next_name = "Ещё" if names is BROWSE_NAMES_RU else "Load more"
+                all_tracks.append(
+                    BrowseFolder(
+                        item_id="next",
+                        provider=self.instance_id,
+                        path=f"{path.rstrip('/')}/next",
+                        name=next_name,
+                        is_playable=False,
+                    )
+                )
+            return all_tracks
+
+        if subpath:
+            return await super().browse(path)
+
+        names = self._get_browse_names()
+
+        folders: list[BrowseFolder] = []
+        base = path if path.endswith("//") else path.rstrip("/") + "/"
+        folders.append(
+            BrowseFolder(
+                item_id=MY_WAVE_PLAYLIST_ID,
+                provider=self.instance_id,
+                path=f"{base}{MY_WAVE_PLAYLIST_ID}",
+                name=names[MY_WAVE_PLAYLIST_ID],
+                is_playable=True,
+            )
+        )
+        if ProviderFeature.LIBRARY_ARTISTS in self.supported_features:
+            folders.append(
+                BrowseFolder(
+                    item_id="artists",
+                    provider=self.instance_id,
+                    path=f"{base}artists",
+                    name=names["artists"],
+                    is_playable=True,
+                )
+            )
+        if ProviderFeature.LIBRARY_ALBUMS in self.supported_features:
+            folders.append(
+                BrowseFolder(
+                    item_id="albums",
+                    provider=self.instance_id,
+                    path=f"{base}albums",
+                    name=names["albums"],
+                    is_playable=True,
+                )
+            )
+        if ProviderFeature.LIBRARY_TRACKS in self.supported_features:
+            folders.append(
+                BrowseFolder(
+                    item_id="tracks",
+                    provider=self.instance_id,
+                    path=f"{base}tracks",
+                    name=names["tracks"],
+                    is_playable=True,
+                )
+            )
+        if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features:
+            folders.append(
+                BrowseFolder(
+                    item_id="playlists",
+                    provider=self.instance_id,
+                    path=f"{base}playlists",
+                    name=names["playlists"],
+                    is_playable=True,
+                )
+            )
+        if len(folders) == 1:
+            return await self.browse(folders[0].path)
+        return folders
+
     # Search
 
     @use_cache(3600 * 24 * 14)
@@ -196,11 +381,15 @@ class YandexMusicProvider(MusicProvider):
     async def get_track(self, prov_track_id: str) -> Track:
         """Get track details by ID.
 
-        :param prov_track_id: The provider track ID.
+        Supports composite item_id (track_id@station_id) for My Wave tracks;
+        only the track_id part is used for the API.
+
+        :param prov_track_id: The provider track ID (or track_id@station_id).
         :return: Track object.
         :raises MediaNotFoundError: If track not found.
         """
-        yandex_track = await self.client.get_track(prov_track_id)
+        track_id, _ = _parse_radio_item_id(prov_track_id)
+        yandex_track = await self.client.get_track(track_id)
         if not yandex_track:
             raise MediaNotFoundError(f"Track {prov_track_id} not found")
         return parse_track(self, yandex_track)
@@ -209,10 +398,31 @@ class YandexMusicProvider(MusicProvider):
     async def get_playlist(self, prov_playlist_id: str) -> Playlist:
         """Get playlist details by ID.
 
-        :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind").
+        Supports virtual playlist MY_WAVE_PLAYLIST_ID (My Wave). Real playlists
+        use format "owner_id:kind".
+
+        :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind" or my_wave).
         :return: Playlist object.
         :raises MediaNotFoundError: If playlist not found.
         """
+        if prov_playlist_id == MY_WAVE_PLAYLIST_ID:
+            names = self._get_browse_names()
+            return Playlist(
+                item_id=MY_WAVE_PLAYLIST_ID,
+                provider=self.instance_id,
+                name=names[MY_WAVE_PLAYLIST_ID],
+                owner="Yandex Music",
+                provider_mappings={
+                    ProviderMapping(
+                        item_id=MY_WAVE_PLAYLIST_ID,
+                        provider_domain=self.domain,
+                        provider_instance=self.instance_id,
+                        is_unique=True,
+                    )
+                },
+                is_editable=False,
+            )
+
         # Parse the playlist ID (format: owner_id:kind)
         if PLAYLIST_ID_SPLITTER in prov_playlist_id:
             owner_id, kind = prov_playlist_id.split(PLAYLIST_ID_SPLITTER, 1)
@@ -225,6 +435,50 @@ class YandexMusicProvider(MusicProvider):
             raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found")
         return parse_playlist(self, playlist)
 
+    async def _get_my_wave_playlist_tracks(self, page: int) -> list[Track]:
+        """Get My Wave tracks for virtual playlist (uncached; uses cursor for page > 0).
+
+        :param page: Page number (0 = first batch, 1+ = next batches via queue cursor).
+        :return: List of Track objects for this page.
+        """
+        queue: str | int | None = None
+        if page > 0:
+            queue = self._my_wave_playlist_next_cursor
+            if not queue:
+                return []
+        yandex_tracks, batch_id = await self.client.get_my_wave_tracks(queue=queue)
+        if batch_id:
+            self._my_wave_batch_id = batch_id
+        if not self._my_wave_radio_started_sent and yandex_tracks:
+            self._my_wave_radio_started_sent = True
+            await self.client.send_rotor_station_feedback(
+                ROTOR_STATION_MY_WAVE,
+                "radioStarted",
+                batch_id=batch_id,
+            )
+        first_track_id_this_batch = None
+        tracks = []
+        for yt in yandex_tracks:
+            try:
+                t = parse_track(self, yt)
+                track_id = (
+                    str(yt.id) if hasattr(yt, "id") and yt.id else getattr(yt, "track_id", None)
+                )
+                if track_id:
+                    if first_track_id_this_batch is None:
+                        first_track_id_this_batch = track_id
+                    t.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_WAVE}"
+                    for pm in t.provider_mappings:
+                        if pm.provider_instance == self.instance_id:
+                            pm.item_id = t.item_id
+                            break
+                tracks.append(t)
+            except InvalidDataError as err:
+                self.logger.debug("Error parsing My Wave track: %s", err)
+        if first_track_id_this_batch is not None:
+            self._my_wave_playlist_next_cursor = first_track_id_this_batch
+        return tracks
+
     # Get related items
 
     @use_cache(3600 * 24 * 30)
@@ -250,14 +504,72 @@ class YandexMusicProvider(MusicProvider):
                     self.logger.debug("Error parsing album track: %s", err)
         return tracks
 
+    @use_cache(3600 * 3)
+    async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
+        """Get similar tracks using Yandex Rotor station for this track.
+
+        Uses rotor station track:{id} so MA radio mode gets Yandex recommendations.
+
+        :param prov_track_id: Provider track ID (plain or track_id@station_id).
+        :param limit: Maximum number of tracks to return.
+        :return: List of similar Track objects.
+        """
+        track_id, _ = _parse_radio_item_id(prov_track_id)
+        station_id = f"track:{track_id}"
+        yandex_tracks, _ = await self.client.get_rotor_station_tracks(station_id, queue=None)
+        tracks = []
+        for yt in yandex_tracks[:limit]:
+            try:
+                tracks.append(parse_track(self, yt))
+            except InvalidDataError as err:
+                self.logger.debug("Error parsing similar track: %s", err)
+        return tracks
+
+    @use_cache(3600 * 3)
+    async def recommendations(self) -> list[RecommendationFolder]:
+        """Get recommendations; includes My Wave (Моя волна) as first folder.
+
+        :return: List of recommendation folders (My Wave with first batch of tracks).
+        """
+        names = self._get_browse_names()
+        yandex_tracks, _ = await self.client.get_my_wave_tracks(queue=None)
+        items: list[Track] = []
+        for yt in yandex_tracks:
+            try:
+                t = parse_track(self, yt)
+                track_id = (
+                    str(yt.id) if hasattr(yt, "id") and yt.id else getattr(yt, "track_id", None)
+                )
+                if track_id:
+                    t.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_WAVE}"
+                    for pm in t.provider_mappings:
+                        if pm.provider_instance == self.instance_id:
+                            pm.item_id = t.item_id
+                            break
+                items.append(t)
+            except InvalidDataError as err:
+                self.logger.debug("Error parsing My Wave track for recommendations: %s", err)
+        return [
+            RecommendationFolder(
+                item_id=MY_WAVE_PLAYLIST_ID,
+                provider=self.instance_id,
+                name=names[MY_WAVE_PLAYLIST_ID],
+                items=UniqueList(items),
+                icon="mdi-waveform",
+            )
+        ]
+
     @use_cache(3600 * 3)
     async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks.
 
-        :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind").
+        :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind" or my_wave).
         :param page: Page number for pagination.
         :return: List of Track objects.
         """
+        if prov_playlist_id == MY_WAVE_PLAYLIST_ID:
+            return await self._get_my_wave_playlist_tracks(page)
+
         # Yandex Music API returns all playlist tracks in one call (no server-side pagination).
         # Return empty list for page > 0 so the controller pagination loop terminates.
         if page > 0:
@@ -406,7 +718,11 @@ class YandexMusicProvider(MusicProvider):
                     self.logger.debug("Error parsing library track: %s", err)
 
     async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
-        """Retrieve library playlists from Yandex Music."""
+        """Retrieve library playlists from Yandex Music.
+
+        Includes the virtual My Wave playlist first, then user playlists.
+        """
+        yield await self.get_playlist(MY_WAVE_PLAYLIST_ID)
         playlists = await self.client.get_user_playlists()
         for playlist in playlists:
             try:
@@ -425,9 +741,10 @@ class YandexMusicProvider(MusicProvider):
         prov_item_id = self._get_provider_item_id(item)
         if not prov_item_id:
             return False
+        track_id, _ = _parse_radio_item_id(prov_item_id)
 
         if item.media_type == MediaType.TRACK:
-            return await self.client.like_track(prov_item_id)
+            return await self.client.like_track(track_id)
         if item.media_type == MediaType.ALBUM:
             return await self.client.like_album(prov_item_id)
         if item.media_type == MediaType.ARTIST:
@@ -437,12 +754,13 @@ class YandexMusicProvider(MusicProvider):
     async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
         """Remove item from library.
 
-        :param prov_item_id: The provider item ID.
+        :param prov_item_id: The provider item ID (may be track_id@station_id for tracks).
         :param media_type: The media type.
         :return: True if successful.
         """
+        track_id, _ = _parse_radio_item_id(prov_item_id)
         if media_type == MediaType.TRACK:
-            return await self.client.unlike_track(prov_item_id)
+            return await self.client.unlike_track(track_id)
         if media_type == MediaType.ALBUM:
             return await self.client.unlike_album(prov_item_id)
         if media_type == MediaType.ARTIST:
@@ -463,8 +781,55 @@ class YandexMusicProvider(MusicProvider):
     ) -> StreamDetails:
         """Get stream details for a track.
 
-        :param item_id: The track ID.
+        :param item_id: The track ID (or track_id@station_id for My Wave).
         :param media_type: The media type (should be TRACK).
         :return: StreamDetails for the track.
         """
         return await self.streaming.get_stream_details(item_id)
+
+    async def on_played(
+        self,
+        media_type: MediaType,
+        prov_item_id: str,
+        fully_played: bool,
+        position: int,
+        media_item: MediaItemType,
+        is_playing: bool = False,
+    ) -> None:
+        """Report playback for rotor feedback when the track is from My Wave.
+
+        Sends trackStarted when the track is currently playing (is_playing=True).
+        trackFinished/skip are sent from on_streamed to use accurate seconds_streamed.
+        """
+        if media_type != MediaType.TRACK:
+            return
+        track_id, station_id = _parse_radio_item_id(prov_item_id)
+        if not station_id:
+            return
+        if is_playing:
+            await self.client.send_rotor_station_feedback(
+                station_id,
+                "trackStarted",
+                track_id=track_id,
+                batch_id=self._my_wave_batch_id,
+            )
+
+    async def on_streamed(self, streamdetails: StreamDetails) -> None:
+        """Report stream completion for My Wave rotor feedback.
+
+        Sends trackFinished or skip with actual seconds_streamed so Yandex
+        can improve recommendations.
+        """
+        track_id, station_id = _parse_radio_item_id(streamdetails.item_id)
+        if not station_id:
+            return
+        seconds = int(streamdetails.seconds_streamed or 0)
+        duration = streamdetails.duration or 0
+        feedback_type = "trackFinished" if duration and seconds >= max(0, duration - 10) else "skip"
+        await self.client.send_rotor_station_feedback(
+            station_id,
+            feedback_type,
+            track_id=track_id,
+            total_played_seconds=seconds,
+            batch_id=self._my_wave_batch_id,
+        )
index b32ed71895888441da5c4ae771d84e28bb5f3700..ab79c19050d30c299223431a17d92cffe1ba3d2e 100644 (file)
@@ -9,7 +9,7 @@ from music_assistant_models.errors import MediaNotFoundError
 from music_assistant_models.media_items import AudioFormat
 from music_assistant_models.streamdetails import StreamDetails
 
-from .constants import CONF_QUALITY, QUALITY_LOSSLESS
+from .constants import CONF_QUALITY, QUALITY_LOSSLESS, RADIO_TRACK_ID_SEP
 
 if TYPE_CHECKING:
     from yandex_music import DownloadInfo
@@ -30,14 +30,20 @@ class YandexMusicStreamingManager:
         self.mass = provider.mass
         self.logger = provider.logger
 
+    def _track_id_from_item_id(self, item_id: str) -> str:
+        """Extract API track ID from item_id (may be track_id@station_id for My Wave)."""
+        if RADIO_TRACK_ID_SEP in item_id:
+            return item_id.split(RADIO_TRACK_ID_SEP, 1)[0]
+        return item_id
+
     async def get_stream_details(self, item_id: str) -> StreamDetails:
         """Get stream details for a track.
 
-        :param item_id: Track ID.
-        :return: StreamDetails for the track.
+        :param item_id: Track ID or composite track_id@station_id for My Wave.
+        :return: StreamDetails for the track (item_id preserved for on_streamed).
         :raises MediaNotFoundError: If stream URL cannot be obtained.
         """
-        # Get track info first
+        track_id = self._track_id_from_item_id(item_id)
         track = await self.provider.get_track(item_id)
         if not track:
             raise MediaNotFoundError(f"Track {item_id} not found")
@@ -51,8 +57,8 @@ class YandexMusicStreamingManager:
 
         # When user wants lossless, try get-file-info first (FLAC; download-info often MP3 only)
         if want_lossless:
-            self.logger.debug("Requesting lossless via get-file-info for track %s", item_id)
-            file_info = await self.client.get_track_file_info_lossless(item_id)
+            self.logger.debug("Requesting lossless via get-file-info for track %s", track_id)
+            file_info = await self.client.get_track_file_info_lossless(track_id)
             if file_info:
                 url = file_info.get("url")
                 codec = file_info.get("codec") or ""
@@ -78,7 +84,7 @@ class YandexMusicStreamingManager:
                     )
 
         # Default: use /tracks/.../download-info and select best quality
-        download_infos = await self.client.get_track_download_info(item_id, get_direct_links=True)
+        download_infos = await self.client.get_track_download_info(track_id, get_direct_links=True)
         if not download_infos:
             raise MediaNotFoundError(f"No stream info available for track {item_id}")
 
@@ -87,7 +93,7 @@ class YandexMusicStreamingManager:
         ]
         self.logger.debug(
             "Stream quality for track %s: config quality=%s, available codecs=%s",
-            item_id,
+            track_id,
             quality_str,
             codecs_available,
         )
@@ -98,7 +104,7 @@ class YandexMusicStreamingManager:
 
         self.logger.debug(
             "Stream selected for track %s: codec=%s, bitrate=%s",
-            item_id,
+            track_id,
             getattr(selected_info, "codec", None),
             getattr(selected_info, "bitrate_in_kbps", None),
         )
index 43e97ba02ef7b7a20d34a96f31a2b21d039ff0a6..69a78675574ca62daab623ffe3eb864fa137bfb7 100644 (file)
@@ -101,3 +101,68 @@ async def test_get_tracks_retry_on_network_error_both_fail() -> None:
         await client.get_tracks(["400"])
 
     assert underlying.tracks.await_count == 2
+
+
+# -- get_my_wave_tracks --------------------------------------------------------
+
+
+async def test_get_my_wave_tracks_returns_tracks_and_batch_id() -> None:
+    """get_my_wave_tracks calls rotor_station_tracks and returns ordered tracks and batch_id."""
+    client, underlying = _make_client()
+
+    seq_track = type("TrackShort", (), {"id": 100, "track_id": 100})()
+    sequence_item = type("SequenceItem", (), {"track": seq_track})()
+    result_obj = type(
+        "StationTracksResult",
+        (),
+        {"sequence": [sequence_item], "batch_id": "batch_abc"},
+    )()
+    underlying.rotor_station_tracks = mock.AsyncMock(return_value=result_obj)
+
+    full_track = type("Track", (), {"id": 100, "title": "My Wave Track"})()
+    underlying.tracks = mock.AsyncMock(return_value=[full_track])
+
+    tracks, batch_id = await client.get_my_wave_tracks()
+
+    underlying.rotor_station_tracks.assert_awaited_once()
+    assert batch_id == "batch_abc"
+    assert len(tracks) == 1
+    assert tracks[0].id == 100
+
+
+async def test_get_my_wave_tracks_empty_sequence_returns_empty() -> None:
+    """When rotor returns no sequence, get_my_wave_tracks returns ([], batch_id or None)."""
+    client, underlying = _make_client()
+
+    result_obj = type("StationTracksResult", (), {"sequence": [], "batch_id": None})()
+    underlying.rotor_station_tracks = mock.AsyncMock(return_value=result_obj)
+
+    tracks, batch_id = await client.get_my_wave_tracks()
+
+    assert tracks == []
+    assert batch_id is None
+    underlying.tracks.assert_not_awaited()
+
+
+async def test_send_rotor_station_feedback_posts() -> None:
+    """send_rotor_station_feedback POSTs to rotor feedback endpoint."""
+    client, underlying = _make_client()
+
+    underlying._request = mock.AsyncMock()
+    underlying.base_url = "https://api.music.yandex.net"
+
+    result = await client.send_rotor_station_feedback(
+        "user:onyourwave",
+        "trackStarted",
+        track_id="12345",
+        batch_id="batch_xyz",
+    )
+
+    assert result is True
+    underlying._request.post.assert_awaited_once()
+    call_args = underlying._request.post.await_args
+    assert "rotor/station/user:onyourwave/feedback" in call_args[0][0]
+    body = call_args[0][1]
+    assert body["type"] == "trackStarted"
+    assert body["trackId"] == "12345"
+    assert body["batchId"] == "batch_xyz"
index 2150c3f47bf0b0b8be6e2d2a0ccf8cf53cebfa59..0dcbf8f9dbee846959321042586374a0f7c3ba26 100644 (file)
@@ -18,6 +18,7 @@ from yandex_music import Track as YandexTrack
 
 from music_assistant.mass import MusicAssistant
 from music_assistant.models.music_provider import MusicProvider
+from music_assistant.providers.yandex_music.constants import BROWSE_NAMES_EN, BROWSE_NAMES_RU
 from tests.common import wait_for_sync_completion
 
 if TYPE_CHECKING:
@@ -356,6 +357,12 @@ async def test_browse(mass: MusicAssistant) -> None:
     root_items = await prov.browse(path=base_path)
     assert root_items is not None
     assert isinstance(root_items, (list, tuple))
+    all_names = set(BROWSE_NAMES_RU.values()) | set(BROWSE_NAMES_EN.values())
+    if root_items:
+        first_name = getattr(root_items[0], "name", None)
+        assert first_name in all_names, (
+            f"First folder name {first_name!r} should be from locale mapping"
+        )
 
     artists_path = f"{prov.instance_id}://artists"
     artists_items = await prov.browse(path=artists_path)
diff --git a/tests/providers/yandex_music/test_my_wave.py b/tests/providers/yandex_music/test_my_wave.py
new file mode 100644 (file)
index 0000000..57af434
--- /dev/null
@@ -0,0 +1,24 @@
+"""Tests for My Wave (Моя волна) browse and rotor feedback helpers."""
+
+from __future__ import annotations
+
+from music_assistant.providers.yandex_music.constants import (
+    RADIO_TRACK_ID_SEP,
+    ROTOR_STATION_MY_WAVE,
+)
+from music_assistant.providers.yandex_music.provider import _parse_radio_item_id
+
+
+def test_parse_radio_item_id_plain_track_id() -> None:
+    """Plain track_id returns (track_id, None)."""
+    assert _parse_radio_item_id("12345") == ("12345", None)
+    assert _parse_radio_item_id("0") == ("0", None)
+
+
+def test_parse_radio_item_id_composite() -> None:
+    """Composite track_id@station_id returns (track_id, station_id)."""
+    assert _parse_radio_item_id(f"12345{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_WAVE}") == (
+        "12345",
+        ROTOR_STATION_MY_WAVE,
+    )
+    assert _parse_radio_item_id("99@user:custom") == ("99", "user:custom")