From 92a6c8643fec687b8af440815dde9c69b5113ad2 Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Tue, 7 Apr 2026 17:04:35 +0300 Subject: [PATCH 01/54] feat(yandex_music): add QR authentication and token auto-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Yandex Passport QR code authentication as the primary login method, replacing manual token entry (kept as advanced fallback). - QR auth flow via YandexQRAuth class with dedicated aiohttp session - Cascading token validation at startup: music_token → x_token refresh → clear - User-configurable "Remember session" toggle for x_token storage - Mobile User-Agent for HAOS compatibility with Yandex Passport - Multi-pattern CSRF extraction for different Yandex page formats - Remove exc_info=True logging that could leak token values Co-Authored-By: Claude Opus 4.6 --- .../providers/yandex_music/__init__.py | 86 +++++- .../providers/yandex_music/api_client.py | 4 +- .../providers/yandex_music/constants.py | 5 + .../providers/yandex_music/provider.py | 54 +++- .../providers/yandex_music/streaming.py | 12 +- .../providers/yandex_music/yandex_auth.py | 269 ++++++++++++++++++ 6 files changed, 413 insertions(+), 17 deletions(-) create mode 100644 music_assistant/providers/yandex_music/yandex_auth.py diff --git a/music_assistant/providers/yandex_music/__init__.py b/music_assistant/providers/yandex_music/__init__.py index 7563d07e9b..083233b848 100644 --- a/music_assistant/providers/yandex_music/__init__.py +++ b/music_assistant/providers/yandex_music/__init__.py @@ -6,14 +6,18 @@ from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType from music_assistant_models.enums import ConfigEntryType, ProviderFeature +from music_assistant_models.errors import InvalidDataError, LoginFailed from .constants import ( + CONF_ACTION_AUTH_QR, CONF_ACTION_CLEAR_AUTH, CONF_BASE_URL, CONF_LIKED_TRACKS_MAX_TRACKS, CONF_MY_WAVE_MAX_TRACKS, CONF_QUALITY, + CONF_REMEMBER_SESSION, CONF_TOKEN, + CONF_X_TOKEN, DEFAULT_BASE_URL, QUALITY_BALANCED, QUALITY_EFFICIENT, @@ -21,6 +25,7 @@ QUALITY_SUPERB, ) from .provider import YandexMusicProvider +from .yandex_auth import perform_qr_auth if TYPE_CHECKING: from music_assistant_models.config_entries import ProviderConfig @@ -55,7 +60,7 @@ async def setup( async def get_config_entries( - mass: MusicAssistant, # noqa: ARG001 + mass: MusicAssistant, instance_id: str | None = None, # noqa: ARG001 action: str | None = None, values: dict[str, ConfigValueType] | None = None, @@ -64,33 +69,96 @@ async def get_config_entries( if values is None: values = {} + # Handle QR auth action + if action == CONF_ACTION_AUTH_QR: + session_id = values.get("session_id") + if not session_id: + raise InvalidDataError("Missing session_id for QR authentication") + x_token, music_token = await perform_qr_auth(mass, str(session_id)) + values[CONF_TOKEN] = music_token + if values.get(CONF_REMEMBER_SESSION, True): + values[CONF_X_TOKEN] = x_token + else: + values[CONF_X_TOKEN] = None + # Handle clear auth action if action == CONF_ACTION_CLEAR_AUTH: values[CONF_TOKEN] = None + values[CONF_X_TOKEN] = None # Check if user is authenticated is_authenticated = bool(values.get(CONF_TOKEN)) + # Dynamic label text + if not is_authenticated: + label_text = ( + "Scan a QR code with the Yandex app on your phone to authenticate.\n\n" + "Alternatively, you can enter a music token manually in the advanced settings." + ) + elif action == CONF_ACTION_AUTH_QR: + label_text = "Authenticated to Yandex Music. Don't forget to save to complete setup." + else: + label_text = "Authenticated to Yandex Music." + return ( - # Authentication + # Status label ConfigEntry( - key=CONF_TOKEN, - type=ConfigEntryType.SECURE_STRING, - label="Yandex Music Token", - description="Enter your Yandex Music OAuth token. " - "See the documentation for how to obtain it.", - required=True, + key="label_text", + type=ConfigEntryType.LABEL, + label=label_text, + ), + # QR authentication (primary) + ConfigEntry( + key=CONF_ACTION_AUTH_QR, + type=ConfigEntryType.ACTION, + label="Login with QR code", + description="Opens a QR code page — scan it with the Yandex app on your phone.", + action=CONF_ACTION_AUTH_QR, + action_label="Login with QR code", + hidden=is_authenticated, + ), + # Remember session toggle + ConfigEntry( + key=CONF_REMEMBER_SESSION, + type=ConfigEntryType.BOOLEAN, + label="Remember session (auto-refresh token)", + description="When enabled, stores a long-lived session token to automatically " + "refresh your music token when it expires. When disabled, you must " + "re-authenticate manually when the token expires.", + default_value=True, hidden=is_authenticated, - value=cast("str", values.get(CONF_TOKEN)) if values else None, ), + # Clear auth ConfigEntry( key=CONF_ACTION_CLEAR_AUTH, type=ConfigEntryType.ACTION, label="Reset authentication", description="Clear the current authentication details.", action=CONF_ACTION_CLEAR_AUTH, + action_label="Reset authentication", hidden=not is_authenticated, ), + # Manual token entry (advanced fallback) + ConfigEntry( + key=CONF_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Yandex Music Token (manual)", + description="Advanced: manually enter a music token instead of using QR login. " + "See the documentation for how to obtain it.", + required=False, + hidden=is_authenticated, + advanced=True, + value=cast("str", values.get(CONF_TOKEN)) if values else None, + ), + # x_token (internal storage, always hidden) + ConfigEntry( + key=CONF_X_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Session token", + hidden=True, + required=False, + value=cast("str", values.get(CONF_X_TOKEN)) if values else None, + ), # Quality ConfigEntry( key=CONF_QUALITY, diff --git a/music_assistant/providers/yandex_music/api_client.py b/music_assistant/providers/yandex_music/api_client.py index b55bf31115..1a5d67ed7a 100644 --- a/music_assistant/providers/yandex_music/api_client.py +++ b/music_assistant/providers/yandex_music/api_client.py @@ -772,10 +772,10 @@ async def _do_request(c: ClientAsync) -> dict[str, Any] | None: ) except Exception as err: LOGGER.warning( - "get-file-info lossless for track %s: Unexpected error: %s", + "get-file-info lossless for track %s: Unexpected %s: %s", track_id, + type(err).__name__, err, - exc_info=True, ) return None diff --git a/music_assistant/providers/yandex_music/constants.py b/music_assistant/providers/yandex_music/constants.py index e86ed1d59b..87865cb5f7 100644 --- a/music_assistant/providers/yandex_music/constants.py +++ b/music_assistant/providers/yandex_music/constants.py @@ -11,8 +11,13 @@ # Actions CONF_ACTION_AUTH = "auth" +CONF_ACTION_AUTH_QR = "auth_qr" CONF_ACTION_CLEAR_AUTH = "clear_auth" +# QR authentication config keys +CONF_X_TOKEN = "x_token" +CONF_REMEMBER_SESSION = "remember_session" + # Labels LABEL_TOKEN = "token_label" LABEL_AUTH_INSTRUCTIONS = "auth_instructions_label" diff --git a/music_assistant/providers/yandex_music/provider.py b/music_assistant/providers/yandex_music/provider.py index 5f013008e5..2e69c99496 100644 --- a/music_assistant/providers/yandex_music/provider.py +++ b/music_assistant/providers/yandex_music/provider.py @@ -47,6 +47,7 @@ CONF_LIKED_TRACKS_MAX_TRACKS, CONF_MY_WAVE_MAX_TRACKS, CONF_TOKEN, + CONF_X_TOKEN, DEFAULT_BASE_URL, DISCOVERY_INITIAL_TRACKS, FOR_YOU_FOLDER_ID, @@ -84,6 +85,7 @@ parse_track, ) from .streaming import YandexMusicStreamingManager +from .yandex_auth import refresh_music_token if TYPE_CHECKING: from music_assistant_models.streamdetails import StreamDetails @@ -157,12 +159,54 @@ def _get_browse_names(self) -> dict[str, str]: async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" token = self.config.get_value(CONF_TOKEN) - if not token: - raise LoginFailed("No Yandex Music token provided") - + x_token = self.config.get_value(CONF_X_TOKEN) base_url = self.config.get_value(CONF_BASE_URL, DEFAULT_BASE_URL) - self._client = YandexMusicClient(str(token), base_url=str(base_url)) - await self._client.connect() + + if not token and not x_token: + raise LoginFailed("No Yandex Music token provided. Please authenticate.") + + # Try existing music token first (fast path) + if token: + try: + self._client = YandexMusicClient(str(token), base_url=str(base_url)) + await self._client.connect() + except LoginFailed: + self.logger.warning("Music token is invalid or expired") + # Clear the dead token so restarts go straight to refresh + self._update_config_value(CONF_TOKEN, None, encrypted=True) + if x_token: + self.logger.info("Attempting to refresh from session token") + token = None + self._client = None + else: + raise + + # Refresh from x_token if music token absent or failed + if not token and x_token: + try: + new_music_token = await refresh_music_token(str(x_token)) + self._update_config_value(CONF_TOKEN, new_music_token, encrypted=True) + self._client = YandexMusicClient(new_music_token, base_url=str(base_url)) + await self._client.connect() + self.logger.info("Refreshed music token from session token") + except LoginFailed as err: + # Definitive auth failure — clear dead credentials + self.logger.warning("Session token is invalid or expired") + self._update_config_value(CONF_TOKEN, None, encrypted=True) + self._update_config_value(CONF_X_TOKEN, None, encrypted=True) + raise LoginFailed("Session token expired. Please re-authenticate.") from err + except asyncio.CancelledError: + raise + except Exception as err: + # Transient/network failure — keep credentials for retry + self.logger.warning( + "Session token refresh failed (network): %s", + type(err).__name__, + ) + raise ProviderUnavailableError( + "Unable to refresh music token right now. Please try again later." + ) from err + # Suppress yandex_music library DEBUG dumps (full API request/response JSON) logging.getLogger("yandex_music").setLevel(self.logger.level + 10) self._streaming = YandexMusicStreamingManager(self) diff --git a/music_assistant/providers/yandex_music/streaming.py b/music_assistant/providers/yandex_music/streaming.py index 28766cf189..b6e1329554 100644 --- a/music_assistant/providers/yandex_music/streaming.py +++ b/music_assistant/providers/yandex_music/streaming.py @@ -547,6 +547,9 @@ async def get_audio_stream( ) from err bytes_before = bytes_yielded + # block_skip = bytes re-downloaded for AES-block alignment. + # Needed below to compute actual HTTP bytes received. + block_skip = bytes_before - block_start async for chunk in self._decrypt_response_stream( response, key_bytes, block_size, bytes_yielded ): @@ -555,7 +558,14 @@ async def get_audio_stream( # window complete — check if EOF window_got = bytes_yielded - bytes_before - if response.status == 200 or window_got < _RANGE_WINDOW: + # received = actual HTTP bytes the server sent for this Range + # request. window_got alone understates the window when + # block_skip > 0 (reconnect at a non-AES-block boundary): + # the decryptor skips block_skip bytes, so window_got would be + # smaller than _RANGE_WINDOW even for a full server response, + # causing premature stream termination without this correction. + received = window_got + block_skip + if response.status == 200 or received < _RANGE_WINDOW: return # full file received or last partial window # Exact-boundary guard: if file size is an exact multiple of # _RANGE_WINDOW the size check above won't catch EOF. diff --git a/music_assistant/providers/yandex_music/yandex_auth.py b/music_assistant/providers/yandex_music/yandex_auth.py new file mode 100644 index 0000000000..fce6c6eafc --- /dev/null +++ b/music_assistant/providers/yandex_music/yandex_auth.py @@ -0,0 +1,269 @@ +"""Yandex Passport QR authentication flow. + +Adapted from AlexxIT/YandexStation (MIT license) and +trudenboy/ma-provider-yandex-station session.py. +Stripped to authentication-only; uses pure aiohttp. +""" + +from __future__ import annotations + +import asyncio +import logging +import re +from typing import TYPE_CHECKING, Any + +import aiohttp +from music_assistant_models.errors import LoginFailed +from yarl import URL + +from music_assistant.helpers.auth import AuthenticationHelper + +if TYPE_CHECKING: + from music_assistant import MusicAssistant + +_LOGGER = logging.getLogger(__name__) + +# Yandex Passport OAuth credentials (public, from Yandex Music Android app) +PASSPORT_CLIENT_ID = "c0ebe342af7d48fbbbfcf2d2eedb8f9e" +PASSPORT_CLIENT_SECRET = "ad0a908f0aa341a182a37ecd75bc319e" + +# Yandex Music OAuth credentials (from yandex-music-api) +MUSIC_CLIENT_ID = "23cabbbdc6cd418abb4b39c32c41195d" +MUSIC_CLIENT_SECRET = "53bc75238f0c4d08a118e51fe9203300" + +# Endpoints +PASSPORT_URL = "https://passport.yandex.ru" +PASSPORT_API_URL = "https://mobileproxy.passport.yandex.net" +MUSIC_TOKEN_URL = "https://oauth.mobile.yandex.net/1/token" + +# Mobile User-Agent required for Yandex Passport to return CSRF token in HTML +# Without it, Passport returns a SPA page with empty csrf_token +_MOBILE_USER_AGENT = ( + "Mozilla/5.0 (Linux; Android 13; Pixel 7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Mobile Safari/537.36" +) + +# Polling settings +QR_POLL_INTERVAL = 2.0 # seconds between status checks +QR_POLL_TIMEOUT = 120.0 # total seconds to wait for QR scan + +# HTTP timeout for Passport/token requests (connect + read) +_REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=30) + + +class YandexQRAuth: + """Yandex Passport QR authentication. + + Uses a dedicated aiohttp session with its own cookie jar + to avoid leaking Yandex cookies into the shared MA session. + """ + + def __init__(self, session: aiohttp.ClientSession) -> None: + """Initialize with a dedicated aiohttp session.""" + self._session = session + + async def _post_json( + self, + url: str, + data: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> dict[str, Any]: + """POST request with HTTP status validation and JSON parsing. + + Wraps aiohttp errors into LoginFailed with actionable messages. + """ + try: + async with self._session.post(url, data=data, headers=headers) as resp: + if resp.status != 200: + raise LoginFailed(f"Yandex returned HTTP {resp.status} for {resp.url.path}") + if "json" not in (resp.content_type or ""): + raise LoginFailed( + f"Unexpected response type from {resp.url.path}: {resp.content_type}" + ) + try: + return await resp.json() # type: ignore[no-any-return] + except (ValueError, aiohttp.ContentTypeError) as err: + raise LoginFailed( + f"Invalid JSON response from {resp.url.path} (HTTP {resp.status})" + ) from err + except LoginFailed: + raise + except aiohttp.ClientError as err: + raise LoginFailed(f"Network error during Yandex auth: {type(err).__name__}") from err + + async def get_qr(self) -> tuple[str, str, str]: + """Start QR code auth session. + + Returns (qr_url, csrf_token, track_id). + Raises LoginFailed on any error. + """ + # Step 1: Get CSRF token from passport page + try: + async with self._session.get(f"{PASSPORT_URL}/am?app_platform=android") as resp: + if resp.status != 200: + raise LoginFailed(f"Yandex Passport returned HTTP {resp.status}") + html = await resp.text() + except aiohttp.ClientError as err: + raise LoginFailed( + f"Network error reaching Yandex Passport: {type(err).__name__}" + ) from err + + # Try multiple patterns — Yandex serves different page formats + # depending on User-Agent, cookies, and repeat requests + csrf_patterns = [ + r'"csrf_token"\s*value="([^"]+)"', # HTML input attribute + r"'csrf_token'\s*:\s*'([^']+)'", # JS object (single quotes) + r'"csrf_token"\s*:\s*"([^"]+)"', # JSON in script tag + ] + csrf_token_value = None + for pattern in csrf_patterns: + match = re.search(pattern, html) + if match and match[1]: + csrf_token_value = match[1] + break + + if not csrf_token_value: + _LOGGER.debug( + "CSRF extraction failed, page length=%d, status=%s", + len(html), + "has_csrf_key" if "csrf_token" in html else "no_csrf_key", + ) + raise LoginFailed("Failed to obtain CSRF token from Yandex Passport") + + csrf_token = csrf_token_value + + # Step 2: Create QR auth session + data = await self._post_json( + f"{PASSPORT_URL}/registration-validations/auth/password/submit", + data={ + "csrf_token": csrf_token, + "retpath": "https://passport.yandex.ru/profile", + "with_code": 1, + }, + ) + + if data.get("status") != "ok": + raise LoginFailed("Failed to create QR auth session") + + track_id_value = data.get("track_id") + if not track_id_value: + raise LoginFailed("Yandex Passport response missing track_id") + track_id = str(track_id_value) + csrf_token = str(data.get("csrf_token", csrf_token)) + qr_url = f"{PASSPORT_URL}/auth/magic/code/?track_id={track_id}" + + _LOGGER.debug("QR auth session created, track_id=%s", track_id) + return qr_url, csrf_token, track_id + + async def check_qr_status(self, csrf_token: str, track_id: str) -> bool: + """Check if QR code was scanned and approved. + + Returns True if approved, False if still pending. + """ + data = await self._post_json( + f"{PASSPORT_URL}/auth/new/magic/status/", + data={"csrf_token": csrf_token, "track_id": track_id}, + ) + return bool(data.get("status") == "ok") + + async def get_x_token(self) -> str: + """Exchange session cookies for x_token. + + Must be called after successful QR auth (cookies are in the session jar). + Uses the public filter_cookies API to build the cookie header. + """ + passport_url = URL("https://passport.yandex.ru") + filtered = self._session.cookie_jar.filter_cookies(passport_url) + if not filtered: + raise LoginFailed("No Yandex session cookies found after QR auth") + cookies = "; ".join(f"{k}={v.value}" for k, v in filtered.items()) + + data = await self._post_json( + f"{PASSPORT_API_URL}/1/bundle/oauth/token_by_sessionid", + data={ + "client_id": PASSPORT_CLIENT_ID, + "client_secret": PASSPORT_CLIENT_SECRET, + }, + headers={ + "Ya-Client-Host": "passport.yandex.ru", + "Ya-Client-Cookie": cookies, + }, + ) + + if "access_token" not in data: + raise LoginFailed("Failed to exchange session for x_token") + + return str(data["access_token"]) + + async def get_music_token(self, x_token: str) -> str: + """Exchange x_token for a music-scoped OAuth token. + + Can be called standalone (no prior QR flow needed) for token refresh. + """ + data = await self._post_json( + MUSIC_TOKEN_URL, + data={ + "client_id": MUSIC_CLIENT_ID, + "client_secret": MUSIC_CLIENT_SECRET, + "grant_type": "x-token", + "access_token": x_token, + }, + ) + + if "access_token" not in data: + raise LoginFailed("Failed to obtain music token from x_token") + + return str(data["access_token"]) + + +async def perform_qr_auth(mass: MusicAssistant, session_id: str) -> tuple[str, str]: + """Perform full QR authentication flow. + + Opens a QR code popup via MA frontend, polls for scan confirmation, + then exchanges for x_token and music_token. + + Returns (x_token, music_token). + """ + jar = aiohttp.CookieJar() + headers = {"User-Agent": _MOBILE_USER_AGENT} + async with aiohttp.ClientSession( + cookie_jar=jar, headers=headers, timeout=_REQUEST_TIMEOUT + ) as session: + auth = YandexQRAuth(session) + + # Get QR code URL + qr_url, csrf_token, track_id = await auth.get_qr() + + # Open QR page in MA frontend popup + async with AuthenticationHelper(mass, session_id) as auth_helper: + auth_helper.send_url(qr_url) + + # Poll for QR scan confirmation + elapsed = 0.0 + while elapsed < QR_POLL_TIMEOUT: + await asyncio.sleep(QR_POLL_INTERVAL) + elapsed += QR_POLL_INTERVAL + + if await auth.check_qr_status(csrf_token, track_id): + _LOGGER.debug("QR code confirmed after %.0fs", elapsed) + break + else: + raise LoginFailed("QR authentication timed out. Please try again.") + + # Exchange cookies → x_token → music_token + x_token = await auth.get_x_token() + music_token = await auth.get_music_token(x_token) + + _LOGGER.debug("QR auth complete, obtained both tokens") + return x_token, music_token + + +async def refresh_music_token(x_token: str) -> str: + """Refresh music token from a stored x_token. + + Creates a throwaway HTTP session for the single API call. + """ + async with aiohttp.ClientSession(timeout=_REQUEST_TIMEOUT) as session: + auth = YandexQRAuth(session) + return await auth.get_music_token(x_token) From 9eea38b4f3c9454ee6c60968ffc488fe7c6c7560 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Apr 2026 14:29:21 +0000 Subject: [PATCH 02/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.7.2 --- .../providers/yandex_music/__init__.py | 2 +- .../providers/yandex_music/test_streaming.py | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/music_assistant/providers/yandex_music/__init__.py b/music_assistant/providers/yandex_music/__init__.py index 083233b848..e65aa07d42 100644 --- a/music_assistant/providers/yandex_music/__init__.py +++ b/music_assistant/providers/yandex_music/__init__.py @@ -6,7 +6,7 @@ from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType from music_assistant_models.enums import ConfigEntryType, ProviderFeature -from music_assistant_models.errors import InvalidDataError, LoginFailed +from music_assistant_models.errors import InvalidDataError from .constants import ( CONF_ACTION_AUTH_QR, diff --git a/tests/providers/yandex_music/test_streaming.py b/tests/providers/yandex_music/test_streaming.py index 1b72f869d9..588361b43d 100644 --- a/tests/providers/yandex_music/test_streaming.py +++ b/tests/providers/yandex_music/test_streaming.py @@ -738,3 +738,54 @@ async def test_get_audio_stream_exact_window_boundary( assert result == plaintext assert len(session.calls) == 1, "second window must not be requested when EOF is detected" + + +async def test_get_audio_stream_continues_after_non_block_boundary_drop( + streaming_manager: YandexMusicStreamingManager, + streaming_provider_stub: StreamingProviderStub, +) -> None: + """TCP drop at a non-AES-block boundary must not cause premature EOF on reconnect. + + Scenario (patched window = 32 bytes = 2 AES blocks, 50-byte file): + - Window 1 (bytes=0-31) drops at byte 17 (not on a 16-byte AES boundary). + - Reconnect re-requests from block_start=16; server returns full 32 bytes. + - Old bug: window_got = 31 < _RANGE_WINDOW = 32 → stream terminates at byte 48, + losing the final 2 bytes of the file. + - Fixed: received = window_got + block_skip = 31 + 1 = 32 = _RANGE_WINDOW + → stream continues to window 2, which delivers the remaining 2 bytes. + """ + small_window = 32 # 2 AES blocks + key = b"\xcc" * 32 + plaintext = b"X" * 50 # 50 bytes → two windows (32 + 2 remaining) + + nonce_16 = bytes(16) + encryptor = Cipher(algorithms.AES(key), modes.CTR(nonce_16)).encryptor() + ciphertext = encryptor.update(plaintext) + encryptor.finalize() + + drop_at = 17 # non-block boundary (17 % 16 != 0) + + # Window 1: bytes=0-31, drops after delivering 17 bytes + resp1 = _MockResponse([ciphertext[:drop_at]], drop_payload_error=True) + # Reconnect: block_start=16, requests bytes=16-47, server returns full 32 bytes + resp2 = _MockResponse([ciphertext[16:48]], status=206) + # Window 2: bytes=48-79, only 2 bytes remain in the file + resp3 = _MockResponse([ciphertext[48:50]], status=206) + + session = _MultiCallHttpSession([resp1, resp2, resp3]) + streaming_provider_stub.mass.http_session = session + + result = b"" + with ( + unittest.mock.patch.object(_streaming_mod, "_RANGE_WINDOW", small_window), + unittest.mock.patch("asyncio.sleep"), + ): + async for chunk in streaming_manager.get_audio_stream( + _make_encrypted_stream_details(key.hex()) + ): + result += chunk + + assert result == plaintext, f"Expected {len(plaintext)} bytes, got {len(result)}" + assert len(session.calls) == 3 + assert session.calls[0]["headers"] == {"Range": "bytes=0-31"} + assert session.calls[1]["headers"] == {"Range": "bytes=16-47"} # AES-aligned reconnect + assert session.calls[2]["headers"] == {"Range": "bytes=48-79"} # second window From 13d605d286b2b7ff107b0c1e0aab3f78eb488f2a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 7 Apr 2026 14:39:41 +0000 Subject: [PATCH 03/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.7.2 --- music_assistant/providers/yandex_music/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/music_assistant/providers/yandex_music/__init__.py b/music_assistant/providers/yandex_music/__init__.py index e65aa07d42..7f113db738 100644 --- a/music_assistant/providers/yandex_music/__init__.py +++ b/music_assistant/providers/yandex_music/__init__.py @@ -138,16 +138,15 @@ async def get_config_entries( action_label="Reset authentication", hidden=not is_authenticated, ), - # Manual token entry (advanced fallback) + # Token storage (populated by QR action or manual entry) ConfigEntry( key=CONF_TOKEN, type=ConfigEntryType.SECURE_STRING, - label="Yandex Music Token (manual)", - description="Advanced: manually enter a music token instead of using QR login. " - "See the documentation for how to obtain it.", - required=False, + label="Yandex Music Token", + description="Music token — populated automatically by QR login, " + "or enter manually. See the documentation for how to obtain it.", + required=True, hidden=is_authenticated, - advanced=True, value=cast("str", values.get(CONF_TOKEN)) if values else None, ), # x_token (internal storage, always hidden) From 8c03ad6d0d42ac84ce3d8eb7a9e2b921ea267ba0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 9 Apr 2026 11:03:02 +0000 Subject: [PATCH 04/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.8.0 --- .../providers/yandex_music/__init__.py | 32 + .../providers/yandex_music/api_client.py | 50 +- .../providers/yandex_music/constants.py | 29 + .../providers/yandex_music/provider.py | 10 +- .../providers/yandex_music/streaming.py | 673 ++++++++++++------ tests/providers/yandex_music/conftest.py | 14 + .../providers/yandex_music/test_streaming.py | 281 +++++++- 7 files changed, 797 insertions(+), 292 deletions(-) diff --git a/music_assistant/providers/yandex_music/__init__.py b/music_assistant/providers/yandex_music/__init__.py index 7f113db738..e893c45e53 100644 --- a/music_assistant/providers/yandex_music/__init__.py +++ b/music_assistant/providers/yandex_music/__init__.py @@ -12,17 +12,21 @@ CONF_ACTION_AUTH_QR, CONF_ACTION_CLEAR_AUTH, CONF_BASE_URL, + CONF_CODECS, CONF_LIKED_TRACKS_MAX_TRACKS, CONF_MY_WAVE_MAX_TRACKS, CONF_QUALITY, CONF_REMEMBER_SESSION, CONF_TOKEN, + CONF_TRANSPORT, CONF_X_TOKEN, DEFAULT_BASE_URL, QUALITY_BALANCED, QUALITY_EFFICIENT, QUALITY_HIGH, QUALITY_SUPERB, + TRANSPORT_ENCRAW, + TRANSPORT_RAW, ) from .provider import YandexMusicProvider from .yandex_auth import perform_qr_auth @@ -172,6 +176,34 @@ async def get_config_entries( ], default_value=QUALITY_BALANCED, ), + # Stream transport (advanced) + ConfigEntry( + key=CONF_TRANSPORT, + type=ConfigEntryType.STRING, + label="Stream transport", + description="Raw streams directly (recommended). " + "Encrypted (encraw) adds AES decryption layer. " + "Both use windowed downloads to prevent CDN drops.", + options=[ + ConfigValueOption("Raw (recommended)", TRANSPORT_RAW), + ConfigValueOption("Encrypted (encraw)", TRANSPORT_ENCRAW), + ], + default_value=TRANSPORT_RAW, + required=False, + advanced=True, + ), + # Codecs override (advanced) + ConfigEntry( + key=CONF_CODECS, + type=ConfigEntryType.STRING, + label="Codecs override", + description="Comma-separated codec priority for get-file-info API. " + "Leave empty to use the default for your quality setting. " + "Example: flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4", + default_value="", + required=False, + advanced=True, + ), # My Wave maximum tracks (advanced) ConfigEntry( key=CONF_MY_WAVE_MAX_TRACKS, diff --git a/music_assistant/providers/yandex_music/api_client.py b/music_assistant/providers/yandex_music/api_client.py index 1a5d67ed7a..3d3d0c9f8c 100644 --- a/music_assistant/providers/yandex_music/api_client.py +++ b/music_assistant/providers/yandex_music/api_client.py @@ -678,18 +678,31 @@ async def get_track_download_info( LOGGER.error("Error fetching download info for track %s: %s", track_id, err) return [] - async def get_track_file_info_lossless(self, track_id: str) -> dict[str, Any] | None: - """Request lossless stream via get-file-info (quality=lossless). + async def get_track_file_info( + self, + track_id: str, + quality: str = "lossless", + codecs: str = GET_FILE_INFO_CODECS, + transport: str = "raw", + ) -> dict[str, Any] | None: + """Request stream via get-file-info for any quality tier. + + The /get-file-info endpoint supports all quality tiers (lossless, nq, lq) + and returns the best available codec based on the codecs parameter order. - The /tracks/{id}/download-info endpoint often returns only MP3; get-file-info - with quality=lossless and codecs=flac,... returns FLAC when available. + With transport="raw", returns a direct unencrypted URL. + With transport="encraw", returns an AES-CTR encrypted URL with decryption key. - Uses manual sign calculation matching yandex-music-downloader-realflac. Uses _call_with_retry for automatic reconnection on transient failures. :param track_id: Track ID. - :return: Parsed downloadInfo dict (url, codec, urls, ...) or None on error. + :param quality: Quality tier ("lossless", "nq", "lq"). + :param codecs: Comma-separated codec preference list. + :param transport: Transport mode ("raw" or "encraw"). + :return: Parsed downloadInfo dict (url, codec, key?, ...) or None on error. """ + # Normalize codecs: strip whitespace from each token to prevent HMAC mismatches + codecs = ",".join(c.strip() for c in codecs.split(",") if c.strip()) def _build_signed_params(client: ClientAsync) -> tuple[str, dict[str, Any]]: """Build URL and signed params using current client and timestamp. @@ -701,16 +714,13 @@ def _build_signed_params(client: ClientAsync) -> tuple[str, dict[str, Any]]: params = { "ts": timestamp, "trackId": track_id, - "quality": "lossless", - "codecs": GET_FILE_INFO_CODECS, - "transports": "encraw", + "quality": quality, + "codecs": codecs, + "transports": transport, } - # Build sign string explicitly matching Yandex API specification: - # concatenate ts + trackId + quality + codecs (commas stripped) + transports. - # Comma stripping matches yandex-music-downloader-realflac reference implementation - # (see get_file_info signing in that project). - codecs_for_sign = GET_FILE_INFO_CODECS.replace(",", "") - param_string = f"{timestamp}{track_id}lossless{codecs_for_sign}encraw" + # Build sign string: ts + trackId + quality + codecs (commas stripped) + transports. + codecs_for_sign = codecs.replace(",", "") + param_string = f"{timestamp}{track_id}{quality}{codecs_for_sign}{transport}" hmac_sign = hmac.new( DEFAULT_SIGN_KEY.encode(), param_string.encode(), @@ -718,7 +728,6 @@ def _build_signed_params(client: ClientAsync) -> tuple[str, dict[str, Any]]: ) # SHA-256 (32 bytes) -> base64 = 44 chars with "=" padding. # Yandex API expects exactly 43 chars (one "=" removed). - # Matches yandex-music-downloader-realflac reference implementation. params["sign"] = base64.b64encode(hmac_sign.digest()).decode()[:-1] url = f"{client.base_url}/get-file-info" return url, params @@ -752,27 +761,28 @@ async def _do_request(c: ClientAsync) -> dict[str, Any] | None: parsed = _parse_file_info_result(result) if parsed: LOGGER.debug( - "get-file-info lossless for track %s: Success, codec=%s", + "get-file-info for track %s: Success, codec=%s, transport=%s", track_id, parsed.get("codec"), + transport, ) return parsed except (BadRequestError, NetworkError) as err: LOGGER.debug( - "get-file-info lossless for track %s: %s %s", + "get-file-info for track %s: %s %s", track_id, type(err).__name__, getattr(err, "message", str(err)) or repr(err), ) except UnauthorizedError as err: LOGGER.debug( - "get-file-info lossless for track %s: UnauthorizedError %s", + "get-file-info for track %s: UnauthorizedError %s", track_id, getattr(err, "message", str(err)) or repr(err), ) except Exception as err: LOGGER.warning( - "get-file-info lossless for track %s: Unexpected %s: %s", + "get-file-info for track %s: Unexpected %s: %s", track_id, type(err).__name__, err, diff --git a/music_assistant/providers/yandex_music/constants.py b/music_assistant/providers/yandex_music/constants.py index 87865cb5f7..8ce005d449 100644 --- a/music_assistant/providers/yandex_music/constants.py +++ b/music_assistant/providers/yandex_music/constants.py @@ -33,6 +33,35 @@ QUALITY_HIGH = "high" # High quality, lossy (~320kbps MP3) QUALITY_SUPERB = "superb" # Highest quality, lossless (FLAC) +# Transport modes for get-file-info API +CONF_TRANSPORT = "transport" +TRANSPORT_RAW = "raw" # Direct unencrypted stream (default) +TRANSPORT_ENCRAW = "encraw" # AES-CTR encrypted stream + +# Custom codecs override (empty = use quality-based default) +CONF_CODECS = "codecs" + +# Quality → get-file-info parameter mapping +# Codecs order determines API priority (first codec = preferred by server) +QUALITY_FILE_INFO_PARAMS: Final[dict[str, dict[str, str]]] = { + QUALITY_SUPERB: { + "quality": "lossless", + "codecs": "flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4", + }, + QUALITY_HIGH: { + "quality": "lossless", + "codecs": "mp3", + }, + QUALITY_BALANCED: { + "quality": "nq", + "codecs": "aac-mp4,aac,mp3,he-aac,he-aac-mp4", + }, + QUALITY_EFFICIENT: { + "quality": "lq", + "codecs": "he-aac-mp4,he-aac,aac,mp3", + }, +} + # Configuration keys for My Wave behavior (kept) CONF_MY_WAVE_MAX_TRACKS: Final[str] = "my_wave_max_tracks" diff --git a/music_assistant/providers/yandex_music/provider.py b/music_assistant/providers/yandex_music/provider.py index 2e69c99496..6d6b067806 100644 --- a/music_assistant/providers/yandex_music/provider.py +++ b/music_assistant/providers/yandex_music/provider.py @@ -2344,12 +2344,12 @@ async def get_audio_stream( ) -> AsyncGenerator[bytes, None]: """Return the audio stream for the provider item. - This method is called when StreamType.CUSTOM is used, enabling on-the-fly - decryption of encrypted FLAC streams without disk I/O. + Uses windowed Range-request streaming to prevent Yandex CDN drops. + Handles both raw (direct) and encrypted (encraw) transports. - :param streamdetails: Stream details containing encrypted URL and decryption key. - :param seek_position: Seek position in seconds (not supported for encrypted streams). - :return: Async generator yielding decrypted audio chunks. + :param streamdetails: Stream details with URL and optional decryption key. + :param seek_position: Seek position in seconds (delegated to ffmpeg). + :return: Async generator yielding audio chunks. """ async for chunk in self.streaming.get_audio_stream(streamdetails, seek_position): yield chunk diff --git a/music_assistant/providers/yandex_music/streaming.py b/music_assistant/providers/yandex_music/streaming.py index b6e1329554..480a87550c 100644 --- a/music_assistant/providers/yandex_music/streaming.py +++ b/music_assistant/providers/yandex_music/streaming.py @@ -4,7 +4,7 @@ import asyncio from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Final import aiohttp from aiohttp import ClientPayloadError, ServerDisconnectedError @@ -17,11 +17,16 @@ from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER from .constants import ( + CONF_CODECS, CONF_QUALITY, + CONF_TRANSPORT, + QUALITY_BALANCED, QUALITY_EFFICIENT, + QUALITY_FILE_INFO_PARAMS, QUALITY_HIGH, QUALITY_SUPERB, RADIO_TRACK_ID_SEP, + TRANSPORT_RAW, ) if TYPE_CHECKING: @@ -30,18 +35,26 @@ from .provider import YandexMusicProvider -# Encrypted-stream tuning constants +# Windowed-stream tuning constants _CHUNK_SIZE = 16384 # smaller than default 65536 for faster first-byte after retry _STREAM_TIMEOUT = aiohttp.ClientTimeout(total=None, sock_read=30) -# Yandex CDN drops TCP every ~6-7 MB per connection (observed via live traffic capture). -# By capping each Range request to 4 MB we stay well below that limit, so CDN drops -# should never occur during normal windowed playback. -_RANGE_WINDOW = 4 * 1024 * 1024 # 4 MB — must be a multiple of AES block size (16) -# Flat short delays for any residual TCP drops (network glitches within a 4 MB window) +# Yandex CDN drops TCP connections for slow consumers (observed at ~45s for raw transport +# at real-time playback rate ~200 KB/s). By capping each Range request to 4 MB we download +# each window quickly, preventing CDN drops for both raw and encrypted transports. +_RANGE_WINDOW = 4 * 1024 * 1024 # 4 MB per Range request +# AES-CTR block size in bytes (used for block-aligned Range requests in encrypted transport) +_AES_BLOCK_SIZE = 16 +# Flat short delays for TCP drops (network glitches within a 4 MB window) _TCP_DROP_DELAYS = (0.5, 1.0, 2.0) # Exponential delays for true network stalls (read timeout) _STALL_DELAYS = (2.0, 4.0, 8.0) +# Normalize Yandex codec names to MA ContentType values +_CODEC_ALIASES: Final[dict[str, str]] = { + "he-aac": "aac", + "mpeg": "mp3", +} + class YandexMusicStreamingManager: """Manages Yandex Music streaming operations.""" @@ -65,6 +78,9 @@ def _track_id_from_item_id(self, item_id: str) -> str: async def get_stream_details(self, item_id: str) -> StreamDetails: """Get stream details for a track. + Uses the unified /get-file-info endpoint for all quality tiers. + Falls back to /tracks/{id}/download-info if get-file-info fails. + :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. @@ -74,103 +90,122 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: if not track: raise MediaNotFoundError(f"Track {item_id} not found") - quality = self.provider.config.get_value(CONF_QUALITY) - quality_str = str(quality) if quality is not None else None - preferred_normalized = (quality_str or "").strip().lower() - - # Check for superb (lossless) quality - want_lossless = preferred_normalized in (QUALITY_SUPERB, "superb") - - # Backward compatibility: also check old "lossless" value (exact match) - if preferred_normalized == "lossless": - want_lossless = True - - # 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", 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 "" - needs_decryption = file_info.get("needs_decryption", False) - - if url and codec.lower() in ("flac", "flac-mp4"): - audio_format = self._build_audio_format(codec) - - # Handle encrypted URLs from encraw transport - if needs_decryption and "key" in file_info: - self.logger.info( - "Streaming encrypted %s for track %s - will decrypt on-the-fly", - codec, - track_id, - ) - # Return StreamType.CUSTOM for streaming decryption. - # can_seek=False: provider always streams from position 0; - # allow_seek=True: ffmpeg handles seek with -ss input flag. - return StreamDetails( - item_id=item_id, - provider=self.provider.instance_id, - audio_format=audio_format, - stream_type=StreamType.CUSTOM, - duration=track.duration, - data={ - "encrypted_url": url, - "decryption_key": file_info["key"], - "codec": codec, - }, - can_seek=False, - allow_seek=True, - ) - # Unencrypted URL, use directly - self.logger.debug( - "Unencrypted stream for track %s: codec=%s", - item_id, - codec, - ) - return StreamDetails( - item_id=item_id, - provider=self.provider.instance_id, - audio_format=audio_format, - stream_type=StreamType.HTTP, - duration=track.duration, - path=url, - can_seek=True, - allow_seek=True, - expiration=50, # get-file-info URLs expire; force MA to re-fetch - ) - - # Default: use /tracks/.../download-info and select best quality - 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}") + quality = ( + str(self.provider.config.get_value(CONF_QUALITY) or QUALITY_BALANCED).strip().lower() + ) + transport = ( + str(self.provider.config.get_value(CONF_TRANSPORT) or TRANSPORT_RAW).strip().lower() + ) + + # Backward compatibility: old "lossless" config value + if quality == "lossless": + quality = QUALITY_SUPERB + + fi_params = QUALITY_FILE_INFO_PARAMS.get( + quality, QUALITY_FILE_INFO_PARAMS[QUALITY_BALANCED] + ) + + # Allow advanced users to override codecs + codecs_override = str(self.provider.config.get_value(CONF_CODECS) or "").strip() + codecs = codecs_override or fi_params["codecs"] - codecs_available = [ - (getattr(i, "codec", None), getattr(i, "bitrate_in_kbps", None)) for i in download_infos - ] self.logger.debug( - "Stream quality for track %s: config quality=%s, available codecs=%s", + "Requesting stream for track %s: quality=%s, transport=%s, codecs=%s", + track_id, + quality, + transport, + codecs, + ) + + file_info = await self.client.get_track_file_info( track_id, - quality_str, - codecs_available, + quality=fi_params["quality"], + codecs=codecs, + transport=transport, + ) + + if file_info and file_info.get("url"): + url = file_info["url"] + codec = file_info.get("codec") or "" + needs_decryption = file_info.get("needs_decryption", False) + + # Gather audio params: API response first, then probe container + bit_rate = file_info.get("bitrate") or file_info.get("bitrate_in_kbps") or 0 + sample_rate = file_info.get("sample_rate") or 0 + bit_depth = file_info.get("bit_depth") or 0 + + if (not sample_rate or not bit_depth) and not needs_decryption: + # Probe raw stream headers for real sample_rate/bit_depth + probed_sr, probed_bd = await self._probe_stream_params(url, codec) + sample_rate = sample_rate or probed_sr + bit_depth = bit_depth or probed_bd + + self.logger.debug( + "Audio params for track %s: codec=%s, bit_rate=%s, sample_rate=%s, bit_depth=%s", + track_id, + codec, + bit_rate, + sample_rate, + bit_depth, + ) + + audio_format = self._build_audio_format( + codec, + bit_rate=bit_rate, + sample_rate=sample_rate, + bit_depth=bit_depth, + ) + + # Always use StreamType.CUSTOM with windowed Range requests to prevent CDN drops. + # can_seek=False: provider always streams from position 0; + # allow_seek=True: ffmpeg handles seek with -ss input flag. + data: dict[str, Any] = { + "url": url, + "codec": codec, + "transport": transport, + # Stored for URL refresh on 4xx: + "fi_quality": fi_params["quality"], + "fi_codecs": codecs, + } + if needs_decryption and "key" in file_info: + data["decryption_key"] = file_info["key"] + + return StreamDetails( + item_id=item_id, + provider=self.provider.instance_id, + audio_format=audio_format, + stream_type=StreamType.CUSTOM, + duration=track.duration, + data=data, + can_seek=False, + allow_seek=True, + ) + + # Fallback: /tracks/{id}/download-info (defensive, should rarely trigger) + self.logger.warning( + "get-file-info failed for track %s, falling back to download-info", track_id ) - selected_info = self._select_best_quality(download_infos, quality_str) + 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}") + selected_info = self._select_best_quality(download_infos, quality) if not selected_info or not selected_info.direct_link: raise MediaNotFoundError(f"No stream URL available for track {item_id}") self.logger.debug( - "Stream selected for track %s: codec=%s, bitrate=%s", + "Fallback stream for track %s: codec=%s, bitrate=%s", track_id, getattr(selected_info, "codec", None), getattr(selected_info, "bitrate_in_kbps", None), ) - bitrate = selected_info.bitrate_in_kbps or 0 - return StreamDetails( item_id=item_id, provider=self.provider.instance_id, - audio_format=self._build_audio_format(selected_info.codec, bit_rate=bitrate), + audio_format=self._build_audio_format( + selected_info.codec, bit_rate=selected_info.bitrate_in_kbps or 0 + ), stream_type=StreamType.HTTP, duration=track.duration, path=selected_info.direct_link, @@ -184,6 +219,8 @@ def _select_best_quality( ) -> DownloadInfo | None: """Select the best quality download info based on user preference. + Used as fallback when get-file-info is unavailable. + :param download_infos: List of DownloadInfo objects. :param preferred_quality: User's quality preference (efficient/high/balanced/superb). :return: Best matching DownloadInfo or None. @@ -202,8 +239,6 @@ def _select_best_quality( # Superb: Prefer FLAC (backward compatibility with "lossless") if preferred_normalized == QUALITY_SUPERB or "lossless" in preferred_normalized: - # Note: flac-mp4 typically comes from get-file-info API, not download-info, - # but we check here for forward compatibility in case the API changes. for codec in ("flac-mp4", "flac"): for info in sorted_infos: if info.codec and info.codec.lower() == codec: @@ -215,12 +250,10 @@ def _select_best_quality( # Efficient: Prefer lowest bitrate AAC/MP3 if preferred_normalized == QUALITY_EFFICIENT: - # Sort ascending for lowest bitrate sorted_infos_asc = sorted( download_infos, key=lambda x: x.bitrate_in_kbps or 999, ) - # Prefer AAC for efficiency, then MP3 (include MP4 container variants) for codec in ("aac-mp4", "aac", "he-aac-mp4", "he-aac", "mp3"): for info in sorted_infos_asc: if info.codec and info.codec.lower() == codec: @@ -229,7 +262,6 @@ def _select_best_quality( # High: Prefer high bitrate MP3 (~320kbps) if preferred_normalized == QUALITY_HIGH: - # Look for MP3 with bitrate >= 256kbps high_quality_mp3 = [ info for info in sorted_infos @@ -239,122 +271,231 @@ def _select_best_quality( and info.bitrate_in_kbps >= 256 ] if high_quality_mp3: - return high_quality_mp3[0] # Already sorted by bitrate descending + return high_quality_mp3[0] - # Fallback: any MP3 available (highest bitrate) for info in sorted_infos: if info.codec and info.codec.lower() == "mp3": return info - # If no MP3, use highest available (excluding FLAC) for info in sorted_infos: if info.codec and info.codec.lower() not in ("flac", "flac-mp4"): return info - # Last resort: highest available return sorted_infos[0] - # Balanced (default): Prefer ~192kbps AAC, or medium quality MP3 - # Look for bitrate around 192kbps (within range 128-256) + # Balanced (default): Prefer ~192kbps AAC balanced_infos = [ info for info in sorted_infos if info.bitrate_in_kbps and 128 <= info.bitrate_in_kbps <= 256 ] if balanced_infos: - # Prefer AAC over MP3 at similar bitrate (include MP4 container variants) for codec in ("aac-mp4", "aac", "he-aac-mp4", "he-aac", "mp3"): for info in balanced_infos: if info.codec and info.codec.lower() == codec: return info return balanced_infos[0] - # Fallback to highest available if no balanced option return sorted_infos[0] if sorted_infos else None def _get_content_type(self, codec: str | None) -> tuple[ContentType, ContentType]: - """Determine container and codec type from Yandex API codec string. + """Determine content_type and codec_type from Yandex API codec string. - Yandex API returns codec strings like "flac-mp4" (FLAC in MP4 container), - "aac-mp4" (AAC in MP4 container), or plain "flac", "mp3", "aac". + Parses the codec string automatically: + - Simple codecs ("flac", "mp3", "aac") → (ContentType., UNKNOWN) + - Compound "codec-container" ("flac-mp4", "aac-mp4") → + (ContentType., ContentType.) - :param codec: Codec string from Yandex API. - :return: Tuple of (content_type/container, codec_type). + content_type always reflects the audio codec (not the container), + so MA's is_lossless() correctly identifies lossless streams and + ffmpeg gets the right decoder name via codec_type. + + :param codec: Codec string from Yandex API (e.g. "flac-mp4", "mp3"). + :return: Tuple of (content_type, codec_type). """ if not codec: return ContentType.UNKNOWN, ContentType.UNKNOWN codec_lower = codec.lower() - # MP4 container variants: codec is inside an MP4 container - if codec_lower == "flac-mp4": - return ContentType.MP4, ContentType.FLAC - if codec_lower in ("aac-mp4", "he-aac-mp4"): - return ContentType.MP4, ContentType.AAC + # Strip container suffix: "flac-mp4" → "flac", "he-aac-mp4" → "he-aac" + has_container = codec_lower.endswith("-mp4") + audio_part = codec_lower[:-4] if has_container else codec_lower - # Plain single-codec formats: codec is implied by content_type, no separate codec_type - if codec_lower == "flac": - return ContentType.FLAC, ContentType.UNKNOWN - if codec_lower in ("mp3", "mpeg"): - return ContentType.MP3, ContentType.UNKNOWN - if codec_lower in ("aac", "he-aac"): - return ContentType.AAC, ContentType.UNKNOWN + # Normalize aliases (he-aac → aac, mpeg → mp3) + audio_part = _CODEC_ALIASES.get(audio_part, audio_part) - return ContentType.UNKNOWN, ContentType.UNKNOWN - - def _get_audio_params(self, codec: str | None) -> tuple[int, int]: - """Return (sample_rate, bit_depth) defaults based on codec string. + try: + content_type = ContentType(audio_part) + except ValueError: + self.logger.debug("Unknown codec from Yandex API: %s", codec) + return ContentType.UNKNOWN, ContentType.UNKNOWN - The Yandex get-file-info API does not return sample rate or bit depth, - so we use codec-based defaults. These values help the core select the - correct PCM output format and avoid unnecessary resampling. + # For compound formats, set codec_type so ffmpeg knows the decoder + codec_type = content_type if has_container else ContentType.UNKNOWN + return content_type, codec_type - :param codec: Codec string from Yandex API (e.g. "flac-mp4", "flac", "mp3"). - :return: Tuple of (sample_rate, bit_depth). - """ - if codec and codec.lower() == "flac-mp4": - return 48000, 24 - # CD-quality defaults for all other codecs - return 44100, 16 + def _build_audio_format( + self, + codec: str | None, + *, + bit_rate: int = 0, + sample_rate: int = 0, + bit_depth: int = 0, + ) -> AudioFormat: + """Build AudioFormat from codec string and optional stream metadata. - def _build_audio_format(self, codec: str | None, bit_rate: int = 0) -> AudioFormat: - """Build AudioFormat with content type and codec-based audio params. + Values of 0 mean "unknown — let MA/ffmpeg detect from the actual stream". + Pass real values from the API response when available. - :param codec: Codec string from Yandex API (e.g. "flac-mp4", "flac", "mp3"). - :param bit_rate: Bitrate in kbps (0 for variable/unknown). + :param codec: Codec string from Yandex API. + :param bit_rate: Bitrate in kbps (0 = unknown). + :param sample_rate: Sample rate in Hz (0 = unknown, detect from stream). + :param bit_depth: Bit depth (0 = unknown, detect from stream). :return: Configured AudioFormat instance. """ content_type, codec_type = self._get_content_type(codec) - sample_rate, bit_depth = self._get_audio_params(codec) - return AudioFormat( - content_type=content_type, - codec_type=codec_type, - bit_rate=bit_rate, - sample_rate=sample_rate, - bit_depth=bit_depth, - ) + kwargs: dict[str, Any] = { + "content_type": content_type, + "codec_type": codec_type, + } + # Only pass non-zero values; AudioFormat defaults to 44100/16 which + # MA/ffmpeg rely on. Passing 0 would override those defaults. + if bit_rate: + kwargs["bit_rate"] = bit_rate + if sample_rate: + kwargs["sample_rate"] = sample_rate + if bit_depth: + kwargs["bit_depth"] = bit_depth + return AudioFormat(**kwargs) + + @staticmethod + def _parse_flac_streaminfo(header: bytes) -> tuple[int, int]: + """Extract sample_rate and bit_depth from FLAC STREAMINFO block. + + FLAC format: 4-byte magic "fLaC", then metadata blocks. + STREAMINFO is always the first block (type 0), 34 bytes payload. + Bytes 10-17 of STREAMINFO contain sample_rate (20 bits), + channels (3 bits), bit_depth (5 bits), total samples (36 bits). - async def _refresh_encrypted_url( + :param header: First 42+ bytes of the FLAC stream. + :return: (sample_rate, bit_depth) or (0, 0) on parse failure. + """ + if len(header) < 42 or header[:4] != b"fLaC": + return 0, 0 + # STREAMINFO payload: 4 magic + 4 block header = 8 byte offset, 34 bytes long + # Bytes 10-13 of payload: sample_rate(20) | channels(3) | bps(5) | total(4 high) + payload = header[8:] # skip "fLaC" + block header + if len(payload) < 34: + return 0, 0 + val = int.from_bytes(payload[10:14], "big") + sample_rate = (val >> 12) & 0xFFFFF + bit_depth = ((val >> 4) & 0x1F) + 1 + return sample_rate, bit_depth + + @staticmethod + def _parse_mp4_audio_params(header: bytes) -> tuple[int, int]: + """Extract sample_rate and bit_depth from MP4/fMP4 container. + + Scans for the 'esds' or 'dfLa' (FLAC-in-MP4) box, or falls back to + parsing the AudioSampleEntry in 'mp4a'/'fLaC' boxes inside 'stsd'. + + :param header: First 8-32 KB of the MP4 stream. + :return: (sample_rate, bit_depth) or (0, 0) if not found. + """ + # Quick scan for dfLa box (FLAC-in-MP4: contains FLAC STREAMINFO) + dfl_pos = header.find(b"dfLa") + if dfl_pos >= 4: + # dfLa box layout after "dfLa" type: + # 4 bytes version/flags + # 4 bytes STREAMINFO block header (type byte + 3-byte length) + # 34 bytes STREAMINFO payload + payload_start = dfl_pos + 4 + 4 + 4 # after type + version/flags + block header + payload = header[payload_start:] + if len(payload) >= 34: + val = int.from_bytes(payload[10:14], "big") + sample_rate = (val >> 12) & 0xFFFFF + bit_depth = ((val >> 4) & 0x1F) + 1 + if 8000 <= sample_rate <= 384000 and 1 <= bit_depth <= 32: + return sample_rate, bit_depth + + # Scan for mp4a AudioSampleEntry (AAC/generic audio in MP4) + mp4a_pos = header.find(b"mp4a") + if mp4a_pos >= 4: + # AudioSampleEntry: 4-byte size, "mp4a", 6 reserved, 2 data_ref, + # 2 version, 2 revision, 4 vendor, 2 channels, 2 sample_size, + # 2 compression_id, 2 packet_size, 4 sample_rate (16.16 fixed-point) + entry_start = mp4a_pos + 4 # after "mp4a" + entry = header[entry_start:] + if len(entry) >= 24: + sample_size = int.from_bytes(entry[18:20], "big") + sr_fixed = int.from_bytes(entry[20:24], "big") + sample_rate = sr_fixed >> 16 + bit_depth = max(0, sample_size) + if 8000 <= sample_rate <= 384000: + return sample_rate, bit_depth + + return 0, 0 + + async def _probe_stream_params(self, url: str, codec: str) -> tuple[int, int]: + """Probe audio params by reading the first bytes of the stream. + + Makes a small Range request to read container/stream headers, + then parses FLAC STREAMINFO or MP4 box structure. + + :param url: Stream URL. + :param codec: Codec string from API (e.g. "flac-mp4", "flac"). + :return: (sample_rate, bit_depth) or (0, 0) if probing fails. + """ + codec_lower = (codec or "").lower() + # Determine how many bytes to read and which parser to use + if codec_lower == "flac": + probe_size = 64 + parser = self._parse_flac_streaminfo + elif "-mp4" in codec_lower: + probe_size = 32768 # MP4 moov/stsd can be further in + parser = self._parse_mp4_audio_params + else: + return 0, 0 # lossy without container — let MA detect + + try: + headers = {"Range": f"bytes=0-{probe_size - 1}"} + async with self.mass.http_session.get( + url, + headers=headers, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status not in (200, 206): + return 0, 0 + header_bytes = await resp.content.read(probe_size) + self.logger.debug("Probe read %d bytes for codec=%s", len(header_bytes), codec) + result = parser(header_bytes) + self.logger.debug("Probe result: sample_rate=%d, bit_depth=%d", *result) + return result + except Exception: + self.logger.debug("Stream probe failed for codec=%s", codec) + return 0, 0 + + async def _refresh_stream_url( self, - track_item_id: str, - current_url: str, - current_key_hex: str, + streamdetails: StreamDetails, http_status: int, bytes_yielded: int, attempt: int, max_retries: int, - ) -> tuple[str, str] | None: - """Re-fetch an expired encrypted stream URL. + ) -> bool: + """Re-fetch an expired stream URL (works for both raw and encraw). - Called when the CDN responds with 4xx (URL expired or access revoked). + Updates streamdetails.data in-place with new URL (and key for encraw). - :return: (new_url, new_key_hex) on success, or None if retries exhausted. + :return: True on success, False if retries exhausted. """ if attempt >= max_retries: - return None - raw_track_id = self._track_id_from_item_id(track_item_id) + return False + data = streamdetails.data + track_id = self._track_id_from_item_id(streamdetails.item_id) self.logger.warning( - "Encrypted stream URL expired (HTTP %d) at %d bytes (attempt %d/%d) — re-fetching", + "Stream URL expired (HTTP %d) at %d bytes (attempt %d/%d) — re-fetching", http_status, bytes_yielded, attempt + 1, @@ -362,12 +503,20 @@ async def _refresh_encrypted_url( ) token = BYPASS_THROTTLER.set(True) try: - file_info = await self.client.get_track_file_info_lossless(raw_track_id) + file_info = await self.client.get_track_file_info( + track_id, + quality=data["fi_quality"], + codecs=data["fi_codecs"], + transport=data.get("transport", TRANSPORT_RAW), + ) finally: BYPASS_THROTTLER.reset(token) if file_info and file_info.get("url"): - return file_info["url"], file_info.get("key", current_key_hex) - return None + data["url"] = file_info["url"] + if "decryption_key" in data and file_info.get("key"): + data["decryption_key"] = file_info["key"] + return True + return False async def _decrypt_response_stream( self, @@ -441,14 +590,14 @@ def _handle_stream_error( attempt += 1 if attempt <= max_retries: self.logger.warning( - "Encrypted stream %s at %d bytes (attempt %d/%d) — retrying", + "Stream %s at %d bytes (attempt %d/%d) — retrying", label, bytes_yielded, attempt, max_retries, ) return attempt, delay - raise MediaNotFoundError(f"Encrypted stream {label} after retries were exhausted") from err + raise MediaNotFoundError(f"Stream {label} after retries were exhausted") from err @staticmethod def _is_content_range_eof(headers: Any, window_end: int) -> bool: @@ -469,120 +618,186 @@ def _is_content_range_eof(headers: Any, window_end: int) -> bool: except ValueError: return False - async def get_audio_stream( - self, streamdetails: StreamDetails, seek_position: int = 0 + async def _iter_raw_response( + self, + response: Any, + bytes_delivered: int, + block_start: int, ) -> AsyncGenerator[bytes, None]: - """Return the audio stream for the provider item with on-the-fly decryption. + """Yield raw (unencrypted) chunks from one HTTP response. - Downloads and decrypts the encrypted stream in windowed Range requests of - _RANGE_WINDOW bytes each. Yandex CDN drops TCP every ~6-7 MB per connection; - keeping each request at 4 MB prevents that limit from being reached. + If the server ignored the Range header (200 instead of 206), skips the + already-delivered prefix transparently. - On connection drop (ClientPayloadError, ServerDisconnectedError), the current - window is retried with a flat short backoff (0.5s/1.0s/2.0s). - On read stall (asyncio.TimeoutError), the current window is retried with - exponential backoff (2s/4s/8s). - On URL expiry (HTTP 4xx), re-fetches the URL and resumes from bytes_yielded. - Up to max_retries retries per window; the retry counter resets on each - successful window so long tracks get the same protection as short ones. + :param response: aiohttp ClientResponse (open context manager). + :param bytes_delivered: Total bytes already sent to the caller. + :param block_start: Requested Range start offset. + :return: Async generator yielding raw audio bytes. + """ + range_ignored = response.status == 200 and block_start > 0 + skip_bytes = bytes_delivered if range_ignored else 0 + async for raw_chunk in response.content.iter_chunked(_CHUNK_SIZE): + if skip_bytes > 0: + if len(raw_chunk) <= skip_bytes: + skip_bytes -= len(raw_chunk) + continue + raw_chunk = raw_chunk[skip_bytes:] # noqa: PLW2901 + skip_bytes = 0 + if raw_chunk: + yield raw_chunk + + async def _handle_expired_url( + self, + streamdetails: StreamDetails, + response_status: int, + bytes_yielded: int, + attempt: int, + max_retries: int, + ) -> bytes | None: + """Handle URL expiry (401/403/410) by refreshing and returning updated key. - If the server ignores a Range header (returns 200 instead of 206), the decryptor - is reset to position 0 so decryption stays consistent with the restarted byte stream. + :return: Updated AES key bytes (or empty bytes for raw), None if exhausted. + :raises MediaNotFoundError: When refresh fails after retries exhausted. + """ + if not await self._refresh_stream_url( + streamdetails, + response_status, + bytes_yielded, + attempt, + max_retries, + ): + raise MediaNotFoundError( + f"Stream URL expired (HTTP {response_status}) after retries exhausted" + ) + data = streamdetails.data + if "decryption_key" in data: + return bytes.fromhex(data["decryption_key"]) + return b"" - :param streamdetails: Stream details containing encrypted URL and key. - :param seek_position: Always 0 (seeking delegated to ffmpeg via allow_seek=True). - :return: Async generator yielding decrypted audio bytes. + @staticmethod + def _validate_encryption_key(data: dict[str, Any]) -> tuple[bool, bytes | None]: + """Validate and extract encryption parameters from stream data. + + :return: (is_encrypted, key_bytes) tuple. + :raises MediaNotFoundError: If AES key length is invalid. """ - encrypted_url: str = streamdetails.data["encrypted_url"] - track_item_id: str = streamdetails.item_id - key_hex: str = streamdetails.data["decryption_key"] - key_bytes = bytes.fromhex(key_hex) + if "decryption_key" not in data: + return False, None + key_bytes = bytes.fromhex(data["decryption_key"]) if len(key_bytes) not in (16, 24, 32): raise MediaNotFoundError(f"Unsupported AES key length: {len(key_bytes)} bytes") + return True, key_bytes + + async def get_audio_stream( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Return the audio stream via windowed Range requests. + + Handles both raw (direct) and encraw (AES-CTR encrypted) transports. + Downloads in windowed Range requests of _RANGE_WINDOW bytes each to prevent + Yandex CDN from dropping slow-consumer TCP connections. + + On connection drop: flat short backoff (0.5s/1.0s/2.0s). + On read stall: exponential backoff (2s/4s/8s). + On URL expiry (HTTP 4xx): re-fetches URL and resumes from bytes_yielded. + Retry counter resets after each successful window. + + :param streamdetails: Stream details with URL (and optional decryption key). + :param seek_position: Always 0 (seeking delegated to ffmpeg via allow_seek=True). + :return: Async generator yielding audio bytes. + """ + data = streamdetails.data + is_encrypted, key_bytes = self._validate_encryption_key(data) - block_size = 16 # AES-CTR block size in bytes max_retries = 6 - bytes_yielded = 0 # total decrypted bytes delivered to caller - attempt = 0 # retry counter; resets to 0 after each successful window + bytes_yielded = 0 + attempt = 0 retry_delay: float = 0.0 while True: if attempt > 0: await asyncio.sleep(retry_delay) - block_start = (bytes_yielded // block_size) * block_size + block_start = ( + (bytes_yielded // _AES_BLOCK_SIZE) * _AES_BLOCK_SIZE + if is_encrypted + else bytes_yielded + ) window_end = block_start + _RANGE_WINDOW - 1 - headers = {"Range": f"bytes={block_start}-{window_end}"} try: async with self.mass.http_session.get( - encrypted_url, headers=headers, timeout=_STREAM_TIMEOUT + data["url"], + headers={"Range": f"bytes={block_start}-{window_end}"}, + timeout=_STREAM_TIMEOUT, ) as response: if response.status in (401, 403, 410): - # URL expired — re-fetch via helper and retry - refreshed = await self._refresh_encrypted_url( - track_item_id, - encrypted_url, - key_hex, + new_key = await self._handle_expired_url( + streamdetails, response.status, bytes_yielded, attempt, max_retries, ) - if refreshed is None: - raise MediaNotFoundError( - f"Encrypted stream URL expired (HTTP {response.status}) " - "after retries exhausted" - ) - encrypted_url, key_hex = refreshed - key_bytes = bytes.fromhex(key_hex) + if is_encrypted: + key_bytes = new_key + attempt += 1 retry_delay = 0.0 - attempt += 1 # consume one retry slot, same as TCP-drop path continue try: response.raise_for_status() except Exception as err: - raise MediaNotFoundError( - f"Failed to fetch encrypted stream: {err}" - ) from err + raise MediaNotFoundError(f"Failed to fetch stream: {err}") from err bytes_before = bytes_yielded - # block_skip = bytes re-downloaded for AES-block alignment. - # Needed below to compute actual HTTP bytes received. - block_skip = bytes_before - block_start - async for chunk in self._decrypt_response_stream( - response, key_bytes, block_size, bytes_yielded - ): - bytes_yielded += len(chunk) - yield chunk - - # window complete — check if EOF - window_got = bytes_yielded - bytes_before - # received = actual HTTP bytes the server sent for this Range - # request. window_got alone understates the window when - # block_skip > 0 (reconnect at a non-AES-block boundary): - # the decryptor skips block_skip bytes, so window_got would be - # smaller than _RANGE_WINDOW even for a full server response, - # causing premature stream termination without this correction. - received = window_got + block_skip + if is_encrypted: + if key_bytes is None: + raise MediaNotFoundError("Missing decryption key") + block_skip = bytes_before - block_start + async for chunk in self._decrypt_response_stream( + response, + key_bytes, + _AES_BLOCK_SIZE, + bytes_yielded, + ): + bytes_yielded += len(chunk) + yield chunk + else: + range_ignored = response.status == 200 and block_start > 0 + block_skip = bytes_before if range_ignored else 0 + async for chunk in self._iter_raw_response( + response, + bytes_before, + block_start, + ): + bytes_yielded += len(chunk) + yield chunk + + received = (bytes_yielded - bytes_before) + block_skip if response.status == 200 or received < _RANGE_WINDOW: - return # full file received or last partial window - # Exact-boundary guard: if file size is an exact multiple of - # _RANGE_WINDOW the size check above won't catch EOF. - # Use Content-Range to confirm no bytes remain. + return if self._is_content_range_eof(response.headers, window_end): return - # more data expected: advance to next window attempt = 0 retry_delay = 0.0 except asyncio.CancelledError: - raise # propagate cancellation immediately, do not retry + raise except (ClientPayloadError, ServerDisconnectedError) as err: attempt, retry_delay = self._handle_stream_error( - err, attempt, max_retries, bytes_yielded, _TCP_DROP_DELAYS, "dropped" + err, + attempt, + max_retries, + bytes_yielded, + _TCP_DROP_DELAYS, + "dropped", ) except TimeoutError as err: attempt, retry_delay = self._handle_stream_error( - err, attempt, max_retries, bytes_yielded, _STALL_DELAYS, "stalled" + err, + attempt, + max_retries, + bytes_yielded, + _STALL_DELAYS, + "stalled", ) diff --git a/tests/providers/yandex_music/conftest.py b/tests/providers/yandex_music/conftest.py index cada4cc804..c0ead409a8 100644 --- a/tests/providers/yandex_music/conftest.py +++ b/tests/providers/yandex_music/conftest.py @@ -32,6 +32,18 @@ def get_item_mapping(self, media_type: MediaType | str, key: str, name: str) -> ) +class ConfigStub: + """Minimal config stub for provider tests.""" + + def __init__(self, values: dict[str, object] | None = None) -> None: + """Initialize with optional config values.""" + self._values = values or {} + + def get_value(self, key: str, default: object = None) -> object: + """Return config value or default.""" + return self._values.get(key, default) + + class StreamingProviderStub: """Minimal provider stub for streaming tests (no Mock). @@ -46,6 +58,7 @@ def __init__(self) -> None: """Initialize stub with minimal client.""" self.client = type("ClientStub", (), {"user_id": 12345})() self.mass = type("MassStub", (), {})() + self.config = ConfigStub() self._warning_count = 0 def _count_warning(self, *args: object, **kwargs: object) -> None: @@ -93,6 +106,7 @@ def __init__(self) -> None: """Initialize stub with tracking logger.""" self.client = type("ClientStub", (), {"user_id": 12345})() self.mass = type("MassStub", (), {})() + self.config = ConfigStub() self.logger = TrackingLogger() diff --git a/tests/providers/yandex_music/test_streaming.py b/tests/providers/yandex_music/test_streaming.py index 588361b43d..327d306781 100644 --- a/tests/providers/yandex_music/test_streaming.py +++ b/tests/providers/yandex_music/test_streaming.py @@ -143,12 +143,12 @@ def test_select_best_quality_none_preferred_returns_highest_bitrate( assert result.bitrate_in_kbps == 320 -def test_get_content_type_flac_mp4_returns_mp4_container_with_flac_codec( +def test_get_content_type_flac_mp4_returns_flac_with_flac_codec( streaming_manager: YandexMusicStreamingManager, ) -> None: - """flac-mp4 codec from get-file-info is mapped to MP4 container with FLAC codec.""" - assert streaming_manager._get_content_type("flac-mp4") == (ContentType.MP4, ContentType.FLAC) - assert streaming_manager._get_content_type("FLAC-MP4") == (ContentType.MP4, ContentType.FLAC) + """flac-mp4 codec: content_type=FLAC (lossless), codec_type=FLAC (ffmpeg decoder).""" + assert streaming_manager._get_content_type("flac-mp4") == (ContentType.FLAC, ContentType.FLAC) + assert streaming_manager._get_content_type("FLAC-MP4") == (ContentType.FLAC, ContentType.FLAC) def test_get_content_type_flac_returns_flac_container_with_unknown_codec( @@ -168,11 +168,11 @@ def test_get_content_type_aac_variants_return_aac( assert streaming_manager._get_content_type("AAC") == (ContentType.AAC, ContentType.UNKNOWN) assert streaming_manager._get_content_type("he-aac") == (ContentType.AAC, ContentType.UNKNOWN) assert streaming_manager._get_content_type("HE-AAC") == (ContentType.AAC, ContentType.UNKNOWN) - # MP4 container variants - assert streaming_manager._get_content_type("aac-mp4") == (ContentType.MP4, ContentType.AAC) - assert streaming_manager._get_content_type("AAC-MP4") == (ContentType.MP4, ContentType.AAC) - assert streaming_manager._get_content_type("he-aac-mp4") == (ContentType.MP4, ContentType.AAC) - assert streaming_manager._get_content_type("HE-AAC-MP4") == (ContentType.MP4, ContentType.AAC) + # MP4 container variants — content_type=AAC (audio codec), codec_type=AAC (ffmpeg decoder) + assert streaming_manager._get_content_type("aac-mp4") == (ContentType.AAC, ContentType.AAC) + assert streaming_manager._get_content_type("AAC-MP4") == (ContentType.AAC, ContentType.AAC) + assert streaming_manager._get_content_type("he-aac-mp4") == (ContentType.AAC, ContentType.AAC) + assert streaming_manager._get_content_type("HE-AAC-MP4") == (ContentType.AAC, ContentType.AAC) # --- Efficient quality tests --- @@ -291,42 +291,87 @@ def test_select_best_quality_high_only_flac_returns_flac( assert result.codec == "flac" -# --- Audio params tests --- +# --- _build_audio_format tests --- -def test_get_audio_params_flac_mp4( +def test_build_audio_format_passes_api_params( streaming_manager: YandexMusicStreamingManager, ) -> None: - """flac-mp4 returns 48kHz/24bit.""" - assert streaming_manager._get_audio_params("flac-mp4") == (48000, 24) + """_build_audio_format forwards API-provided params to AudioFormat.""" + fmt = streaming_manager._build_audio_format( + "flac-mp4", + bit_rate=0, + sample_rate=48000, + bit_depth=24, + ) + assert fmt.content_type == ContentType.FLAC + assert fmt.sample_rate == 48000 + assert fmt.bit_depth == 24 -def test_get_audio_params_flac_mp4_case_insensitive( +def test_build_audio_format_keeps_defaults_when_zero( streaming_manager: YandexMusicStreamingManager, ) -> None: - """flac-mp4 matching is case-insensitive.""" - assert streaming_manager._get_audio_params("FLAC-MP4") == (48000, 24) + """Without explicit params, AudioFormat keeps its defaults (44100/16).""" + fmt = streaming_manager._build_audio_format("mp3") + assert fmt.content_type == ContentType.MP3 + assert fmt.sample_rate == 44100 + assert fmt.bit_depth == 16 + +# --- Container probe parser tests --- -def test_get_audio_params_flac( + +def test_parse_flac_streaminfo_valid( streaming_manager: YandexMusicStreamingManager, ) -> None: - """Plain FLAC returns CD-quality defaults.""" - assert streaming_manager._get_audio_params("flac") == (44100, 16) + """Parse real FLAC STREAMINFO: 48kHz, 24-bit.""" + # Build a minimal FLAC header: magic + block header + 34-byte STREAMINFO + # STREAMINFO bytes 10-13: sample_rate(20) | channels(3) | bps(5) | total(36 high bits) + # 48000 Hz = 0xBB80, 24-bit = 23 (stored as bps-1), stereo = 1 (channels-1) + # bits: 00001011101110000000 001 10111 0000... + # = 0x0BB80 << 12 | 0x1 << 9 | 23 << 4 | 0x0 = 0x0BB80BE0 ... but let's compute: + sr = 48000 + channels_minus1 = 1 # stereo + bps_minus1 = 23 # 24-bit + val = (sr << 12) | (channels_minus1 << 9) | (bps_minus1 << 4) + # Build 34-byte STREAMINFO payload + payload = bytearray(34) + payload[10:14] = val.to_bytes(4, "big") + # Full header: "fLaC" + block header (type=0, length=34) + payload + block_header = b"\x80" + (34).to_bytes(3, "big") # last-metadata-block flag + length + header = b"fLaC" + block_header + bytes(payload) + + result = streaming_manager._parse_flac_streaminfo(header) + assert result == (48000, 24) -def test_get_audio_params_mp3( +def test_parse_flac_streaminfo_invalid( streaming_manager: YandexMusicStreamingManager, ) -> None: - """MP3 returns CD-quality defaults.""" - assert streaming_manager._get_audio_params("mp3") == (44100, 16) + """Non-FLAC data returns (0, 0).""" + assert streaming_manager._parse_flac_streaminfo(b"not flac data") == (0, 0) + assert streaming_manager._parse_flac_streaminfo(b"") == (0, 0) -def test_get_audio_params_none( +def test_parse_mp4_dfla_box( streaming_manager: YandexMusicStreamingManager, ) -> None: - """None codec returns CD-quality defaults.""" - assert streaming_manager._get_audio_params(None) == (44100, 16) + """Parse dfLa box (FLAC-in-MP4) with STREAMINFO inside.""" + sr = 48000 + bps_minus1 = 23 + val = (sr << 12) | (1 << 9) | (bps_minus1 << 4) + streaminfo = bytearray(34) + streaminfo[10:14] = val.to_bytes(4, "big") + # dfLa box: size(4) + "dfLa" + version/flags(4) + block_header(4) + STREAMINFO(34) + block_header = b"\x80\x00\x00\x22" # type=0 (last), length=34 + box_size = (4 + 4 + 4 + 4 + 34).to_bytes(4, "big") + dfla_box = box_size + b"dfLa" + b"\x00\x00\x00\x00" + block_header + bytes(streaminfo) + # Wrap in some padding to simulate real MP4 structure + header = b"\x00" * 100 + dfla_box + b"\x00" * 100 + + result = streaming_manager._parse_mp4_audio_params(header) + assert result == (48000, 24) # --- get_audio_stream tests --- @@ -340,12 +385,15 @@ def _make_encrypted_stream_details( return StreamDetails( item_id="test_track_123", provider="yandex_music_instance", - audio_format=AudioFormat(content_type=ContentType.MP4), + audio_format=AudioFormat(content_type=ContentType.FLAC), stream_type=StreamType.CUSTOM, data={ - "encrypted_url": url, + "url": url, "decryption_key": key_hex, "codec": "flac-mp4", + "transport": "encraw", + "fi_quality": "lossless", + "fi_codecs": "flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4", }, ) @@ -450,7 +498,7 @@ async def test_get_audio_stream_http_error_raises_media_not_found( _MockResponse([], error=RuntimeError("403 Forbidden")) ) - with pytest.raises(MediaNotFoundError, match="Failed to fetch encrypted stream"): + with pytest.raises(MediaNotFoundError, match="Failed to fetch stream"): async for _ in streaming_manager.get_audio_stream(sd): pass @@ -538,9 +586,9 @@ def _get(_url: str, **_kwargs: object) -> _MockResponse: streaming_provider_stub.mass.http_session = unittest.mock.MagicMock() streaming_provider_stub.mass.http_session.get = _get - # Mock get_track_file_info_lossless to return a fresh URL + # Mock get_track_file_info to return a fresh URL streaming_provider_stub.client = unittest.mock.AsyncMock() - streaming_provider_stub.client.get_track_file_info_lossless = unittest.mock.AsyncMock( + streaming_provider_stub.client.get_track_file_info = unittest.mock.AsyncMock( return_value={"url": fresh_url, "codec": "flac-mp4", "key": key.hex()} ) streaming_manager.client = streaming_provider_stub.client @@ -553,8 +601,11 @@ def _get(_url: str, **_kwargs: object) -> _MockResponse: result += chunk assert result == plaintext - streaming_provider_stub.client.get_track_file_info_lossless.assert_called_once_with( - "test_track_123" + streaming_provider_stub.client.get_track_file_info.assert_called_once_with( + "test_track_123", + quality="lossless", + codecs="flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4", + transport="encraw", ) @@ -567,7 +618,7 @@ async def test_get_audio_stream_raises_after_all_retries_on_410( sd = _make_encrypted_stream_details(key.hex()) streaming_provider_stub.mass.http_session = _MockHttpSession(_MockResponse([], status=410)) streaming_provider_stub.client = unittest.mock.AsyncMock() - streaming_provider_stub.client.get_track_file_info_lossless = unittest.mock.AsyncMock( + streaming_provider_stub.client.get_track_file_info = unittest.mock.AsyncMock( return_value={"url": "https://cdn.example.com/still-expired.flac", "key": key.hex()} ) streaming_manager.client = streaming_provider_stub.client @@ -676,15 +727,13 @@ async def test_get_audio_stream_fails_immediately_when_url_refresh_returns_nothi streaming_manager: YandexMusicStreamingManager, streaming_provider_stub: StreamingProviderStub, ) -> None: - """If get_track_file_info_lossless returns no URL, stream fails without wasting retries.""" + """If get_track_file_info returns no URL, stream fails without wasting retries.""" key = b"\x88" * 32 sd = _make_encrypted_stream_details(key.hex()) streaming_provider_stub.mass.http_session = _MockHttpSession(_MockResponse([], status=410)) streaming_provider_stub.client = unittest.mock.AsyncMock() # Simulate API returning no usable URL (None result) - streaming_provider_stub.client.get_track_file_info_lossless = unittest.mock.AsyncMock( - return_value=None - ) + streaming_provider_stub.client.get_track_file_info = unittest.mock.AsyncMock(return_value=None) streaming_manager.client = streaming_provider_stub.client with ( @@ -695,7 +744,7 @@ async def test_get_audio_stream_fails_immediately_when_url_refresh_returns_nothi pass # Should have given up after attempt 0 (refresh returned None → no stale URL reuse) - assert streaming_provider_stub.client.get_track_file_info_lossless.call_count == 1 + assert streaming_provider_stub.client.get_track_file_info.call_count == 1 async def test_get_audio_stream_exact_window_boundary( @@ -789,3 +838,159 @@ async def test_get_audio_stream_continues_after_non_block_boundary_drop( assert session.calls[0]["headers"] == {"Range": "bytes=0-31"} assert session.calls[1]["headers"] == {"Range": "bytes=16-47"} # AES-aligned reconnect assert session.calls[2]["headers"] == {"Range": "bytes=48-79"} # second window + + +# --- Raw (unencrypted) windowed streaming tests --- + + +def _make_raw_stream_details( + url: str = "https://cdn.example.com/track.flac", + codec: str = "flac-mp4", +) -> StreamDetails: + """Build StreamDetails for raw (unencrypted) windowed stream tests.""" + return StreamDetails( + item_id="test_track_123", + provider="yandex_music_instance", + audio_format=AudioFormat(content_type=ContentType.FLAC), + stream_type=StreamType.CUSTOM, + data={ + "url": url, + "codec": codec, + "transport": "raw", + "fi_quality": "lossless", + "fi_codecs": "flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4", + }, + ) + + +async def test_get_audio_stream_raw_single_window( + streaming_manager: YandexMusicStreamingManager, + streaming_provider_stub: StreamingProviderStub, +) -> None: + """Raw stream smaller than _RANGE_WINDOW is fetched in one request.""" + plaintext = b"Hello raw FLAC data!" * 50 # 1000 bytes + sd = _make_raw_stream_details() + streaming_provider_stub.mass.http_session = _MockHttpSession(_MockResponse([plaintext])) + + result = b"" + async for chunk in streaming_manager.get_audio_stream(sd): + result += chunk + + assert result == plaintext + + +async def test_get_audio_stream_raw_multi_window( + streaming_manager: YandexMusicStreamingManager, + streaming_provider_stub: StreamingProviderStub, +) -> None: + """Raw stream larger than _RANGE_WINDOW uses multiple windowed requests.""" + small_window = 32 + plaintext = b"A" * 50 # 50 bytes → two windows (32 + 18) + + resp1 = _MockResponse([plaintext[:small_window]], status=206) + resp2 = _MockResponse([plaintext[small_window:]], status=206) + session = _MultiCallHttpSession([resp1, resp2]) + streaming_provider_stub.mass.http_session = session + + result = b"" + with unittest.mock.patch.object(_streaming_mod, "_RANGE_WINDOW", small_window): + async for chunk in streaming_manager.get_audio_stream(_make_raw_stream_details()): + result += chunk + + assert result == plaintext + assert len(session.calls) == 2 + assert session.calls[0]["headers"] == {"Range": "bytes=0-31"} + assert session.calls[1]["headers"] == {"Range": "bytes=32-63"} + + +async def test_get_audio_stream_raw_retry_on_drop( + streaming_manager: YandexMusicStreamingManager, + streaming_provider_stub: StreamingProviderStub, +) -> None: + """Raw stream reconnects with correct Range header after TCP drop.""" + plaintext = b"B" * 96 + drop_at = 48 + + first_resp = _MockResponse([plaintext[:drop_at]], drop_payload_error=True) + second_resp = _MockResponse([plaintext[drop_at:]], status=206) + session = _MultiCallHttpSession([first_resp, second_resp]) + streaming_provider_stub.mass.http_session = session + + result = b"" + with unittest.mock.patch("asyncio.sleep"): + async for chunk in streaming_manager.get_audio_stream(_make_raw_stream_details()): + result += chunk + + assert result == plaintext + assert len(session.calls) == 2 + # Raw uses exact byte offset (no AES block alignment) + assert session.calls[1]["headers"] == {"Range": f"bytes={drop_at}-{drop_at + 4194304 - 1}"} + + +async def test_get_audio_stream_raw_url_refresh_on_403( + streaming_manager: YandexMusicStreamingManager, + streaming_provider_stub: StreamingProviderStub, +) -> None: + """Raw stream refreshes URL on 403 and continues.""" + plaintext = b"C" * 64 + fresh_url = "https://cdn.example.com/refreshed-track.flac" + + expired_resp = _MockResponse([], status=403) + fresh_resp = _MockResponse([plaintext]) + + call_count = 0 + + def _get(_url: str, **_kwargs: object) -> _MockResponse: + nonlocal call_count + call_count += 1 + return expired_resp if call_count == 1 else fresh_resp + + streaming_provider_stub.mass.http_session = unittest.mock.MagicMock() + streaming_provider_stub.mass.http_session.get = _get + + streaming_provider_stub.client = unittest.mock.AsyncMock() + streaming_provider_stub.client.get_track_file_info = unittest.mock.AsyncMock( + return_value={"url": fresh_url, "codec": "flac-mp4"} + ) + streaming_manager.client = streaming_provider_stub.client + + result = b"" + with unittest.mock.patch("asyncio.sleep"): + async for chunk in streaming_manager.get_audio_stream(_make_raw_stream_details()): + result += chunk + + assert result == plaintext + streaming_provider_stub.client.get_track_file_info.assert_called_once_with( + "test_track_123", + quality="lossless", + codecs="flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4", + transport="raw", + ) + + +async def test_get_audio_stream_raw_resets_on_range_ignored( + streaming_manager: YandexMusicStreamingManager, + streaming_provider_stub: StreamingProviderStub, +) -> None: + """If server returns 200 instead of 206 after raw reconnect, skip already-delivered bytes.""" + small_window = 32 + plaintext = b"G" * 96 # 96 bytes + + drop_at = 48 + # First response drops after 48 bytes + first_resp = _MockResponse([plaintext[:drop_at]], drop_payload_error=True) + # Second response ignores Range and returns full file with 200 + second_resp = _MockResponse([plaintext], status=200) + session = _MultiCallHttpSession([first_resp, second_resp]) + streaming_provider_stub.mass.http_session = session + + result = b"" + with ( + unittest.mock.patch.object(_streaming_mod, "_RANGE_WINDOW", small_window), + unittest.mock.patch("asyncio.sleep"), + ): + async for chunk in streaming_manager.get_audio_stream(_make_raw_stream_details()): + result += chunk + + # Should get the full plaintext without duplication + assert result == plaintext From aeb818ac3660fb5b2a8ca44f58bf0dbccbfa005a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 9 Apr 2026 11:34:54 +0000 Subject: [PATCH 05/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.8.0 --- .../providers/yandex_music/test_integration.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/providers/yandex_music/test_integration.py b/tests/providers/yandex_music/test_integration.py index a984f25eda..fdf14a68e1 100644 --- a/tests/providers/yandex_music/test_integration.py +++ b/tests/providers/yandex_music/test_integration.py @@ -133,6 +133,14 @@ async def yandex_music_provider( mock_client.get_artist_albums = mock.AsyncMock(return_value=[album]) mock_client.get_artist_tracks = mock.AsyncMock(return_value=[track]) mock_client.get_playlist = mock.AsyncMock(return_value=playlist) + mock_client.get_track_file_info = mock.AsyncMock( + return_value={ + "url": "https://example.com/yandex_track.mp3", + "codec": "mp3", + "bitrate_in_kbps": 320, + "needs_decryption": False, + } + ) mock_client.get_track_download_info = mock.AsyncMock(return_value=[download_info]) mock_client.get_track_lyrics = mock.AsyncMock(return_value=(None, False)) mock_client.get_track_lyrics_from_track = mock.AsyncMock(return_value=(None, False)) @@ -207,8 +215,14 @@ async def yandex_music_provider_lossless( mock_client.get_artist_albums = mock.AsyncMock(return_value=[album]) mock_client.get_artist_tracks = mock.AsyncMock(return_value=[track]) mock_client.get_playlist = mock.AsyncMock(return_value=playlist) - # get-file-info lossless is tried first; mock returns None so we use download_info path - mock_client.get_track_file_info_lossless = mock.AsyncMock(return_value=None) + mock_client.get_track_file_info = mock.AsyncMock( + return_value={ + "url": "https://example.com/yandex_track.flac", + "codec": "flac", + "bitrate_in_kbps": 0, + "needs_decryption": False, + } + ) mock_client.get_track_download_info = mock.AsyncMock(return_value=download_infos) mock_client.get_track_lyrics = mock.AsyncMock(return_value=(None, False)) mock_client.get_track_lyrics_from_track = mock.AsyncMock(return_value=(None, False)) From e66dbe70fa1604da327a91a4792bb0b22fff46e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 9 Apr 2026 11:53:35 +0000 Subject: [PATCH 06/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.8.0 --- tests/providers/yandex_music/test_integration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/providers/yandex_music/test_integration.py b/tests/providers/yandex_music/test_integration.py index fdf14a68e1..a4e56da6ca 100644 --- a/tests/providers/yandex_music/test_integration.py +++ b/tests/providers/yandex_music/test_integration.py @@ -325,8 +325,8 @@ async def test_get_stream_details(mass: MusicAssistant) -> None: assert prov is not None stream_details = await prov.get_stream_details("400", MediaType.TRACK) assert stream_details is not None - assert stream_details.stream_type == StreamType.HTTP - assert stream_details.path == "https://example.com/yandex_track.mp3" + assert stream_details.stream_type == StreamType.CUSTOM + assert stream_details.data["url"] == "https://example.com/yandex_track.mp3" @pytest.mark.usefixtures("yandex_music_provider_lossless") @@ -339,7 +339,7 @@ async def test_get_stream_details_returns_flac_when_lossless_selected( stream_details = await prov.get_stream_details("400", MediaType.TRACK) assert stream_details is not None assert stream_details.audio_format.content_type == ContentType.FLAC - assert stream_details.path == "https://example.com/yandex_track.flac" + assert stream_details.data["url"] == "https://example.com/yandex_track.flac" @pytest.mark.usefixtures("yandex_music_provider") From bd16eba37da669e2347df7d53d976b8e73133b07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 9 Apr 2026 12:11:51 +0000 Subject: [PATCH 07/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.8.0 --- music_assistant/providers/yandex_music/streaming.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_music/streaming.py b/music_assistant/providers/yandex_music/streaming.py index 480a87550c..94befad21c 100644 --- a/music_assistant/providers/yandex_music/streaming.py +++ b/music_assistant/providers/yandex_music/streaming.py @@ -427,9 +427,9 @@ def _parse_mp4_audio_params(header: bytes) -> tuple[int, int]: # 2 compression_id, 2 packet_size, 4 sample_rate (16.16 fixed-point) entry_start = mp4a_pos + 4 # after "mp4a" entry = header[entry_start:] - if len(entry) >= 24: + if len(entry) >= 28: sample_size = int.from_bytes(entry[18:20], "big") - sr_fixed = int.from_bytes(entry[20:24], "big") + sr_fixed = int.from_bytes(entry[24:28], "big") sample_rate = sr_fixed >> 16 bit_depth = max(0, sample_size) if 8000 <= sample_rate <= 384000: @@ -472,6 +472,8 @@ async def _probe_stream_params(self, url: str, codec: str) -> tuple[int, int]: result = parser(header_bytes) self.logger.debug("Probe result: sample_rate=%d, bit_depth=%d", *result) return result + except asyncio.CancelledError: + raise except Exception: self.logger.debug("Stream probe failed for codec=%s", codec) return 0, 0 From 5c89c3ca1def48548c90e0fbb1d903539d2df68b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 9 Apr 2026 15:03:43 +0000 Subject: [PATCH 08/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.8.0 --- music_assistant/providers/yandex_music/streaming.py | 10 ++++++++-- music_assistant/providers/yandex_music/yandex_auth.py | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/music_assistant/providers/yandex_music/streaming.py b/music_assistant/providers/yandex_music/streaming.py index 94befad21c..678d52d381 100644 --- a/music_assistant/providers/yandex_music/streaming.py +++ b/music_assistant/providers/yandex_music/streaming.py @@ -673,7 +673,10 @@ async def _handle_expired_url( ) data = streamdetails.data if "decryption_key" in data: - return bytes.fromhex(data["decryption_key"]) + try: + return bytes.fromhex(data["decryption_key"]) + except ValueError as err: + raise MediaNotFoundError(f"Invalid decryption key: {err}") from err return b"" @staticmethod @@ -685,7 +688,10 @@ def _validate_encryption_key(data: dict[str, Any]) -> tuple[bool, bytes | None]: """ if "decryption_key" not in data: return False, None - key_bytes = bytes.fromhex(data["decryption_key"]) + try: + key_bytes = bytes.fromhex(data["decryption_key"]) + except ValueError as err: + raise MediaNotFoundError(f"Invalid decryption key: {err}") from err if len(key_bytes) not in (16, 24, 32): raise MediaNotFoundError(f"Unsupported AES key length: {len(key_bytes)} bytes") return True, key_bytes diff --git a/music_assistant/providers/yandex_music/yandex_auth.py b/music_assistant/providers/yandex_music/yandex_auth.py index fce6c6eafc..e8239031d3 100644 --- a/music_assistant/providers/yandex_music/yandex_auth.py +++ b/music_assistant/providers/yandex_music/yandex_auth.py @@ -89,7 +89,7 @@ async def _post_json( ) from err except LoginFailed: raise - except aiohttp.ClientError as err: + except (aiohttp.ClientError, TimeoutError) as err: raise LoginFailed(f"Network error during Yandex auth: {type(err).__name__}") from err async def get_qr(self) -> tuple[str, str, str]: @@ -104,7 +104,7 @@ async def get_qr(self) -> tuple[str, str, str]: if resp.status != 200: raise LoginFailed(f"Yandex Passport returned HTTP {resp.status}") html = await resp.text() - except aiohttp.ClientError as err: + except (aiohttp.ClientError, TimeoutError) as err: raise LoginFailed( f"Network error reaching Yandex Passport: {type(err).__name__}" ) from err From 139a75febe629472538233cd6a8e9533875e7dbc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 21:07:45 +0000 Subject: [PATCH 09/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.0 --- .../providers/yandex_music/__init__.py | 45 +-- .../providers/yandex_music/api_client.py | 9 +- .../providers/yandex_music/manifest.json | 2 +- .../providers/yandex_music/provider.py | 7 +- .../providers/yandex_music/yandex_auth.py | 292 +++--------------- .../providers/yandex_music/test_api_client.py | 3 +- .../yandex_music/test_yandex_auth.py | 254 +++++++++++++++ 7 files changed, 322 insertions(+), 290 deletions(-) create mode 100644 tests/providers/yandex_music/test_yandex_auth.py diff --git a/music_assistant/providers/yandex_music/__init__.py b/music_assistant/providers/yandex_music/__init__.py index e893c45e53..4a027dc476 100644 --- a/music_assistant/providers/yandex_music/__init__.py +++ b/music_assistant/providers/yandex_music/__init__.py @@ -6,27 +6,23 @@ from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType from music_assistant_models.enums import ConfigEntryType, ProviderFeature -from music_assistant_models.errors import InvalidDataError +from music_assistant_models.errors import LoginFailed from .constants import ( CONF_ACTION_AUTH_QR, CONF_ACTION_CLEAR_AUTH, CONF_BASE_URL, - CONF_CODECS, CONF_LIKED_TRACKS_MAX_TRACKS, CONF_MY_WAVE_MAX_TRACKS, CONF_QUALITY, CONF_REMEMBER_SESSION, CONF_TOKEN, - CONF_TRANSPORT, CONF_X_TOKEN, DEFAULT_BASE_URL, QUALITY_BALANCED, QUALITY_EFFICIENT, QUALITY_HIGH, QUALITY_SUPERB, - TRANSPORT_ENCRAW, - TRANSPORT_RAW, ) from .provider import YandexMusicProvider from .yandex_auth import perform_qr_auth @@ -77,7 +73,7 @@ async def get_config_entries( if action == CONF_ACTION_AUTH_QR: session_id = values.get("session_id") if not session_id: - raise InvalidDataError("Missing session_id for QR authentication") + raise LoginFailed("Missing session_id for QR authentication") x_token, music_token = await perform_qr_auth(mass, str(session_id)) values[CONF_TOKEN] = music_token if values.get(CONF_REMEMBER_SESSION, True): @@ -146,11 +142,12 @@ async def get_config_entries( ConfigEntry( key=CONF_TOKEN, type=ConfigEntryType.SECURE_STRING, - label="Yandex Music Token", - description="Music token — populated automatically by QR login, " - "or enter manually. See the documentation for how to obtain it.", - required=True, + label="Yandex Music Token (manual)", + description="Advanced: manually enter a music token. " + "See the documentation for how to obtain it.", + required=False, hidden=is_authenticated, + advanced=True, value=cast("str", values.get(CONF_TOKEN)) if values else None, ), # x_token (internal storage, always hidden) @@ -176,34 +173,6 @@ async def get_config_entries( ], default_value=QUALITY_BALANCED, ), - # Stream transport (advanced) - ConfigEntry( - key=CONF_TRANSPORT, - type=ConfigEntryType.STRING, - label="Stream transport", - description="Raw streams directly (recommended). " - "Encrypted (encraw) adds AES decryption layer. " - "Both use windowed downloads to prevent CDN drops.", - options=[ - ConfigValueOption("Raw (recommended)", TRANSPORT_RAW), - ConfigValueOption("Encrypted (encraw)", TRANSPORT_ENCRAW), - ], - default_value=TRANSPORT_RAW, - required=False, - advanced=True, - ), - # Codecs override (advanced) - ConfigEntry( - key=CONF_CODECS, - type=ConfigEntryType.STRING, - label="Codecs override", - description="Comma-separated codec priority for get-file-info API. " - "Leave empty to use the default for your quality setting. " - "Example: flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4", - default_value="", - required=False, - advanced=True, - ), # My Wave maximum tracks (advanced) ConfigEntry( key=CONF_MY_WAVE_MAX_TRACKS, diff --git a/music_assistant/providers/yandex_music/api_client.py b/music_assistant/providers/yandex_music/api_client.py index 3d3d0c9f8c..982b419fef 100644 --- a/music_assistant/providers/yandex_music/api_client.py +++ b/music_assistant/providers/yandex_music/api_client.py @@ -29,6 +29,7 @@ from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER, Throttler if TYPE_CHECKING: + from ya_passport_auth import SecretStr from yandex_music import DownloadInfo from yandex_music.feed.feed import Feed from yandex_music.landing.chart_info import ChartInfo @@ -51,10 +52,10 @@ class YandexMusicClient: """Wrapper around yandex-music-api ClientAsync.""" - def __init__(self, token: str, base_url: str | None = None) -> None: + def __init__(self, token: SecretStr, base_url: str | None = None) -> None: """Initialize the Yandex Music client. - :param token: Yandex Music OAuth token. + :param token: Yandex Music OAuth token (wrapped in SecretStr). :param base_url: Optional API base URL (defaults to Yandex Music API). """ self._token = token @@ -79,7 +80,9 @@ async def connect(self) -> bool: :raises LoginFailed: If the token is invalid. """ try: - self._client = await ClientAsync(self._token, base_url=self._base_url).init() + self._client = await ClientAsync( + self._token.get_secret(), base_url=self._base_url + ).init() if self._client.me is None or self._client.me.account is None: raise LoginFailed("Failed to get account info") self._user_id = self._client.me.account.uid diff --git a/music_assistant/providers/yandex_music/manifest.json b/music_assistant/providers/yandex_music/manifest.json index 8fb8a38bb6..08e095dfdf 100644 --- a/music_assistant/providers/yandex_music/manifest.json +++ b/music_assistant/providers/yandex_music/manifest.json @@ -7,6 +7,6 @@ "codeowners": ["@TrudenBoy"], "credits": ["[yandex-music-api](https://github.com/MarshalX/yandex-music-api)"], "documentation": "https://music-assistant.io/music-providers/yandex-music/", - "requirements": ["yandex-music==2.2.0"], + "requirements": ["yandex-music==2.2.0", "ya-passport-auth>=1.0.0"], "multi_instance": true } diff --git a/music_assistant/providers/yandex_music/provider.py b/music_assistant/providers/yandex_music/provider.py index 6d6b067806..0e397fc489 100644 --- a/music_assistant/providers/yandex_music/provider.py +++ b/music_assistant/providers/yandex_music/provider.py @@ -33,6 +33,7 @@ UniqueList, ) from PIL import Image as PilImage +from ya_passport_auth import SecretStr from music_assistant.controllers.cache import use_cache from music_assistant.models.music_provider import MusicProvider @@ -168,7 +169,7 @@ async def handle_async_init(self) -> None: # Try existing music token first (fast path) if token: try: - self._client = YandexMusicClient(str(token), base_url=str(base_url)) + self._client = YandexMusicClient(SecretStr(str(token)), base_url=str(base_url)) await self._client.connect() except LoginFailed: self.logger.warning("Music token is invalid or expired") @@ -184,8 +185,8 @@ async def handle_async_init(self) -> None: # Refresh from x_token if music token absent or failed if not token and x_token: try: - new_music_token = await refresh_music_token(str(x_token)) - self._update_config_value(CONF_TOKEN, new_music_token, encrypted=True) + new_music_token = await refresh_music_token(SecretStr(str(x_token))) + self._update_config_value(CONF_TOKEN, new_music_token.get_secret(), encrypted=True) self._client = YandexMusicClient(new_music_token, base_url=str(base_url)) await self._client.connect() self.logger.info("Refreshed music token from session token") diff --git a/music_assistant/providers/yandex_music/yandex_auth.py b/music_assistant/providers/yandex_music/yandex_auth.py index e8239031d3..16961385a0 100644 --- a/music_assistant/providers/yandex_music/yandex_auth.py +++ b/music_assistant/providers/yandex_music/yandex_auth.py @@ -1,20 +1,21 @@ """Yandex Passport QR authentication flow. -Adapted from AlexxIT/YandexStation (MIT license) and -trudenboy/ma-provider-yandex-station session.py. -Stripped to authentication-only; uses pure aiohttp. +Delegates all Passport interactions to the ``ya-passport-auth`` library. +This module exposes three helpers consumed by the provider: + +* ``perform_qr_auth`` — full QR login (UI popup → tokens) +* ``refresh_music_token`` — x_token → fresh music token +* ``validate_x_token`` — quick liveness check for an x_token """ from __future__ import annotations -import asyncio import logging -import re -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -import aiohttp from music_assistant_models.errors import LoginFailed -from yarl import URL +from ya_passport_auth import PassportClient, SecretStr +from ya_passport_auth.exceptions import QRTimeoutError, YaPassportError from music_assistant.helpers.auth import AuthenticationHelper @@ -23,247 +24,50 @@ _LOGGER = logging.getLogger(__name__) -# Yandex Passport OAuth credentials (public, from Yandex Music Android app) -PASSPORT_CLIENT_ID = "c0ebe342af7d48fbbbfcf2d2eedb8f9e" -PASSPORT_CLIENT_SECRET = "ad0a908f0aa341a182a37ecd75bc319e" - -# Yandex Music OAuth credentials (from yandex-music-api) -MUSIC_CLIENT_ID = "23cabbbdc6cd418abb4b39c32c41195d" -MUSIC_CLIENT_SECRET = "53bc75238f0c4d08a118e51fe9203300" - -# Endpoints -PASSPORT_URL = "https://passport.yandex.ru" -PASSPORT_API_URL = "https://mobileproxy.passport.yandex.net" -MUSIC_TOKEN_URL = "https://oauth.mobile.yandex.net/1/token" - -# Mobile User-Agent required for Yandex Passport to return CSRF token in HTML -# Without it, Passport returns a SPA page with empty csrf_token -_MOBILE_USER_AGENT = ( - "Mozilla/5.0 (Linux; Android 13; Pixel 7) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/120.0.0.0 Mobile Safari/537.36" -) - -# Polling settings -QR_POLL_INTERVAL = 2.0 # seconds between status checks -QR_POLL_TIMEOUT = 120.0 # total seconds to wait for QR scan - -# HTTP timeout for Passport/token requests (connect + read) -_REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=30) - - -class YandexQRAuth: - """Yandex Passport QR authentication. - - Uses a dedicated aiohttp session with its own cookie jar - to avoid leaking Yandex cookies into the shared MA session. - """ - - def __init__(self, session: aiohttp.ClientSession) -> None: - """Initialize with a dedicated aiohttp session.""" - self._session = session - - async def _post_json( - self, - url: str, - data: dict[str, Any] | None = None, - headers: dict[str, str] | None = None, - ) -> dict[str, Any]: - """POST request with HTTP status validation and JSON parsing. - - Wraps aiohttp errors into LoginFailed with actionable messages. - """ - try: - async with self._session.post(url, data=data, headers=headers) as resp: - if resp.status != 200: - raise LoginFailed(f"Yandex returned HTTP {resp.status} for {resp.url.path}") - if "json" not in (resp.content_type or ""): - raise LoginFailed( - f"Unexpected response type from {resp.url.path}: {resp.content_type}" - ) - try: - return await resp.json() # type: ignore[no-any-return] - except (ValueError, aiohttp.ContentTypeError) as err: - raise LoginFailed( - f"Invalid JSON response from {resp.url.path} (HTTP {resp.status})" - ) from err - except LoginFailed: - raise - except (aiohttp.ClientError, TimeoutError) as err: - raise LoginFailed(f"Network error during Yandex auth: {type(err).__name__}") from err - - async def get_qr(self) -> tuple[str, str, str]: - """Start QR code auth session. - - Returns (qr_url, csrf_token, track_id). - Raises LoginFailed on any error. - """ - # Step 1: Get CSRF token from passport page - try: - async with self._session.get(f"{PASSPORT_URL}/am?app_platform=android") as resp: - if resp.status != 200: - raise LoginFailed(f"Yandex Passport returned HTTP {resp.status}") - html = await resp.text() - except (aiohttp.ClientError, TimeoutError) as err: - raise LoginFailed( - f"Network error reaching Yandex Passport: {type(err).__name__}" - ) from err - - # Try multiple patterns — Yandex serves different page formats - # depending on User-Agent, cookies, and repeat requests - csrf_patterns = [ - r'"csrf_token"\s*value="([^"]+)"', # HTML input attribute - r"'csrf_token'\s*:\s*'([^']+)'", # JS object (single quotes) - r'"csrf_token"\s*:\s*"([^"]+)"', # JSON in script tag - ] - csrf_token_value = None - for pattern in csrf_patterns: - match = re.search(pattern, html) - if match and match[1]: - csrf_token_value = match[1] - break - - if not csrf_token_value: - _LOGGER.debug( - "CSRF extraction failed, page length=%d, status=%s", - len(html), - "has_csrf_key" if "csrf_token" in html else "no_csrf_key", - ) - raise LoginFailed("Failed to obtain CSRF token from Yandex Passport") - - csrf_token = csrf_token_value - - # Step 2: Create QR auth session - data = await self._post_json( - f"{PASSPORT_URL}/registration-validations/auth/password/submit", - data={ - "csrf_token": csrf_token, - "retpath": "https://passport.yandex.ru/profile", - "with_code": 1, - }, - ) - - if data.get("status") != "ok": - raise LoginFailed("Failed to create QR auth session") - - track_id_value = data.get("track_id") - if not track_id_value: - raise LoginFailed("Yandex Passport response missing track_id") - track_id = str(track_id_value) - csrf_token = str(data.get("csrf_token", csrf_token)) - qr_url = f"{PASSPORT_URL}/auth/magic/code/?track_id={track_id}" - - _LOGGER.debug("QR auth session created, track_id=%s", track_id) - return qr_url, csrf_token, track_id - - async def check_qr_status(self, csrf_token: str, track_id: str) -> bool: - """Check if QR code was scanned and approved. - - Returns True if approved, False if still pending. - """ - data = await self._post_json( - f"{PASSPORT_URL}/auth/new/magic/status/", - data={"csrf_token": csrf_token, "track_id": track_id}, - ) - return bool(data.get("status") == "ok") - - async def get_x_token(self) -> str: - """Exchange session cookies for x_token. - - Must be called after successful QR auth (cookies are in the session jar). - Uses the public filter_cookies API to build the cookie header. - """ - passport_url = URL("https://passport.yandex.ru") - filtered = self._session.cookie_jar.filter_cookies(passport_url) - if not filtered: - raise LoginFailed("No Yandex session cookies found after QR auth") - cookies = "; ".join(f"{k}={v.value}" for k, v in filtered.items()) - - data = await self._post_json( - f"{PASSPORT_API_URL}/1/bundle/oauth/token_by_sessionid", - data={ - "client_id": PASSPORT_CLIENT_ID, - "client_secret": PASSPORT_CLIENT_SECRET, - }, - headers={ - "Ya-Client-Host": "passport.yandex.ru", - "Ya-Client-Cookie": cookies, - }, - ) - - if "access_token" not in data: - raise LoginFailed("Failed to exchange session for x_token") - - return str(data["access_token"]) - - async def get_music_token(self, x_token: str) -> str: - """Exchange x_token for a music-scoped OAuth token. - - Can be called standalone (no prior QR flow needed) for token refresh. - """ - data = await self._post_json( - MUSIC_TOKEN_URL, - data={ - "client_id": MUSIC_CLIENT_ID, - "client_secret": MUSIC_CLIENT_SECRET, - "grant_type": "x-token", - "access_token": x_token, - }, - ) - - if "access_token" not in data: - raise LoginFailed("Failed to obtain music token from x_token") - - return str(data["access_token"]) - async def perform_qr_auth(mass: MusicAssistant, session_id: str) -> tuple[str, str]: """Perform full QR authentication flow. Opens a QR code popup via MA frontend, polls for scan confirmation, - then exchanges for x_token and music_token. + then returns tokens as plain strings for MA config storage. Returns (x_token, music_token). """ - jar = aiohttp.CookieJar() - headers = {"User-Agent": _MOBILE_USER_AGENT} - async with aiohttp.ClientSession( - cookie_jar=jar, headers=headers, timeout=_REQUEST_TIMEOUT - ) as session: - auth = YandexQRAuth(session) - - # Get QR code URL - qr_url, csrf_token, track_id = await auth.get_qr() - - # Open QR page in MA frontend popup - async with AuthenticationHelper(mass, session_id) as auth_helper: - auth_helper.send_url(qr_url) - - # Poll for QR scan confirmation - elapsed = 0.0 - while elapsed < QR_POLL_TIMEOUT: - await asyncio.sleep(QR_POLL_INTERVAL) - elapsed += QR_POLL_INTERVAL - - if await auth.check_qr_status(csrf_token, track_id): - _LOGGER.debug("QR code confirmed after %.0fs", elapsed) - break - else: - raise LoginFailed("QR authentication timed out. Please try again.") - - # Exchange cookies → x_token → music_token - x_token = await auth.get_x_token() - music_token = await auth.get_music_token(x_token) - - _LOGGER.debug("QR auth complete, obtained both tokens") - return x_token, music_token - - -async def refresh_music_token(x_token: str) -> str: - """Refresh music token from a stored x_token. - - Creates a throwaway HTTP session for the single API call. - """ - async with aiohttp.ClientSession(timeout=_REQUEST_TIMEOUT) as session: - auth = YandexQRAuth(session) - return await auth.get_music_token(x_token) + try: + async with PassportClient.create() as client: + qr = await client.start_qr_login() + + async with AuthenticationHelper(mass, session_id) as auth_helper: + auth_helper.send_url(qr.qr_url) + creds = await client.poll_qr_until_confirmed(qr) + + x_token = creds.x_token.get_secret() + music_token = creds.music_token + if music_token is None: + raise LoginFailed("QR auth succeeded but no music token was returned") + + _LOGGER.debug("QR auth complete, obtained both tokens") + return x_token, music_token.get_secret() + + except QRTimeoutError as err: + raise LoginFailed("QR authentication timed out. Please try again.") from err + except YaPassportError as err: + raise LoginFailed(f"Yandex auth error: {err}") from err + + +async def refresh_music_token(x_token: SecretStr) -> SecretStr: + """Exchange an x_token for a fresh music-scoped OAuth token.""" + try: + async with PassportClient.create() as client: + return await client.refresh_music_token(x_token) + except YaPassportError as err: + raise LoginFailed(f"Failed to refresh music token: {err}") from err + + +async def validate_x_token(x_token: SecretStr) -> bool: + """Return True if *x_token* is still accepted by Yandex Passport.""" + try: + async with PassportClient.create() as client: + return bool(await client.validate_x_token(x_token)) + except YaPassportError: + return False diff --git a/tests/providers/yandex_music/test_api_client.py b/tests/providers/yandex_music/test_api_client.py index 9e8dcb29b2..8ba9afc62b 100644 --- a/tests/providers/yandex_music/test_api_client.py +++ b/tests/providers/yandex_music/test_api_client.py @@ -10,6 +10,7 @@ import pytest from music_assistant_models.errors import ResourceTemporarilyUnavailable +from ya_passport_auth import SecretStr from yandex_music.exceptions import NetworkError from yandex_music.rotor.dashboard import Dashboard from yandex_music.rotor.station_result import StationResult @@ -29,7 +30,7 @@ def _make_client() -> tuple[YandexMusicClient, mock.AsyncMock]: :return: Tuple of (YandexMusicClient, mock_underlying_client). """ - client = YandexMusicClient(token="fake_token") + client = YandexMusicClient(token=SecretStr("fake_token")) mock_underlying = mock.AsyncMock() client._client = mock_underlying client._user_id = 12345 diff --git a/tests/providers/yandex_music/test_yandex_auth.py b/tests/providers/yandex_music/test_yandex_auth.py new file mode 100644 index 0000000000..e70e52b363 --- /dev/null +++ b/tests/providers/yandex_music/test_yandex_auth.py @@ -0,0 +1,254 @@ +"""Unit tests for yandex_auth.py (ya-passport-auth based authentication).""" + +from __future__ import annotations + +from unittest import mock + +import pytest +from music_assistant_models.errors import LoginFailed +from ya_passport_auth import Credentials, QrSession, SecretStr +from ya_passport_auth.exceptions import ( + InvalidCredentialsError, + QRTimeoutError, + RateLimitedError, +) +from ya_passport_auth.exceptions import ( + NetworkError as PassportNetworkError, +) + +from music_assistant.providers.yandex_music.yandex_auth import ( + perform_qr_auth, + refresh_music_token, + validate_x_token, +) + +# -- helpers ------------------------------------------------------------------- + + +def _make_credentials( + x_token: str = "test_x_token", # noqa: S107 + music_token: str | None = "test_music_token", # noqa: S107 +) -> Credentials: + """Build a Credentials dataclass for testing.""" + return Credentials( + x_token=SecretStr(x_token), + music_token=SecretStr(music_token) if music_token else None, + ) + + +def _make_qr_session() -> QrSession: + """Build a QrSession for testing.""" + return QrSession( + track_id="track123", + csrf_token="csrf_abc", + qr_url="https://passport.yandex.ru/auth/magic/code/?track_id=track123", + ) + + +# -- perform_qr_auth ---------------------------------------------------------- + + +async def test_perform_qr_auth_success() -> None: + """QR flow returns (x_token, music_token) as plain strings.""" + qr = _make_qr_session() + creds = _make_credentials() + mock_client = mock.AsyncMock() + mock_client.start_qr_login.return_value = qr + mock_client.poll_qr_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.yandex_auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + x_token, music_token = await perform_qr_auth(mock_mass, "session_1") + + assert x_token == "test_x_token" + assert music_token == "test_music_token" + mock_client.start_qr_login.assert_awaited_once() + mock_client.poll_qr_until_confirmed.assert_awaited_once_with(qr) + + +async def test_perform_qr_auth_sends_qr_url() -> None: + """QR URL is sent to the AuthenticationHelper.""" + qr = _make_qr_session() + creds = _make_credentials() + mock_client = mock.AsyncMock() + mock_client.start_qr_login.return_value = qr + mock_client.poll_qr_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.yandex_auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + await perform_qr_auth(mock_mass, "session_1") + + mock_auth_helper.__aenter__.return_value.send_url.assert_called_once_with(qr.qr_url) + + +async def test_perform_qr_auth_timeout_raises_login_failed() -> None: + """QRTimeoutError from library is mapped to LoginFailed.""" + qr = _make_qr_session() + mock_client = mock.AsyncMock() + mock_client.start_qr_login.return_value = qr + mock_client.poll_qr_until_confirmed.side_effect = QRTimeoutError("timed out") + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.yandex_auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="timed out"): + await perform_qr_auth(mock_mass, "session_1") + + +async def test_perform_qr_auth_passport_error_raises_login_failed() -> None: + """Generic YaPassportError is mapped to LoginFailed.""" + mock_client = mock.AsyncMock() + mock_client.start_qr_login.side_effect = PassportNetworkError("connection lost") + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.yandex_auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="Yandex auth error"): + await perform_qr_auth(mock_mass, "session_1") + + +async def test_perform_qr_auth_no_music_token_raises() -> None: + """Credentials without music_token raises LoginFailed.""" + qr = _make_qr_session() + creds = _make_credentials(music_token=None) + mock_client = mock.AsyncMock() + mock_client.start_qr_login.return_value = qr + mock_client.poll_qr_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.yandex_auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="no music token"): + await perform_qr_auth(mock_mass, "session_1") + + +# -- refresh_music_token ------------------------------------------------------- + + +async def test_refresh_music_token_success() -> None: + """Successful refresh returns a SecretStr.""" + mock_client = mock.AsyncMock() + mock_client.refresh_music_token.return_value = SecretStr("new_music_token") + + with mock.patch( + "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + result = await refresh_music_token(SecretStr("my_x_token")) + + assert result.get_secret() == "new_music_token" + mock_client.refresh_music_token.assert_awaited_once() + + +async def test_refresh_music_token_auth_error_raises_login_failed() -> None: + """Auth failure during refresh is mapped to LoginFailed.""" + mock_client = mock.AsyncMock() + mock_client.refresh_music_token.side_effect = InvalidCredentialsError("bad token") + + with mock.patch( + "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="Failed to refresh"): + await refresh_music_token(SecretStr("bad_x_token")) + + +# -- validate_x_token ---------------------------------------------------------- + + +async def test_validate_x_token_valid() -> None: + """Valid x_token returns True.""" + mock_client = mock.AsyncMock() + mock_client.validate_x_token.return_value = True + + with mock.patch( + "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + result = await validate_x_token(SecretStr("good_token")) + + assert result is True + + +async def test_validate_x_token_error_returns_false() -> None: + """Any YaPassportError returns False (graceful degradation).""" + mock_client = mock.AsyncMock() + mock_client.validate_x_token.side_effect = RateLimitedError("429") + + with mock.patch( + "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + result = await validate_x_token(SecretStr("some_token")) + + assert result is False From cc4893601c27ef069ce1ffd80db29c8ba6f68642 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 21:30:23 +0000 Subject: [PATCH 10/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.0 --- music_assistant/providers/yandex_music/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music_assistant/providers/yandex_music/manifest.json b/music_assistant/providers/yandex_music/manifest.json index 08e095dfdf..0c0f18cbee 100644 --- a/music_assistant/providers/yandex_music/manifest.json +++ b/music_assistant/providers/yandex_music/manifest.json @@ -7,6 +7,6 @@ "codeowners": ["@TrudenBoy"], "credits": ["[yandex-music-api](https://github.com/MarshalX/yandex-music-api)"], "documentation": "https://music-assistant.io/music-providers/yandex-music/", - "requirements": ["yandex-music==2.2.0", "ya-passport-auth>=1.0.0"], + "requirements": ["yandex-music==2.2.0", "ya-passport-auth==1.0.0"], "multi_instance": true } From 86cc63c3a5f3734272d492289e203d17d1a05548 Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Sat, 11 Apr 2026 00:43:02 +0300 Subject: [PATCH 11/54] chore: regenerate requirements_all.txt Add ya-passport-auth==1.0.0 from yandex_music manifest. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- requirements_all.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements_all.txt b/requirements_all.txt index 0574db7a0d..424a2994ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -78,6 +78,7 @@ unidecode==1.4.0 uv>=0.8.0 websocket-client==1.9.0 xmltodict==1.0.4 +ya-passport-auth==1.0.0 yandex-music==2.2.0 ytmusicapi==1.11.5 zeroconf==0.148.0 From 7e33d1e057551e8e3a359ad694bbe165dbfa0e8e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Apr 2026 04:52:00 +0000 Subject: [PATCH 12/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.0 --- music_assistant/providers/yandex_music/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/music_assistant/providers/yandex_music/__init__.py b/music_assistant/providers/yandex_music/__init__.py index 4a027dc476..9583c5c845 100644 --- a/music_assistant/providers/yandex_music/__init__.py +++ b/music_assistant/providers/yandex_music/__init__.py @@ -6,7 +6,7 @@ from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType from music_assistant_models.enums import ConfigEntryType, ProviderFeature -from music_assistant_models.errors import LoginFailed +from music_assistant_models.errors import InvalidDataError from .constants import ( CONF_ACTION_AUTH_QR, @@ -73,7 +73,7 @@ async def get_config_entries( if action == CONF_ACTION_AUTH_QR: session_id = values.get("session_id") if not session_id: - raise LoginFailed("Missing session_id for QR authentication") + raise InvalidDataError("Missing session_id for QR authentication") x_token, music_token = await perform_qr_auth(mass, str(session_id)) values[CONF_TOKEN] = music_token if values.get(CONF_REMEMBER_SESSION, True): @@ -145,7 +145,7 @@ async def get_config_entries( label="Yandex Music Token (manual)", description="Advanced: manually enter a music token. " "See the documentation for how to obtain it.", - required=False, + required=True, hidden=is_authenticated, advanced=True, value=cast("str", values.get(CONF_TOKEN)) if values else None, From 4cbb97611746baa0a9dd8408c9d3a5bf4dedcf64 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Apr 2026 19:59:56 +0000 Subject: [PATCH 13/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.0 --- music_assistant/providers/yandex_music/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_music/manifest.json b/music_assistant/providers/yandex_music/manifest.json index 0c0f18cbee..29e3df1978 100644 --- a/music_assistant/providers/yandex_music/manifest.json +++ b/music_assistant/providers/yandex_music/manifest.json @@ -7,6 +7,6 @@ "codeowners": ["@TrudenBoy"], "credits": ["[yandex-music-api](https://github.com/MarshalX/yandex-music-api)"], "documentation": "https://music-assistant.io/music-providers/yandex-music/", - "requirements": ["yandex-music==2.2.0", "ya-passport-auth==1.0.0"], + "requirements": ["yandex-music==2.2.0", "ya-passport-auth==1.2.3"], "multi_instance": true } diff --git a/requirements_all.txt b/requirements_all.txt index b691dcf904..a23562bae4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -78,7 +78,7 @@ unidecode==1.4.0 uv>=0.8.0 websocket-client==1.9.0 xmltodict==1.0.4 -ya-passport-auth==1.0.0 +ya-passport-auth==1.2.3 yandex-music==2.2.0 ytmusicapi==1.11.5 zeroconf==0.148.0 From 8101c408583742bddafed8c1ca713c94d9031808 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Apr 2026 13:11:04 +0000 Subject: [PATCH 14/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.0 --- .../providers/yandex_music/provider.py | 16 ++++- .../providers/yandex_music/streaming.py | 38 ++++++++-- .../providers/yandex_music/test_streaming.py | 72 +++++++++++++++++++ 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/music_assistant/providers/yandex_music/provider.py b/music_assistant/providers/yandex_music/provider.py index 0e397fc489..24b7873777 100644 --- a/music_assistant/providers/yandex_music/provider.py +++ b/music_assistant/providers/yandex_music/provider.py @@ -47,6 +47,7 @@ CONF_BASE_URL, CONF_LIKED_TRACKS_MAX_TRACKS, CONF_MY_WAVE_MAX_TRACKS, + CONF_QUALITY, CONF_TOKEN, CONF_X_TOKEN, DEFAULT_BASE_URL, @@ -2349,12 +2350,25 @@ async def get_audio_stream( Handles both raw (direct) and encrypted (encraw) transports. :param streamdetails: Stream details with URL and optional decryption key. - :param seek_position: Seek position in seconds (delegated to ffmpeg). + :param seek_position: Seek position in seconds (handled by provider for raw transport). :return: Async generator yielding audio chunks. """ async for chunk in self.streaming.get_audio_stream(streamdetails, seek_position): yield chunk + async def get_rotor_station_tracks( + self, station_id: str, queue: str | int | None = None + ) -> tuple[list[Any], str | None]: + """Fetch tracks from a rotor station (My Wave, similar, etc.). + + Wrapper around client.get_rotor_station_tracks for use by ynison plugin. + """ + return await self.client.get_rotor_station_tracks(station_id, queue=queue) + + def get_quality(self) -> str: + """Return the configured audio quality tier (e.g. 'balanced', 'superb').""" + return str(self.config.get_value(CONF_QUALITY) or "").strip().lower() + async def resolve_image(self, path: str) -> str | bytes: """Resolve wave cover image with background color fill for transparent PNGs. diff --git a/music_assistant/providers/yandex_music/streaming.py b/music_assistant/providers/yandex_music/streaming.py index 678d52d381..3efbff210a 100644 --- a/music_assistant/providers/yandex_music/streaming.py +++ b/music_assistant/providers/yandex_music/streaming.py @@ -157,12 +157,13 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: ) # Always use StreamType.CUSTOM with windowed Range requests to prevent CDN drops. - # can_seek=False: provider always streams from position 0; - # allow_seek=True: ffmpeg handles seek with -ss input flag. + # Raw transport: can_seek=True — provider handles seek via Range byte offset. + # Encrypted transport: seeking disabled — AES-CTR seek not implemented. data: dict[str, Any] = { "url": url, "codec": codec, "transport": transport, + "bit_rate": bit_rate, # Stored for URL refresh on 4xx: "fi_quality": fi_params["quality"], "fi_codecs": codecs, @@ -177,8 +178,8 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: stream_type=StreamType.CUSTOM, duration=track.duration, data=data, - can_seek=False, - allow_seek=True, + can_seek=not needs_decryption, + allow_seek=not needs_decryption, ) # Fallback: /tracks/{id}/download-info (defensive, should rarely trigger) @@ -696,6 +697,30 @@ def _validate_encryption_key(data: dict[str, Any]) -> tuple[bool, bytes | None]: raise MediaNotFoundError(f"Unsupported AES key length: {len(key_bytes)} bytes") return True, key_bytes + def _calculate_seek_offset( + self, data: dict[str, Any], seek_position: int, is_encrypted: bool + ) -> int: + """Calculate initial byte offset for raw transport seeking. + + :param data: Stream data dict (must contain 'bit_rate' in kbps). + :param seek_position: Seek offset in seconds. + :param is_encrypted: Whether the stream uses AES encryption. + :return: Byte offset to start streaming from (0 if not applicable). + """ + if seek_position <= 0 or is_encrypted: + return 0 + bit_rate = data.get("bit_rate") or 0 + if not bit_rate: + return 0 + byte_offset = seek_position * bit_rate * 1000 // 8 + self.logger.debug( + "Seeking to %ds: byte offset %d (bitrate %d kbps)", + seek_position, + byte_offset, + bit_rate, + ) + return byte_offset + async def get_audio_stream( self, streamdetails: StreamDetails, seek_position: int = 0 ) -> AsyncGenerator[bytes, None]: @@ -711,14 +736,15 @@ async def get_audio_stream( Retry counter resets after each successful window. :param streamdetails: Stream details with URL (and optional decryption key). - :param seek_position: Always 0 (seeking delegated to ffmpeg via allow_seek=True). + :param seek_position: Seek offset in seconds for raw transport (0 = from start). :return: Async generator yielding audio bytes. """ data = streamdetails.data is_encrypted, key_bytes = self._validate_encryption_key(data) + initial_byte_offset = self._calculate_seek_offset(data, seek_position, is_encrypted) max_retries = 6 - bytes_yielded = 0 + bytes_yielded = initial_byte_offset attempt = 0 retry_delay: float = 0.0 diff --git a/tests/providers/yandex_music/test_streaming.py b/tests/providers/yandex_music/test_streaming.py index 327d306781..cf47cf0571 100644 --- a/tests/providers/yandex_music/test_streaming.py +++ b/tests/providers/yandex_music/test_streaming.py @@ -846,6 +846,7 @@ async def test_get_audio_stream_continues_after_non_block_boundary_drop( def _make_raw_stream_details( url: str = "https://cdn.example.com/track.flac", codec: str = "flac-mp4", + bit_rate: int = 0, ) -> StreamDetails: """Build StreamDetails for raw (unencrypted) windowed stream tests.""" return StreamDetails( @@ -857,6 +858,7 @@ def _make_raw_stream_details( "url": url, "codec": codec, "transport": "raw", + "bit_rate": bit_rate, "fi_quality": "lossless", "fi_codecs": "flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4", }, @@ -994,3 +996,73 @@ async def test_get_audio_stream_raw_resets_on_range_ignored( # Should get the full plaintext without duplication assert result == plaintext + + +async def test_get_audio_stream_raw_seek_starts_from_byte_offset( + streaming_manager: YandexMusicStreamingManager, + streaming_provider_stub: StreamingProviderStub, +) -> None: + """Raw stream with seek_position starts Range requests from calculated byte offset.""" + # 320 kbps = 40000 bytes/sec; seek to 10s → offset 400000 + bit_rate = 320 + seek_seconds = 10 + expected_offset = int(seek_seconds * bit_rate * 1000 / 8) # 400000 + + plaintext = b"S" * 64 + resp = _MockResponse([plaintext], status=206) + session = _MultiCallHttpSession([resp]) + streaming_provider_stub.mass.http_session = session + + sd = _make_raw_stream_details(bit_rate=bit_rate) + result = b"" + async for chunk in streaming_manager.get_audio_stream(sd, seek_position=seek_seconds): + result += chunk + + assert result == plaintext + assert len(session.calls) == 1 + range_header = session.calls[0]["headers"]["Range"] + assert range_header.startswith(f"bytes={expected_offset}-") + + +async def test_get_audio_stream_raw_seek_zero_bitrate_starts_from_zero( + streaming_manager: YandexMusicStreamingManager, + streaming_provider_stub: StreamingProviderStub, +) -> None: + """When bit_rate is 0, seek_position is ignored and stream starts from byte 0.""" + plaintext = b"Z" * 64 + resp = _MockResponse([plaintext]) + session = _MultiCallHttpSession([resp]) + streaming_provider_stub.mass.http_session = session + + sd = _make_raw_stream_details(bit_rate=0) + result = b"" + async for chunk in streaming_manager.get_audio_stream(sd, seek_position=30): + result += chunk + + assert result == plaintext + assert session.calls[0]["headers"]["Range"].startswith("bytes=0-") + + +async def test_get_audio_stream_encrypted_ignores_seek_position( + streaming_manager: YandexMusicStreamingManager, + streaming_provider_stub: StreamingProviderStub, +) -> None: + """Encrypted stream always starts from byte 0 regardless of seek_position.""" + key = b"\x01" * 16 + key_hex = key.hex() + plaintext = b"E" * 64 + cipher = Cipher(algorithms.AES(key), modes.CTR(b"\x00" * 16)) + encryptor = cipher.encryptor() + ciphertext = encryptor.update(plaintext) + encryptor.finalize() + + resp = _MockResponse([ciphertext]) + session = _MultiCallHttpSession([resp]) + streaming_provider_stub.mass.http_session = session + + sd = _make_encrypted_stream_details(key_hex) + result = b"" + async for chunk in streaming_manager.get_audio_stream(sd, seek_position=30): + result += chunk + + assert result == plaintext + assert session.calls[0]["headers"]["Range"].startswith("bytes=0-") From 4343118c9cc9aabf437c42eaed371bfd85daaa67 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 07:54:31 +0000 Subject: [PATCH 15/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.0 --- music_assistant/providers/yandex_music/api_client.py | 2 ++ music_assistant/providers/yandex_music/streaming.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_music/api_client.py b/music_assistant/providers/yandex_music/api_client.py index 982b419fef..18d733af75 100644 --- a/music_assistant/providers/yandex_music/api_client.py +++ b/music_assistant/providers/yandex_music/api_client.py @@ -783,6 +783,8 @@ async def _do_request(c: ClientAsync) -> dict[str, Any] | None: track_id, getattr(err, "message", str(err)) or repr(err), ) + except asyncio.CancelledError: + raise except Exception as err: LOGGER.warning( "get-file-info for track %s: Unexpected %s: %s", diff --git a/music_assistant/providers/yandex_music/streaming.py b/music_assistant/providers/yandex_music/streaming.py index 3efbff210a..e1083383d8 100644 --- a/music_assistant/providers/yandex_music/streaming.py +++ b/music_assistant/providers/yandex_music/streaming.py @@ -398,8 +398,8 @@ def _parse_flac_streaminfo(header: bytes) -> tuple[int, int]: def _parse_mp4_audio_params(header: bytes) -> tuple[int, int]: """Extract sample_rate and bit_depth from MP4/fMP4 container. - Scans for the 'esds' or 'dfLa' (FLAC-in-MP4) box, or falls back to - parsing the AudioSampleEntry in 'mp4a'/'fLaC' boxes inside 'stsd'. + Scans for the 'dfLa' (FLAC-in-MP4) box, or falls back to parsing + the AudioSampleEntry in an 'mp4a' box to read sample size and sample rate. :param header: First 8-32 KB of the MP4 stream. :return: (sample_rate, bit_depth) or (0, 0) if not found. From 8d817178d974093c4b5d9b7c4f686fd8b8dcf036 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 08:10:37 +0000 Subject: [PATCH 16/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.0 --- music_assistant/providers/yandex_music/streaming.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/music_assistant/providers/yandex_music/streaming.py b/music_assistant/providers/yandex_music/streaming.py index e1083383d8..7abb7df890 100644 --- a/music_assistant/providers/yandex_music/streaming.py +++ b/music_assistant/providers/yandex_music/streaming.py @@ -157,8 +157,9 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: ) # Always use StreamType.CUSTOM with windowed Range requests to prevent CDN drops. - # Raw transport: can_seek=True — provider handles seek via Range byte offset. - # Encrypted transport: seeking disabled — AES-CTR seek not implemented. + # can_seek=True only when we can compute a byte offset (raw + known bitrate). + # allow_seek=True lets ffmpeg handle time-based seeking when can_seek is False. + can_seek = not needs_decryption and bit_rate > 0 data: dict[str, Any] = { "url": url, "codec": codec, @@ -178,7 +179,7 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: stream_type=StreamType.CUSTOM, duration=track.duration, data=data, - can_seek=not needs_decryption, + can_seek=can_seek, allow_seek=not needs_decryption, ) From fb45738d8b145ab892d93b720a2577e3861c56d1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 17 Apr 2026 20:33:01 +0000 Subject: [PATCH 17/54] feat(yandex_music): sync provider from ma-provider-yandex-music v2.9.1 --- music_assistant/providers/yandex_music/streaming.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/music_assistant/providers/yandex_music/streaming.py b/music_assistant/providers/yandex_music/streaming.py index 7abb7df890..e497f7222c 100644 --- a/music_assistant/providers/yandex_music/streaming.py +++ b/music_assistant/providers/yandex_music/streaming.py @@ -157,9 +157,14 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: ) # Always use StreamType.CUSTOM with windowed Range requests to prevent CDN drops. - # can_seek=True only when we can compute a byte offset (raw + known bitrate). - # allow_seek=True lets ffmpeg handle time-based seeking when can_seek is False. - can_seek = not needs_decryption and bit_rate > 0 + # can_seek=True only for codecs where bitrate * time yields a decodable byte + # offset — i.e. raw MP3. MP4-container codecs (aac-mp4, flac-mp4) need the + # ftyp/moov init atoms at the file start, so byte-offset seeks land in mdat + # with no codec config and produce undecodable data. Raw FLAC frames aren't + # fixed-size either, so byte-rate math doesn't land on a frame boundary. + # allow_seek=True lets ffmpeg handle time-based seeking via -ss in those cases. + byte_seekable = codec.lower() in ("mp3", "mpeg") + can_seek = not needs_decryption and bit_rate > 0 and byte_seekable data: dict[str, Any] = { "url": url, "codec": codec, From 431fe98da9fca6f509351a42fe038a5b9f217c03 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 19 Apr 2026 11:10:15 +0000 Subject: [PATCH 18/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.0 --- .../providers/yandex_music/__init__.py | 47 +- .../providers/yandex_music/api_client.py | 150 +++- .../providers/yandex_music/auth.py | 347 +++++++++ .../providers/yandex_music/constants.py | 8 + .../providers/yandex_music/manifest.json | 2 +- .../providers/yandex_music/parsers.py | 24 +- .../providers/yandex_music/provider.py | 189 ++++- .../providers/yandex_music/yandex_auth.py | 73 -- requirements_all.txt | 2 +- .../providers/yandex_music/test_api_client.py | 241 +++++- tests/providers/yandex_music/test_auth.py | 721 ++++++++++++++++++ .../yandex_music/test_browse_pins_history.py | 202 +++++ tests/providers/yandex_music/test_parsers.py | 50 ++ .../yandex_music/test_recommendations.py | 51 +- .../yandex_music/test_yandex_auth.py | 254 ------ 15 files changed, 1974 insertions(+), 387 deletions(-) create mode 100644 music_assistant/providers/yandex_music/auth.py delete mode 100644 music_assistant/providers/yandex_music/yandex_auth.py create mode 100644 tests/providers/yandex_music/test_auth.py create mode 100644 tests/providers/yandex_music/test_browse_pins_history.py delete mode 100644 tests/providers/yandex_music/test_yandex_auth.py diff --git a/music_assistant/providers/yandex_music/__init__.py b/music_assistant/providers/yandex_music/__init__.py index 9583c5c845..f3d56476e0 100644 --- a/music_assistant/providers/yandex_music/__init__.py +++ b/music_assistant/providers/yandex_music/__init__.py @@ -8,13 +8,16 @@ from music_assistant_models.enums import ConfigEntryType, ProviderFeature from music_assistant_models.errors import InvalidDataError +from .auth import perform_device_auth, perform_qr_auth from .constants import ( + CONF_ACTION_AUTH_DEVICE, CONF_ACTION_AUTH_QR, CONF_ACTION_CLEAR_AUTH, CONF_BASE_URL, CONF_LIKED_TRACKS_MAX_TRACKS, CONF_MY_WAVE_MAX_TRACKS, CONF_QUALITY, + CONF_REFRESH_TOKEN, CONF_REMEMBER_SESSION, CONF_TOKEN, CONF_X_TOKEN, @@ -25,7 +28,6 @@ QUALITY_SUPERB, ) from .provider import YandexMusicProvider -from .yandex_auth import perform_qr_auth if TYPE_CHECKING: from music_assistant_models.config_entries import ProviderConfig @@ -47,6 +49,7 @@ ProviderFeature.LIBRARY_TRACKS_EDIT, ProviderFeature.BROWSE, ProviderFeature.SIMILAR_TRACKS, + ProviderFeature.SIMILAR_ARTISTS, ProviderFeature.RECOMMENDATIONS, ProviderFeature.LYRICS, } @@ -81,10 +84,26 @@ async def get_config_entries( else: values[CONF_X_TOKEN] = None + # Handle Device Flow auth action (yields x_token + refresh_token, + # so we get silent auto-refresh on music-token AND x_token expiry) + if action == CONF_ACTION_AUTH_DEVICE: + session_id = values.get("session_id") + if not session_id: + raise InvalidDataError("Missing session_id for device authentication") + x_token, music_token, refresh_token = await perform_device_auth(mass, str(session_id)) + values[CONF_TOKEN] = music_token + if values.get(CONF_REMEMBER_SESSION, True): + values[CONF_X_TOKEN] = x_token + values[CONF_REFRESH_TOKEN] = refresh_token + else: + values[CONF_X_TOKEN] = None + values[CONF_REFRESH_TOKEN] = None + # Handle clear auth action if action == CONF_ACTION_CLEAR_AUTH: values[CONF_TOKEN] = None values[CONF_X_TOKEN] = None + values[CONF_REFRESH_TOKEN] = None # Check if user is authenticated is_authenticated = bool(values.get(CONF_TOKEN)) @@ -92,10 +111,11 @@ async def get_config_entries( # Dynamic label text if not is_authenticated: label_text = ( - "Scan a QR code with the Yandex app on your phone to authenticate.\n\n" + "Open a verification URL on any device and enter the short code, " + "or scan a QR code with the Yandex app on your phone.\n\n" "Alternatively, you can enter a music token manually in the advanced settings." ) - elif action == CONF_ACTION_AUTH_QR: + elif action in (CONF_ACTION_AUTH_QR, CONF_ACTION_AUTH_DEVICE): label_text = "Authenticated to Yandex Music. Don't forget to save to complete setup." else: label_text = "Authenticated to Yandex Music." @@ -107,7 +127,17 @@ async def get_config_entries( type=ConfigEntryType.LABEL, label=label_text, ), - # QR authentication (primary) + # Device Flow authentication (primary) + ConfigEntry( + key=CONF_ACTION_AUTH_DEVICE, + type=ConfigEntryType.ACTION, + label="Login with device code", + description=("Open a verification URL on any device and enter the short code."), + action=CONF_ACTION_AUTH_DEVICE, + action_label="Login with device code", + hidden=is_authenticated, + ), + # QR authentication (alternative) ConfigEntry( key=CONF_ACTION_AUTH_QR, type=ConfigEntryType.ACTION, @@ -159,6 +189,15 @@ async def get_config_entries( required=False, value=cast("str", values.get(CONF_X_TOKEN)) if values else None, ), + # refresh_token (internal storage, always hidden — device flow only) + ConfigEntry( + key=CONF_REFRESH_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Refresh token", + hidden=True, + required=False, + value=cast("str", values.get(CONF_REFRESH_TOKEN)) if values else None, + ), # Quality ConfigEntry( key=CONF_QUALITY, diff --git a/music_assistant/providers/yandex_music/api_client.py b/music_assistant/providers/yandex_music/api_client.py index 18d733af75..71a5d21b82 100644 --- a/music_assistant/providers/yandex_music/api_client.py +++ b/music_assistant/providers/yandex_music/api_client.py @@ -263,26 +263,66 @@ async def send_rotor_station_feedback( :param total_played_seconds: Seconds played (for trackFinished, skip). :return: True if the request succeeded. """ - 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 - - async def _post(c: ClientAsync) -> bool: - url = f"{c.base_url}/rotor/station/{station_id}/feedback" - await c._request.post(url, payload) - return True + timestamp = datetime.now(UTC).isoformat().replace("+00:00", "Z") + + async def _send(c: ClientAsync) -> bool: + if feedback_type == "radioStarted": + return bool( + await c.rotor_station_feedback_radio_started( + station_id, + from_="YandexMusicDesktopAppWindows", + batch_id=batch_id, + timestamp=timestamp, + ) + ) + if feedback_type == "trackStarted": + if track_id is None: + return False + return bool( + await c.rotor_station_feedback_track_started( + station_id, + track_id=track_id, + batch_id=batch_id, + timestamp=timestamp, + ) + ) + if feedback_type == "trackFinished": + if track_id is None: + return False + return bool( + await c.rotor_station_feedback_track_finished( + station_id, + track_id=track_id, + total_played_seconds=float(total_played_seconds or 0), + batch_id=batch_id, + timestamp=timestamp, + ) + ) + if feedback_type == "skip": + if track_id is None: + return False + return bool( + await c.rotor_station_feedback_skip( + station_id, + track_id=track_id, + total_played_seconds=float(total_played_seconds or 0), + batch_id=batch_id, + timestamp=timestamp, + ) + ) + return bool( + await c.rotor_station_feedback( + station_id, + type_=feedback_type, + timestamp=timestamp, + track_id=track_id, + total_played_seconds=total_played_seconds, + batch_id=batch_id, + ) + ) try: - result = await self._call_no_retry(_post) + result = await self._call_no_retry(_send) LOGGER.debug( "Rotor feedback %s track_id=%s total_played_seconds=%s", feedback_type, @@ -561,27 +601,20 @@ async def get_album_with_tracks(self, album_id: str) -> YandexAlbum | None: """Get an album with its tracks. Uses the same semantics as the web client: albums/{id}/with-tracks - with resumeStream, richTracks, withListeningFinished when the library - passes them through. + with resumeStream, richTracks, withListeningFinished. :param album_id: Album ID. :return: Album object with tracks or None if not found. """ - - async def _fetch(c: ClientAsync) -> YandexAlbum | None: - try: - return await c.albums_with_tracks( + try: + return await self._call_with_retry( + lambda c: c.albums_with_tracks( album_id, resumeStream=True, richTracks=True, withListeningFinished=True, ) - except TypeError: - # Older yandex-music may not accept these kwargs - return await c.albums_with_tracks(album_id) - - try: - return await self._call_with_retry(_fetch) + ) except (BadRequestError, NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error fetching album with tracks %s: %s", album_id, err) return None @@ -619,6 +652,59 @@ async def get_artist_albums( LOGGER.error("Error fetching artist albums %s: %s", artist_id, err) return [] + async def get_pins(self) -> Any | None: + """Get the user's pinned items (artists/albums/playlists/waves). + + :return: PinsList object or None on error. + """ + try: + return await self._call_with_retry(lambda c: c.pins()) + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.error("Error fetching pins: %s", err) + return None + + async def get_music_history(self) -> Any | None: + """Get the user's listening history (grouped by day). + + :return: MusicHistory object or None on error. + """ + try: + return await self._call_with_retry(lambda c: c.music_history()) + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.error("Error fetching music history: %s", err) + return None + + async def get_artist_about(self, artist_id: str) -> Any | None: + """Get artist enrichment info: description, monthly listeners, links. + + :param artist_id: Artist ID. + :return: ArtistAbout object or None on error/missing. + """ + try: + return await self._call_with_retry(lambda c: c.artists_about(artist_id)) + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.error("Error fetching artist about %s: %s", artist_id, err) + return None + + async def get_similar_artists( + self, artist_id: str, limit: int = DEFAULT_LIMIT + ) -> list[YandexArtist]: + """Get artists similar to the given one. + + :param artist_id: Artist ID. + :param limit: Maximum number of artists. + :return: List of similar artist objects. + """ + try: + result = await self._call_with_retry(lambda c: c.artists_similar(artist_id)) + if result is None or not result.similar_artists: + return [] + similar: list[YandexArtist] = result.similar_artists + return similar[:limit] + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.error("Error fetching similar artists %s: %s", artist_id, err) + return [] + async def get_artist_tracks( self, artist_id: str, limit: int = DEFAULT_LIMIT ) -> list[YandexTrack]: @@ -738,7 +824,9 @@ def _build_signed_params(client: ClientAsync) -> tuple[str, dict[str, Any]]: def _parse_file_info_result(raw: dict[str, Any] | None) -> dict[str, Any] | None: if not raw or not isinstance(raw, dict): return None - download_info = raw.get("download_info") + # yandex-music v3 no longer normalises camelCase keys inside + # Response.result, so /get-file-info returns "downloadInfo" as-is. + download_info = raw.get("download_info") or raw.get("downloadInfo") if not download_info or not download_info.get("url"): return None diff --git a/music_assistant/providers/yandex_music/auth.py b/music_assistant/providers/yandex_music/auth.py new file mode 100644 index 0000000000..f321b87945 --- /dev/null +++ b/music_assistant/providers/yandex_music/auth.py @@ -0,0 +1,347 @@ +"""Yandex Music authentication flows. + +Two user-facing login paths, both backed by ``ya-passport-auth``: + +* **QR flow** — :func:`perform_qr_auth` opens a QR popup via the MA frontend + and polls Passport until the user scans/confirms. Yields + ``(x_token, music_token)``. +* **Device Flow** — :func:`perform_device_auth` serves a short user code on + an MA-hosted intermediate page and polls Passport until confirmation. + Yields the full ``(x_token, music_token, refresh_token)`` triple thanks + to ``ya-passport-auth`` v1.3.0 reusing the same Passport Android + ``client_id`` as the QR flow. + +Token maintenance helpers (:func:`refresh_music_token`, +:func:`refresh_credentials_via_passport`, :func:`validate_x_token`) live +alongside the login flows. +""" + +from __future__ import annotations + +import asyncio +import html +import json +import logging +from typing import TYPE_CHECKING + +from aiohttp import web +from music_assistant_models.errors import LoginFailed +from ya_passport_auth import Credentials, PassportClient, SecretStr +from ya_passport_auth.exceptions import ( + DeviceCodeTimeoutError, + QRTimeoutError, + YaPassportError, +) + +from music_assistant.helpers.auth import AuthenticationHelper + +if TYPE_CHECKING: + from music_assistant import MusicAssistant + +_LOGGER = logging.getLogger(__name__) + +_DEVICE_CODE_PAGE_PATH = "/yandex_music/device_code" +# Seconds to keep the status endpoint alive after the flow finishes so the +# intermediate page has a chance to poll once more and close itself. +_POST_AUTH_GRACE_SECONDS = 3 + + +def _build_device_code_page( + user_code: str, + verification_url: str, + status_url: str, +) -> str: + """Render the HTML page shown to the user during Device Flow login. + + Yandex's verification page does not pre-fill the code from query params, + and the MA frontend opens auth URLs in a new tab, so the user would + otherwise have no signal that authorization succeeded. The page polls the + status endpoint and closes itself (or shows a success message) when the + backend signals completion. + """ + safe_code = html.escape(user_code) + safe_url = html.escape(verification_url, quote=True) + # json.dumps emits a JS string literal, but `` would still break + # out of the surrounding + + +""" + + +async def perform_device_auth(mass: MusicAssistant, session_id: str) -> tuple[str, str, str]: + """Perform Yandex OAuth Device Flow and return credential tokens. + + Asks Yandex for a device code, presents it to the user via an intermediate + HTML page served from MA's own webserver, then polls until the user + confirms or the code expires. + + Returns (x_token, music_token, refresh_token) as plain strings for MA + config storage. + """ + try: + async with PassportClient.create() as client: + session = await client.start_device_login() + + _LOGGER.info( + "Device flow started: open %s and enter code %s (expires in %ss)", + session.verification_url, + session.user_code, + session.expires_in, + ) + + page_path = f"{_DEVICE_CODE_PAGE_PATH}/{session_id}" + status_path = f"{page_path}/status" + status_url = f"{mass.webserver.base_url}{status_path}" + state = {"value": "pending"} + + page_html = _build_device_code_page( + session.user_code, session.verification_url, status_url + ) + + async def _serve_page(_request: web.Request) -> web.Response: + return web.Response( + text=page_html, + content_type="text/html", + charset="utf-8", + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + "Expires": "0", + }, + ) + + async def _serve_status(_request: web.Request) -> web.Response: + return web.json_response( + {"state": state["value"]}, + headers={"Cache-Control": "no-store"}, + ) + + mass.webserver.register_dynamic_route(page_path, _serve_page, "GET") + mass.webserver.register_dynamic_route(status_path, _serve_status, "GET") + try: + async with AuthenticationHelper(mass, session_id) as auth_helper: + auth_helper.send_url(f"{mass.webserver.base_url}{page_path}") + try: + creds = await client.poll_device_until_confirmed(session) + except asyncio.CancelledError: + # Don't mark cancellations as auth failures. + raise + except Exception: + state["value"] = "failed" + # Give the page one more poll to surface the failure + # message before we tear the status route down. + await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) + raise + state["value"] = "done" + # Give the intermediate page one more poll to pick up "done" + # and close itself before we tear the status route down. + await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) + finally: + mass.webserver.unregister_dynamic_route(page_path, "GET") + mass.webserver.unregister_dynamic_route(status_path, "GET") + + music_token = creds.music_token + if music_token is None: + raise LoginFailed("Device auth succeeded but no music token was returned") + refresh_token = creds.refresh_token + if refresh_token is None: + raise LoginFailed("Device auth succeeded but no refresh token was returned") + + _LOGGER.debug("Device flow complete, obtained full credential triple") + return ( + creds.x_token.get_secret(), + music_token.get_secret(), + refresh_token.get_secret(), + ) + + except DeviceCodeTimeoutError as err: + raise LoginFailed("Device authentication timed out. Please try again.") from err + except YaPassportError as err: + raise LoginFailed(f"Yandex device auth error: {err}") from err + + +async def perform_qr_auth(mass: MusicAssistant, session_id: str) -> tuple[str, str]: + """Perform full QR authentication flow. + + Opens a QR code popup via MA frontend, polls for scan confirmation, + then returns tokens as plain strings for MA config storage. + + Returns (x_token, music_token). + """ + try: + async with PassportClient.create() as client: + qr = await client.start_qr_login() + + async with AuthenticationHelper(mass, session_id) as auth_helper: + auth_helper.send_url(qr.qr_url) + creds = await client.poll_qr_until_confirmed(qr) + + x_token = creds.x_token.get_secret() + music_token = creds.music_token + if music_token is None: + raise LoginFailed("QR auth succeeded but no music token was returned") + + _LOGGER.debug("QR auth complete, obtained both tokens") + return x_token, music_token.get_secret() + + except QRTimeoutError as err: + raise LoginFailed("QR authentication timed out. Please try again.") from err + except YaPassportError as err: + raise LoginFailed(f"Yandex auth error: {err}") from err + + +async def refresh_music_token(x_token: SecretStr) -> SecretStr: + """Exchange an x_token for a fresh music-scoped OAuth token.""" + try: + async with PassportClient.create() as client: + return await client.refresh_music_token(x_token) + except YaPassportError as err: + raise LoginFailed(f"Failed to refresh music token: {err}") from err + + +async def refresh_credentials_via_passport( + x_token: SecretStr, refresh_token: SecretStr +) -> Credentials: + """Silently re-issue the full credential triple using a refresh token. + + Only available for accounts authenticated via the Device Flow (QR login + does not yield a ``refresh_token``). Rotates both ``x_token`` and + ``refresh_token`` server-side, so callers must persist the returned + Credentials. + """ + try: + async with PassportClient.create() as client: + return await client.refresh_credentials( + Credentials(x_token=x_token, refresh_token=refresh_token) + ) + except YaPassportError as err: + raise LoginFailed(f"Failed to refresh credentials: {err}") from err + + +async def validate_x_token(x_token: SecretStr) -> bool: + """Return True if *x_token* is still accepted by Yandex Passport.""" + try: + async with PassportClient.create() as client: + return bool(await client.validate_x_token(x_token)) + except YaPassportError: + return False diff --git a/music_assistant/providers/yandex_music/constants.py b/music_assistant/providers/yandex_music/constants.py index 8ce005d449..87fb938df8 100644 --- a/music_assistant/providers/yandex_music/constants.py +++ b/music_assistant/providers/yandex_music/constants.py @@ -12,10 +12,12 @@ # Actions CONF_ACTION_AUTH = "auth" CONF_ACTION_AUTH_QR = "auth_qr" +CONF_ACTION_AUTH_DEVICE = "auth_device" CONF_ACTION_CLEAR_AUTH = "clear_auth" # QR authentication config keys CONF_X_TOKEN = "x_token" +CONF_REFRESH_TOKEN: Final[str] = "refresh_token" CONF_REMEMBER_SESSION = "remember_session" # Labels @@ -173,6 +175,8 @@ # Top-level browse groups "for_you": "Для вас", "collection": "Коллекция", + "pinned": "Закреплённое", + "history": "История прослушиваний", # Waves / Radio (rotor station categories) "waves": "Радио", "radio": "Радио", @@ -245,6 +249,8 @@ # Top-level browse groups "for_you": "For You", "collection": "Collection", + "pinned": "Pinned", + "history": "Listening History", # Waves / Radio (rotor station categories) "waves": "Radio", "radio": "Radio", @@ -369,6 +375,8 @@ # Top-level browse group folders FOR_YOU_FOLDER_ID: Final[str] = "for_you" COLLECTION_FOLDER_ID: Final[str] = "collection" +PINNED_ITEMS_FOLDER_ID: Final[str] = "pinned" +LISTENING_HISTORY_FOLDER_ID: Final[str] = "history" # Preferred display order for wave categories (rotor station types) WAVE_CATEGORY_DISPLAY_ORDER: Final[list[str]] = [ diff --git a/music_assistant/providers/yandex_music/manifest.json b/music_assistant/providers/yandex_music/manifest.json index 29e3df1978..28e2bb32d9 100644 --- a/music_assistant/providers/yandex_music/manifest.json +++ b/music_assistant/providers/yandex_music/manifest.json @@ -7,6 +7,6 @@ "codeowners": ["@TrudenBoy"], "credits": ["[yandex-music-api](https://github.com/MarshalX/yandex-music-api)"], "documentation": "https://music-assistant.io/music-providers/yandex-music/", - "requirements": ["yandex-music==2.2.0", "ya-passport-auth==1.2.3"], + "requirements": ["yandex-music[async]==3.0.0", "ya-passport-auth==1.3.0"], "multi_instance": true } diff --git a/music_assistant/providers/yandex_music/parsers.py b/music_assistant/providers/yandex_music/parsers.py index fab0b27c75..55c8bea743 100644 --- a/music_assistant/providers/yandex_music/parsers.py +++ b/music_assistant/providers/yandex_music/parsers.py @@ -11,6 +11,7 @@ ContentType, ImageType, ) +from music_assistant_models.errors import InvalidDataError from music_assistant_models.media_items import ( Album, Artist, @@ -68,13 +69,21 @@ def _get_image_url(cover_uri: str | None, size: str = IMAGE_SIZE_LARGE) -> str | return f"https://{cover_uri.replace('%%', size)}" -def parse_artist(provider: YandexMusicProvider, artist_obj: YandexArtist) -> Artist: +def parse_artist( + provider: YandexMusicProvider, + artist_obj: YandexArtist, + *, + about: object | None = None, +) -> Artist: """Parse Yandex artist object to MA Artist model. :param provider: The Yandex Music provider instance. :param artist_obj: Yandex artist object. + :param about: Optional ArtistAbout enrichment (description + listener stats). :return: Music Assistant Artist model. """ + if artist_obj.id is None: + raise InvalidDataError("Yandex artist missing id") artist_id = str(artist_obj.id) artist = Artist( item_id=artist_id, @@ -118,6 +127,15 @@ def parse_artist(provider: YandexMusicProvider, artist_obj: YandexArtist) -> Art ] ) + if about is not None: + description = getattr(about, "description", None) + if description: + artist.metadata.description = description + stats = getattr(about, "stats", None) + monthly = getattr(stats, "last_month_listeners", None) if stats else None + if monthly: + artist.metadata.popularity = max(0, min(100, monthly // 10000)) + return artist @@ -128,6 +146,8 @@ def parse_album(provider: YandexMusicProvider, album_obj: YandexAlbum) -> Album: :param album_obj: Yandex album object. :return: Music Assistant Album model. """ + if album_obj.id is None: + raise InvalidDataError("Yandex album missing id") name, version = parse_title_and_version( album_obj.title or "Unknown Album", album_obj.version or None, @@ -229,6 +249,8 @@ def parse_track( :param lyrics_synced: Whether lyrics are in synced LRC format. :return: Music Assistant Track model. """ + if track_obj.id is None: + raise InvalidDataError("Yandex track missing id") name, version = parse_title_and_version( track_obj.title or "Unknown Track", track_obj.version or None, diff --git a/music_assistant/providers/yandex_music/provider.py b/music_assistant/providers/yandex_music/provider.py index 24b7873777..a34deb5c71 100644 --- a/music_assistant/providers/yandex_music/provider.py +++ b/music_assistant/providers/yandex_music/provider.py @@ -39,6 +39,7 @@ from music_assistant.models.music_provider import MusicProvider from .api_client import YandexMusicClient +from .auth import refresh_credentials_via_passport, refresh_music_token from .constants import ( BROWSE_INITIAL_TRACKS, BROWSE_NAMES_EN, @@ -48,6 +49,7 @@ CONF_LIKED_TRACKS_MAX_TRACKS, CONF_MY_WAVE_MAX_TRACKS, CONF_QUALITY, + CONF_REFRESH_TOKEN, CONF_TOKEN, CONF_X_TOKEN, DEFAULT_BASE_URL, @@ -55,10 +57,12 @@ FOR_YOU_FOLDER_ID, IMAGE_SIZE_MEDIUM, LIKED_TRACKS_PLAYLIST_ID, + LISTENING_HISTORY_FOLDER_ID, MY_WAVE_BATCH_SIZE, MY_WAVE_PLAYLIST_ID, MY_WAVES_FOLDER_ID, MY_WAVES_SET_FOLDER_ID, + PINNED_ITEMS_FOLDER_ID, PLAYLIST_ID_SPLITTER, RADIO_FOLDER_ID, RADIO_TRACK_ID_SEP, @@ -87,7 +91,6 @@ parse_track, ) from .streaming import YandexMusicStreamingManager -from .yandex_auth import refresh_music_token if TYPE_CHECKING: from music_assistant_models.streamdetails import StreamDetails @@ -158,10 +161,51 @@ def _get_browse_names(self) -> dict[str, str]: use_russian = False return BROWSE_NAMES_RU if use_russian else BROWSE_NAMES_EN + async def _reauth_via_refresh_token( + self, x_token: str, refresh_token: str, base_url: str, original_err: Exception + ) -> None: + """Silently re-issue full credentials when x_token refresh fails. + + Device-flow accounts have a refresh_token that can mint a new + x_token + refresh_token + music_token without any user interaction. + Persists the rotated triple and connects the client. Any failure + here is terminal — clears all credentials and forces re-auth. + """ + try: + new_creds = await refresh_credentials_via_passport( + SecretStr(x_token), SecretStr(refresh_token) + ) + except LoginFailed as err2: + self.logger.warning("Session and refresh tokens are both expired") + self._update_config_value(CONF_TOKEN, None, encrypted=True) + self._update_config_value(CONF_X_TOKEN, None, encrypted=True) + self._update_config_value(CONF_REFRESH_TOKEN, None, encrypted=True) + raise LoginFailed("Session expired. Please re-authenticate.") from err2 + + new_music_token = new_creds.music_token + new_refresh_token = new_creds.refresh_token + if new_music_token is None or new_refresh_token is None: + self._update_config_value(CONF_TOKEN, None, encrypted=True) + self._update_config_value(CONF_X_TOKEN, None, encrypted=True) + self._update_config_value(CONF_REFRESH_TOKEN, None, encrypted=True) + raise LoginFailed( + "Credential refresh returned an incomplete response." + ) from original_err + + self._update_config_value(CONF_TOKEN, new_music_token.get_secret(), encrypted=True) + self._update_config_value(CONF_X_TOKEN, new_creds.x_token.get_secret(), encrypted=True) + self._update_config_value( + CONF_REFRESH_TOKEN, new_refresh_token.get_secret(), encrypted=True + ) + self._client = YandexMusicClient(new_music_token, base_url=base_url) + await self._client.connect() + self.logger.info("Re-issued credentials silently from refresh token") + async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" token = self.config.get_value(CONF_TOKEN) x_token = self.config.get_value(CONF_X_TOKEN) + refresh_token = self.config.get_value(CONF_REFRESH_TOKEN) base_url = self.config.get_value(CONF_BASE_URL, DEFAULT_BASE_URL) if not token and not x_token: @@ -192,11 +236,19 @@ async def handle_async_init(self) -> None: await self._client.connect() self.logger.info("Refreshed music token from session token") except LoginFailed as err: - # Definitive auth failure — clear dead credentials - self.logger.warning("Session token is invalid or expired") - self._update_config_value(CONF_TOKEN, None, encrypted=True) - self._update_config_value(CONF_X_TOKEN, None, encrypted=True) - raise LoginFailed("Session token expired. Please re-authenticate.") from err + # x_token refresh failed. If a refresh_token is available + # (device-flow accounts), try silent re-issue of the full + # credential triple before giving up. + if refresh_token: + await self._reauth_via_refresh_token( + str(x_token), str(refresh_token), str(base_url), err + ) + else: + # Definitive auth failure — clear dead credentials + self.logger.warning("Session token is invalid or expired") + self._update_config_value(CONF_TOKEN, None, encrypted=True) + self._update_config_value(CONF_X_TOKEN, None, encrypted=True) + raise LoginFailed("Session token expired. Please re-authenticate.") from err except asyncio.CancelledError: raise except Exception as err: @@ -292,6 +344,14 @@ async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | Brow if subpath == MY_WAVES_SET_FOLDER_ID: return await self._browse_vibe_sets(path, path_parts) + # Pinned items folder + if subpath == PINNED_ITEMS_FOLDER_ID: + return await self._browse_pins() + + # Listening history folder + if subpath == LISTENING_HISTORY_FOLDER_ID: + return await self._browse_history() + # Handle waves_landing/ path (Featured Waves from /landing-blocks/waves) if subpath == WAVES_LANDING_FOLDER_ID: return await self._browse_waves_landing(path, path_parts) @@ -312,6 +372,8 @@ async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | Brow WAVES_LANDING_FOLDER_ID, FOR_YOU_FOLDER_ID, COLLECTION_FOLDER_ID, + PINNED_ITEMS_FOLDER_ID, + LISTENING_HISTORY_FOLDER_ID, } if subpath and subpath not in _known_folders: # Handle direct wave station_id (e.g. "activity:workout") passed when @@ -393,6 +455,26 @@ async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | Brow is_playable=False, ) ) + # Pinned items — user-pinned artists/albums/playlists/waves + folders.append( + BrowseFolder( + item_id=PINNED_ITEMS_FOLDER_ID, + provider=self.instance_id, + path=f"{base}{PINNED_ITEMS_FOLDER_ID}", + name=names.get(PINNED_ITEMS_FOLDER_ID, "Pinned"), + is_playable=False, + ) + ) + # Listening history — recently played tracks/albums + folders.append( + BrowseFolder( + item_id=LISTENING_HISTORY_FOLDER_ID, + provider=self.instance_id, + path=f"{base}{LISTENING_HISTORY_FOLDER_ID}", + name=names.get(LISTENING_HISTORY_FOLDER_ID, "Listening History"), + is_playable=False, + ) + ) if len(folders) == 1: return await self.browse(folders[0].path) return folders @@ -722,6 +804,75 @@ async def _browse_collection( ) return folders + async def _browse_pins(self) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse user's pinned items (artists/albums/playlists from Yandex Pins). + + Resolves each pin to its full media item via existing single-item lookups. + Wave pins are skipped — MA has no native concept for them. + + :return: List of resolved media items. + """ + pins_list = await self.client.get_pins() + pins = getattr(pins_list, "pins", None) if pins_list else None + if not pins: + return [] + + items: list[MediaItemType] = [] + for pin in pins: + pin_type = getattr(pin, "type", None) + data = getattr(pin, "data", None) + if data is None: + continue + try: + if pin_type == "artist_item" and getattr(data, "id", None) is not None: + items.append(await self.get_artist(str(data.id))) + elif pin_type == "album_item" and getattr(data, "id", None) is not None: + items.append(await self.get_album(str(data.id))) + elif pin_type == "playlist_item": + uid = getattr(data, "uid", None) + kind = getattr(data, "kind", None) + if uid is not None and kind is not None: + items.append(await self.get_playlist(f"{uid}:{kind}")) + except (MediaNotFoundError, InvalidDataError) as err: + self.logger.debug("Skipping pin %s: %s", pin_type, err) + return items + + async def _browse_history(self) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse user's recent listening history (flattened across days). + + Filters to ``type == "track"`` entries only — album/playlist context + items in the history feed are dropped. Tracks are de-duplicated by + id and returned in most-recent-first order. + + :return: List of recently played Track items. + """ + history = await self.client.get_music_history() + tabs = getattr(history, "history_tabs", None) if history else None + if not tabs: + return [] + + seen_track_ids: set[str] = set() + tracks: list[Track] = [] + for tab in tabs: + groups = getattr(tab, "items", None) or [] + for group in groups: + history_items = getattr(group, "tracks", None) or [] + for hist_item in history_items: + if getattr(hist_item, "type", None) != "track": + continue + full = getattr(getattr(hist_item, "data", None), "full_model", None) + if full is None or getattr(full, "id", None) is None: + continue + track_key = str(full.id) + if track_key in seen_track_ids: + continue + seen_track_ids.add(track_key) + try: + tracks.append(parse_track(self, full)) + except InvalidDataError as err: + self.logger.debug("Skipping history track: %s", err) + return tracks + async def _browse_picks( self, path: str, path_parts: list[str] ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: @@ -1407,16 +1558,19 @@ async def search( @use_cache(3600 * 24 * 30) async def get_artist(self, prov_artist_id: str) -> Artist: - """Get artist details by ID. + """Get artist details by ID, enriched with description and listener stats. :param prov_artist_id: The provider artist ID. :return: Artist object. :raises MediaNotFoundError: If artist not found. """ - artist = await self.client.get_artist(prov_artist_id) + artist, about = await asyncio.gather( + self.client.get_artist(prov_artist_id), + self.client.get_artist_about(prov_artist_id), + ) if not artist: raise MediaNotFoundError(f"Artist {prov_artist_id} not found") - return parse_artist(self, artist) + return parse_artist(self, artist, about=about) @use_cache(3600 * 24 * 30) async def get_album(self, prov_album_id: str) -> Album: @@ -1709,6 +1863,23 @@ async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[ self.logger.debug("Error parsing similar track: %s", err) return tracks + @use_cache(3600 * 3) + async def get_similar_artists(self, prov_artist_id: str, limit: int = 25) -> list[Artist]: + """Get artists similar to the given one via Yandex artists/similar endpoint. + + :param prov_artist_id: Provider artist ID. + :param limit: Maximum number of artists to return. + :return: List of similar Artist objects. + """ + yandex_artists = await self.client.get_similar_artists(prov_artist_id, limit=limit) + artists: list[Artist] = [] + for ya in yandex_artists: + try: + artists.append(parse_artist(self, ya)) + except InvalidDataError as err: + self.logger.debug("Error parsing similar artist: %s", err) + return artists + async def recommendations(self) -> list[RecommendationFolder]: """Get recommendations with multiple discovery folders. diff --git a/music_assistant/providers/yandex_music/yandex_auth.py b/music_assistant/providers/yandex_music/yandex_auth.py deleted file mode 100644 index 16961385a0..0000000000 --- a/music_assistant/providers/yandex_music/yandex_auth.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Yandex Passport QR authentication flow. - -Delegates all Passport interactions to the ``ya-passport-auth`` library. -This module exposes three helpers consumed by the provider: - -* ``perform_qr_auth`` — full QR login (UI popup → tokens) -* ``refresh_music_token`` — x_token → fresh music token -* ``validate_x_token`` — quick liveness check for an x_token -""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -from music_assistant_models.errors import LoginFailed -from ya_passport_auth import PassportClient, SecretStr -from ya_passport_auth.exceptions import QRTimeoutError, YaPassportError - -from music_assistant.helpers.auth import AuthenticationHelper - -if TYPE_CHECKING: - from music_assistant import MusicAssistant - -_LOGGER = logging.getLogger(__name__) - - -async def perform_qr_auth(mass: MusicAssistant, session_id: str) -> tuple[str, str]: - """Perform full QR authentication flow. - - Opens a QR code popup via MA frontend, polls for scan confirmation, - then returns tokens as plain strings for MA config storage. - - Returns (x_token, music_token). - """ - try: - async with PassportClient.create() as client: - qr = await client.start_qr_login() - - async with AuthenticationHelper(mass, session_id) as auth_helper: - auth_helper.send_url(qr.qr_url) - creds = await client.poll_qr_until_confirmed(qr) - - x_token = creds.x_token.get_secret() - music_token = creds.music_token - if music_token is None: - raise LoginFailed("QR auth succeeded but no music token was returned") - - _LOGGER.debug("QR auth complete, obtained both tokens") - return x_token, music_token.get_secret() - - except QRTimeoutError as err: - raise LoginFailed("QR authentication timed out. Please try again.") from err - except YaPassportError as err: - raise LoginFailed(f"Yandex auth error: {err}") from err - - -async def refresh_music_token(x_token: SecretStr) -> SecretStr: - """Exchange an x_token for a fresh music-scoped OAuth token.""" - try: - async with PassportClient.create() as client: - return await client.refresh_music_token(x_token) - except YaPassportError as err: - raise LoginFailed(f"Failed to refresh music token: {err}") from err - - -async def validate_x_token(x_token: SecretStr) -> bool: - """Return True if *x_token* is still accepted by Yandex Passport.""" - try: - async with PassportClient.create() as client: - return bool(await client.validate_x_token(x_token)) - except YaPassportError: - return False diff --git a/requirements_all.txt b/requirements_all.txt index 0a5c4d83fe..1991641a73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -87,7 +87,7 @@ unidecode==1.4.0 uv>=0.8.0 websocket-client==1.9.0 xmltodict==1.0.4 -ya-passport-auth==1.2.3 +ya-passport-auth==1.3.0 yandex-music==2.2.0 ytmusicapi==1.11.5 zeroconf==0.148.0 diff --git a/tests/providers/yandex_music/test_api_client.py b/tests/providers/yandex_music/test_api_client.py index 8ba9afc62b..861fb3af56 100644 --- a/tests/providers/yandex_music/test_api_client.py +++ b/tests/providers/yandex_music/test_api_client.py @@ -166,12 +166,10 @@ async def test_get_my_wave_tracks_empty_sequence_returns_empty() -> None: underlying.tracks.assert_not_awaited() -async def test_send_rotor_station_feedback_posts() -> None: - """send_rotor_station_feedback POSTs to rotor feedback endpoint.""" +async def test_send_rotor_station_feedback_track_started() -> None: + """send_rotor_station_feedback delegates trackStarted to public helper.""" client, underlying = _make_client() - - underlying._request = mock.AsyncMock() - underlying.base_url = "https://api.music.yandex.net" + underlying.rotor_station_feedback_track_started = mock.AsyncMock(return_value=True) result = await client.send_rotor_station_feedback( "user:onyourwave", @@ -181,13 +179,197 @@ async def test_send_rotor_station_feedback_posts() -> None: ) 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" + underlying.rotor_station_feedback_track_started.assert_awaited_once() + args, kwargs = underlying.rotor_station_feedback_track_started.await_args + assert args[0] == "user:onyourwave" + assert kwargs["track_id"] == "12345" + assert kwargs["batch_id"] == "batch_xyz" + assert "timestamp" in kwargs + + +async def test_send_rotor_station_feedback_radio_started() -> None: + """send_rotor_station_feedback delegates radioStarted to public helper with from_.""" + client, underlying = _make_client() + underlying.rotor_station_feedback_radio_started = mock.AsyncMock(return_value=True) + + result = await client.send_rotor_station_feedback( + "user:onyourwave", + "radioStarted", + batch_id="batch_xyz", + ) + + assert result is True + underlying.rotor_station_feedback_radio_started.assert_awaited_once() + _, kwargs = underlying.rotor_station_feedback_radio_started.await_args + assert kwargs["from_"] == "YandexMusicDesktopAppWindows" + assert kwargs["batch_id"] == "batch_xyz" + + +async def test_send_rotor_station_feedback_track_finished() -> None: + """send_rotor_station_feedback delegates trackFinished with total_played_seconds.""" + client, underlying = _make_client() + underlying.rotor_station_feedback_track_finished = mock.AsyncMock(return_value=True) + + result = await client.send_rotor_station_feedback( + "user:onyourwave", + "trackFinished", + track_id="12345", + total_played_seconds=42, + batch_id="batch_xyz", + ) + + assert result is True + underlying.rotor_station_feedback_track_finished.assert_awaited_once() + _, kwargs = underlying.rotor_station_feedback_track_finished.await_args + assert kwargs["track_id"] == "12345" + assert kwargs["total_played_seconds"] == 42.0 + assert kwargs["batch_id"] == "batch_xyz" + + +async def test_send_rotor_station_feedback_skip() -> None: + """send_rotor_station_feedback delegates skip to public helper.""" + client, underlying = _make_client() + underlying.rotor_station_feedback_skip = mock.AsyncMock(return_value=True) + + result = await client.send_rotor_station_feedback( + "user:onyourwave", + "skip", + track_id="12345", + total_played_seconds=10, + ) + + assert result is True + underlying.rotor_station_feedback_skip.assert_awaited_once() + _, kwargs = underlying.rotor_station_feedback_skip.await_args + assert kwargs["track_id"] == "12345" + assert kwargs["total_played_seconds"] == 10.0 + + +# -- get_similar_artists ------------------------------------------------------ + + +async def test_get_similar_artists_returns_list() -> None: + """get_similar_artists returns the similar_artists list from artists_similar().""" + client, underlying = _make_client() + similar = [type("Artist", (), {"id": i, "name": f"A{i}"})() for i in (1, 2, 3)] + result_obj = type("ArtistSimilar", (), {"similar_artists": similar})() + underlying.artists_similar = mock.AsyncMock(return_value=result_obj) + + result = await client.get_similar_artists("100") + + underlying.artists_similar.assert_awaited_once_with("100") + assert result == similar + + +async def test_get_similar_artists_respects_limit() -> None: + """get_similar_artists truncates results to the requested limit.""" + client, underlying = _make_client() + similar = [type("Artist", (), {"id": i})() for i in range(10)] + result_obj = type("ArtistSimilar", (), {"similar_artists": similar})() + underlying.artists_similar = mock.AsyncMock(return_value=result_obj) + + result = await client.get_similar_artists("100", limit=3) + + assert len(result) == 3 + assert [a.id for a in result] == [0, 1, 2] + + +async def test_get_similar_artists_handles_none_response() -> None: + """get_similar_artists returns [] when underlying call returns None.""" + client, underlying = _make_client() + underlying.artists_similar = mock.AsyncMock(return_value=None) + + result = await client.get_similar_artists("100") + + assert result == [] + + +async def test_get_similar_artists_handles_empty_field() -> None: + """get_similar_artists returns [] when similar_artists is empty/None.""" + client, underlying = _make_client() + result_obj = type("ArtistSimilar", (), {"similar_artists": None})() + underlying.artists_similar = mock.AsyncMock(return_value=result_obj) + + result = await client.get_similar_artists("100") + + assert result == [] + + +async def test_get_similar_artists_returns_empty_on_network_error() -> None: + """get_similar_artists returns [] when underlying raises NetworkError.""" + client, underlying = _make_client() + underlying.artists_similar = mock.AsyncMock( + side_effect=[NetworkError("timeout"), NetworkError("again")] + ) + + result = await client.get_similar_artists("100") + + assert result == [] + + +# -- get_pins / get_music_history / get_artist_about ------------------------- + + +async def test_get_pins_returns_list_object() -> None: + """get_pins forwards the underlying pins() result.""" + client, underlying = _make_client() + pins_obj = type("PinsList", (), {"pins": [type("Pin", (), {"type": "album_item"})()]})() + underlying.pins = mock.AsyncMock(return_value=pins_obj) + + result = await client.get_pins() + + underlying.pins.assert_awaited_once_with() + assert result is pins_obj + + +async def test_get_pins_returns_none_on_network_error() -> None: + """get_pins returns None when retries are exhausted.""" + client, underlying = _make_client() + underlying.pins = mock.AsyncMock(side_effect=NetworkError("boom")) + + result = await client.get_pins() + + assert result is None + + +async def test_get_music_history_returns_object() -> None: + """get_music_history forwards the underlying music_history() result.""" + client, underlying = _make_client() + history = type("MusicHistory", (), {"history_tabs": []})() + underlying.music_history = mock.AsyncMock(return_value=history) + + result = await client.get_music_history() + + underlying.music_history.assert_awaited_once_with() + assert result is history + + +async def test_get_music_history_returns_none_on_network_error() -> None: + """get_music_history returns None on persistent NetworkError.""" + client, underlying = _make_client() + underlying.music_history = mock.AsyncMock(side_effect=NetworkError("boom")) + + assert await client.get_music_history() is None + + +async def test_get_artist_about_returns_object() -> None: + """get_artist_about forwards the underlying artists_about() result.""" + client, underlying = _make_client() + about = type("ArtistAbout", (), {"description": "x", "stats": None})() + underlying.artists_about = mock.AsyncMock(return_value=about) + + result = await client.get_artist_about("42") + + underlying.artists_about.assert_awaited_once_with("42") + assert result is about + + +async def test_get_artist_about_returns_none_on_network_error() -> None: + """get_artist_about returns None on persistent NetworkError.""" + client, underlying = _make_client() + underlying.artists_about = mock.AsyncMock(side_effect=NetworkError("boom")) + + assert await client.get_artist_about("42") is None # -- LRC regex tests --------------------------------------------------------- @@ -362,6 +544,41 @@ async def test_get_dashboard_stations_returns_personalized_stations() -> None: underlying.rotor_stations_dashboard.assert_called_once() +# -- get_track_file_info: response key normalization ------------------------- + + +async def test_get_track_file_info_parses_camelcase_download_info() -> None: + """get_track_file_info parses the v3-style camelCase ``downloadInfo`` key. + + The yandex-music v3 client no longer recursively normalises camelCase keys + inside ``Response.result``. The raw JSON for /get-file-info comes back as + ``{"downloadInfo": {...}}`` — the provider must accept both shapes. + """ + client, underlying = _make_client() + + raw_response = { + "downloadInfo": { + "trackId": "132401416", + "quality": "lossless", + "codec": "flac-mp4", + "bitrate": 0, + "transport": "raw", + "url": "https://example.com/flac-mp4.bin", + "realId": "132401416", + } + } + underlying._request = mock.MagicMock() + underlying._request.get = mock.AsyncMock(return_value=raw_response) + underlying.base_url = "https://api.music.yandex.net" + + result = await client.get_track_file_info("132401416") + + assert result is not None + assert result["url"] == "https://example.com/flac-mp4.bin" + assert result["codec"] == "flac-mp4" + assert result["needs_decryption"] is False + + async def test_get_dashboard_stations_empty_on_error() -> None: """get_dashboard_stations() returns empty list on network error.""" client, underlying = _make_client() diff --git a/tests/providers/yandex_music/test_auth.py b/tests/providers/yandex_music/test_auth.py new file mode 100644 index 0000000000..20bd77ef4b --- /dev/null +++ b/tests/providers/yandex_music/test_auth.py @@ -0,0 +1,721 @@ +"""Unit tests for auth.py (ya-passport-auth QR + Device Flow).""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Awaitable, Callable, Generator +from typing import TYPE_CHECKING +from unittest import mock + +import pytest +from music_assistant_models.errors import LoginFailed +from ya_passport_auth import Credentials, DeviceCodeSession, QrSession, SecretStr +from ya_passport_auth.exceptions import ( + DeviceCodeTimeoutError, + InvalidCredentialsError, + QRTimeoutError, + RateLimitedError, +) +from ya_passport_auth.exceptions import ( + NetworkError as PassportNetworkError, +) + +from music_assistant.providers.yandex_music.auth import ( + perform_device_auth, + perform_qr_auth, + refresh_credentials_via_passport, + refresh_music_token, + validate_x_token, +) + +if TYPE_CHECKING: + from aiohttp import web + + +@pytest.fixture(autouse=True) +def skip_grace_sleep() -> Generator[mock.AsyncMock, None, None]: + """Bypass the post-auth grace ``asyncio.sleep`` so tests run instantly.""" + with mock.patch( + "music_assistant.providers.yandex_music.auth.asyncio.sleep", + new=mock.AsyncMock(), + ) as patched: + yield patched + + +# -- helpers ------------------------------------------------------------------- + + +def _make_device_session( + user_code: str = "ABCD-1234", + verification_url: str = "https://oauth.yandex.ru/device", + interval: int = 1, + expires_in: int = 600, +) -> DeviceCodeSession: + """Build a DeviceCodeSession for testing.""" + return DeviceCodeSession( + device_code=SecretStr("dev-code-xyz"), + user_code=user_code, + verification_url=verification_url, + expires_in=expires_in, + interval=interval, + ) + + +def _make_credentials( + x_token: str = "test_x_token", # noqa: S107 + music_token: str | None = "test_music_token", # noqa: S107 + refresh_token: str | None = "test_refresh_token", # noqa: S107 +) -> Credentials: + """Build a Credentials dataclass for testing.""" + return Credentials( + x_token=SecretStr(x_token), + music_token=SecretStr(music_token) if music_token else None, + refresh_token=SecretStr(refresh_token) if refresh_token else None, + ) + + +def _make_qr_session() -> QrSession: + """Build a QrSession for testing.""" + return QrSession( + track_id="track123", + csrf_token="csrf_abc", + qr_url="https://passport.yandex.ru/auth/magic/code/?track_id=track123", + ) + + +# -- perform_device_auth ------------------------------------------------------- + + +async def test_perform_device_auth_returns_three_tokens() -> None: + """Device flow returns (x_token, music_token, refresh_token).""" + session = _make_device_session() + creds = _make_credentials() + mock_client = mock.AsyncMock() + mock_client.start_device_login.return_value = session + mock_client.poll_device_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_mass.webserver.base_url = "http://ma.local:8095" + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + x_token, music_token, refresh_token = await perform_device_auth(mock_mass, "session_1") + + assert x_token == "test_x_token" + assert music_token == "test_music_token" + assert refresh_token == "test_refresh_token" + mock_client.start_device_login.assert_awaited_once() + mock_client.poll_device_until_confirmed.assert_awaited_once_with(session) + + +async def test_perform_device_auth_serves_intermediate_page_and_cleans_up() -> None: + """A temporary HTML page + status endpoint are registered and unregistered after.""" + session = _make_device_session( + user_code="WXYZ-9999", + verification_url="https://oauth.yandex.ru/device", + ) + creds = _make_credentials() + mock_client = mock.AsyncMock() + mock_client.start_device_login.return_value = session + mock_client.poll_device_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_mass.webserver.base_url = "http://ma.local:8095" + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + await perform_device_auth(mock_mass, "session_1") + + expected_path = "/yandex_music/device_code/session_1" + expected_status_path = f"{expected_path}/status" + + registered_paths = [ + (c.args[0], c.args[2]) for c in mock_mass.webserver.register_dynamic_route.call_args_list + ] + assert (expected_path, "GET") in registered_paths + assert (expected_status_path, "GET") in registered_paths + + unregistered_paths = [ + c.args for c in mock_mass.webserver.unregister_dynamic_route.call_args_list + ] + assert (expected_path, "GET") in unregistered_paths + assert (expected_status_path, "GET") in unregistered_paths + + mock_auth_helper.__aenter__.return_value.send_url.assert_called_once_with( + f"http://ma.local:8095{expected_path}" + ) + + +async def test_perform_device_auth_status_endpoint_reports_done_after_success() -> None: + """The status endpoint reports state=done after the device flow completes. + + Without this the popup window (opened via target=_blank) has no signal to + close itself after the user confirms the code. + """ + session = _make_device_session() + creds = _make_credentials() + mock_client = mock.AsyncMock() + mock_client.start_device_login.return_value = session + mock_client.poll_device_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_mass.webserver.base_url = "http://ma.local:8095" + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + await perform_device_auth(mock_mass, "session_xyz") + + status_call = next( + c + for c in mock_mass.webserver.register_dynamic_route.call_args_list + if c.args[0].endswith("/status") + ) + status_handler = status_call.args[1] + response = await status_handler(mock.MagicMock()) + assert isinstance(response.body, bytes) + payload = json.loads(response.body) + assert payload["state"] == "done" + + +async def test_perform_device_auth_status_reports_failed_on_error( + skip_grace_sleep: mock.AsyncMock, +) -> None: + """When poll fails, status endpoint reports failed and grace sleep still fires. + + Otherwise the page would race with route teardown and only ever see 404s + instead of the 'failed' message. + """ + session = _make_device_session() + mock_client = mock.AsyncMock() + mock_client.start_device_login.return_value = session + mock_client.poll_device_until_confirmed.side_effect = DeviceCodeTimeoutError("expired") + + mock_mass = mock.MagicMock() + mock_mass.webserver.base_url = "http://ma.local:8095" + mock_auth_helper = mock.AsyncMock() + + status_handlers: list[Callable[[web.Request], Awaitable[web.Response]]] = [] + + def _capture( + path: str, + handler: Callable[[web.Request], Awaitable[web.Response]], + _method: str, + ) -> None: + if path.endswith("/status"): + status_handlers.append(handler) + + mock_mass.webserver.register_dynamic_route.side_effect = _capture + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="timed out"): + await perform_device_auth(mock_mass, "session_fail") + + assert status_handlers, "status handler should have been registered" + response = await status_handlers[0](mock.MagicMock()) + assert isinstance(response.body, bytes) + payload = json.loads(response.body) + assert payload["state"] == "failed" + # Grace sleep must fire on failure so the page can observe "failed" before teardown. + skip_grace_sleep.assert_awaited() + + +async def test_perform_device_auth_does_not_mark_cancellation_as_failure( + skip_grace_sleep: mock.AsyncMock, +) -> None: + """CancelledError must propagate without marking state as 'failed' or sleeping.""" + session = _make_device_session() + mock_client = mock.AsyncMock() + mock_client.start_device_login.return_value = session + mock_client.poll_device_until_confirmed.side_effect = asyncio.CancelledError() + + mock_mass = mock.MagicMock() + mock_mass.webserver.base_url = "http://ma.local:8095" + mock_auth_helper = mock.AsyncMock() + + status_handlers: list[Callable[[web.Request], Awaitable[web.Response]]] = [] + + def _capture( + path: str, + handler: Callable[[web.Request], Awaitable[web.Response]], + _method: str, + ) -> None: + if path.endswith("/status"): + status_handlers.append(handler) + + mock_mass.webserver.register_dynamic_route.side_effect = _capture + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(asyncio.CancelledError): + await perform_device_auth(mock_mass, "session_cancel") + + assert status_handlers, "status handler should have been registered" + response = await status_handlers[0](mock.MagicMock()) + assert isinstance(response.body, bytes) + payload = json.loads(response.body) + assert payload["state"] == "pending" + skip_grace_sleep.assert_not_awaited() + + +async def test_perform_device_auth_route_handler_renders_code_and_url() -> None: + """The registered route handler returns HTML containing the code + verification URL.""" + session = _make_device_session( + user_code="ABCD-1234", + verification_url="https://oauth.yandex.ru/device", + ) + creds = _make_credentials() + mock_client = mock.AsyncMock() + mock_client.start_device_login.return_value = session + mock_client.poll_device_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_mass.webserver.base_url = "http://ma.local:8095" + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + await perform_device_auth(mock_mass, "session_1") + + page_call = next( + c + for c in mock_mass.webserver.register_dynamic_route.call_args_list + if not c.args[0].endswith("/status") + ) + handler = page_call.args[1] + response = await handler(mock.MagicMock()) + body = response.text + assert body is not None + assert "ABCD-1234" in body + assert "https://oauth.yandex.ru/device" in body + assert response.content_type == "text/html" + + +async def test_perform_device_auth_timeout_raises_login_failed() -> None: + """DeviceCodeTimeoutError from library is mapped to LoginFailed and the route is freed.""" + session = _make_device_session() + mock_client = mock.AsyncMock() + mock_client.start_device_login.return_value = session + mock_client.poll_device_until_confirmed.side_effect = DeviceCodeTimeoutError("expired") + + mock_mass = mock.MagicMock() + mock_mass.webserver.base_url = "http://ma.local:8095" + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="timed out"): + await perform_device_auth(mock_mass, "session_1") + + unregistered_paths = [ + c.args for c in mock_mass.webserver.unregister_dynamic_route.call_args_list + ] + assert ("/yandex_music/device_code/session_1", "GET") in unregistered_paths + assert ("/yandex_music/device_code/session_1/status", "GET") in unregistered_paths + + +async def test_perform_device_auth_ya_passport_error_raises_login_failed() -> None: + """Generic YaPassportError from library is mapped to LoginFailed.""" + mock_client = mock.AsyncMock() + mock_client.start_device_login.side_effect = PassportNetworkError("offline") + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="device auth error"): + await perform_device_auth(mock_mass, "session_1") + + +async def test_perform_device_auth_no_music_token_raises_login_failed() -> None: + """Credentials without music_token raises LoginFailed.""" + session = _make_device_session() + creds = _make_credentials(music_token=None) + mock_client = mock.AsyncMock() + mock_client.start_device_login.return_value = session + mock_client.poll_device_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="no music token"): + await perform_device_auth(mock_mass, "session_1") + + +async def test_perform_device_auth_no_refresh_token_raises_login_failed() -> None: + """Credentials without refresh_token raises LoginFailed.""" + session = _make_device_session() + creds = _make_credentials(refresh_token=None) + mock_client = mock.AsyncMock() + mock_client.start_device_login.return_value = session + mock_client.poll_device_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="no refresh token"): + await perform_device_auth(mock_mass, "session_1") + + +# -- perform_qr_auth ---------------------------------------------------------- + + +async def test_perform_qr_auth_success() -> None: + """QR flow returns (x_token, music_token) as plain strings.""" + qr = _make_qr_session() + creds = _make_credentials() + mock_client = mock.AsyncMock() + mock_client.start_qr_login.return_value = qr + mock_client.poll_qr_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + x_token, music_token = await perform_qr_auth(mock_mass, "session_1") + + assert x_token == "test_x_token" + assert music_token == "test_music_token" + mock_client.start_qr_login.assert_awaited_once() + mock_client.poll_qr_until_confirmed.assert_awaited_once_with(qr) + + +async def test_perform_qr_auth_sends_qr_url() -> None: + """QR URL is sent to the AuthenticationHelper.""" + qr = _make_qr_session() + creds = _make_credentials() + mock_client = mock.AsyncMock() + mock_client.start_qr_login.return_value = qr + mock_client.poll_qr_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + await perform_qr_auth(mock_mass, "session_1") + + mock_auth_helper.__aenter__.return_value.send_url.assert_called_once_with(qr.qr_url) + + +async def test_perform_qr_auth_timeout_raises_login_failed() -> None: + """QRTimeoutError from library is mapped to LoginFailed.""" + qr = _make_qr_session() + mock_client = mock.AsyncMock() + mock_client.start_qr_login.return_value = qr + mock_client.poll_qr_until_confirmed.side_effect = QRTimeoutError("timed out") + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="timed out"): + await perform_qr_auth(mock_mass, "session_1") + + +async def test_perform_qr_auth_passport_error_raises_login_failed() -> None: + """Generic YaPassportError is mapped to LoginFailed.""" + mock_client = mock.AsyncMock() + mock_client.start_qr_login.side_effect = PassportNetworkError("connection lost") + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="Yandex auth error"): + await perform_qr_auth(mock_mass, "session_1") + + +async def test_perform_qr_auth_no_music_token_raises() -> None: + """Credentials without music_token raises LoginFailed.""" + qr = _make_qr_session() + creds = _make_credentials(music_token=None) + mock_client = mock.AsyncMock() + mock_client.start_qr_login.return_value = qr + mock_client.poll_qr_until_confirmed.return_value = creds + + mock_mass = mock.MagicMock() + mock_auth_helper = mock.AsyncMock() + + with ( + mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create, + mock.patch( + "music_assistant.providers.yandex_music.auth.AuthenticationHelper", + return_value=mock_auth_helper, + ), + ): + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="no music token"): + await perform_qr_auth(mock_mass, "session_1") + + +# -- refresh_music_token ------------------------------------------------------- + + +async def test_refresh_music_token_success() -> None: + """Successful refresh returns a SecretStr.""" + mock_client = mock.AsyncMock() + mock_client.refresh_music_token.return_value = SecretStr("new_music_token") + + with mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + result = await refresh_music_token(SecretStr("my_x_token")) + + assert result.get_secret() == "new_music_token" + mock_client.refresh_music_token.assert_awaited_once() + + +async def test_refresh_music_token_auth_error_raises_login_failed() -> None: + """Auth failure during refresh is mapped to LoginFailed.""" + mock_client = mock.AsyncMock() + mock_client.refresh_music_token.side_effect = InvalidCredentialsError("bad token") + + with mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="Failed to refresh"): + await refresh_music_token(SecretStr("bad_x_token")) + + +# -- validate_x_token ---------------------------------------------------------- + + +async def test_validate_x_token_valid() -> None: + """Valid x_token returns True.""" + mock_client = mock.AsyncMock() + mock_client.validate_x_token.return_value = True + + with mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + result = await validate_x_token(SecretStr("good_token")) + + assert result is True + + +async def test_validate_x_token_error_returns_false() -> None: + """Any YaPassportError returns False (graceful degradation).""" + mock_client = mock.AsyncMock() + mock_client.validate_x_token.side_effect = RateLimitedError("429") + + with mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + result = await validate_x_token(SecretStr("some_token")) + + assert result is False + + +# -- refresh_credentials_via_passport ------------------------------------------ + + +async def test_refresh_credentials_via_passport_success() -> None: + """Successful refresh returns full Credentials triple.""" + new_creds = _make_credentials( + x_token="new_x", + music_token="new_music", + refresh_token="new_refresh", + ) + mock_client = mock.AsyncMock() + mock_client.refresh_credentials.return_value = new_creds + + with mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + result = await refresh_credentials_via_passport( + SecretStr("old_x"), SecretStr("old_refresh") + ) + + assert result.x_token.get_secret() == "new_x" + assert result.music_token is not None + assert result.music_token.get_secret() == "new_music" + assert result.refresh_token is not None + assert result.refresh_token.get_secret() == "new_refresh" + mock_client.refresh_credentials.assert_awaited_once() + + +async def test_refresh_credentials_via_passport_error_raises_login_failed() -> None: + """Auth failure during credential refresh is mapped to LoginFailed.""" + mock_client = mock.AsyncMock() + mock_client.refresh_credentials.side_effect = InvalidCredentialsError("dead") + + with mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(LoginFailed, match="Failed to refresh credentials"): + await refresh_credentials_via_passport(SecretStr("bad_x"), SecretStr("bad_refresh")) diff --git a/tests/providers/yandex_music/test_browse_pins_history.py b/tests/providers/yandex_music/test_browse_pins_history.py new file mode 100644 index 0000000000..4e6d4ffdc9 --- /dev/null +++ b/tests/providers/yandex_music/test_browse_pins_history.py @@ -0,0 +1,202 @@ +"""Tests for the Pins and Listening History browse handlers.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from music_assistant_models.errors import InvalidDataError, MediaNotFoundError +from music_assistant_models.media_items import Album, Artist, Playlist, Track + +from music_assistant.providers.yandex_music.provider import YandexMusicProvider + + +@pytest.fixture +def provider_mock() -> Mock: + """Return a mock Yandex Music provider with cache + client stubs.""" + provider = Mock(spec=YandexMusicProvider) + provider.domain = "yandex_music" + provider.instance_id = "yandex_music_instance" + provider.logger = Mock() + provider.client = AsyncMock() + provider.client.user_id = 12345 + provider.mass = Mock() + provider.mass.cache = AsyncMock() + provider.mass.cache.get = AsyncMock(return_value=None) + provider.mass.cache.set = AsyncMock() + return provider + + +@pytest.mark.asyncio +async def test_browse_pins_returns_empty_when_no_pins(provider_mock: Mock) -> None: + """_browse_pins returns [] when client returns None.""" + provider_mock.client.get_pins = AsyncMock(return_value=None) + + result = await YandexMusicProvider._browse_pins(provider_mock) + + assert result == [] + + +@pytest.mark.asyncio +async def test_browse_pins_returns_empty_when_pins_field_missing( + provider_mock: Mock, +) -> None: + """_browse_pins returns [] when PinsList.pins is None.""" + provider_mock.client.get_pins = AsyncMock(return_value=type("PinsList", (), {"pins": None})()) + + result = await YandexMusicProvider._browse_pins(provider_mock) + + assert result == [] + + +@pytest.mark.asyncio +async def test_browse_pins_resolves_artist_album_playlist(provider_mock: Mock) -> None: + """_browse_pins routes each pin type to the corresponding lookup.""" + artist_pin = type( + "Pin", + (), + {"type": "artist_item", "data": type("D", (), {"id": 11})()}, + )() + album_pin = type( + "Pin", + (), + {"type": "album_item", "data": type("D", (), {"id": 22})()}, + )() + playlist_pin = type( + "Pin", + (), + {"type": "playlist_item", "data": type("D", (), {"uid": 33, "kind": 44})()}, + )() + pins = type("PinsList", (), {"pins": [artist_pin, album_pin, playlist_pin]})() + provider_mock.client.get_pins = AsyncMock(return_value=pins) + + artist = Mock(spec=Artist) + album = Mock(spec=Album) + playlist = Mock(spec=Playlist) + provider_mock.get_artist = AsyncMock(return_value=artist) + provider_mock.get_album = AsyncMock(return_value=album) + provider_mock.get_playlist = AsyncMock(return_value=playlist) + + result = await YandexMusicProvider._browse_pins(provider_mock) + + provider_mock.get_artist.assert_awaited_once_with("11") + provider_mock.get_album.assert_awaited_once_with("22") + provider_mock.get_playlist.assert_awaited_once_with("33:44") + assert result == [artist, album, playlist] + + +@pytest.mark.asyncio +async def test_browse_pins_skips_wave_and_missing_data(provider_mock: Mock) -> None: + """_browse_pins ignores wave pins and pins with missing data.""" + wave_pin = type( + "Pin", + (), + {"type": "wave_item", "data": type("D", (), {})()}, + )() + bad_pin = type("Pin", (), {"type": "album_item", "data": None})() + pins = type("PinsList", (), {"pins": [wave_pin, bad_pin]})() + provider_mock.client.get_pins = AsyncMock(return_value=pins) + provider_mock.get_album = AsyncMock() + + result = await YandexMusicProvider._browse_pins(provider_mock) + + assert result == [] + provider_mock.get_album.assert_not_called() + + +@pytest.mark.asyncio +async def test_browse_pins_skips_lookup_errors(provider_mock: Mock) -> None: + """_browse_pins survives MediaNotFoundError during single-item lookups.""" + album_pin = type( + "Pin", + (), + {"type": "album_item", "data": type("D", (), {"id": 22})()}, + )() + pins = type("PinsList", (), {"pins": [album_pin]})() + provider_mock.client.get_pins = AsyncMock(return_value=pins) + provider_mock.get_album = AsyncMock(side_effect=MediaNotFoundError("gone")) + + result = await YandexMusicProvider._browse_pins(provider_mock) + + assert result == [] + + +@pytest.mark.asyncio +async def test_browse_history_returns_empty_when_no_history( + provider_mock: Mock, +) -> None: + """_browse_history returns [] when client returns None.""" + provider_mock.client.get_music_history = AsyncMock(return_value=None) + + result = await YandexMusicProvider._browse_history(provider_mock) + + assert result == [] + + +@pytest.mark.asyncio +async def test_browse_history_flattens_and_deduplicates(provider_mock: Mock) -> None: + """_browse_history flattens days→groups→tracks and de-dupes by track id.""" + + def make_track(track_id: int) -> object: + full = type("Track", (), {"id": track_id, "title": f"T{track_id}"})() + data = type("D", (), {"full_model": full})() + return type("HistItem", (), {"type": "track", "data": data})() + + group1 = type("Group", (), {"tracks": [make_track(1), make_track(2)]})() + group2 = type("Group", (), {"tracks": [make_track(2), make_track(3)]})() # dup id=2 + tab1 = type("Tab", (), {"items": [group1]})() + tab2 = type("Tab", (), {"items": [group2]})() + history = type("MusicHistory", (), {"history_tabs": [tab1, tab2]})() + provider_mock.client.get_music_history = AsyncMock(return_value=history) + + parsed = [Mock(spec=Track), Mock(spec=Track), Mock(spec=Track)] + with patch( + "music_assistant.providers.yandex_music.provider.parse_track", + side_effect=parsed, + ): + result = await YandexMusicProvider._browse_history(provider_mock) + + assert result == parsed # 3 unique tracks across two days + + +@pytest.mark.asyncio +async def test_browse_history_skips_non_track_items(provider_mock: Mock) -> None: + """_browse_history ignores items with type != 'track'.""" + album_item = type( + "HistItem", + (), + { + "type": "album", + "data": type("D", (), {"full_model": type("X", (), {"id": 99})()})(), + }, + )() + group = type("Group", (), {"tracks": [album_item]})() + tab = type("Tab", (), {"items": [group]})() + history = type("MusicHistory", (), {"history_tabs": [tab]})() + provider_mock.client.get_music_history = AsyncMock(return_value=history) + + with patch("music_assistant.providers.yandex_music.provider.parse_track") as parse_track: + result = await YandexMusicProvider._browse_history(provider_mock) + parse_track.assert_not_called() + + assert result == [] + + +@pytest.mark.asyncio +async def test_browse_history_skips_invalid_track(provider_mock: Mock) -> None: + """_browse_history drops tracks where parse_track raises InvalidDataError.""" + full = type("Track", (), {"id": 1, "title": "T1"})() + data = type("D", (), {"full_model": full})() + item = type("HistItem", (), {"type": "track", "data": data})() + group = type("Group", (), {"tracks": [item]})() + tab = type("Tab", (), {"items": [group]})() + history = type("MusicHistory", (), {"history_tabs": [tab]})() + provider_mock.client.get_music_history = AsyncMock(return_value=history) + + with patch( + "music_assistant.providers.yandex_music.provider.parse_track", + side_effect=InvalidDataError("nope"), + ): + result = await YandexMusicProvider._browse_history(provider_mock) + + assert result == [] diff --git a/tests/providers/yandex_music/test_parsers.py b/tests/providers/yandex_music/test_parsers.py index 18294ae056..1fd39d4b91 100644 --- a/tests/providers/yandex_music/test_parsers.py +++ b/tests/providers/yandex_music/test_parsers.py @@ -95,6 +95,56 @@ def test_parse_artist_with_cover(provider_stub: ProviderStub) -> None: assert "avatars.yandex.net" in (result.metadata.images[0].path or "") +def test_parse_artist_with_about(provider_stub: ProviderStub) -> None: + """parse_artist enriches description and popularity from ArtistAbout.""" + artist_obj = _artist_from_fixture(FIXTURES_DIR / "artists" / "with_cover.json") + assert artist_obj is not None + + about = type( + "ArtistAbout", + (), + { + "description": "Singer-songwriter from somewhere.", + "stats": type("Stats", (), {"last_month_listeners": 250_000})(), + }, + )() + + result = parse_artist(cast("YandexMusicProvider", provider_stub), artist_obj, about=about) + assert result.metadata.description == "Singer-songwriter from somewhere." + # 250000 // 10000 == 25 + assert result.metadata.popularity == 25 + + +def test_parse_artist_about_missing_fields(provider_stub: ProviderStub) -> None: + """parse_artist tolerates ArtistAbout with missing description/stats.""" + artist_obj = _artist_from_fixture(FIXTURES_DIR / "artists" / "with_cover.json") + assert artist_obj is not None + + about = type("ArtistAbout", (), {"description": None, "stats": None})() + + result = parse_artist(cast("YandexMusicProvider", provider_stub), artist_obj, about=about) + assert result.metadata.description is None + assert result.metadata.popularity is None + + +def test_parse_artist_about_clamps_popularity(provider_stub: ProviderStub) -> None: + """parse_artist caps very large monthly listeners at popularity 100.""" + artist_obj = _artist_from_fixture(FIXTURES_DIR / "artists" / "with_cover.json") + assert artist_obj is not None + + about = type( + "ArtistAbout", + (), + { + "description": "", + "stats": type("Stats", (), {"last_month_listeners": 50_000_000})(), + }, + )() + + result = parse_artist(cast("YandexMusicProvider", provider_stub), artist_obj, about=about) + assert result.metadata.popularity == 100 + + @pytest.mark.parametrize("example", ALBUM_FIXTURES, ids=lambda val: val.stem) def test_parse_album(example: pathlib.Path, provider_stub: ProviderStub) -> None: """Test we can parse albums from fixture JSON.""" diff --git a/tests/providers/yandex_music/test_recommendations.py b/tests/providers/yandex_music/test_recommendations.py index 1f09250aa7..83e45a062a 100644 --- a/tests/providers/yandex_music/test_recommendations.py +++ b/tests/providers/yandex_music/test_recommendations.py @@ -7,7 +7,13 @@ import pytest from music_assistant_models.errors import InvalidDataError -from music_assistant_models.media_items import Album, Playlist, RecommendationFolder, Track +from music_assistant_models.media_items import ( + Album, + Artist, + Playlist, + RecommendationFolder, + Track, +) from music_assistant.providers.yandex_music.constants import ( BROWSE_NAMES_EN, @@ -851,3 +857,46 @@ async def return_no_tag(_category: str) -> None: result = await YandexMusicProvider.recommendations(provider_mock) assert result == [] + + +@pytest.mark.asyncio +async def test_get_similar_artists_returns_parsed(provider_mock: Mock) -> None: + """get_similar_artists parses each artist from the underlying client.""" + yandex_artists = [Mock(), Mock(), Mock()] + provider_mock.client.get_similar_artists = AsyncMock(return_value=yandex_artists) + + parsed = [Mock(spec=Artist) for _ in yandex_artists] + with patch( + "music_assistant.providers.yandex_music.provider.parse_artist", + side_effect=parsed, + ): + result = await YandexMusicProvider.get_similar_artists(provider_mock, "42", limit=10) + + provider_mock.client.get_similar_artists.assert_awaited_once_with("42", limit=10) + assert result == parsed + + +@pytest.mark.asyncio +async def test_get_similar_artists_skips_invalid(provider_mock: Mock) -> None: + """get_similar_artists skips artists that fail to parse.""" + yandex_artists = [Mock(), Mock()] + provider_mock.client.get_similar_artists = AsyncMock(return_value=yandex_artists) + + parsed_ok = Mock(spec=Artist) + with patch( + "music_assistant.providers.yandex_music.provider.parse_artist", + side_effect=[InvalidDataError("missing id"), parsed_ok], + ): + result = await YandexMusicProvider.get_similar_artists(provider_mock, "99") + + assert result == [parsed_ok] + + +@pytest.mark.asyncio +async def test_get_similar_artists_empty(provider_mock: Mock) -> None: + """get_similar_artists returns [] when client returns no artists.""" + provider_mock.client.get_similar_artists = AsyncMock(return_value=[]) + + result = await YandexMusicProvider.get_similar_artists(provider_mock, "42") + + assert result == [] diff --git a/tests/providers/yandex_music/test_yandex_auth.py b/tests/providers/yandex_music/test_yandex_auth.py deleted file mode 100644 index e70e52b363..0000000000 --- a/tests/providers/yandex_music/test_yandex_auth.py +++ /dev/null @@ -1,254 +0,0 @@ -"""Unit tests for yandex_auth.py (ya-passport-auth based authentication).""" - -from __future__ import annotations - -from unittest import mock - -import pytest -from music_assistant_models.errors import LoginFailed -from ya_passport_auth import Credentials, QrSession, SecretStr -from ya_passport_auth.exceptions import ( - InvalidCredentialsError, - QRTimeoutError, - RateLimitedError, -) -from ya_passport_auth.exceptions import ( - NetworkError as PassportNetworkError, -) - -from music_assistant.providers.yandex_music.yandex_auth import ( - perform_qr_auth, - refresh_music_token, - validate_x_token, -) - -# -- helpers ------------------------------------------------------------------- - - -def _make_credentials( - x_token: str = "test_x_token", # noqa: S107 - music_token: str | None = "test_music_token", # noqa: S107 -) -> Credentials: - """Build a Credentials dataclass for testing.""" - return Credentials( - x_token=SecretStr(x_token), - music_token=SecretStr(music_token) if music_token else None, - ) - - -def _make_qr_session() -> QrSession: - """Build a QrSession for testing.""" - return QrSession( - track_id="track123", - csrf_token="csrf_abc", - qr_url="https://passport.yandex.ru/auth/magic/code/?track_id=track123", - ) - - -# -- perform_qr_auth ---------------------------------------------------------- - - -async def test_perform_qr_auth_success() -> None: - """QR flow returns (x_token, music_token) as plain strings.""" - qr = _make_qr_session() - creds = _make_credentials() - mock_client = mock.AsyncMock() - mock_client.start_qr_login.return_value = qr - mock_client.poll_qr_until_confirmed.return_value = creds - - mock_mass = mock.MagicMock() - mock_auth_helper = mock.AsyncMock() - - with ( - mock.patch( - "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", - ) as mock_create, - mock.patch( - "music_assistant.providers.yandex_music.yandex_auth.AuthenticationHelper", - return_value=mock_auth_helper, - ), - ): - mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) - mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) - - x_token, music_token = await perform_qr_auth(mock_mass, "session_1") - - assert x_token == "test_x_token" - assert music_token == "test_music_token" - mock_client.start_qr_login.assert_awaited_once() - mock_client.poll_qr_until_confirmed.assert_awaited_once_with(qr) - - -async def test_perform_qr_auth_sends_qr_url() -> None: - """QR URL is sent to the AuthenticationHelper.""" - qr = _make_qr_session() - creds = _make_credentials() - mock_client = mock.AsyncMock() - mock_client.start_qr_login.return_value = qr - mock_client.poll_qr_until_confirmed.return_value = creds - - mock_mass = mock.MagicMock() - mock_auth_helper = mock.AsyncMock() - - with ( - mock.patch( - "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", - ) as mock_create, - mock.patch( - "music_assistant.providers.yandex_music.yandex_auth.AuthenticationHelper", - return_value=mock_auth_helper, - ), - ): - mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) - mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) - - await perform_qr_auth(mock_mass, "session_1") - - mock_auth_helper.__aenter__.return_value.send_url.assert_called_once_with(qr.qr_url) - - -async def test_perform_qr_auth_timeout_raises_login_failed() -> None: - """QRTimeoutError from library is mapped to LoginFailed.""" - qr = _make_qr_session() - mock_client = mock.AsyncMock() - mock_client.start_qr_login.return_value = qr - mock_client.poll_qr_until_confirmed.side_effect = QRTimeoutError("timed out") - - mock_mass = mock.MagicMock() - mock_auth_helper = mock.AsyncMock() - - with ( - mock.patch( - "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", - ) as mock_create, - mock.patch( - "music_assistant.providers.yandex_music.yandex_auth.AuthenticationHelper", - return_value=mock_auth_helper, - ), - ): - mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) - mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) - - with pytest.raises(LoginFailed, match="timed out"): - await perform_qr_auth(mock_mass, "session_1") - - -async def test_perform_qr_auth_passport_error_raises_login_failed() -> None: - """Generic YaPassportError is mapped to LoginFailed.""" - mock_client = mock.AsyncMock() - mock_client.start_qr_login.side_effect = PassportNetworkError("connection lost") - - mock_mass = mock.MagicMock() - mock_auth_helper = mock.AsyncMock() - - with ( - mock.patch( - "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", - ) as mock_create, - mock.patch( - "music_assistant.providers.yandex_music.yandex_auth.AuthenticationHelper", - return_value=mock_auth_helper, - ), - ): - mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) - mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) - - with pytest.raises(LoginFailed, match="Yandex auth error"): - await perform_qr_auth(mock_mass, "session_1") - - -async def test_perform_qr_auth_no_music_token_raises() -> None: - """Credentials without music_token raises LoginFailed.""" - qr = _make_qr_session() - creds = _make_credentials(music_token=None) - mock_client = mock.AsyncMock() - mock_client.start_qr_login.return_value = qr - mock_client.poll_qr_until_confirmed.return_value = creds - - mock_mass = mock.MagicMock() - mock_auth_helper = mock.AsyncMock() - - with ( - mock.patch( - "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", - ) as mock_create, - mock.patch( - "music_assistant.providers.yandex_music.yandex_auth.AuthenticationHelper", - return_value=mock_auth_helper, - ), - ): - mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) - mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) - - with pytest.raises(LoginFailed, match="no music token"): - await perform_qr_auth(mock_mass, "session_1") - - -# -- refresh_music_token ------------------------------------------------------- - - -async def test_refresh_music_token_success() -> None: - """Successful refresh returns a SecretStr.""" - mock_client = mock.AsyncMock() - mock_client.refresh_music_token.return_value = SecretStr("new_music_token") - - with mock.patch( - "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", - ) as mock_create: - mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) - mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) - - result = await refresh_music_token(SecretStr("my_x_token")) - - assert result.get_secret() == "new_music_token" - mock_client.refresh_music_token.assert_awaited_once() - - -async def test_refresh_music_token_auth_error_raises_login_failed() -> None: - """Auth failure during refresh is mapped to LoginFailed.""" - mock_client = mock.AsyncMock() - mock_client.refresh_music_token.side_effect = InvalidCredentialsError("bad token") - - with mock.patch( - "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", - ) as mock_create: - mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) - mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) - - with pytest.raises(LoginFailed, match="Failed to refresh"): - await refresh_music_token(SecretStr("bad_x_token")) - - -# -- validate_x_token ---------------------------------------------------------- - - -async def test_validate_x_token_valid() -> None: - """Valid x_token returns True.""" - mock_client = mock.AsyncMock() - mock_client.validate_x_token.return_value = True - - with mock.patch( - "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", - ) as mock_create: - mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) - mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) - - result = await validate_x_token(SecretStr("good_token")) - - assert result is True - - -async def test_validate_x_token_error_returns_false() -> None: - """Any YaPassportError returns False (graceful degradation).""" - mock_client = mock.AsyncMock() - mock_client.validate_x_token.side_effect = RateLimitedError("429") - - with mock.patch( - "music_assistant.providers.yandex_music.yandex_auth.PassportClient.create", - ) as mock_create: - mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) - mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) - - result = await validate_x_token(SecretStr("some_token")) - - assert result is False From 917ecc023a2a68eb245f016b762cc271f36f2786 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 19 Apr 2026 11:21:43 +0000 Subject: [PATCH 19/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.0 --- music_assistant/providers/yandex_music/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_music/auth.py b/music_assistant/providers/yandex_music/auth.py index f321b87945..6a5dbfe053 100644 --- a/music_assistant/providers/yandex_music/auth.py +++ b/music_assistant/providers/yandex_music/auth.py @@ -203,11 +203,11 @@ async def perform_device_auth(mass: MusicAssistant, session_id: str) -> tuple[st session = await client.start_device_login() _LOGGER.info( - "Device flow started: open %s and enter code %s (expires in %ss)", + "Device flow started: open %s (expires in %ss)", session.verification_url, - session.user_code, session.expires_in, ) + _LOGGER.debug("Device flow user_code issued: %s", session.user_code) page_path = f"{_DEVICE_CODE_PAGE_PATH}/{session_id}" status_path = f"{page_path}/status" From faebce6be1e9acb95c2d917cfee3b9d8dc30bf75 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 10:49:17 +0000 Subject: [PATCH 20/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.0 --- music_assistant/providers/yandex_music/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music_assistant/providers/yandex_music/manifest.json b/music_assistant/providers/yandex_music/manifest.json index 28e2bb32d9..a96a5dc410 100644 --- a/music_assistant/providers/yandex_music/manifest.json +++ b/music_assistant/providers/yandex_music/manifest.json @@ -7,6 +7,6 @@ "codeowners": ["@TrudenBoy"], "credits": ["[yandex-music-api](https://github.com/MarshalX/yandex-music-api)"], "documentation": "https://music-assistant.io/music-providers/yandex-music/", - "requirements": ["yandex-music[async]==3.0.0", "ya-passport-auth==1.3.0"], + "requirements": ["yandex-music==3.0.0", "ya-passport-auth==1.3.0"], "multi_instance": true } From f4c164209827f02e3a83243dcd4a951ddeeabb69 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 11:01:26 +0000 Subject: [PATCH 21/54] feat(kion_music): sync provider from ma-provider-kion-music v2.6.7 --- .../providers/kion_music/__init__.py | 46 +- .../providers/kion_music/api_client.py | 979 +++++--- .../providers/kion_music/constants.py | 305 ++- .../providers/kion_music/manifest.json | 13 +- .../providers/kion_music/parsers.py | 84 +- .../providers/kion_music/provider.py | 2047 +++++++++++++++-- .../providers/kion_music/streaming.py | 487 +++- requirements_all.txt | 2 +- .../__snapshots__/test_parsers.ambr | 18 +- tests/providers/kion_music/conftest.py | 19 + tests/providers/kion_music/test_api_client.py | 38 +- .../providers/kion_music/test_integration.py | 354 --- tests/providers/kion_music/test_my_mix.py | 24 - tests/providers/kion_music/test_parsers.py | 8 +- tests/providers/kion_music/test_streaming.py | 116 +- 15 files changed, 3528 insertions(+), 1012 deletions(-) delete mode 100644 tests/providers/kion_music/test_integration.py delete mode 100644 tests/providers/kion_music/test_my_mix.py diff --git a/music_assistant/providers/kion_music/__init__.py b/music_assistant/providers/kion_music/__init__.py index 3beab037f9..30093d2b6e 100644 --- a/music_assistant/providers/kion_music/__init__.py +++ b/music_assistant/providers/kion_music/__init__.py @@ -10,11 +10,15 @@ from .constants import ( CONF_ACTION_CLEAR_AUTH, CONF_BASE_URL, + CONF_LIKED_TRACKS_MAX_TRACKS, + CONF_MY_WAVE_MAX_TRACKS, CONF_QUALITY, CONF_TOKEN, DEFAULT_BASE_URL, + QUALITY_BALANCED, + QUALITY_EFFICIENT, QUALITY_HIGH, - QUALITY_LOSSLESS, + QUALITY_SUPERB, ) from .provider import KionMusicProvider @@ -25,7 +29,6 @@ from music_assistant.mass import MusicAssistant from music_assistant.models import ProviderInstanceType - SUPPORTED_FEATURES = { ProviderFeature.LIBRARY_ARTISTS, ProviderFeature.LIBRARY_ALBUMS, @@ -40,6 +43,7 @@ ProviderFeature.BROWSE, ProviderFeature.SIMILAR_TRACKS, ProviderFeature.RECOMMENDATIONS, + ProviderFeature.LYRICS, } @@ -68,6 +72,7 @@ async def get_config_entries( is_authenticated = bool(values.get(CONF_TOKEN)) return ( + # Authentication ConfigEntry( key=CONF_TOKEN, type=ConfigEntryType.SECURE_STRING, @@ -86,24 +91,53 @@ async def get_config_entries( action=CONF_ACTION_CLEAR_AUTH, hidden=not is_authenticated, ), + # Quality ConfigEntry( key=CONF_QUALITY, type=ConfigEntryType.STRING, label="Audio quality", description="Select preferred audio quality.", options=[ - ConfigValueOption("High (320 kbps)", QUALITY_HIGH), - ConfigValueOption("Lossless (FLAC)", QUALITY_LOSSLESS), + ConfigValueOption("Efficient (AAC ~64kbps)", QUALITY_EFFICIENT), + ConfigValueOption("Balanced (AAC ~192kbps)", QUALITY_BALANCED), + ConfigValueOption("High (MP3 ~320kbps)", QUALITY_HIGH), + ConfigValueOption("Superb (FLAC Lossless)", QUALITY_SUPERB), ], - default_value=QUALITY_HIGH, + default_value=QUALITY_BALANCED, + ), + # My Mix maximum tracks (advanced) + ConfigEntry( + key=CONF_MY_WAVE_MAX_TRACKS, + type=ConfigEntryType.INTEGER, + label="My Mix maximum tracks", + description="Maximum number of tracks to fetch for My Mix playlist. " + "Lower values load faster but provide fewer tracks. Default: 150.", + range=(10, 1000), + default_value=150, + required=False, + advanced=True, + ), + # Liked Tracks maximum tracks (advanced) + ConfigEntry( + key=CONF_LIKED_TRACKS_MAX_TRACKS, + type=ConfigEntryType.INTEGER, + label="Liked Tracks maximum tracks", + description="Maximum number of tracks to show in Liked Tracks virtual playlist. " + "Higher values may significantly increase load time. " + "Lower values load faster. Default: 500.", + range=(50, 2000), + default_value=500, + required=False, + advanced=True, ), + # API Base URL (advanced) ConfigEntry( key=CONF_BASE_URL, type=ConfigEntryType.STRING, label="API Base URL", description="API endpoint base URL. " "Only change if KION Music changes their API endpoint. " - "Default: https://music.mts.ru/ya_proxy_api", + f"Default: {DEFAULT_BASE_URL}", default_value=DEFAULT_BASE_URL, required=False, advanced=True, diff --git a/music_assistant/providers/kion_music/api_client.py b/music_assistant/providers/kion_music/api_client.py index a5a5646578..c3f830387e 100644 --- a/music_assistant/providers/kion_music/api_client.py +++ b/music_assistant/providers/kion_music/api_client.py @@ -2,9 +2,16 @@ from __future__ import annotations +import asyncio +import base64 +import hashlib +import hmac import logging +import re +import time +from collections.abc import Awaitable, Callable from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast from music_assistant_models.errors import ( LoginFailed, @@ -13,26 +20,36 @@ ) from yandex_music import Album as YandexAlbum from yandex_music import Artist as YandexArtist -from yandex_music import ClientAsync, Search, TrackShort +from yandex_music import ClientAsync, MixLink, Search, TrackShort from yandex_music import Playlist as YandexPlaylist from yandex_music import Track as YandexTrack from yandex_music.exceptions import BadRequestError, NetworkError, UnauthorizedError -from yandex_music.utils.sign_request import get_sign_request +from yandex_music.utils.sign_request import DEFAULT_SIGN_KEY + +from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER, Throttler if TYPE_CHECKING: from yandex_music import DownloadInfo + from yandex_music.feed.feed import Feed + from yandex_music.landing.chart_info import ChartInfo + from yandex_music.landing.landing import Landing + from yandex_music.landing.landing_list import LandingList + from yandex_music.rotor.dashboard import Dashboard + from yandex_music.rotor.station_result import StationResult -from .constants import DEFAULT_BASE_URL, DEFAULT_LIMIT, ROTOR_FEEDBACK_FROM, ROTOR_STATION_MY_MIX +from .constants import DEFAULT_LIMIT, ROTOR_STATION_MY_MIX # get-file-info with quality=lossless returns FLAC; default /tracks/.../download-info often does not -# Prefer flac-mp4/aac-mp4 (KION API moved to these formats around 2025) +# Prefer flac-mp4/aac-mp4 (Kion API moved to these formats around 2025) GET_FILE_INFO_CODECS = "flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4" LOGGER = logging.getLogger(__name__) +_T = TypeVar("_T") + class KionMusicClient: - """Wrapper around yandex-music-api ClientAsync.""" + """Wrapper around kion-music-api ClientAsync.""" def __init__(self, token: str, base_url: str | None = None) -> None: """Initialize the KION Music client. @@ -41,9 +58,12 @@ def __init__(self, token: str, base_url: str | None = None) -> None: :param base_url: Optional API base URL (defaults to KION Music API). """ self._token = token - self._base_url = base_url or DEFAULT_BASE_URL + self._base_url = base_url self._client: ClientAsync | None = None self._user_id: int | None = None + self._last_reconnect_at: float = -30.0 # allow first reconnect immediately + self._reconnect_lock = asyncio.Lock() + self._throttler = Throttler(rate_limit=5, period=1.0) @property def user_id(self) -> int: @@ -65,7 +85,7 @@ async def connect(self) -> bool: self._user_id = self._client.me.account.uid LOGGER.debug("Connected to KION Music as user %s", self._user_id) return True - except UnauthorizedError as err: + except (UnauthorizedError, BadRequestError) as err: raise LoginFailed("Invalid KION Music token") from err except NetworkError as err: msg = "Network error connecting to KION Music" @@ -76,23 +96,93 @@ async def disconnect(self) -> None: self._client = None self._user_id = None - def _ensure_connected(self) -> ClientAsync: - """Ensure the client is connected and return it.""" - if self._client is None: - raise ProviderUnavailableError("Client not connected, call connect() first") - return self._client + async def _ensure_connected(self) -> ClientAsync: + """Ensure the client is connected, attempting reconnect if needed.""" + if self._client is not None: + return self._client + async with self._reconnect_lock: + # Re-check after acquiring lock — another task may have connected already + if self._client is not None: + return self._client # type: ignore[unreachable] + LOGGER.info("Client disconnected, attempting to reconnect...") + try: + await self.connect() + except LoginFailed: + raise + except Exception as err: + raise ProviderUnavailableError("Client not connected and reconnect failed") from err + return cast("ClientAsync", 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): + if isinstance(err, NetworkError) and not self._is_rate_limit_error(err): return True msg = str(err).lower() return "disconnect" in msg or "connection" in msg or "timeout" in msg + def _is_rate_limit_error(self, err: Exception) -> bool: + """Return True if the exception indicates a rate-limit response from Kion.""" + if not isinstance(err, NetworkError): + return False + msg = str(err).lower() + return "429" in msg or "too many requests" in msg or "rate limit" in msg + async def _reconnect(self) -> None: - """Disconnect and connect again to recover from Server disconnected / connection errors.""" - await self.disconnect() - await self.connect() + """Disconnect and connect again to recover from Server disconnected / connection errors. + + Enforces a 30-second cooldown between reconnect attempts to avoid hammering Kion + and triggering rate limiting. A lock ensures concurrent callers don't bypass the cooldown. + """ + async with self._reconnect_lock: + now = time.monotonic() + if now - self._last_reconnect_at < 30.0: + raise ProviderUnavailableError("Reconnect cooldown active, skipping") + self._last_reconnect_at = now + await self.disconnect() + await self.connect() + + async def _call_with_retry(self, func: Callable[[ClientAsync], Awaitable[_T]]) -> _T: + """Execute an async API call with throttling and one reconnect attempt on connection error. + + :param func: Async callable that takes a ClientAsync and returns a result. + :return: The result of the API call. + """ + if not BYPASS_THROTTLER.get(): + await self._throttler.acquire() + client = await self._ensure_connected() + try: + return await func(client) + except Exception as err: + if self._is_rate_limit_error(err): + raise ResourceTemporarilyUnavailable( + "KION Music rate limit", backoff_time=60 + ) from err + if not self._is_connection_error(err): + raise + LOGGER.warning("Connection error, reconnecting and retrying: %s", err) + try: + await self._reconnect() + except Exception as recon_err: + raise ProviderUnavailableError("Reconnect failed") from recon_err + client = cast("ClientAsync", self._client) + return await func(client) + + async def _call_no_retry(self, func: Callable[[ClientAsync], Awaitable[_T]]) -> _T: + """Execute an async API call without reconnect retry on call failure. + + Used for fire-and-forget calls (e.g. rotor feedback) where a failed request + should be silently dropped rather than triggering a reconnect cycle that + could cause rate limiting. Note: _ensure_connected() is still called to + establish the initial connection if needed; only the reconnect-on-error + path is skipped. + + :param func: Async callable that takes a ClientAsync and returns a result. + :return: The result of the API call. + """ + if not BYPASS_THROTTLER.get(): + await self._throttler.acquire() + client = await self._ensure_connected() + return await func(client) # Rotor (radio station) methods @@ -107,48 +197,41 @@ async def get_rotor_station_tracks( :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_mix_tracks( + try: + result = await self._call_with_retry( + lambda c: c.rotor_station_tracks(station_id, settings2=True, queue=queue) + ) + except BadRequestError as err: + LOGGER.warning("Error fetching rotor station %s tracks: %s", station_id, err) + return ([], None) + except (NetworkError, ProviderUnavailableError) as err: + LOGGER.warning("Error fetching rotor station tracks: %s", err) + return ([], None) + + 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) + try: + full_tracks = await self.get_tracks(track_ids) + except ResourceTemporarilyUnavailable as err: + LOGGER.warning("Error fetching rotor station track details: %s", err) + return ([], result.batch_id if result else None) + 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) + + async def get_my_wave_tracks( self, queue: str | int | None = None ) -> tuple[list[YandexTrack], str | None]: - """Get tracks from the My Mix (Мой Микс) radio station. + """Get tracks from the My Mix 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). @@ -168,22 +251,21 @@ async def send_rotor_station_feedback( """Send rotor station feedback for My Mix recommendations. Used to report radioStarted, trackStarted, trackFinished, skip so that - the service can improve subsequent recommendations. + Kion can improve subsequent recommendations. :param station_id: Station ID (e.g. ROTOR_STATION_MY_MIX). :param feedback_type: One of 'radioStarted', 'trackStarted', 'trackFinished', 'skip'. - :param batch_id: Optional batch ID from the last get_my_mix_tracks response. + :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"] = ROTOR_FEEDBACK_FROM + payload["from"] = "KionMusicDesktopAppWindows" if track_id is not None: payload["trackId"] = track_id if total_played_seconds is not None: @@ -191,46 +273,50 @@ async def send_rotor_station_feedback( 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 + async def _post(c: ClientAsync) -> bool: + url = f"{c.base_url}/rotor/station/{station_id}/feedback" + await c._request.post(url, payload) + return True + + try: + result = await self._call_no_retry(_post) + LOGGER.debug( + "Rotor feedback %s track_id=%s total_played_seconds=%s", + feedback_type, + track_id, + total_played_seconds, + ) + return result + except BadRequestError as err: + LOGGER.warning("Rotor feedback %s failed: %s", feedback_type, err) + return False + except (NetworkError, ProviderUnavailableError) as err: + LOGGER.warning("Rotor feedback %s failed: %s", feedback_type, err) + return False # Library methods async def get_liked_tracks(self) -> list[TrackShort]: - """Get user's liked tracks. + """Get user's liked tracks sorted by timestamp (most recent first). - :return: List of liked track objects. + :return: List of liked track objects sorted in reverse chronological order. """ - client = self._ensure_connected() try: - result = await client.users_likes_tracks() + result = await self._call_with_retry(lambda c: c.users_likes_tracks()) if result is None: return [] - return result.tracks or [] - except (BadRequestError, NetworkError) as err: + tracks = result.tracks or [] + # Sort by timestamp in descending order (most recently liked first) + # TrackShort objects have a timestamp field containing the date the track was liked + return sorted( + tracks, + key=lambda t: getattr(t, "timestamp", datetime.min.replace(tzinfo=UTC)), + reverse=True, + ) + except BadRequestError as err: + LOGGER.error("Error fetching liked tracks: %s", err) + raise ResourceTemporarilyUnavailable("Failed to fetch liked tracks") from err + except (NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error fetching liked tracks: %s", err) raise ResourceTemporarilyUnavailable("Failed to fetch liked tracks") from err @@ -242,53 +328,55 @@ async def get_liked_albums(self, batch_size: int = 50) -> list[YandexAlbum]: :return: List of liked album objects with full details. """ - client = self._ensure_connected() try: - result = await client.users_likes_albums() - if result is None: - return [] - album_ids = [ - str(like.album.id) for like in result if like.album is not None and like.album.id - ] - if not album_ids: - return [] - # Fetch full album details in batches to get cover_uri and other metadata - # batch_size is now a parameter with default 50 - full_albums: list[YandexAlbum] = [] - for i in range(0, len(album_ids), batch_size): - batch = album_ids[i : i + batch_size] - try: - batch_result = await client.albums(batch) - if batch_result: - full_albums.extend(batch_result) - except (BadRequestError, NetworkError) as batch_err: - LOGGER.warning("Error fetching album details batch: %s", batch_err) - # Fall back to minimal data for this batch - batch_set = set(batch) - for like in result: - if ( - like.album is not None - and like.album.id - and str(like.album.id) in batch_set - ): - full_albums.append(like.album) - return full_albums - except (BadRequestError, NetworkError) as err: + result = await self._call_with_retry(lambda c: c.users_likes_albums()) + except BadRequestError as err: + LOGGER.error("Error fetching liked albums: %s", err) + raise ResourceTemporarilyUnavailable("Failed to fetch liked albums") from err + except (NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error fetching liked albums: %s", err) raise ResourceTemporarilyUnavailable("Failed to fetch liked albums") from err + if result is None: + return [] + album_ids = [ + str(like.album.id) for like in result if like.album is not None and like.album.id + ] + if not album_ids: + return [] + # Fetch full album details in batches to get cover_uri and other metadata + full_albums: list[YandexAlbum] = [] + for i in range(0, len(album_ids), batch_size): + batch = album_ids[i : i + batch_size] + try: + batch_result = await self._call_with_retry( + lambda c, _b=batch: c.albums(_b) # type: ignore[misc] + ) + if batch_result: + full_albums.extend(batch_result) + except (BadRequestError, NetworkError, ProviderUnavailableError) as batch_err: + LOGGER.warning("Error fetching album details batch: %s", batch_err) + # Fall back to minimal data for this batch + batch_set = set(batch) + for like in result: + if like.album is not None and like.album.id and str(like.album.id) in batch_set: + full_albums.append(like.album) + return full_albums + async def get_liked_artists(self) -> list[YandexArtist]: """Get user's liked artists. :return: List of liked artist objects. """ - client = self._ensure_connected() try: - result = await client.users_likes_artists() + result = await self._call_with_retry(lambda c: c.users_likes_artists()) if result is None: return [] return [like.artist for like in result if like.artist is not None] - except (BadRequestError, NetworkError) as err: + except BadRequestError as err: + LOGGER.error("Error fetching liked artists: %s", err) + raise ResourceTemporarilyUnavailable("Failed to fetch liked artists") from err + except (NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error fetching liked artists: %s", err) raise ResourceTemporarilyUnavailable("Failed to fetch liked artists") from err @@ -297,15 +385,38 @@ async def get_user_playlists(self) -> list[YandexPlaylist]: :return: List of playlist objects. """ - client = self._ensure_connected() try: - result = await client.users_playlists_list() + result = await self._call_with_retry(lambda c: c.users_playlists_list()) if result is None: return [] return list(result) - except (BadRequestError, NetworkError) as err: + except BadRequestError as err: LOGGER.error("Error fetching playlists: %s", err) raise ResourceTemporarilyUnavailable("Failed to fetch playlists") from err + except (NetworkError, ProviderUnavailableError) as err: + LOGGER.error("Error fetching playlists: %s", err) + raise ResourceTemporarilyUnavailable("Failed to fetch playlists") from err + + async def get_liked_playlists(self) -> list[YandexPlaylist]: + """Get user's liked/saved editorial playlists. + + :return: List of liked playlist objects. + """ + try: + result = await self._call_with_retry(lambda c: c.users_likes_playlists()) + if result is None: + return [] + playlists = [] + for like in result: + if like.playlist is not None: + playlists.append(like.playlist) + return playlists + except BadRequestError as err: + LOGGER.error("Error fetching liked playlists: %s", err) + raise ResourceTemporarilyUnavailable("Failed to fetch liked playlists") from err + except (NetworkError, ProviderUnavailableError) as err: + LOGGER.error("Error fetching liked playlists: %s", err) + raise ResourceTemporarilyUnavailable("Failed to fetch liked playlists") from err # Search @@ -322,10 +433,14 @@ async def search( :param limit: Maximum number of results per type. :return: Search results object. """ - client = self._ensure_connected() try: - return await client.search(query, type_=search_type, page=0, nocorrect=False) - except (BadRequestError, NetworkError) as err: + return await self._call_with_retry( + lambda c: c.search(query, type_=search_type, page=0, nocorrect=False) + ) + except BadRequestError as err: + LOGGER.error("Search error: %s", err) + raise ResourceTemporarilyUnavailable("Search failed") from err + except (NetworkError, ProviderUnavailableError) as err: LOGGER.error("Search error: %s", err) raise ResourceTemporarilyUnavailable("Search failed") from err @@ -337,14 +452,78 @@ async def get_track(self, track_id: str) -> YandexTrack | None: :param track_id: Track ID. :return: Track object or None if not found. """ - client = self._ensure_connected() try: - tracks = await client.tracks([track_id]) + tracks = await self._call_with_retry(lambda c: c.tracks([track_id])) return tracks[0] if tracks else None - except (BadRequestError, NetworkError) as err: + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error fetching track %s: %s", track_id, err) return None + async def get_track_lyrics(self, track_id: str) -> tuple[str | None, bool]: + """Get lyrics for a track. + + Fetches lyrics from KION Music API. Returns the lyrics text and whether + it's in synced LRC format (with timestamps) or plain text. + + Note: This method fetches the track first to check lyrics_available. If you + already have the YandexTrack object, use get_track_lyrics_from_track() to + avoid a redundant API call. + + :param track_id: Track ID. + :return: Tuple of (lyrics_text, is_synced). Returns (None, False) if unavailable. + """ + try: + tracks = await self._call_with_retry(lambda c: c.tracks([track_id])) + if not tracks: + return None, False + + return await self.get_track_lyrics_from_track(tracks[0]) + + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.debug("Error fetching lyrics for track %s: %s", track_id, err) + return None, False + except Exception as err: + # Catch any other errors (e.g., geo-restrictions, API changes) + LOGGER.debug("Unexpected error fetching lyrics for track %s: %s", track_id, err) + return None, False + + async def get_track_lyrics_from_track(self, track: YandexTrack) -> tuple[str | None, bool]: + """Get lyrics for an already-fetched track. + + Avoids the extra tracks([track_id]) API call when the YandexTrack object + is already available. + + :param track: YandexTrack object (already fetched). + :return: Tuple of (lyrics_text, is_synced). Returns (None, False) if unavailable. + """ + track_id = getattr(track, "id", None) or getattr(track, "track_id", "unknown") + try: + if not getattr(track, "lyrics_available", False): + LOGGER.debug("Lyrics not available for track %s", track_id) + return None, False + + track_lyrics = await track.get_lyrics_async() + if not track_lyrics: + LOGGER.debug("Failed to get lyrics metadata for track %s", track_id) + return None, False + + lyrics_text = await track_lyrics.fetch_lyrics_async() + if not lyrics_text: + return None, False + + # Check if it's LRC format (synced lyrics have timestamps like [00:12.34]) + # Use re.search without ^ so metadata lines like [ar:Artist] don't prevent detection + is_synced = bool(re.search(r"\[\d{1,2}:\d{1,2}(?:\.\d{2,3})?\]", lyrics_text)) + return lyrics_text, is_synced + + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.debug("Error fetching lyrics for track %s: %s", track_id, err) + return None, False + except Exception as err: + # Catch any other errors (e.g., geo-restrictions, API changes) + LOGGER.debug("Unexpected error fetching lyrics for track %s: %s", track_id, err) + return None, False + async def get_tracks(self, track_ids: list[str]) -> list[YandexTrack]: """Get multiple tracks by IDs. @@ -352,22 +531,15 @@ async def get_tracks(self, track_ids: list[str]) -> list[YandexTrack]: :return: List of track objects. :raises ResourceTemporarilyUnavailable: On network errors after retry. """ - client = self._ensure_connected() try: - result = await client.tracks(track_ids) + result = await self._call_with_retry(lambda c: c.tracks(track_ids)) return result or [] - except NetworkError as err: - # Retry once on network errors (timeout, disconnect, etc.) - LOGGER.warning("Network error fetching tracks, retrying once: %s", err) - try: - result = await client.tracks(track_ids) - return result or [] - except NetworkError as retry_err: - LOGGER.error("Error fetching tracks (retry failed): %s", retry_err) - raise ResourceTemporarilyUnavailable("Failed to fetch tracks") from retry_err except BadRequestError as err: LOGGER.error("Error fetching tracks: %s", err) return [] + except (NetworkError, ProviderUnavailableError) as err: + LOGGER.error("Error fetching tracks (retry failed): %s", err) + raise ResourceTemporarilyUnavailable("Failed to fetch tracks") from err async def get_album(self, album_id: str) -> YandexAlbum | None: """Get a single album by ID. @@ -375,11 +547,10 @@ async def get_album(self, album_id: str) -> YandexAlbum | None: :param album_id: Album ID. :return: Album object or None if not found. """ - client = self._ensure_connected() try: - albums = await client.albums([album_id]) + albums = await self._call_with_retry(lambda c: c.albums([album_id])) return albums[0] if albums else None - except (BadRequestError, NetworkError) as err: + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error fetching album %s: %s", album_id, err) return None @@ -393,18 +564,22 @@ async def get_album_with_tracks(self, album_id: str) -> YandexAlbum | None: :param album_id: Album ID. :return: Album object with tracks or None if not found. """ - client = self._ensure_connected() + + async def _fetch(c: ClientAsync) -> YandexAlbum | None: + try: + return await c.albums_with_tracks( + album_id, + resumeStream=True, + richTracks=True, + withListeningFinished=True, + ) + except TypeError: + # Older kion-music may not accept these kwargs + return await c.albums_with_tracks(album_id) + try: - return await client.albums_with_tracks( - album_id, - resumeStream=True, - richTracks=True, - withListeningFinished=True, - ) - except TypeError: - # Older yandex-music may not accept these kwargs - return await client.albums_with_tracks(album_id) - except (BadRequestError, NetworkError) as err: + return await self._call_with_retry(_fetch) + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error fetching album with tracks %s: %s", album_id, err) return None @@ -414,11 +589,10 @@ async def get_artist(self, artist_id: str) -> YandexArtist | None: :param artist_id: Artist ID. :return: Artist object or None if not found. """ - client = self._ensure_connected() try: - artists = await client.artists([artist_id]) + artists = await self._call_with_retry(lambda c: c.artists([artist_id])) return artists[0] if artists else None - except (BadRequestError, NetworkError) as err: + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error fetching artist %s: %s", artist_id, err) return None @@ -431,13 +605,14 @@ async def get_artist_albums( :param limit: Maximum number of albums. :return: List of album objects. """ - client = self._ensure_connected() try: - result = await client.artists_direct_albums(artist_id, page=0, page_size=limit) + result = await self._call_with_retry( + lambda c: c.artists_direct_albums(artist_id, page=0, page_size=limit) + ) if result is None: return [] return result.albums or [] - except (BadRequestError, NetworkError) as err: + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error fetching artist albums %s: %s", artist_id, err) return [] @@ -450,13 +625,14 @@ async def get_artist_tracks( :param limit: Maximum number of tracks. :return: List of track objects. """ - client = self._ensure_connected() try: - result = await client.artists_tracks(artist_id, page=0, page_size=limit) + result = await self._call_with_retry( + lambda c: c.artists_tracks(artist_id, page=0, page_size=limit) + ) if result is None: return [] return result.tracks or [] - except (BadRequestError, NetworkError) as err: + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error fetching artist tracks %s: %s", artist_id, err) return [] @@ -468,18 +644,19 @@ async def get_playlist(self, user_id: str, playlist_id: str) -> YandexPlaylist | :return: Playlist object or None if not found. :raises ResourceTemporarilyUnavailable: On network errors. """ - client = self._ensure_connected() try: - result = await client.users_playlists(kind=int(playlist_id), user_id=user_id) + result = await self._call_with_retry( + lambda c: c.users_playlists(kind=int(playlist_id), user_id=user_id) + ) if isinstance(result, list): return result[0] if result else None return result - except NetworkError as err: - LOGGER.warning("Network error fetching playlist %s/%s: %s", user_id, playlist_id, err) - raise ResourceTemporarilyUnavailable("Failed to fetch playlist") from err except BadRequestError as err: LOGGER.error("Error fetching playlist %s/%s: %s", user_id, playlist_id, err) return None + except (NetworkError, ProviderUnavailableError) as err: + LOGGER.warning("Network error fetching playlist %s/%s: %s", user_id, playlist_id, err) + raise ResourceTemporarilyUnavailable("Failed to fetch playlist") from err # Streaming @@ -492,11 +669,12 @@ async def get_track_download_info( :param get_direct_links: Whether to get direct download links. :return: List of download info objects. """ - client = self._ensure_connected() try: - result = await client.tracks_download_info(track_id, get_direct_links=get_direct_links) + result = await self._call_with_retry( + lambda c: c.tracks_download_info(track_id, get_direct_links=get_direct_links) + ) return result or [] - except (BadRequestError, NetworkError) as err: + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error fetching download info for track %s: %s", track_id, err) return [] @@ -506,89 +684,380 @@ async def get_track_file_info_lossless(self, track_id: str) -> dict[str, Any] | The /tracks/{id}/download-info endpoint often returns only MP3; get-file-info with quality=lossless and codecs=flac,... returns FLAC when available. - Includes retry with reconnect on transient connection errors so that a - momentary disconnect does not silently fall back to lossy quality. + Uses manual sign calculation matching kion-music-downloader-realflac. + Uses _call_with_retry for automatic reconnection on transient failures. :param track_id: Track ID. :return: Parsed downloadInfo dict (url, codec, urls, ...) or None on error. """ + def _build_signed_params(client: ClientAsync) -> tuple[str, dict[str, Any]]: + """Build URL and signed params using current client and timestamp. + + Called on each attempt by _call_with_retry, so the HMAC signature + is recomputed with a fresh timestamp on every retry. + """ + timestamp = int(time.time()) + params = { + "ts": timestamp, + "trackId": track_id, + "quality": "lossless", + "codecs": GET_FILE_INFO_CODECS, + "transports": "encraw", + } + # Build sign string explicitly matching Kion API specification: + # concatenate ts + trackId + quality + codecs (commas stripped) + transports. + # Comma stripping matches kion-music-downloader-realflac reference implementation + # (see get_file_info signing in that project). + codecs_for_sign = GET_FILE_INFO_CODECS.replace(",", "") + param_string = f"{timestamp}{track_id}lossless{codecs_for_sign}encraw" + hmac_sign = hmac.new( + DEFAULT_SIGN_KEY.encode(), + param_string.encode(), + hashlib.sha256, + ) + # SHA-256 (32 bytes) -> base64 = 44 chars with "=" padding. + # Kion API expects exactly 43 chars (one "=" removed). + # Matches kion-music-downloader-realflac reference implementation. + params["sign"] = base64.b64encode(hmac_sign.digest()).decode().rstrip("=") + url = f"{client.base_url}/get-file-info" + return url, params + def _parse_file_info_result(raw: dict[str, Any] | None) -> dict[str, Any] | None: if not raw or not isinstance(raw, dict): return None - download_info = raw.get("download_info") + # yandex-music v3 no longer normalises camelCase keys inside + # Response.result, so /get-file-info returns "downloadInfo" as-is. + download_info = raw.get("download_info") or raw.get("downloadInfo") if not download_info or not download_info.get("url"): return None - return cast("dict[str, Any]", download_info) - for attempt in range(2): - client = self._ensure_connected() - sign = get_sign_request(track_id) - base_params = { - "ts": sign.timestamp, - "trackId": track_id, - "quality": "lossless", - "codecs": GET_FILE_INFO_CODECS, - "sign": sign.value, - } + result = cast("dict[str, Any]", download_info) - url = f"{client.base_url}/get-file-info" - params_encraw = {**base_params, "transports": "encraw"} - try: - result = await client.request.get(url, params=params_encraw) - return _parse_file_info_result(result) - except UnauthorizedError as err: + if "key" in download_info: + result["needs_decryption"] = True LOGGER.debug( - "get-file-info lossless for track %s (transports=encraw): %s %s", + "Encrypted URL received for track %s, will require decryption", track_id, - type(err).__name__, - getattr(err, "message", str(err)) or repr(err), ) + else: + result["needs_decryption"] = False + + return result + + async def _do_request(c: ClientAsync) -> dict[str, Any] | None: + url, params = _build_signed_params(c) + return await c._request.get(url, params=params) # type: ignore[no-any-return] + + try: + result = await self._call_with_retry(_do_request) + parsed = _parse_file_info_result(result) + if parsed: LOGGER.debug( - "If you have KION Music Plus and this track has lossless, " - "try a token from the web client (music.mts.ru)." + "get-file-info lossless for track %s: Success, codec=%s", + track_id, + parsed.get("codec"), ) - params_raw = {**base_params, "transports": "raw"} - try: - result = await client.request.get(url, params=params_raw) - return _parse_file_info_result(result) - except (BadRequestError, NetworkError, UnauthorizedError) as retry_err: - LOGGER.debug( - "get-file-info lossless for track %s (transports=raw): %s %s", - track_id, - type(retry_err).__name__, - getattr(retry_err, "message", str(retry_err)) or repr(retry_err), - ) - return None - except BadRequestError as err: + return parsed + except (BadRequestError, NetworkError) as err: + LOGGER.debug( + "get-file-info lossless for track %s: %s %s", + track_id, + type(err).__name__, + getattr(err, "message", str(err)) or repr(err), + ) + except UnauthorizedError as err: + LOGGER.debug( + "get-file-info lossless for track %s: UnauthorizedError %s", + track_id, + getattr(err, "message", str(err)) or repr(err), + ) + except Exception as err: + LOGGER.warning( + "get-file-info lossless for track %s: Unexpected error: %s", + track_id, + err, + exc_info=True, + ) + + return None + + # Discovery / recommendations + + async def get_feed(self) -> Feed | None: + """Get personalized feed with generated playlists (Playlist of the Day, etc.). + + :return: Feed object with generated_playlists, or None on error. + """ + try: + return await self._call_with_retry(lambda c: c.feed()) + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.debug("Error fetching feed: %s", err) + return None + + async def get_chart(self, chart_option: str = "") -> ChartInfo | None: + """Get chart data. + + :param chart_option: Optional chart variant (e.g. 'world', 'russia'). + :return: ChartInfo object or None on error. + """ + try: + return await self._call_with_retry(lambda c: c.chart(chart_option)) + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.debug("Error fetching chart: %s", err) + return None + + async def get_new_releases(self) -> LandingList | None: + """Get new album releases. + + :return: LandingList with new_releases (list of album IDs) or None on error. + """ + try: + return await self._call_with_retry(lambda c: c.new_releases()) + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.debug("Error fetching new releases: %s", err) + return None + + async def get_new_playlists(self) -> LandingList | None: + """Get new editorial playlists. + + :return: LandingList with new_playlists (list of PlaylistId) or None on error. + """ + try: + return await self._call_with_retry(lambda c: c.new_playlists()) + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.debug("Error fetching new playlists: %s", err) + return None + + async def get_albums(self, album_ids: list[str]) -> list[YandexAlbum]: + """Get multiple albums by IDs. + + :param album_ids: List of album IDs. + :return: List of album objects. + """ + try: + result = await self._call_with_retry(lambda c: c.albums(album_ids)) + return result or [] + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.debug("Error fetching albums: %s", err) + return [] + + async def get_playlists(self, playlist_ids: list[str]) -> list[YandexPlaylist]: + """Get multiple playlists by IDs (format: 'uid:kind'). + + :param playlist_ids: List of playlist IDs in 'uid:kind' format. + :return: List of playlist objects. + """ + try: + result = await self._call_with_retry(lambda c: c.playlists_list(playlist_ids)) + return result or [] + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.debug("Error fetching playlists: %s", err) + return [] + + async def get_tag_playlists(self, tag_id: str) -> list[YandexPlaylist]: + """Get playlists for a specific tag (mood, era, activity, genre, etc.). + + Tags are used for curated collections like 'chill', '80s', 'workout', 'rock', etc. + The API returns playlist IDs which are then fetched in full. + + :param tag_id: Tag identifier (e.g. 'chill', '80s', 'workout', 'rock'). + :return: List of playlist objects with full details. + """ + try: + tag_result = await self._call_with_retry(lambda c: c.tags(tag_id)) + if not tag_result or not tag_result.ids: + LOGGER.debug("No playlists found for tag: %s", tag_id) + return [] + + # Convert PlaylistId objects to 'uid:kind' format + playlist_ids = [f"{pid.uid}:{pid.kind}" for pid in tag_result.ids] + + # Fetch full playlist details + return await self.get_playlists(playlist_ids) + except BadRequestError as err: + LOGGER.debug("Tag %s not found: %s", tag_id, err) + return [] + except (NetworkError, ProviderUnavailableError) as err: + LOGGER.debug("Error fetching tag %s playlists: %s", tag_id, err) + return [] + + async def get_landing_tags(self) -> list[tuple[str, str]]: + """Discover available tag slugs from the landing mixes block. + + Uses the landing("mixes") API which returns MixLink entities + containing tag URLs (e.g., /tag/chill/) and display titles. + Filters out editorial post entries (/post/ URLs) which have no playlists. + + :return: List of (tag_slug, title) tuples for real tag entries only. + """ + try: + landing: Landing | None = await self._call_with_retry(lambda c: c.landing("mixes")) + if not landing or not landing.blocks: + return [] + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.debug("Error fetching landing tags: %s", err) + return [] + + tags: list[tuple[str, str]] = [] + for block in landing.blocks: + if not block.entities: + continue + for entity in block.entities: + if entity.type == "mix-link" and isinstance(entity.data, MixLink): + url = entity.data.url # e.g., "/tag/chill/" or "/post/..." + # Filter out editorial posts — only include /tag/ URLs + if not url.startswith("/tag/"): + continue + slug = url.strip("/").split("/")[-1] + if slug: + tags.append((slug, entity.data.title)) + return tags + + async def get_mixes_waves(self) -> list[dict[str, Any]] | None: + """Get AI Wave Set stations from /landing-blocks/mixes-waves endpoint. + + Returns structured mix data with categories and station items, each + containing station_id, title, seeds, and visual metadata. + + :return: List of mix category dicts, or None on error. + """ + return await self._get_landing_waves("mixes-waves") + + async def get_waves_landing(self) -> list[dict[str, Any]] | None: + """Get featured wave stations from /landing-blocks/waves endpoint. + + Returns Kion-curated wave categories with station items — the "Волны" + landing page content, separate from the full rotor/stations/list and from + the AI mixes-waves sets. + + :return: List of wave category dicts, or None on error. + """ + return await self._get_landing_waves("waves") + + async def _get_landing_waves(self, block: str) -> list[dict[str, Any]] | None: + """Fetch wave categories from a /landing-blocks/ endpoint. + + Note: Response keys are auto-converted from camelCase to snake_case + by the kion-music library's JSON parser. + + :param block: Block name, e.g. 'waves' or 'mixes-waves'. + :return: List of wave category dicts, or None on error. + """ + + async def _get(c: ClientAsync) -> dict[str, Any]: + url = f"{c.base_url}/landing-blocks/{block}" + return await c._request.get(url) # type: ignore[no-any-return] + + try: + result = await self._call_with_retry(_get) + if result and isinstance(result, dict): + waves = result.get("waves", []) LOGGER.debug( - "get-file-info lossless for track %s: %s %s", - track_id, - type(err).__name__, - getattr(err, "message", str(err)) or repr(err), + "landing-blocks/%s returned %d categories", + block, + len(waves) if isinstance(waves, list) else -1, ) - return None - except (NetworkError, Exception) as err: - if attempt == 0 and self._is_connection_error(err): - LOGGER.warning( - "Connection error on get-file-info lossless for track %s, reconnecting: %s", - track_id, - err, - ) - try: - await self._reconnect() - except Exception as recon_err: - LOGGER.debug("Reconnect failed: %s", recon_err) - return None + return waves if isinstance(waves, list) else [] + return None + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.debug("Error fetching landing-blocks/%s: %s", block, err) + return None + + async def get_wave_stations( + self, language: str | None = None + ) -> list[tuple[str, str, str, str | None]]: + """Get available rotor wave stations grouped by category. + + Calls rotor_stations_list() — equivalent to the rotor/stations/list API endpoint. + Filters out personal stations (type 'user') since My Mix is handled separately. + + :param language: Language for station names (e.g. 'ru', 'en'). Defaults to API default. + :return: List of (station_id, category, name, image_url) tuples, + e.g. ('genre:rock', 'genre', 'Рок', 'https://...'). + """ + try: + results: list[StationResult] = await self._call_with_retry( + lambda c: c.rotor_stations_list(language) + ) + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.warning("Error fetching wave stations: %s", err) + return [] + + stations: list[tuple[str, str, str, str | None]] = [] + for result in results or []: + station = result.station + if station is None or station.id is None: + continue + category = station.id.type + tag = station.id.tag + if not category or not tag: + continue + if category in ("user", "local-language"): + # Skip personal stations (My Mix is handled separately) + # and local-language stations (Kion returns overlapping tracks across them) + continue + station_id = f"{category}:{tag}" + name = station.name or result.rup_title or tag + image_url: str | None = None + raw_url = station.full_image_url or (station.icon.image_url if station.icon else None) + if raw_url: + # Kion avatar URIs use '%%' as a size placeholder; replace it with + # the desired size. If no placeholder, append the size as a suffix + # since these URLs return HTTP 400 without a size component. + if not raw_url.startswith("http"): + raw_url = f"https://{raw_url}" + if "%%" in raw_url: + image_url = raw_url.replace("%%", "400x400") else: - LOGGER.debug( - "get-file-info lossless for track %s: %s %s", - track_id, - type(err).__name__, - getattr(err, "message", str(err)) or repr(err), - ) - return None - return None + image_url = f"{raw_url}/400x400" + stations.append((station_id, category, name, image_url)) + return stations + + async def get_dashboard_stations(self) -> list[tuple[str, str, str | None]]: + """Get personalized recommended stations for the current user. + + Calls rotor_stations_dashboard() — returns user-specific stations based + on listening history, unlike rotor_stations_list() which is non-personalized. + + :return: List of (station_id, name, image_url) tuples, + e.g. ('genre:rock', 'Рок', 'https://...'). + """ + try: + dashboard: Dashboard | None = await self._call_with_retry( + lambda c: c.rotor_stations_dashboard() + ) + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: + LOGGER.warning("Error fetching dashboard stations: %s", err) + return [] + + if not dashboard or not dashboard.stations: + return [] + + stations: list[tuple[str, str, str | None]] = [] + for result in dashboard.stations: + station = result.station + if station is None or station.id is None: + continue + category = station.id.type + tag = station.id.tag + if not category or not tag: + continue + if category == "user": + continue + station_id = f"{category}:{tag}" + name = station.name or result.rup_title or tag + image_url: str | None = None + raw_url = station.full_image_url or (station.icon.image_url if station.icon else None) + if raw_url: + if not raw_url.startswith("http"): + raw_url = f"https://{raw_url}" + if "%%" in raw_url: + image_url = raw_url.replace("%%", "400x400") + else: + image_url = f"{raw_url}/400x400" + stations.append((station_id, name, image_url)) + return stations # Library modifications @@ -598,11 +1067,10 @@ async def like_track(self, track_id: str) -> bool: :param track_id: Track ID to like. :return: True if successful. """ - client = self._ensure_connected() try: - result = await client.users_likes_tracks_add(track_id) + result = await self._call_with_retry(lambda c: c.users_likes_tracks_add(track_id)) return result is not None - except (BadRequestError, NetworkError) as err: + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error liking track %s: %s", track_id, err) return False @@ -612,11 +1080,10 @@ async def unlike_track(self, track_id: str) -> bool: :param track_id: Track ID to unlike. :return: True if successful. """ - client = self._ensure_connected() try: - result = await client.users_likes_tracks_remove(track_id) + result = await self._call_with_retry(lambda c: c.users_likes_tracks_remove(track_id)) return result is not None - except (BadRequestError, NetworkError) as err: + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error unliking track %s: %s", track_id, err) return False @@ -626,11 +1093,10 @@ async def like_album(self, album_id: str) -> bool: :param album_id: Album ID to like. :return: True if successful. """ - client = self._ensure_connected() try: - result = await client.users_likes_albums_add(album_id) + result = await self._call_with_retry(lambda c: c.users_likes_albums_add(album_id)) return result is not None - except (BadRequestError, NetworkError) as err: + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error liking album %s: %s", album_id, err) return False @@ -640,11 +1106,10 @@ async def unlike_album(self, album_id: str) -> bool: :param album_id: Album ID to unlike. :return: True if successful. """ - client = self._ensure_connected() try: - result = await client.users_likes_albums_remove(album_id) + result = await self._call_with_retry(lambda c: c.users_likes_albums_remove(album_id)) return result is not None - except (BadRequestError, NetworkError) as err: + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error unliking album %s: %s", album_id, err) return False @@ -654,11 +1119,10 @@ async def like_artist(self, artist_id: str) -> bool: :param artist_id: Artist ID to like. :return: True if successful. """ - client = self._ensure_connected() try: - result = await client.users_likes_artists_add(artist_id) + result = await self._call_with_retry(lambda c: c.users_likes_artists_add(artist_id)) return result is not None - except (BadRequestError, NetworkError) as err: + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error liking artist %s: %s", artist_id, err) return False @@ -668,10 +1132,9 @@ async def unlike_artist(self, artist_id: str) -> bool: :param artist_id: Artist ID to unlike. :return: True if successful. """ - client = self._ensure_connected() try: - result = await client.users_likes_artists_remove(artist_id) + result = await self._call_with_retry(lambda c: c.users_likes_artists_remove(artist_id)) return result is not None - except (BadRequestError, NetworkError) as err: + except (BadRequestError, NetworkError, ProviderUnavailableError) as err: LOGGER.error("Error unliking artist %s: %s", artist_id, err) return False diff --git a/music_assistant/providers/kion_music/constants.py b/music_assistant/providers/kion_music/constants.py index 70b8a805f4..a597d8aa68 100644 --- a/music_assistant/providers/kion_music/constants.py +++ b/music_assistant/providers/kion_music/constants.py @@ -19,15 +19,23 @@ # API defaults DEFAULT_LIMIT: Final[int] = 50 -DEFAULT_BASE_URL: Final[str] = "https://music.mts.ru/ya_proxy_api" +DEFAULT_BASE_URL: Final[str] = "https://api.music.yandex.net" +WEB_BASE_URL: Final[str] = "https://music.yandex.ru" -# Quality options -QUALITY_HIGH = "high" -QUALITY_LOSSLESS = "lossless" +# Quality options (matching reference implementation) +QUALITY_EFFICIENT = "efficient" # Low quality, efficient bandwidth (~64kbps AAC) +QUALITY_BALANCED = "balanced" # Medium quality, balanced performance (~192kbps AAC) +QUALITY_HIGH = "high" # High quality, lossy (~320kbps MP3) +QUALITY_SUPERB = "superb" # Highest quality, lossless (FLAC) -# Default tuning values for My Mix / browse / discovery behaviour -MY_MIX_MAX_TRACKS: Final[int] = 150 -MY_MIX_BATCH_SIZE: Final[int] = 3 +# Configuration keys for My Mix behavior (kept) +CONF_MY_WAVE_MAX_TRACKS: Final[str] = "my_wave_max_tracks" + +# Configuration keys for Liked Tracks behavior (kept) +CONF_LIKED_TRACKS_MAX_TRACKS: Final[str] = "liked_tracks_max_tracks" + +# Hardcoded default values for removed config entries +MY_WAVE_BATCH_SIZE: Final[int] = 3 TRACK_BATCH_SIZE: Final[int] = 50 DISCOVERY_INITIAL_TRACKS: Final[int] = 20 BROWSE_INITIAL_TRACKS: Final[int] = 15 @@ -37,35 +45,302 @@ IMAGE_SIZE_MEDIUM = "400x400" IMAGE_SIZE_LARGE = "1000x1000" +# Locale-aware provider display names for owner normalization +PROVIDER_DISPLAY_NAME_RU: Final[str] = "KION Music" +PROVIDER_DISPLAY_NAME_EN: Final[str] = "KION Music" + +# Known API-returned system owner name variants (all locales/capitalizations) +# All entries are lowercase; compare with owner_name.lower() for case-insensitive lookup +YANDEX_SYSTEM_OWNER_NAMES: Final[frozenset[str]] = frozenset( + { + "кион музыка", + "кион.музыка", + "kion.music", + "kionmusic", + "kion music", + } +) + # ID separators PLAYLIST_ID_SPLITTER: Final[str] = ":" # Rotor (radio) station identifiers ROTOR_STATION_MY_MIX: Final[str] = "user:onyourwave" -# Client identifier for rotor radioStarted feedback. -# The API expects a "from" field identifying the client; the desktop app -# identifier ensures the rotor API returns proper recommendations. -ROTOR_FEEDBACK_FROM: Final[str] = "YandexMusicDesktopAppWindows" - # Virtual playlist ID for My Mix (used in get_playlist / get_playlist_tracks; not owner_id:kind) -MY_MIX_PLAYLIST_ID: Final[str] = "my_mix" +MY_WAVE_PLAYLIST_ID: Final[str] = "my_wave" + +# Virtual playlist ID for Liked Tracks +LIKED_TRACKS_PLAYLIST_ID: Final[str] = "liked_tracks" # Composite item_id for My Mix 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_mix": "Мой Микс", + "my_wave": "Мой микс", "artists": "Мои исполнители", "albums": "Мои альбомы", "tracks": "Мне нравится", "playlists": "Мои плейлисты", + "feed": "Для вас", + "chart": "Чарт", + "new_releases": "Новинки", + "new_playlists": "Новые плейлисты", + # Picks & Mixes + "picks": "Подборки", + "mixes": "Миксы", + "mood": "Настроение", + "activity": "Активность", + "era": "Эпоха", + "genres": "Жанры", + # Mood tags + "chill": "Расслабляющее", + "sad": "Грустное", + "romantic": "Романтическое", + "party": "Вечеринка", + "relax": "Релакс", + # Activity tags + "workout": "Тренировка", + "focus": "Концентрация", + "morning": "Утро", + "evening": "Вечер", + "driving": "В дороге", # noqa: RUF001 + # Era tags + "80s": "80-е", # noqa: RUF001 + "90s": "90-е", # noqa: RUF001 + "2000s": "2000-е", # noqa: RUF001 + "retro": "Ретро", + # Genre tags + "rock": "Рок", + "jazz": "Джаз", + "classical": "Классика", + "electronic": "Электроника", + "rnb": "R&B", + "hiphop": "Хип-хоп", + "top": "Топ", + "newbies": "По жанру", + # Landing-discovered tags + "in the mood": "В настроение", # noqa: RUF001 + "background": "Послушать фоном", + # Seasonal tags + "winter": "Зима", + "summer": "Лето", + "autumn": "Осень", + "spring": "Весна", + "newyear": "Новый год", + # Liked Tracks + "liked_tracks": "Мне нравится", + # Discovery + "top_picks": "Топ подборки", + "mood_mix": "Настроение", + "activity_mix": "Активность", + "seasonal_mix": "Сезонное", + # Top-level browse groups + "for_you": "Для вас", + "collection": "Коллекция", + # Waves / Radio (rotor station categories) + "waves": "Радио", + "radio": "Радио", + "my_waves": "Персональные", + "my_waves_set": "AI Сеты", + "waves_landing": "Избранные миксы", + "genre": "Жанры", + "epoch": "Эпоха", + "local": "Местное", } BROWSE_NAMES_EN: Final[dict[str, str]] = { - "my_mix": "My Mix", + "my_wave": "My Mix", "artists": "My Artists", "albums": "My Albums", "tracks": "My Favorites", "playlists": "My Playlists", + "feed": "Made for You", + "chart": "Chart", + "new_releases": "New Releases", + "new_playlists": "New Playlists", + # Picks & Mixes + "picks": "Picks", + "mixes": "Mixes", + "mood": "Mood", + "activity": "Activity", + "era": "Era", + "genres": "Genres", + # Mood tags + "chill": "Chill", + "sad": "Sad", + "romantic": "Romantic", + "party": "Party", + "relax": "Relax", + # Activity tags + "workout": "Workout", + "focus": "Focus", + "morning": "Morning", + "evening": "Evening", + "driving": "Driving", + # Era tags + "80s": "80s", + "90s": "90s", + "2000s": "2000s", + "retro": "Retro", + # Genre tags + "rock": "Rock", + "jazz": "Jazz", + "classical": "Classical", + "electronic": "Electronic", + "rnb": "R&B", + "hiphop": "Hip-Hop", + "top": "Top", + "newbies": "By Genre", + # Landing-discovered tags + "in the mood": "In the Mood", + "background": "Background", + # Seasonal tags + "winter": "Winter", + "summer": "Summer", + "autumn": "Autumn", + "spring": "Spring", + "newyear": "New Year", + # Liked Tracks + "liked_tracks": "My Favorites", + # Discovery + "top_picks": "Top Picks", + "mood_mix": "Mood Mix", + "activity_mix": "Activity Mix", + "seasonal_mix": "Seasonal", + # Top-level browse groups + "for_you": "For You", + "collection": "Collection", + # Waves / Radio (rotor station categories) + "waves": "Radio", + "radio": "Radio", + "my_waves": "Personal", + "my_waves_set": "AI Mix Sets", + "waves_landing": "Featured Mixes", + "genre": "Genres", + "epoch": "Era", + "local": "Local", +} + +# Tag categories for Picks and Recommendations +# Used by _get_valid_tags_for_category to validate tags at runtime. +TAG_CATEGORY_MOOD: Final[list[str]] = [ + "chill", + "sad", + "romantic", + "party", + "relax", + "in the mood", +] +TAG_CATEGORY_ACTIVITY: Final[list[str]] = [ + "workout", + "focus", + "morning", + "evening", + "driving", + "background", +] +TAG_CATEGORY_ERA: Final[list[str]] = ["80s", "90s", "2000s", "retro"] +TAG_CATEGORY_GENRES: Final[list[str]] = [ + "rock", + "jazz", + "classical", + "electronic", + "rnb", + "hiphop", + "top", + "newbies", +] + +# Tag slug -> display category mapping +# Used to categorize dynamically discovered tags into browse folders. +# Tags not in this mapping default to "mood" category. +TAG_SLUG_CATEGORY: Final[dict[str, str]] = { + # Mood + "chill": "mood", + "sad": "mood", + "romantic": "mood", + "party": "mood", + "relax": "mood", + "in the mood": "mood", + # Activity + "workout": "activity", + "focus": "activity", + "morning": "activity", + "evening": "activity", + "driving": "activity", + "background": "activity", + # Era + "80s": "era", + "90s": "era", + "2000s": "era", + "retro": "era", + # Genres + "rock": "genres", + "jazz": "genres", + "classical": "genres", + "electronic": "genres", + "rnb": "genres", + "hiphop": "genres", + "top": "genres", + "newbies": "genres", + # Seasonal (for mixes) + "winter": "seasonal", + "spring": "seasonal", + "summer": "seasonal", + "autumn": "seasonal", + "newyear": "seasonal", +} + +# Preferred tag order within categories (discovered tags sorted by this) +TAG_CATEGORY_ORDER: Final[dict[str, list[str]]] = { + "mood": ["chill", "sad", "romantic", "party", "relax", "in the mood"], + "activity": ["workout", "focus", "morning", "evening", "driving", "background"], + "era": ["80s", "90s", "2000s", "retro"], + "genres": ["rock", "jazz", "classical", "electronic", "rnb", "hiphop", "top", "newbies"], +} + +# Seasonal tags mapped to months (month number -> tag) +TAG_SEASONAL_MAP: Final[dict[int, str]] = { + 1: "winter", # January + 2: "winter", # February + 3: "spring", # March (validated at runtime; falls back to autumn if unavailable) + 4: "spring", # April + 5: "spring", # May + 6: "summer", # June + 7: "summer", # July + 8: "summer", # August + 9: "autumn", # September + 10: "autumn", # October + 11: "autumn", # November + 12: "winter", # December } + +# Tags for Mixes (seasonal collections) +TAG_MIXES: Final[list[str]] = ["winter", "spring", "summer", "autumn", "newyear"] + +# Waves by tag (rotor stations) — canonical ID is "waves", "radio" is an alias +WAVES_FOLDER_ID: Final[str] = "waves" +RADIO_FOLDER_ID: Final[str] = "radio" + +# Personalized waves subfolder (rotor/stations/dashboard) +MY_WAVES_FOLDER_ID: Final[str] = "my_waves" + +# AI Mix Sets subfolder (from /landing-blocks/mixes-waves) +MY_WAVES_SET_FOLDER_ID: Final[str] = "my_waves_set" + +# Featured Mixes subfolder inside Radio (from /landing-blocks/waves) +WAVES_LANDING_FOLDER_ID: Final[str] = "waves_landing" + +# Top-level browse group folders +FOR_YOU_FOLDER_ID: Final[str] = "for_you" +COLLECTION_FOLDER_ID: Final[str] = "collection" + +# Preferred display order for wave categories (rotor station types) +WAVE_CATEGORY_DISPLAY_ORDER: Final[list[str]] = [ + "genre", + "mood", + "activity", + "epoch", + "local", +] diff --git a/music_assistant/providers/kion_music/manifest.json b/music_assistant/providers/kion_music/manifest.json index 9b5cdc2d44..0d25ab7a20 100644 --- a/music_assistant/providers/kion_music/manifest.json +++ b/music_assistant/providers/kion_music/manifest.json @@ -3,9 +3,16 @@ "domain": "kion_music", "stage": "beta", "name": "KION Music", - "description": "Stream music from KION Music (MTS) service.", - "codeowners": ["@TrudenBoy"], + "description": "Stream music, personalized recommendations, and lossless FLAC from KION Music — including My Wave radio and library sync.", + "codeowners": [ + "@TrudenBoy" + ], + "credits": [ + "[yandex-music-api](https://github.com/MarshalX/yandex-music-api)" + ], "documentation": "https://music-assistant.io/music-providers/kion-music/", - "requirements": ["yandex-music==2.2.0"], + "requirements": [ + "yandex-music==3.0.0" + ], "multi_instance": true } diff --git a/music_assistant/providers/kion_music/parsers.py b/music_assistant/providers/kion_music/parsers.py index 8588ae6714..121f5f6b19 100644 --- a/music_assistant/providers/kion_music/parsers.py +++ b/music_assistant/providers/kion_music/parsers.py @@ -11,6 +11,7 @@ ContentType, ImageType, ) +from music_assistant_models.errors import InvalidDataError from music_assistant_models.media_items import ( Album, Artist, @@ -24,7 +25,13 @@ from music_assistant.helpers.util import parse_title_and_version -from .constants import IMAGE_SIZE_LARGE +from .constants import ( + IMAGE_SIZE_LARGE, + PROVIDER_DISPLAY_NAME_EN, + PROVIDER_DISPLAY_NAME_RU, + WEB_BASE_URL, + YANDEX_SYSTEM_OWNER_NAMES, +) if TYPE_CHECKING: from yandex_music import Album as YandexAlbum @@ -35,27 +42,42 @@ from .provider import KionMusicProvider +def get_canonical_provider_name(provider: KionMusicProvider) -> str: + """Return the locale-aware canonical display name for the KION Music system account. + + :param provider: The KION Music provider instance. + :return: Localized provider display name. + """ + with suppress(Exception): + locale = (provider.mass.metadata.locale or "en_US").lower() + if locale.startswith("ru"): + return PROVIDER_DISPLAY_NAME_RU + return PROVIDER_DISPLAY_NAME_EN + + def _get_image_url(cover_uri: str | None, size: str = IMAGE_SIZE_LARGE) -> str | None: - """Convert cover URI to full URL. + """Convert Kion cover URI to full URL. - :param cover_uri: Cover URI template. + :param cover_uri: Kion cover URI template. :param size: Image size (e.g., '1000x1000'). :return: Full image URL or None. """ if not cover_uri: return None - # Cover URIs come in format "avatars.yandex.net/get-music-content/xxx/yyy/%%" + # Cover URIs come in format "avatars.kion.net/get-music-content/xxx/yyy/%%" # Replace %% with the desired size return f"https://{cover_uri.replace('%%', size)}" def parse_artist(provider: KionMusicProvider, artist_obj: YandexArtist) -> Artist: - """Parse a KION Music artist object to MA Artist model. + """Parse Kion artist object to MA Artist model. :param provider: The KION Music provider instance. - :param artist_obj: API artist object. + :param artist_obj: Kion artist object. :return: Music Assistant Artist model. """ + if artist_obj.id is None: + raise InvalidDataError("Yandex artist missing id") artist_id = str(artist_obj.id) artist = Artist( item_id=artist_id, @@ -66,7 +88,7 @@ def parse_artist(provider: KionMusicProvider, artist_obj: YandexArtist) -> Artis item_id=artist_id, provider_domain=provider.domain, provider_instance=provider.instance_id, - url=f"https://music.mts.ru/artist/{artist_id}", + url=f"{WEB_BASE_URL}/artist/{artist_id}", ) }, ) @@ -103,12 +125,14 @@ def parse_artist(provider: KionMusicProvider, artist_obj: YandexArtist) -> Artis def parse_album(provider: KionMusicProvider, album_obj: YandexAlbum) -> Album: - """Parse a KION Music album object to MA Album model. + """Parse Kion album object to MA Album model. :param provider: The KION Music provider instance. - :param album_obj: API album object. + :param album_obj: Kion album object. :return: Music Assistant Album model. """ + if album_obj.id is None: + raise InvalidDataError("Yandex album missing id") name, version = parse_title_and_version( album_obj.title or "Unknown Album", album_obj.version or None, @@ -131,7 +155,7 @@ def parse_album(provider: KionMusicProvider, album_obj: YandexAlbum) -> Album: audio_format=AudioFormat( content_type=ContentType.UNKNOWN, ), - url=f"https://music.mts.ru/album/{album_id}", + url=f"{WEB_BASE_URL}/album/{album_id}", available=available, ) }, @@ -196,13 +220,22 @@ def parse_album(provider: KionMusicProvider, album_obj: YandexAlbum) -> Album: return album -def parse_track(provider: KionMusicProvider, track_obj: YandexTrack) -> Track: - """Parse a KION Music track object to MA Track model. +def parse_track( + provider: KionMusicProvider, + track_obj: YandexTrack, + lyrics: str | None = None, + lyrics_synced: bool = False, +) -> Track: + """Parse Kion track object to MA Track model. :param provider: The KION Music provider instance. - :param track_obj: API track object. + :param track_obj: Kion track object. + :param lyrics: Optional lyrics text. + :param lyrics_synced: Whether lyrics are in synced LRC format. :return: Music Assistant Track model. """ + if track_obj.id is None: + raise InvalidDataError("Yandex track missing id") name, version = parse_title_and_version( track_obj.title or "Unknown Track", track_obj.version or None, @@ -212,7 +245,7 @@ def parse_track(provider: KionMusicProvider, track_obj: YandexTrack) -> Track: # Determine availability available = track_obj.available or False - # Duration is in milliseconds in KION API + # Duration is in milliseconds in Kion API duration = (track_obj.duration_ms or 0) // 1000 track = Track( @@ -229,7 +262,7 @@ def parse_track(provider: KionMusicProvider, track_obj: YandexTrack) -> Track: audio_format=AudioFormat( content_type=ContentType.UNKNOWN, ), - url=f"https://music.mts.ru/track/{track_id}", + url=f"{WEB_BASE_URL}/track/{track_id}", available=available, ) }, @@ -269,20 +302,27 @@ def parse_track(provider: KionMusicProvider, track_obj: YandexTrack) -> Track: if track_obj.content_warning: track.metadata.explicit = track_obj.content_warning == "explicit" + # Lyrics + if lyrics: + if lyrics_synced: + track.metadata.lrc_lyrics = lyrics + else: + track.metadata.lyrics = lyrics + return track def parse_playlist( provider: KionMusicProvider, playlist_obj: YandexPlaylist, owner_name: str | None = None ) -> Playlist: - """Parse a KION Music playlist object to MA Playlist model. + """Parse Kion playlist object to MA Playlist model. :param provider: The KION Music provider instance. - :param playlist_obj: API playlist object. + :param playlist_obj: Kion playlist object. :param owner_name: Optional owner name override. :return: Music Assistant Playlist model. """ - # Playlist ID is a combination of owner uid and playlist kind + # Playlist ID in Kion is a combination of owner uid and playlist kind owner_id = str(playlist_obj.owner.uid) if playlist_obj.owner else str(provider.client.user_id) playlist_kind = str(playlist_obj.kind) playlist_id = f"{owner_id}:{playlist_kind}" @@ -297,7 +337,11 @@ def parse_playlist( elif is_editable: owner_name = "Me" else: - owner_name = "KION Music" + owner_name = get_canonical_provider_name(provider) + + # Normalize all known system account name variants to locale-aware canonical form + if owner_name and owner_name.lower() in YANDEX_SYSTEM_OWNER_NAMES: + owner_name = get_canonical_provider_name(provider) playlist = Playlist( item_id=playlist_id, @@ -309,7 +353,7 @@ def parse_playlist( item_id=playlist_id, provider_domain=provider.domain, provider_instance=provider.instance_id, - url=f"https://music.mts.ru/users/{owner_id}/playlists/{playlist_kind}", + url=f"{WEB_BASE_URL}/users/{owner_id}/playlists/{playlist_kind}", is_unique=is_editable, ) }, diff --git a/music_assistant/providers/kion_music/provider.py b/music_assistant/providers/kion_music/provider.py index 60c8d296f8..f59fcf27e0 100644 --- a/music_assistant/providers/kion_music/provider.py +++ b/music_assistant/providers/kion_music/provider.py @@ -2,11 +2,15 @@ from __future__ import annotations +import asyncio import logging -from collections.abc import Sequence -from typing import TYPE_CHECKING +import random +from collections.abc import AsyncGenerator, Sequence +from datetime import UTC, datetime +from io import BytesIO +from typing import TYPE_CHECKING, Any -from music_assistant_models.enums import MediaType, ProviderFeature +from music_assistant_models.enums import ImageType, MediaType, ProviderFeature from music_assistant_models.errors import ( InvalidDataError, LoginFailed, @@ -19,6 +23,7 @@ Artist, BrowseFolder, ItemMapping, + MediaItemImage, MediaItemType, Playlist, ProviderMapping, @@ -27,6 +32,7 @@ Track, UniqueList, ) +from PIL import Image as PilImage from music_assistant.controllers.cache import use_cache from music_assistant.models.music_provider import MusicProvider @@ -36,24 +42,50 @@ BROWSE_INITIAL_TRACKS, BROWSE_NAMES_EN, BROWSE_NAMES_RU, + COLLECTION_FOLDER_ID, CONF_BASE_URL, + CONF_LIKED_TRACKS_MAX_TRACKS, + CONF_MY_WAVE_MAX_TRACKS, CONF_TOKEN, DEFAULT_BASE_URL, DISCOVERY_INITIAL_TRACKS, - MY_MIX_BATCH_SIZE, - MY_MIX_MAX_TRACKS, - MY_MIX_PLAYLIST_ID, + FOR_YOU_FOLDER_ID, + IMAGE_SIZE_MEDIUM, + LIKED_TRACKS_PLAYLIST_ID, + MY_WAVE_BATCH_SIZE, + MY_WAVE_PLAYLIST_ID, + MY_WAVES_FOLDER_ID, + MY_WAVES_SET_FOLDER_ID, PLAYLIST_ID_SPLITTER, + RADIO_FOLDER_ID, RADIO_TRACK_ID_SEP, ROTOR_STATION_MY_MIX, + TAG_CATEGORY_ACTIVITY, + TAG_CATEGORY_ERA, + TAG_CATEGORY_GENRES, + TAG_CATEGORY_MOOD, + TAG_CATEGORY_ORDER, + TAG_MIXES, + TAG_SEASONAL_MAP, + TAG_SLUG_CATEGORY, TRACK_BATCH_SIZE, + WAVE_CATEGORY_DISPLAY_ORDER, + WAVES_FOLDER_ID, + WAVES_LANDING_FOLDER_ID, +) +from .parsers import ( + _get_image_url as get_image_url, +) +from .parsers import ( + get_canonical_provider_name, + parse_album, + parse_artist, + parse_playlist, + parse_track, ) -from .parsers import parse_album, parse_artist, parse_playlist, parse_track from .streaming import KionMusicStreamingManager if TYPE_CHECKING: - from collections.abc import AsyncGenerator - from music_assistant_models.streamdetails import StreamDetails @@ -72,16 +104,30 @@ def _parse_radio_item_id(item_id: str) -> tuple[str, str | None]: return (item_id, None) +class _WaveState: + """Per-station mutable state for rotor wave playback.""" + + def __init__(self) -> None: + self.batch_id: str | None = None + self.last_track_id: str | None = None + self.seen_track_ids: set[str] = set() + self.radio_started_sent: bool = False + self.lock: asyncio.Lock = asyncio.Lock() + + class KionMusicProvider(MusicProvider): """Implementation of a KION Music MusicProvider.""" _client: KionMusicClient | None = None _streaming: KionMusicStreamingManager | None = None - _my_mix_batch_id: str | None = None - _my_mix_last_track_id: str | None = None # last track id for "Load more" (API queue param) - _my_mix_playlist_next_cursor: str | None = None # first_track_id for next playlist page - _my_mix_radio_started_sent: bool = False - _my_mix_seen_track_ids: set[str] # Track IDs seen in current My Mix session + _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 + _my_wave_seen_track_ids: set[str] # Track IDs seen in current My Mix session + _my_wave_lock: asyncio.Lock # Protects My Mix mutable state + _wave_states: dict[str, _WaveState] # Per-station state for tagged wave stations + _wave_bg_colors: dict[str, str] # image_url -> hex bg color for transparent covers @property def client(self) -> KionMusicClient: @@ -102,7 +148,9 @@ def _get_browse_names(self) -> dict[str, str]: try: locale = (self.mass.metadata.locale or "en_US").lower() use_russian = locale.startswith("ru") - except Exception: + self.logger.debug("Locale detection: locale=%s, use_russian=%s", locale, use_russian) + except Exception as err: + self.logger.debug("Locale detection failed: %s", err) use_russian = False return BROWSE_NAMES_RU if use_russian else BROWSE_NAMES_EN @@ -115,11 +163,15 @@ async def handle_async_init(self) -> None: base_url = self.config.get_value(CONF_BASE_URL, DEFAULT_BASE_URL) self._client = KionMusicClient(str(token), base_url=str(base_url)) await self._client.connect() - # Suppress yandex_music library DEBUG dumps (full API request/response JSON) + # Suppress kion_music library DEBUG dumps (full API request/response JSON) logging.getLogger("yandex_music").setLevel(self.logger.level + 10) self._streaming = KionMusicStreamingManager(self) # Initialize My Mix duplicate tracking - self._my_mix_seen_track_ids = set() + self._my_wave_seen_track_ids = set() + self._my_wave_lock = asyncio.Lock() + # Initialize per-station wave state dict + self._wave_states = {} + self._wave_bg_colors = {} self.logger.info("Successfully connected to KION Music") async def unload(self, is_removed: bool = False) -> None: @@ -150,162 +202,456 @@ def get_item_mapping(self, media_type: MediaType | str, key: str, name: str) -> name=name, ) - async def _fetch_my_mix_tracks( - self, - *, - max_tracks: int = MY_MIX_MAX_TRACKS, - max_batches: int = MY_MIX_BATCH_SIZE, - initial_queue: str | int | None = None, - seen_track_ids: set[str] | None = None, - ) -> tuple[list[Track], str | None, str | None, set[str]]: - """Fetch My Mix tracks with de-duplication and radio feedback. - - :param max_tracks: Maximum number of tracks to return. - :param max_batches: Maximum number of API batch calls. - :param initial_queue: Optional track ID for API pagination. - :param seen_track_ids: Already-seen track IDs for de-duplication. - :return: (tracks, last_batch_id, last_first_track_id, updated_seen_ids). - """ - if seen_track_ids is None: - seen_track_ids = set() + async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse provider items with locale-based folder names. - tracks: list[Track] = [] + Root level shows My Mix (personalised radio), For You (picks & mixes), + Collection (liked tracks/albums/artists/playlists), Radio (rotor stations + by genre/mood/activity/era/local) and AI Mix Sets. Names are in Russian + when MA locale is ru_*, otherwise in English. My Mix 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://waves). + """ + 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: + async with self._my_wave_lock: + return await self._browse_my_wave(path, sub_subpath) + + # For You folder (picks + mixes) + if subpath == FOR_YOU_FOLDER_ID: + return await self._browse_for_you(path, path_parts) + + # Collection folder (library items) + if subpath == COLLECTION_FOLDER_ID: + return await self._browse_collection(path) + + # Handle picks/ path (mood, activity, era, genres) + if subpath == "picks": + return await self._browse_picks(path, path_parts) + + # Handle mixes/ path (seasonal collections) + if subpath == "mixes": + return await self._browse_mixes(path, path_parts) + + # Handle waves/ and radio/ paths (rotor stations by genre/mood/activity) + if subpath in (WAVES_FOLDER_ID, RADIO_FOLDER_ID): + return await self._browse_waves(path, path_parts) + + # Handle my_waves_set/ path (AI Mix Sets from /landing-blocks/mixes-waves) + if subpath == MY_WAVES_SET_FOLDER_ID: + return await self._browse_vibe_sets(path, path_parts) + + # Handle waves_landing/ path (Featured Mixes from /landing-blocks/waves) + if subpath == WAVES_LANDING_FOLDER_ID: + return await self._browse_waves_landing(path, path_parts) + + # Handle direct tag subpath (when folder is played by URI, the full path + # "picks/category/tag" is lost and only the tag slug arrives as subpath). + # Skip the API call for standard top-level folders that are never tag slugs. + _known_folders = { + "artists", + "albums", + "tracks", + "playlists", + LIKED_TRACKS_PLAYLIST_ID, + WAVES_FOLDER_ID, + RADIO_FOLDER_ID, + MY_WAVES_FOLDER_ID, + MY_WAVES_SET_FOLDER_ID, + WAVES_LANDING_FOLDER_ID, + FOR_YOU_FOLDER_ID, + COLLECTION_FOLDER_ID, + } + if subpath and subpath not in _known_folders: + # Handle direct wave station_id (e.g. "activity:workout") passed when + # MA plays a wave station folder using its item_id as the path subpath. + # Station IDs have format "category:tag" where category is non-numeric. + if ":" in subpath: + cat_part = subpath.split(":", 1)[0] + if not cat_part.isdigit(): + return await self._browse_wave_station(subpath) + + discovered_tags = await self._get_discovered_tag_slugs() + if subpath in discovered_tags: + return await self._get_tag_playlists_as_browse(subpath) + + if subpath: + return await super().browse(path) + + names = self._get_browse_names() + + folders: list[BrowseFolder] = [] + base = path if path.endswith("//") else path.rstrip("/") + "/" + # My Mix folder (always enabled — Яндекс «Мой микс») + 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, + ) + ) + # For You folder — Picks + Mixes (Яндекс «Для вас») + folders.append( + BrowseFolder( + item_id=FOR_YOU_FOLDER_ID, + provider=self.instance_id, + path=f"{base}{FOR_YOU_FOLDER_ID}", + name=names.get(FOR_YOU_FOLDER_ID, "For You"), + is_playable=False, + ) + ) + # Collection folder — library items (Яндекс «Коллекция») + has_library = any( + f in self.supported_features + for f in ( + ProviderFeature.LIBRARY_ARTISTS, + ProviderFeature.LIBRARY_ALBUMS, + ProviderFeature.LIBRARY_TRACKS, + ProviderFeature.LIBRARY_PLAYLISTS, + ) + ) + if has_library: + folders.append( + BrowseFolder( + item_id=COLLECTION_FOLDER_ID, + provider=self.instance_id, + path=f"{base}{COLLECTION_FOLDER_ID}", + name=names.get(COLLECTION_FOLDER_ID, "Collection"), + is_playable=False, + ) + ) + # Radio folder — rotor stations (Яндекс волны, renamed to Radio) + folders.append( + BrowseFolder( + item_id=RADIO_FOLDER_ID, + provider=self.instance_id, + path=f"{base}{RADIO_FOLDER_ID}", + name=names.get(RADIO_FOLDER_ID, "Radio"), + is_playable=False, + ) + ) + # AI Mix Sets — parametric stations from /landing-blocks/mixes-waves + folders.append( + BrowseFolder( + item_id=MY_WAVES_SET_FOLDER_ID, + provider=self.instance_id, + path=f"{base}{MY_WAVES_SET_FOLDER_ID}", + name=names.get(MY_WAVES_SET_FOLDER_ID, "AI Mix Sets"), + is_playable=False, + ) + ) + if len(folders) == 1: + return await self.browse(folders[0].path) + return folders + + async def _browse_my_wave( + self, path: str, sub_subpath: str | None + ) -> list[Track | BrowseFolder]: + """Browse My Mix tracks (must be called under _my_wave_lock). + + :param path: Full browse path. + :param sub_subpath: Sub-path part ('next' for load more, or track_id cursor). + :return: List of Track and optional BrowseFolder for "Load more". + """ + max_tracks_config = int( + self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type] + ) + batch_size_config = MY_WAVE_BATCH_SIZE + + # Effective limit on tracks to collect for this call: + # initial browse is capped to BROWSE_INITIAL_TRACKS to avoid marking + # extra tracks as "seen" that are never shown to the user. + effective_limit = min( + BROWSE_INITIAL_TRACKS if sub_subpath != "next" else max_tracks_config, + max_tracks_config, + ) + + # Root my_wave: fetch up to batch_size_config batches so Play adds more tracks. + # "Load more" always uses single next batch. + max_batches = batch_size_config if sub_subpath != "next" else 1 + + # Reset seen tracks on fresh browse (not "load more") + if sub_subpath != "next": + self._my_wave_seen_track_ids = set() + + 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 - last_first_track_id: str | None = None - queue: str | int | None = initial_queue + first_track_id_this_batch: str | None = None + total_track_count = 0 for _ in range(max_batches): - if len(tracks) >= max_tracks: + if total_track_count >= effective_limit: break - yandex_tracks, batch_id = await self.client.get_my_mix_tracks(queue=queue) + yandex_tracks, batch_id = await self.client.get_my_wave_tracks(queue=queue) if batch_id: - self._my_mix_batch_id = batch_id + self._my_wave_batch_id = batch_id last_batch_id = batch_id - if not self._my_mix_radio_started_sent and yandex_tracks: - self._my_mix_radio_started_sent = True - await self.client.send_rotor_station_feedback( + if not self._my_wave_radio_started_sent and yandex_tracks: + sent = await self.client.send_rotor_station_feedback( ROTOR_STATION_MY_MIX, "radioStarted", batch_id=batch_id, ) - first_track_id_this_batch: str | None = None + if sent: + self._my_wave_radio_started_sent = True + first_track_id_this_batch = None for yt in yandex_tracks: - if len(tracks) >= max_tracks: + if total_track_count >= effective_limit: break - 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 track_id in seen_track_ids: - self.logger.debug("Skipping duplicate My Mix track: %s", track_id) - continue - seen_track_ids.add(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_MIX}" - 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 Mix track: %s", err) + + track = self._parse_my_wave_track(yt, self._my_wave_seen_track_ids) + if track is None: + continue + all_tracks.append(track) + total_track_count += 1 + + track_id = track.item_id.split(RADIO_TRACK_ID_SEP, 1)[0] + if first_track_id_this_batch is None: + first_track_id_this_batch = track_id + if first_track_id_this_batch is not None: - last_first_track_id = first_track_id_this_batch - if not batch_id or not yandex_tracks or len(tracks) >= max_tracks: + self._my_wave_last_track_id = first_track_id_this_batch + if ( + first_track_id_this_batch is None + or not batch_id + or not yandex_tracks + or total_track_count >= effective_limit + ): break queue = first_track_id_this_batch - return (tracks, last_batch_id, last_first_track_id, seen_track_ids) + # Only show "Load more" if we haven't reached the limit and there's more data + if last_batch_id and total_track_count < max_tracks_config: + names = self._get_browse_names() + next_name = "Ещё" if names == 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 - async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Browse provider items with locale-based folder names and My Mix. + def _parse_my_wave_track(self, yt: Any, seen_ids: set[str]) -> Track | None: + """Parse a Kion track into a My Mix Track with composite item_id. - Root level shows My Mix, artists, albums, liked tracks, playlists. Names - are in Russian when MA locale is ru_*, otherwise in English. My Mix - tracks use item_id format track_id@station_id for rotor feedback. + Extracts the track_id, checks for duplicates in the seen_ids set, + sets composite item_id (track_id@station_id), and updates provider_mappings. + Callers using shared state must hold _my_wave_lock. - :param path: The path to browse (e.g. provider_id:// or provider_id://artists). + :param yt: Kion track object from rotor station response. + :param seen_ids: Set of already-seen track IDs to check and update. + :return: Parsed Track with composite item_id, or None if duplicate/invalid. """ - if ProviderFeature.BROWSE not in self.supported_features: - raise NotImplementedError + try: + t = parse_track(self, yt) + except InvalidDataError as err: + self.logger.debug("Error parsing My Mix track: %s", err) + return None + + track_id = str(yt.id) if hasattr(yt, "id") and yt.id else getattr(yt, "track_id", None) + if not track_id: + return t + + if track_id in seen_ids: + self.logger.debug("Skipping duplicate My Mix track: %s", track_id) + return None + + seen_ids.add(track_id) + t.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_MIX}" + for pm in t.provider_mappings: + if pm.provider_instance == self.instance_id: + pm.item_id = t.item_id + break + return t - 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 + @use_cache(3600) + async def _validate_tag(self, tag_slug: str) -> bool: + """Check if a tag has playlists by calling client.get_tag_playlists(). - if subpath == MY_MIX_PLAYLIST_ID: - max_batches = MY_MIX_BATCH_SIZE if sub_subpath != "next" else 1 + :param tag_slug: Tag identifier (e.g. 'chill', '80s'). + :return: True if the tag has at least one playlist. + """ + try: + playlists = await self.client.get_tag_playlists(tag_slug) + return len(playlists) > 0 + except Exception as err: + self.logger.debug("Tag validation failed for %s: %s", tag_slug, err) + return False - if sub_subpath != "next": - self._my_mix_seen_track_ids = set() + @use_cache(3600) + async def _get_valid_tags_for_category(self, category: str) -> list[str]: + """Get validated tags for a category (only those with playlists). - queue: str | int | None = None - if sub_subpath == "next": - queue = self._my_mix_last_track_id - elif sub_subpath: - queue = sub_subpath - - ( - fetched, - last_batch_id, - last_first_track_id, - self._my_mix_seen_track_ids, - ) = await self._fetch_my_mix_tracks( - max_batches=max_batches, - initial_queue=queue, - seen_track_ids=self._my_mix_seen_track_ids, - ) - if last_first_track_id is not None: - self._my_mix_last_track_id = last_first_track_id + Combines hardcoded tags from the category lists with any landing-discovered + tags, validates each by calling client.tags(), and returns only those with + playlists. - all_tracks: list[Track | BrowseFolder] = list(fetched) + :param category: Category name ('mood', 'activity', 'era', 'genres'). + :return: List of valid tag slugs. + """ + category_lists: dict[str, list[str]] = { + "mood": list(TAG_CATEGORY_MOOD), + "activity": list(TAG_CATEGORY_ACTIVITY), + "era": list(TAG_CATEGORY_ERA), + "genres": list(TAG_CATEGORY_GENRES), + } + tags = category_lists.get(category, []) - # Apply initial tracks limit if not in "load more" mode - if sub_subpath != "next": - if len(all_tracks) > BROWSE_INITIAL_TRACKS: - all_tracks = all_tracks[:BROWSE_INITIAL_TRACKS] + # Add landing-discovered tags for this category + try: + landing_tags = await self.client.get_landing_tags() + for slug, _title in landing_tags: + cat = TAG_SLUG_CATEGORY.get(slug, "mood") + if cat == category and slug not in tags: + tags.append(slug) + except Exception as err: + self.logger.debug("Landing tag discovery failed: %s", err) + + # Validate tags in parallel with bounded concurrency + sem = asyncio.Semaphore(8) + + async def _check(tag: str) -> str | None: + async with sem: + return tag if await self._validate_tag(tag) else None + + results = await asyncio.gather(*[_check(tag) for tag in tags]) + return [tag for tag in results if tag is not None] + + @use_cache(3600) + async def _get_discovered_tags(self, locale: str) -> list[tuple[str, str]]: + """Get all available tags by combining hardcoded tags with landing discovery. + + Starts with all hardcoded tags from category lists, adds landing-discovered + tags, validates each via client.tags(), and returns only those with playlists. + Results are cached for 1 hour. The locale parameter is included in the cache + key so that a locale change invalidates the cached result. + + :param locale: Current metadata locale (used as part of cache key). + :return: List of (slug, title) tuples for tags that have playlists. + """ + names = self._get_browse_names() - # Only show "Load more" if we haven't reached the limit and there's more data - if last_batch_id and len(fetched) < MY_MIX_MAX_TRACKS: - names = self._get_browse_names() - next_name = "Ещё" if names == 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 + # Collect all hardcoded tags (non-seasonal) + all_tags: dict[str, str] = {} + for slug, cat in TAG_SLUG_CATEGORY.items(): + if cat != "seasonal": + all_tags[slug] = names.get(slug, slug.title()) - if subpath: - return await super().browse(path) + # Add landing-discovered tags + try: + landing_tags = await self.client.get_landing_tags() + for slug, title in landing_tags: + if slug not in all_tags: + all_tags[slug] = title + except Exception as err: + self.logger.debug("Failed to discover tags from landing API: %s", err) + + # Validate tags in parallel with bounded concurrency + sem = asyncio.Semaphore(8) + + async def _check(slug: str) -> bool: + async with sem: + return await self._validate_tag(slug) + + tag_items = list(all_tags.items()) + results = await asyncio.gather(*[_check(slug) for slug, _ in tag_items]) + return [ + (slug, title) for (slug, title), valid in zip(tag_items, results, strict=True) if valid + ] + + async def _get_discovered_tag_slugs(self) -> set[str]: + """Get set of all valid tag slugs (cached). + + :return: Set of tag slug strings that have playlists. + """ + discovered = await self._get_discovered_tags(self.mass.metadata.locale or "en_US") + return {slug for slug, _title in discovered} + + async def _browse_for_you( + self, path: str, path_parts: list[str] + ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse «For You» folder — shows Picks and Mixes sub-folders. + :param path: Full browse path. + :param path_parts: Split path parts after ://. + :return: List of sub-folders (Picks, Mixes). + """ + names = self._get_browse_names() + # Strip the for_you segment to build child paths that route to picks/mixes + # Path format: ...//for_you → child paths should be ...//picks, ...//mixes + # We build base from the root (before for_you) by dropping the last segment. + base_parts = path.split("//", 1) + root_base = (base_parts[0] + "//") if len(base_parts) > 1 else path.rstrip("/") + "/" + + if len(path_parts) == 1: + return [ + BrowseFolder( + item_id="picks", + provider=self.instance_id, + path=f"{root_base}picks", + name=names.get("picks", "Picks"), + is_playable=False, + ), + BrowseFolder( + item_id="mixes", + provider=self.instance_id, + path=f"{root_base}mixes", + name=names.get("mixes", "Mixes"), + is_playable=False, + ), + ] + # Deeper path: delegate to picks or mixes handler via canonical paths + return await super().browse(path) + + async def _browse_collection( + self, path: str + ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse «Collection» folder — shows library sub-folders (tracks/artists/albums/playlists). + + :param path: Full browse path. + :return: List of library sub-folders. + """ names = self._get_browse_names() + base_parts = path.split("//", 1) + root_base = (base_parts[0] + "//") if len(base_parts) > 1 else path.rstrip("/") + "/" folders: list[BrowseFolder] = [] - base = path if path.endswith("//") else path.rstrip("/") + "/" - folders.append( - BrowseFolder( - item_id=MY_MIX_PLAYLIST_ID, - provider=self.instance_id, - path=f"{base}{MY_MIX_PLAYLIST_ID}", - name=names[MY_MIX_PLAYLIST_ID], - is_playable=True, + if ProviderFeature.LIBRARY_TRACKS in self.supported_features: + folders.append( + BrowseFolder( + item_id="tracks", + provider=self.instance_id, + path=f"{root_base}tracks", + name=names["tracks"], + 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", + path=f"{root_base}artists", name=names["artists"], is_playable=True, ) @@ -315,35 +661,638 @@ async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | Brow BrowseFolder( item_id="albums", provider=self.instance_id, - path=f"{base}albums", + path=f"{root_base}albums", name=names["albums"], is_playable=True, ) ) - if ProviderFeature.LIBRARY_TRACKS in self.supported_features: + if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features: folders.append( BrowseFolder( - item_id="tracks", + item_id="playlists", provider=self.instance_id, - path=f"{base}tracks", - name=names["tracks"], + path=f"{root_base}playlists", + name=names["playlists"], is_playable=True, ) ) - if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features: + return folders + + async def _browse_picks( + self, path: str, path_parts: list[str] + ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse picks folder using hardcoded tags validated against the API. + + Tags are sourced from hardcoded category lists and landing API discovery, + then validated via client.tags() to ensure they have playlists. + Only categories with at least one valid tag are shown. + + :param path: Full browse path. + :param path_parts: Split path parts after ://. + :return: List of folders or playlists. + """ + names = self._get_browse_names() + base = path.rstrip("/") + "/" + + # Get validated tags + discovered = await self._get_discovered_tags(self.mass.metadata.locale or "en_US") + + # Categorize valid tags + categorized: dict[str, list[tuple[str, str]]] = {} + for slug, title in discovered: + cat = TAG_SLUG_CATEGORY.get(slug, "mood") + # Skip seasonal tags — they belong in mixes, not picks + if cat == "seasonal": + continue + categorized.setdefault(cat, []).append((slug, title)) + + # Sort tags within each category by preferred order + for cat, cat_tags in categorized.items(): + order = TAG_CATEGORY_ORDER.get(cat, []) + order_map = {s: i for i, s in enumerate(order)} + cat_tags.sort(key=lambda t: order_map.get(t[0], len(order))) + + # picks/ - show category folders (only those with valid tags) + if len(path_parts) == 1: + category_display_order = ["mood", "activity", "era", "genres"] + folders: list[BrowseFolder] = [] + for cat in category_display_order: + if cat in categorized: + folders.append( + BrowseFolder( + item_id=cat, + provider=self.instance_id, + path=f"{base}{cat}", + name=names.get(cat, cat.title()), + is_playable=False, + ) + ) + # Show any extra categories not in the standard order + for cat in categorized: + if cat not in category_display_order: + folders.append( + BrowseFolder( + item_id=cat, + provider=self.instance_id, + path=f"{base}{cat}", + name=names.get(cat, cat.title()), + is_playable=False, + ) + ) + return folders + + category: str | None = path_parts[1] if len(path_parts) > 1 else None + tag: str | None = path_parts[2] if len(path_parts) > 2 else None + + self.logger.debug( + "Browse picks: path=%s, category=%s, tag=%s", + path, + category, + tag, + ) + + # picks/category/ - show valid tag folders for this category + if category and not tag: + category_tags = categorized.get(category, []) + folders = [] + for slug, title in category_tags: + folders.append( + BrowseFolder( + item_id=slug, + provider=self.instance_id, + path=f"{base}{slug}", + name=names.get(slug, title), + is_playable=False, + ) + ) + self.logger.debug("Returning %d tag folders for category %s", len(folders), category) + return folders + + # picks/category/tag - show playlists for the tag + if tag: + discovered_slugs = {slug for slug, _ in discovered} + if tag in discovered_slugs: + self.logger.debug("Fetching playlists for tag: %s", tag) + return await self._get_tag_playlists_as_browse(tag) + + self.logger.debug("No match found, returning empty list") + return [] + + async def _browse_mixes( + self, path: str, path_parts: list[str] + ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse mixes folder (seasonal collections) using hardcoded tags. + + Uses TAG_MIXES directly and validates each tag via client.tags() + to check if it has playlists. Does not depend on landing API discovery. + + :param path: Full browse path. + :param path_parts: Split path parts after ://. + :return: List of folders or playlists. + """ + names = self._get_browse_names() + base = path.rstrip("/") + "/" + + # Validate seasonal tags in parallel (no landing dependency) + sem = asyncio.Semaphore(5) + + async def _check(tag: str) -> str | None: + async with sem: + return tag if await self._validate_tag(tag) else None + + results = await asyncio.gather(*[_check(t) for t in TAG_MIXES]) + available_mixes = [t for t in results if t is not None] + + # mixes/ - show seasonal folders (only valid ones) + if len(path_parts) == 1: + folders = [] + for t in available_mixes: + folders.append( + BrowseFolder( + item_id=t, + provider=self.instance_id, + path=f"{base}{t}", + name=names.get(t, t.title()), + is_playable=False, + ) + ) + return folders + + # mixes/tag - show playlists for the tag + tag = path_parts[1] if len(path_parts) > 1 else None + if tag and tag in TAG_MIXES: + return await self._get_tag_playlists_as_browse(tag) + + return [] + + def _get_wave_state(self, station_id: str) -> _WaveState: + """Get or create per-station wave state. + + :param station_id: Rotor station ID (e.g. 'genre:rock', 'mood:chill'). + :return: _WaveState instance for this station. + """ + return self._wave_states.setdefault(station_id, _WaveState()) + + async def _browse_waves( + self, path: str, path_parts: list[str] + ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse waves folder (rotor stations by genre/mood/activity/epoch/local). + + Fetches available stations from the Kion rotor API and groups them by category. + + :param path: Full browse path. + :param path_parts: Split path parts after ://. + :return: List of folders or tracks. + """ + names = self._get_browse_names() + base = path.rstrip("/") + "/" + + locale = (self.mass.metadata.locale or "en_US").lower() + language = "ru" if locale.startswith("ru") else "en" + + all_stations = await self.client.get_wave_stations(language) + + # Group stations by category, preserving image_url + categorized: dict[str, list[tuple[str, str, str | None]]] = {} + for station_id, cat_key, name, image_url in all_stations: + categorized.setdefault(cat_key, []).append((station_id, name, image_url)) + + # waves/ — show category folders + if len(path_parts) == 1: + folders: list[BrowseFolder] = [] + # Personalized "My Mixes" first — only show if dashboard returns stations + dashboard_stations = await self._get_dashboard_stations_cached() + if dashboard_stations: + folders.append( + BrowseFolder( + item_id=MY_WAVES_FOLDER_ID, + provider=self.instance_id, + path=f"{base}{MY_WAVES_FOLDER_ID}", + name=names.get(MY_WAVES_FOLDER_ID, "My Mixes"), + is_playable=False, + ) + ) + # Featured Mixes — only show if landing-blocks/waves returns data + waves_landing = await self._get_waves_landing_cached() + if waves_landing: + folders.append( + BrowseFolder( + item_id=WAVES_LANDING_FOLDER_ID, + provider=self.instance_id, + path=f"{base}{WAVES_LANDING_FOLDER_ID}", + name=names.get(WAVES_LANDING_FOLDER_ID, "Featured Mixes"), + is_playable=False, + ) + ) + for cat in WAVE_CATEGORY_DISPLAY_ORDER: + if cat in categorized: + folders.append( + BrowseFolder( + item_id=cat, + provider=self.instance_id, + path=f"{base}{cat}", + name=names.get(cat, cat.title()), + is_playable=False, + ) + ) + # Append any categories returned by API that aren't in the predefined order + for cat in categorized: + if cat not in WAVE_CATEGORY_DISPLAY_ORDER: + folders.append( + BrowseFolder( + item_id=cat, + provider=self.instance_id, + path=f"{base}{cat}", + name=names.get(cat, cat.title()), + is_playable=False, + ) + ) + return folders + + category: str | None = path_parts[1] if len(path_parts) > 1 else None + tag: str | None = path_parts[2] if len(path_parts) > 2 else None + + # waves/my_waves/ — show personalized stations from dashboard + if category == MY_WAVES_FOLDER_ID and not tag: + return await self._browse_my_waves_stations(path) + + # waves/waves_landing/... — redirect to Featured Mixes browse + if category == WAVES_LANDING_FOLDER_ID: + return await self._browse_waves_landing(path, path_parts[1:]) + + # waves/my_waves/[/next] — play a specific personal station + # The full station_id has format "genre:allrock", not "my_waves:allrock". + # Resolve by matching against dashboard stations cache. + if category == MY_WAVES_FOLDER_ID and tag: + dashboard_stations = await self._get_dashboard_stations_cached() + for sid, _, _ in dashboard_stations: + sid_tag = sid.split(":", 1)[1] if ":" in sid else sid + if sid_tag == tag: + return await self._browse_wave_station(sid, path=path) + # Fallback: try tag as direct station_id (e.g. "genre:allrock" passed verbatim) + if ":" in tag: + return await self._browse_wave_station(tag, path=path) + return [] + + # waves// — show station folders with artwork + if category and not tag: + cat_stations = categorized.get(category, []) + folders = [] + for station_id, station_name, image_url in cat_stations: + tag_part = station_id.split(":", 1)[1] if ":" in station_id else station_id + station_image: MediaItemImage | None = None + if image_url: + station_image = MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=self.instance_id, + remotely_accessible=True, + ) + folders.append( + BrowseFolder( + item_id=station_id, + provider=self.instance_id, + path=f"{base}{tag_part}", + name=station_name, + is_playable=True, + image=station_image, + ) + ) + return folders + + # waves//[/next] — stream tracks from rotor station + if category and tag: + station_id = f"{category}:{tag}" + return await self._browse_wave_station(station_id, path=path) + + return [] + + @use_cache(600) + async def _get_dashboard_stations_cached(self) -> list[tuple[str, str, str | None]]: + """Get personalized dashboard stations, cached for 10 minutes. + + :return: List of (station_id, name, image_url) tuples. + """ + return await self.client.get_dashboard_stations() + + async def _browse_my_waves_stations(self, path: str) -> list[BrowseFolder]: + """Browse personalized wave stations from rotor/stations/dashboard. + + Names are resolved from the non-personalized station list so that + stations show their actual genre/mood name (e.g. "Рок") rather than + the generic "Мой микс" label that the dashboard API returns. + + :param path: Full browse path (used to build sub-paths). + :return: List of playable BrowseFolder items, one per station. + """ + stations = await self._get_dashboard_stations_cached() + + # Build a name map from the non-personalized list for proper localized names. + locale = (self.mass.metadata.locale or "en_US").lower() + language = "ru" if locale.startswith("ru") else "en" + all_stations = await self.client.get_wave_stations(language) + station_name_map: dict[str, str] = {sid: name for sid, _, name, _ in all_stations} + + base = path.rstrip("/") + "/" + folders: list[BrowseFolder] = [] + for station_id, fallback_name, image_url in stations: + # Use full station_id (e.g. "genre:rock") in path to avoid collisions + # when two stations share the same tag but differ by category. + # The routing fallback (if ":" in tag) handles this correctly. + name = station_name_map.get(station_id, fallback_name) + station_image: MediaItemImage | None = None + if image_url: + station_image = MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=self.instance_id, + remotely_accessible=True, + ) folders.append( BrowseFolder( - item_id="playlists", + item_id=station_id, provider=self.instance_id, - path=f"{base}playlists", - name=names["playlists"], + path=f"{base}{station_id}", + name=name, is_playable=True, + image=station_image, ) ) - if len(folders) == 1: - return await self.browse(folders[0].path) return folders + async def _browse_wave_station( + self, station_id: str, path: str = "" + ) -> list[Track | BrowseFolder]: + """Browse a rotor wave station and return tracks. + + Fetches tracks from the rotor station, deduplicates within the current session, + and sends radioStarted feedback on first call. Appends a "Load more" BrowseFolder + at the end so MA can continue fetching the next batch automatically (radio mode). + + :param station_id: Rotor station ID (e.g. 'genre:rock', 'mood:chill'). + :param path: Current browse path, used to construct the "Load more" next path. + :return: List of Track objects with composite item_id (track_id@station_id), + followed by a "Load more" BrowseFolder if more tracks are available. + """ + state = self._get_wave_state(station_id) + async with state.lock: + max_tracks = int( + self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type] + ) + + self.logger.debug( + "Browse wave station: station_id=%s path=%s last_track_id=%s", + station_id, + path, + state.last_track_id, + ) + yandex_tracks, batch_id = await self.client.get_rotor_station_tracks( + station_id, queue=state.last_track_id + ) + if batch_id: + state.batch_id = batch_id + + if not state.radio_started_sent and yandex_tracks: + sent = await self.client.send_rotor_station_feedback( + station_id, + "radioStarted", + batch_id=batch_id, + ) + if sent: + state.radio_started_sent = True + + tracks: list[Track] = [] + first_track_id: str | None = None + for yt in yandex_tracks: + if len(state.seen_track_ids) >= max_tracks: + break + track = self._parse_my_wave_track(yt, state.seen_track_ids) + if track is None: + continue + # Override station_id in composite item_id to reflect this specific station + old_item_id = track.item_id + track_id = old_item_id.split(RADIO_TRACK_ID_SEP, 1)[0] + track.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{station_id}" + # Keep provider mappings in sync with the new item_id + for pm in getattr(track, "provider_mappings", []): + if ( + getattr(pm, "item_id", None) == old_item_id + and getattr(pm, "provider_instance", None) == self.instance_id + ): + pm.item_id = track.item_id + if first_track_id is None: + first_track_id = track_id + tracks.append(track) + + if first_track_id is not None: + state.last_track_id = first_track_id + + self.logger.debug( + "Wave station %s returned %d tracks: %s", + station_id, + len(tracks), + [t.item_id.split(RADIO_TRACK_ID_SEP, 1)[0] for t in tracks[:5]], + ) + result: list[Track | BrowseFolder] = list(tracks) + + # Append "Load more" sentinel so MA knows to call browse again for next batch. + # This mirrors the My Mix mechanism and enables continuous radio playback. + if tracks and len(state.seen_track_ids) < max_tracks and path: + names = self._get_browse_names() + next_name = "Ещё" if names == BROWSE_NAMES_RU else "Load more" + # Append /next to the current path (same pattern as _browse_my_wave). + # This makes each "Load more" path unique (e.g. /next/next/next...) + # so MA never serves a cached result for subsequent presses. + result.append( + BrowseFolder( + item_id="next", + provider=self.instance_id, + path=f"{path.rstrip('/')}/next", + name=next_name, + is_playable=False, + ) + ) + + return result + + @staticmethod + def _extract_wave_item_cover(item: dict[str, Any]) -> tuple[str | None, str | None]: + """Extract cover URI and background color from a wave/mix item. + + :param item: Wave or mix item dict from the API. + :return: (cover_uri, bg_color) tuple where bg_color is a hex string or None. + """ + agent_uri = item.get("agent", {}).get("cover", {}).get("uri", "") + cover_uri = agent_uri or item.get("compact_image_url") + bg_color = item.get("colors", {}).get("average") + return cover_uri, bg_color + + @use_cache(3600) + async def _get_mixes_waves_cached(self) -> list[dict[str, Any]] | None: + """Get AI Wave Set data from /landing-blocks/mixes-waves, cached for 1 hour. + + :return: List of mix category dicts from the API, or None on error. + """ + return await self.client.get_mixes_waves() + + @use_cache(3600) + async def _get_waves_landing_cached(self) -> list[dict[str, Any]] | None: + """Get Featured Mixes data from /landing-blocks/waves, cached for 1 hour. + + :return: List of wave category dicts from the API, or None on error. + """ + return await self.client.get_waves_landing() + + async def _browse_waves_landing( + self, path: str, path_parts: list[str] + ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse Featured Mixes (from /landing-blocks/waves). + + :param path: Full browse path. + :param path_parts: Split path parts after ://. + :return: List of folders or tracks. + """ + waves_data = await self._get_waves_landing_cached() + return await self._browse_wave_categories( + path, path_parts, waves_data or [], WAVES_LANDING_FOLDER_ID + ) + + async def _browse_wave_categories( + self, + path: str, + path_parts: list[str], + categories_data: list[dict[str, Any]], + id_prefix: str, + ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse wave-like category folders and their station items. + + Shared logic for both 'my_waves_set' browse trees: + - Level 1 (e.g. my_waves_set/): category folders + - Level 2 (e.g. my_waves_set/ai-sets/): playable station folders with artwork + - Level 3+ (e.g. my_waves_set/ai-sets/genre:rock[/next]): track listing + + :param path: Full browse path. + :param path_parts: Split path parts after ://. + :param categories_data: List of category dicts from the API. + :param id_prefix: Prefix for BrowseFolder item_id (e.g. 'my_waves_set'). + :return: List of folders or tracks. + """ + base = path.rstrip("/") + "/" + + if not categories_data: + return [] + + # Level 1 → category folders + if len(path_parts) == 1: + folders: list[BrowseFolder] = [] + for wave_category in categories_data: + cat_id = wave_category.get("id", "") + cat_title = wave_category.get("title", "") + items = wave_category.get("items", []) + if not items or not cat_id: + continue + display_name = cat_title.capitalize() if cat_title else cat_id.capitalize() + folders.append( + BrowseFolder( + item_id=f"{id_prefix}_{cat_id}", + provider=self.instance_id, + path=f"{base}{cat_id}", + name=display_name, + is_playable=False, + ) + ) + return folders + + category_id = path_parts[1] if len(path_parts) > 1 else None + if not category_id: + return [] + + # Level 3+ → stream tracks from rotor station + if len(path_parts) > 2: + station_id = path_parts[2] + return await self._browse_wave_station(station_id, path=path) + + # Level 2 → playable station folders with artwork + for wave_category in categories_data: + if wave_category.get("id") == category_id: + items = wave_category.get("items", []) + result: list[BrowseFolder] = [] + for item in items: + station_id = item.get("station_id", "") + title = item.get("title", "") + if not station_id or not title: + continue + cover_uri, bg_color = self._extract_wave_item_cover(item) + image: MediaItemImage | None = None + if cover_uri: + if cover_uri.startswith("http"): + img_url: str = cover_uri.replace("%%", IMAGE_SIZE_MEDIUM) + else: + raw = get_image_url(cover_uri) + img_url = "" if raw is None else raw + if img_url: + if bg_color: + # Append bg_color as URL fragment for cache-key uniqueness. + # MA will call resolve_image() to composite the transparent PNG. + if len(self._wave_bg_colors) > 200: + self._wave_bg_colors.clear() + img_url = f"{img_url}#{bg_color.lstrip('#')}" + self._wave_bg_colors[img_url] = bg_color + image = MediaItemImage( + type=ImageType.THUMB, + path=img_url, + provider=self.instance_id, + remotely_accessible=bg_color is None, + ) + result.append( + BrowseFolder( + item_id=station_id, + provider=self.instance_id, + path=f"{base}{station_id}", + name=title, + is_playable=True, + image=image, + ) + ) + return result + + return [] + + async def _browse_vibe_sets( + self, path: str, path_parts: list[str] + ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse AI Mix Sets (from /landing-blocks/mixes-waves). + + :param path: Full browse path. + :param path_parts: Split path parts after ://. + :return: List of folders or tracks. + """ + mixes_data = await self._get_mixes_waves_cached() + return await self._browse_wave_categories( + path, path_parts, mixes_data or [], MY_WAVES_SET_FOLDER_ID + ) + + @use_cache(600) + async def _get_tag_playlists_as_browse( + self, tag_id: str + ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Get playlists for a tag and return as browse items. + + :param tag_id: Tag identifier (e.g. 'chill', '80s'). + :return: List of Playlist objects. + """ + self.logger.debug("Fetching playlists for tag: %s", tag_id) + playlists = await self.client.get_tag_playlists(tag_id) + self.logger.debug("Got %d playlists for tag %s", len(playlists), tag_id) + result: list[Playlist] = [] + for playlist in playlists: + try: + result.append(parse_playlist(self, playlist)) + except InvalidDataError as err: + self.logger.debug("Error parsing tag playlist: %s", err) + self.logger.debug("Parsed %d playlists for tag %s", len(result), tag_id) + return result + # Search @use_cache(3600 * 24 * 14) @@ -360,7 +1309,7 @@ async def search( result = SearchResults() # Determine search type based on requested media types - # Map MediaType to KION API search type + # Map MediaType to Kion API search type type_mapping = { MediaType.TRACK: "track", MediaType.ALBUM: "album", @@ -443,7 +1392,7 @@ async def get_track(self, prov_track_id: str) -> Track: Supports composite item_id (track_id@station_id) for My Mix tracks; only the track_id part is used for the API. Normalizes the ID before - caching so that "12345" and "12345@user:onyourwave" share one cache entry. + caching to avoid duplicate cache entries. :param prov_track_id: The provider track ID (or track_id@station_id). :return: Track object. @@ -454,38 +1403,43 @@ async def get_track(self, prov_track_id: str) -> Track: @use_cache(3600 * 24 * 30) async def _get_track_cached(self, track_id: str) -> Track: - """Fetch and cache track details by normalized track ID. + """Get track details by normalized ID (cached). - :param track_id: Plain track ID (no station suffix). + :param track_id: Normalized track ID (without station suffix). :return: Track object. :raises MediaNotFoundError: If track not found. """ yandex_track = await self.client.get_track(track_id) if not yandex_track: raise MediaNotFoundError(f"Track {track_id} not found") - return parse_track(self, yandex_track) - @use_cache(3600 * 24 * 30) + # Use the already-fetched track object to avoid a duplicate API call + lyrics, lyrics_synced = await self.client.get_track_lyrics_from_track(yandex_track) + + return parse_track(self, yandex_track, lyrics=lyrics, lyrics_synced=lyrics_synced) + async def get_playlist(self, prov_playlist_id: str) -> Playlist: """Get playlist details by ID. - Supports virtual playlist MY_MIX_PLAYLIST_ID (My Mix). Real playlists - use format "owner_id:kind". + Supports virtual playlists MY_WAVE_PLAYLIST_ID (My Mix) and + LIKED_TRACKS_PLAYLIST_ID (Liked Tracks). Real playlists use format "owner_id:kind". - :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind" or my_mix). + :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind", + my_wave, or liked_tracks). :return: Playlist object. :raises MediaNotFoundError: If playlist not found. """ - if prov_playlist_id == MY_MIX_PLAYLIST_ID: + # Virtual playlists - not cached (locale-dependent names) + if prov_playlist_id == MY_WAVE_PLAYLIST_ID: names = self._get_browse_names() return Playlist( - item_id=MY_MIX_PLAYLIST_ID, + item_id=MY_WAVE_PLAYLIST_ID, provider=self.instance_id, - name=names[MY_MIX_PLAYLIST_ID], - owner="KION Music", + name=names[MY_WAVE_PLAYLIST_ID], + owner=get_canonical_provider_name(self), provider_mappings={ ProviderMapping( - item_id=MY_MIX_PLAYLIST_ID, + item_id=MY_WAVE_PLAYLIST_ID, provider_domain=self.domain, provider_instance=self.instance_id, is_unique=True, @@ -494,6 +1448,35 @@ async def get_playlist(self, prov_playlist_id: str) -> Playlist: is_editable=False, ) + if prov_playlist_id == LIKED_TRACKS_PLAYLIST_ID: + names = self._get_browse_names() + return Playlist( + item_id=LIKED_TRACKS_PLAYLIST_ID, + provider=self.instance_id, + name=names[LIKED_TRACKS_PLAYLIST_ID], + owner=get_canonical_provider_name(self), + provider_mappings={ + ProviderMapping( + item_id=LIKED_TRACKS_PLAYLIST_ID, + provider_domain=self.domain, + provider_instance=self.instance_id, + is_unique=True, + ) + }, + is_editable=False, + ) + + # Real playlists - use cached method + return await self._get_real_playlist(prov_playlist_id) + + @use_cache(3600 * 24 * 30) + async def _get_real_playlist(self, prov_playlist_id: str) -> Playlist: + """Get real playlist details by ID (cached). + + :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind"). + :return: Playlist object. + :raises MediaNotFoundError: If playlist not found. + """ # 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) @@ -506,36 +1489,134 @@ async def get_playlist(self, prov_playlist_id: str) -> Playlist: raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found") return parse_playlist(self, playlist) - async def _get_my_mix_playlist_tracks(self, page: int) -> list[Track]: + async def _get_my_wave_playlist_tracks(self, page: int) -> list[Track]: """Get My Mix tracks for virtual playlist (uncached; uses cursor for page > 0). + Fetches MY_WAVE_BATCH_SIZE Rotor API batches per page call to reduce + the number of round-trips when the player controller paginates through pages. + :param page: Page number (0 = first batch, 1+ = next batches via queue cursor). :return: List of Track objects for this page. """ - if page == 0: - self._my_mix_seen_track_ids = set() + async with self._my_wave_lock: + max_tracks_config = int( + self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type] + ) - queue: str | int | None = None - if page > 0: - queue = self._my_mix_playlist_next_cursor - if not queue: + # Reset seen tracks on first page + if page == 0: + self._my_wave_seen_track_ids = set() + + queue: str | int | None = None + if page > 0: + queue = self._my_wave_playlist_next_cursor + if not queue: + return [] + + # Check if we've already reached the limit + if len(self._my_wave_seen_track_ids) >= max_tracks_config: return [] - if len(self._my_mix_seen_track_ids) >= MY_MIX_MAX_TRACKS: + tracks: list[Track] = [] + next_cursor: str | None = None + + # Fetch MY_WAVE_BATCH_SIZE Rotor API batches per page to reduce API round-trips + for _ in range(MY_WAVE_BATCH_SIZE): + if len(self._my_wave_seen_track_ids) >= max_tracks_config: + break + + 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: + sent = await self.client.send_rotor_station_feedback( + ROTOR_STATION_MY_MIX, + "radioStarted", + batch_id=batch_id, + ) + if sent: + self._my_wave_radio_started_sent = True + + if not yandex_tracks: + break + + first_track_id_this_batch = None + for yt in yandex_tracks: + if len(self._my_wave_seen_track_ids) >= max_tracks_config: + break + + track = self._parse_my_wave_track(yt, self._my_wave_seen_track_ids) + if track is None: + continue + + tracks.append(track) + track_id = track.item_id.split(RADIO_TRACK_ID_SEP, 1)[0] + if first_track_id_this_batch is None: + first_track_id_this_batch = track_id + + if first_track_id_this_batch is not None: + next_cursor = first_track_id_this_batch + queue = first_track_id_this_batch + else: + # All tracks in this batch were duplicates or failed to parse + break + + # Store cursor for next page call (None clears pagination so next call returns []) + self._my_wave_playlist_next_cursor = next_cursor + return tracks + + async def _get_liked_tracks_playlist_tracks(self, page: int) -> list[Track]: + """Get liked tracks for virtual playlist (sorted in reverse chronological order). + + :param page: Page number (0 = all tracks limited by config, >0 = empty for pagination). + :return: List of Track objects. + """ + # Liked tracks API returns all tracks at once, so only return tracks on page 0 + if page > 0: return [] - ( - tracks, - _, - last_first_track_id, - self._my_mix_seen_track_ids, - ) = await self._fetch_my_mix_tracks( - max_batches=1, - initial_queue=queue, - seen_track_ids=self._my_mix_seen_track_ids, + max_tracks_config = int( + self.config.get_value(CONF_LIKED_TRACKS_MAX_TRACKS) or 500 # type: ignore[arg-type] ) - if last_first_track_id is not None: - self._my_mix_playlist_next_cursor = last_first_track_id + + # Fetch liked tracks (already sorted in reverse chronological order by api_client) + track_shorts = await self.client.get_liked_tracks() + if not track_shorts: + self.logger.debug("No liked tracks found") + return [] + + # Apply max tracks limit + track_shorts = track_shorts[:max_tracks_config] + + # Fetch full track details in batches + track_ids = [str(ts.track_id) for ts in track_shorts if ts.track_id] + + batch_size = TRACK_BATCH_SIZE + full_tracks = [] + for i in range(0, len(track_ids), batch_size): + batch_ids = track_ids[i : i + batch_size] + batch_result = await self.client.get_tracks(batch_ids) + full_tracks.extend(batch_result) + + # Create track ID to full track mapping by track ID directly + track_map = {} + for t in full_tracks: + if hasattr(t, "id") and t.id: + track_map[str(t.id)] = t + + # Parse tracks in the original order (reverse chronological) + tracks = [] + for track_id in track_ids: + # track_id may be compound "trackId:albumId", extract base ID for lookup + base_id = track_id.split(":")[0] if ":" in track_id else track_id + found = track_map.get(track_id) or track_map.get(base_id) + if found: + try: + tracks.append(parse_track(self, found)) + except InvalidDataError as err: + self.logger.debug("Error parsing liked track %s: %s", track_id, err) + + self.logger.debug("Liked tracks: fetched %s, parsed %s", len(track_shorts), len(tracks)) return tracks # Get related items @@ -565,9 +1646,9 @@ async def get_album_tracks(self, prov_album_id: str) -> list[Track]: @use_cache(3600 * 3) async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]: - """Get similar tracks using rotor station for this track. + """Get similar tracks using Kion Rotor station for this track. - Uses rotor station track:{id} so MA radio mode gets recommendations. + Uses rotor station track:{id} so MA radio mode gets Kion recommendations. :param prov_track_id: Provider track ID (plain or track_id@station_id). :param limit: Maximum number of tracks to return. @@ -584,39 +1665,404 @@ async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[ self.logger.debug("Error parsing similar track: %s", err) return tracks - @use_cache(600) # Cache for 10 minutes async def recommendations(self) -> list[RecommendationFolder]: - """Get recommendations; includes My Mix (Мой Микс) as first folder. + """Get recommendations with multiple discovery folders. - Fetches fresh tracks on each call for discovery experience. + Returns My Mix, Feed (Made for You), Chart, New Releases, and + New Playlists sections. - :return: List of recommendation folders (My Mix with tracks). + :return: List of recommendation folders. """ - items, _, _, _ = await self._fetch_my_mix_tracks( - max_tracks=DISCOVERY_INITIAL_TRACKS, + folders: list[RecommendationFolder] = [] + + folder = await self._get_my_wave_recommendations() + if folder: + folders.append(folder) + + folder = await self._get_feed_recommendations() + if folder: + folders.append(folder) + + folder = await self._get_chart_recommendations() + if folder: + folders.append(folder) + + folder = await self._get_new_releases_recommendations() + if folder: + folders.append(folder) + + folder = await self._get_new_playlists_recommendations() + if folder: + folders.append(folder) + + # Picks & Mixes recommendations + folder = await self._get_top_picks_recommendations() + if folder: + folders.append(folder) + + # Mood mix: select tag outside cache so rotation actually works + mood_tag = await self._pick_random_tag_for_category("mood") + if mood_tag: + folder = await self._get_mood_mix_recommendations(mood_tag) + if folder: + folders.append(folder) + + # Activity mix: select tag outside cache so rotation actually works + activity_tag = await self._pick_random_tag_for_category("activity") + if activity_tag: + folder = await self._get_activity_mix_recommendations(activity_tag) + if folder: + folders.append(folder) + + folder = await self._get_seasonal_mix_recommendations() + if folder: + folders.append(folder) + + return folders + + @use_cache(600) + async def _get_my_wave_recommendations(self) -> RecommendationFolder | None: + """Get My Mix recommendation folder with personalized tracks. + + :return: RecommendationFolder with My Mix tracks, or None if empty. + """ + max_tracks_config = int( + self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type] ) + batch_size_config = MY_WAVE_BATCH_SIZE + + seen_track_ids: set[str] = set() + items: list[Track] = [] + queue: str | int | None = None + + for _ in range(batch_size_config): + if len(seen_track_ids) >= max_tracks_config: + break + + yandex_tracks, _ = await self.client.get_my_wave_tracks(queue=queue) + if not yandex_tracks: + break + + first_track_id_this_batch = None + for yt in yandex_tracks: + if len(seen_track_ids) >= max_tracks_config: + break + + track = self._parse_my_wave_track(yt, seen_ids=seen_track_ids) + if track is None: + continue + + items.append(track) + track_id = track.item_id.split(RADIO_TRACK_ID_SEP, 1)[0] + if first_track_id_this_batch is None: + first_track_id_this_batch = track_id + + queue = first_track_id_this_batch + if not queue: + break + + if not items: + return None + + initial_tracks_limit = DISCOVERY_INITIAL_TRACKS + if len(items) > initial_tracks_limit: + items = items[:initial_tracks_limit] names = self._get_browse_names() - return [ - RecommendationFolder( - item_id=MY_MIX_PLAYLIST_ID, - provider=self.instance_id, - name=names[MY_MIX_PLAYLIST_ID], - items=UniqueList(items), - icon="mdi-waveform", - ) + 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(1800) + async def _get_feed_recommendations(self) -> RecommendationFolder | None: + """Get personalized feed playlists (Playlist of the Day, DejaVu, etc.). + + :return: RecommendationFolder with generated playlists, or None if unavailable. + """ + feed = await self.client.get_feed() + if not feed or not feed.generated_playlists: + return None + items: list[Playlist] = [] + for gen_playlist in feed.generated_playlists: + if gen_playlist.data and gen_playlist.ready: + try: + items.append(parse_playlist(self, gen_playlist.data)) + except InvalidDataError as err: + self.logger.debug("Error parsing feed playlist: %s", err) + if not items: + return None + names = self._get_browse_names() + return RecommendationFolder( + item_id="feed", + provider=self.instance_id, + name=names["feed"], + items=UniqueList(items), + icon="mdi-account-music", + ) + + @use_cache(3600) + async def _get_chart_recommendations(self) -> RecommendationFolder | None: + """Get chart tracks (hot tracks of the month). + + :return: RecommendationFolder with chart tracks, or None if unavailable. + """ + chart_info = await self.client.get_chart() + if not chart_info or not chart_info.chart: + return None + playlist = chart_info.chart + if not playlist.tracks: + return None + # TrackShort objects in chart context have .track (full Track) and .chart (position) + tracks: list[Track] = [] + for track_short in playlist.tracks[:20]: + track_obj = getattr(track_short, "track", None) + if not track_obj: + continue + try: + tracks.append(parse_track(self, track_obj)) + except InvalidDataError as err: + self.logger.debug("Error parsing chart track: %s", err) + if not tracks: + return None + names = self._get_browse_names() + return RecommendationFolder( + item_id="chart", + provider=self.instance_id, + name=names["chart"], + items=UniqueList(tracks), + icon="mdi-chart-line", + ) + + @use_cache(3600) + async def _get_new_releases_recommendations(self) -> RecommendationFolder | None: + """Get new album releases. + + :return: RecommendationFolder with new albums, or None if unavailable. + """ + releases = await self.client.get_new_releases() + if not releases or not releases.new_releases: + return None + # new_releases is a list of album IDs (int) — need to batch-fetch full details + album_ids = [str(aid) for aid in releases.new_releases[:20]] + if not album_ids: + return None + full_albums = await self.client.get_albums(album_ids) + if not full_albums: + return None + albums: list[Album] = [] + for album in full_albums: + try: + albums.append(parse_album(self, album)) + except InvalidDataError as err: + self.logger.debug("Error parsing new release album: %s", err) + if not albums: + return None + names = self._get_browse_names() + return RecommendationFolder( + item_id="new_releases", + provider=self.instance_id, + name=names["new_releases"], + items=UniqueList(albums), + icon="mdi-new-box", + ) + + @use_cache(3600) + async def _get_new_playlists_recommendations(self) -> RecommendationFolder | None: + """Get new editorial playlists. + + :return: RecommendationFolder with new playlists, or None if unavailable. + """ + result = await self.client.get_new_playlists() + if not result or not result.new_playlists: + return None + # new_playlists is a list of PlaylistId objects (uid, kind) — fetch full details + playlist_ids = [ + f"{pid.uid}:{pid.kind}" + for pid in result.new_playlists[:20] + if hasattr(pid, "uid") and hasattr(pid, "kind") ] + if not playlist_ids: + return None + full_playlists = await self.client.get_playlists(playlist_ids) + if not full_playlists: + return None + playlists: list[Playlist] = [] + for playlist in full_playlists: + try: + playlists.append(parse_playlist(self, playlist)) + except InvalidDataError as err: + self.logger.debug("Error parsing new playlist: %s", err) + if not playlists: + return None + names = self._get_browse_names() + return RecommendationFolder( + item_id="new_playlists", + provider=self.instance_id, + name=names["new_playlists"], + items=UniqueList(playlists), + icon="mdi-playlist-star", + ) + + @use_cache(3600) + async def _get_top_picks_recommendations(self) -> RecommendationFolder | None: + """Get Top Picks recommendation folder (tag: top). + + :return: RecommendationFolder with top playlists, or None if unavailable. + """ + playlists = await self.client.get_tag_playlists("top") + if not playlists: + return None + items: list[Playlist] = [] + for playlist in playlists[:10]: + try: + items.append(parse_playlist(self, playlist)) + except InvalidDataError as err: + self.logger.debug("Error parsing top picks playlist: %s", err) + if not items: + return None + names = self._get_browse_names() + return RecommendationFolder( + item_id="top_picks", + provider=self.instance_id, + name=names.get("top_picks", "Top Picks"), + items=UniqueList(items), + icon="mdi-star", + ) + + async def _pick_random_tag_for_category(self, category: str) -> str | None: + """Pick a random valid tag for a category (not cached — enables rotation). + + :param category: Category name ('mood', 'activity', etc.). + :return: Random tag slug, or None if no valid tags. + """ + valid_tags = await self._get_valid_tags_for_category(category) + if not valid_tags: + return None + return random.choice(valid_tags) + + @use_cache(1800) + async def _get_mood_mix_recommendations(self, mood_tag: str) -> RecommendationFolder | None: + """Get Mood Mix recommendation folder for a specific tag. + + :param mood_tag: Preselected mood tag slug. + :return: RecommendationFolder with mood playlists, or None if unavailable. + """ + playlists = await self.client.get_tag_playlists(mood_tag) + if not playlists: + self.logger.debug("No playlists for mood tag %s, skipping recommendation", mood_tag) + return None + items: list[Playlist] = [] + for playlist in playlists[:8]: + try: + items.append(parse_playlist(self, playlist)) + except InvalidDataError as err: + self.logger.debug("Error parsing mood playlist: %s", err) + if not items: + return None + names = self._get_browse_names() + tag_name = names.get(mood_tag, mood_tag.title()) + return RecommendationFolder( + item_id="mood_mix", + provider=self.instance_id, + name=f"{names.get('mood_mix', 'Mood')}: {tag_name}", + items=UniqueList(items), + icon="mdi-emoticon-outline", + ) + + @use_cache(1800) + async def _get_activity_mix_recommendations( + self, activity_tag: str + ) -> RecommendationFolder | None: + """Get Activity Mix recommendation folder for a specific tag. + + :param activity_tag: Preselected activity tag slug. + :return: RecommendationFolder with activity playlists, or None if unavailable. + """ + playlists = await self.client.get_tag_playlists(activity_tag) + if not playlists: + self.logger.debug( + "No playlists for activity tag %s, skipping recommendation", activity_tag + ) + return None + items: list[Playlist] = [] + for playlist in playlists[:8]: + try: + items.append(parse_playlist(self, playlist)) + except InvalidDataError as err: + self.logger.debug("Error parsing activity playlist: %s", err) + if not items: + return None + names = self._get_browse_names() + tag_name = names.get(activity_tag, activity_tag.title()) + return RecommendationFolder( + item_id="activity_mix", + provider=self.instance_id, + name=f"{names.get('activity_mix', 'Activity')}: {tag_name}", + items=UniqueList(items), + icon="mdi-run", + ) + + @use_cache(3600 * 6) + async def _get_seasonal_mix_recommendations(self) -> RecommendationFolder | None: + """Get Seasonal Mix recommendation folder (based on current month). + + :return: RecommendationFolder with seasonal playlists, or None if unavailable. + """ + # Determine current season tag + current_month = datetime.now(tz=UTC).month + seasonal_tag = TAG_SEASONAL_MAP.get(current_month, "autumn") + + # Validate the seasonal tag; fall back to autumn if not available + if not await self._validate_tag(seasonal_tag): + seasonal_tag = "autumn" + + playlists = await self.client.get_tag_playlists(seasonal_tag) + if not playlists: + return None + items: list[Playlist] = [] + for playlist in playlists[:8]: + try: + items.append(parse_playlist(self, playlist)) + except InvalidDataError as err: + self.logger.debug("Error parsing seasonal playlist: %s", err) + if not items: + return None + names = self._get_browse_names() + tag_name = names.get(seasonal_tag, seasonal_tag.title()) + return RecommendationFolder( + item_id="seasonal_mix", + provider=self.instance_id, + name=f"{names.get('seasonal_mix', 'Seasonal')}: {tag_name}", + items=UniqueList(items), + icon="mdi-weather-sunny", + ) @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" or my_mix). + :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind", + my_wave, or liked_tracks). :param page: Page number for pagination. :return: List of Track objects. """ - if prov_playlist_id == MY_MIX_PLAYLIST_ID: - return await self._get_my_mix_playlist_tracks(page) + self.logger.debug( + "get_playlist_tracks called: prov_playlist_id=%s, page=%s", prov_playlist_id, page + ) + + if prov_playlist_id == MY_WAVE_PLAYLIST_ID: + self.logger.debug("Fetching My Mix tracks") + return await self._get_my_wave_playlist_tracks(page) + + if prov_playlist_id == LIKED_TRACKS_PLAYLIST_ID: + self.logger.debug("Fetching Liked Tracks for virtual playlist") + result = await self._get_liked_tracks_playlist_tracks(page) + self.logger.debug("Liked Tracks playlist returned %s tracks", len(result)) + return result # KION 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. @@ -657,7 +2103,7 @@ async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> lis if not tracks_list: return [] - # API returns TrackShort objects, we need to fetch full track info + # Kion returns TrackShort objects, we need to fetch full track info track_ids = [ str(track.track_id) if hasattr(track, "track_id") else str(track.id) for track in tracks_list @@ -667,9 +2113,10 @@ async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> lis return [] # Fetch full track details in batches to avoid timeouts + batch_size = TRACK_BATCH_SIZE full_tracks = [] - for i in range(0, len(track_ids), TRACK_BATCH_SIZE): - batch = track_ids[i : i + TRACK_BATCH_SIZE] + for i in range(0, len(track_ids), batch_size): + batch = track_ids[i : i + batch_size] batch_result = await self.client.get_tracks(batch) if not batch_result: self.logger.warning( @@ -739,7 +2186,8 @@ async def get_library_artists(self) -> AsyncGenerator[Artist, None]: async def get_library_albums(self) -> AsyncGenerator[Album, None]: """Retrieve library albums from KION Music.""" - albums = await self.client.get_liked_albums(batch_size=TRACK_BATCH_SIZE) + batch_size = TRACK_BATCH_SIZE + albums = await self.client.get_liked_albums(batch_size=batch_size) for album in albums: try: yield parse_album(self, album) @@ -754,8 +2202,9 @@ async def get_library_tracks(self) -> AsyncGenerator[Track, None]: # Fetch full track details in batches track_ids = [str(ts.track_id) for ts in track_shorts if ts.track_id] - for i in range(0, len(track_ids), TRACK_BATCH_SIZE): - batch_ids = track_ids[i : i + TRACK_BATCH_SIZE] + batch_size = TRACK_BATCH_SIZE + for i in range(0, len(track_ids), batch_size): + batch_ids = track_ids[i : i + batch_size] full_tracks = await self.client.get_tracks(batch_ids) for track in full_tracks: try: @@ -766,15 +2215,30 @@ async def get_library_tracks(self) -> AsyncGenerator[Track, None]: async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: """Retrieve library playlists from KION Music. - Includes the virtual My Mix playlist first, then user playlists. + Includes virtual playlists (My Mix and Liked Tracks if enabled), user-created playlists, + and user-liked editorial playlists (returned by a separate API endpoint). """ - yield await self.get_playlist(MY_MIX_PLAYLIST_ID) + yield await self.get_playlist(MY_WAVE_PLAYLIST_ID) + yield await self.get_playlist(LIKED_TRACKS_PLAYLIST_ID) + seen_ids: set[str] = set() + # User-created playlists playlists = await self.client.get_user_playlists() for playlist in playlists: try: - yield parse_playlist(self, playlist) + parsed = parse_playlist(self, playlist) + seen_ids.add(parsed.item_id) + yield parsed except InvalidDataError as err: self.logger.debug("Error parsing library playlist: %s", err) + # User-liked editorial playlists (not in users_playlists_list) + liked_playlists = await self.client.get_liked_playlists() + for playlist in liked_playlists: + try: + parsed = parse_playlist(self, playlist) + if parsed.item_id not in seen_ids: + yield parsed + except InvalidDataError as err: + self.logger.debug("Error parsing liked playlist: %s", err) # Library edit methods @@ -833,6 +2297,66 @@ async def get_stream_details( """ return await self.streaming.get_stream_details(item_id) + async def get_audio_stream( + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Return the audio stream for the provider item. + + This method is called when StreamType.CUSTOM is used, enabling on-the-fly + decryption of encrypted FLAC streams without disk I/O. + + :param streamdetails: Stream details containing encrypted URL and decryption key. + :param seek_position: Seek position in seconds (not supported for encrypted streams). + :return: Async generator yielding decrypted audio chunks. + """ + async for chunk in self.streaming.get_audio_stream(streamdetails, seek_position): + yield chunk + + async def resolve_image(self, path: str) -> str | bytes: + """Resolve wave cover image with background color fill for transparent PNGs. + + If the image URL has an associated background color (stored in _wave_bg_colors), + downloads the PNG from Kion CDN and composites it on a solid color background + using Pillow, returning JPEG bytes. Falls back to the original URL on any error. + + :param path: Image URL (may include #rrggbb fragment used as cache key). + :return: Composited JPEG bytes, or original path string as fallback. + """ + bg_color = self._wave_bg_colors.get(path) + if not bg_color: + return path + + # Strip the #color fragment before fetching the actual image + fetch_url = path.split("#", maxsplit=1)[0] if "#" in path else path + try: + async with self.mass.http_session.get(fetch_url) as resp: + resp.raise_for_status() + raw = await resp.read() + except Exception as err: + self.logger.debug("Failed to fetch wave cover %s: %s", fetch_url, err) + return fetch_url + + def _composite() -> bytes: + bg_clean = bg_color.lstrip("#") + try: + r = int(bg_clean[0:2], 16) + g = int(bg_clean[2:4], 16) + b = int(bg_clean[4:6], 16) + except (ValueError, IndexError): + return raw + fg = PilImage.open(BytesIO(raw)).convert("RGBA") + bg = PilImage.new("RGBA", fg.size, (r, g, b, 255)) + bg.paste(fg, mask=fg) + out = BytesIO() + bg.convert("RGB").save(out, "JPEG", quality=92) + return out.getvalue() + + try: + return await asyncio.to_thread(_composite) + except Exception as err: + self.logger.debug("Wave cover composite failed for %s: %s", fetch_url, err) + return fetch_url + async def on_played( self, media_type: MediaType, @@ -846,36 +2370,143 @@ async def on_played( Sends trackStarted when the track is currently playing (is_playing=True). trackFinished/skip are sent from on_streamed to use accurate seconds_streamed. + + Also auto-enables "Don't stop the music" for any queue playing a radio track + so that MA refills the queue via get_similar_tracks when < 5 tracks remain. """ + # Radio feedback always enabled if media_type != MediaType.TRACK: return track_id, station_id = _parse_radio_item_id(prov_item_id) if not station_id: return + # Auto-enable "Don't stop the music" on every on_played call for radio tracks. + # Calling on every invocation (not just is_playing=True) ensures it fires even + # for short tracks that finish before the 30-second periodic callback. + self._ensure_dont_stop_the_music(prov_item_id) if is_playing: + if station_id == ROTOR_STATION_MY_MIX: + batch_id = self._my_wave_batch_id + else: + state = self._wave_states.get(station_id) + batch_id = state.batch_id if state else None await self.client.send_rotor_station_feedback( station_id, "trackStarted", track_id=track_id, - batch_id=self._my_mix_batch_id, + batch_id=batch_id, ) + # Remove duplicate call that was under is_playing guard. + # _ensure_dont_stop_the_music is now called unconditionally above. + + def _ensure_dont_stop_the_music(self, prov_item_id: str) -> None: + """Enable 'Don't stop the music' on queues playing this specific radio item. + + Iterates all queues and enables the setting on queues whose current track + mapping matches this exact composite item_id (track_id@station_id) for this + provider instance. + + Also sets queue.radio_source directly to the current track because + enqueued_media_items is empty for BrowseFolder-initiated playback, which + normally prevents MA's auto-fill from triggering. Setting radio_source + directly bypasses that gap so _fill_radio_tracks runs when < 5 tracks remain. + """ + for queue in self.mass.player_queues: + current = queue.current_item + if current is None or current.media_item is None: + continue + item = current.media_item + # Match by provider instance and exact composite item_id + for mapping in getattr(item, "provider_mappings", []): + if ( + mapping.provider_instance == self.instance_id + and mapping.item_id == prov_item_id + ): + # Set radio_source directly so MA's fill mechanism works even when + # the queue was started from a BrowseFolder (enqueued_media_items empty). + if not queue.radio_source and isinstance(item, Track): + queue.radio_source = [item] + if not queue.dont_stop_the_music_enabled: + try: + self.mass.player_queues.set_dont_stop_the_music( + queue.queue_id, dont_stop_the_music_enabled=True + ) + self.logger.info( + "Auto-enabled 'Don't stop the music' for queue %s (radio station)", + queue.display_name, + ) + except Exception as err: + self.logger.debug( + "Could not enable 'Don't stop the music' for queue %s: %s", + queue.display_name, + err, + ) + break + + def _ensure_dont_stop_the_music_for_queue(self, queue_id: str | None) -> None: + """Enable 'Don't stop the music' for a specific queue by ID. + + Faster variant of _ensure_dont_stop_the_music used from on_streamed where + queue_id is available directly, avoiding iteration over all queues. + """ + if not queue_id: + return + queue = self.mass.player_queues.get(queue_id) + if queue is None: + return + current = queue.current_item + if current is None or current.media_item is None: + return + item = current.media_item + for mapping in getattr(item, "provider_mappings", []): + if ( + mapping.provider_instance == self.instance_id + and RADIO_TRACK_ID_SEP in mapping.item_id + ): + if not queue.radio_source and isinstance(item, Track): + queue.radio_source = [item] + if not queue.dont_stop_the_music_enabled: + try: + self.mass.player_queues.set_dont_stop_the_music( + queue_id, dont_stop_the_music_enabled=True + ) + self.logger.info( + "Auto-enabled 'Don't stop the music' for queue %s (radio)", + queue.display_name, + ) + except Exception as err: + self.logger.debug( + "Could not enable 'Don't stop the music' for queue %s: %s", + queue.display_name, + err, + ) + break async def on_streamed(self, streamdetails: StreamDetails) -> None: """Report stream completion for My Mix rotor feedback. - Sends trackFinished or skip with actual seconds_streamed so the service + Sends trackFinished or skip with actual seconds_streamed so Kion can improve recommendations. """ + # Radio feedback always enabled track_id, station_id = _parse_radio_item_id(streamdetails.item_id) if not station_id: return + # Also ensure Don't stop the music is active — on_streamed fires even for + # very short tracks and we have queue_id here directly. + self._ensure_dont_stop_the_music_for_queue(streamdetails.queue_id) 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" + if station_id == ROTOR_STATION_MY_MIX: + batch_id = self._my_wave_batch_id + else: + state = self._wave_states.get(station_id) + batch_id = state.batch_id if state else None await self.client.send_rotor_station_feedback( station_id, feedback_type, track_id=track_id, total_played_seconds=seconds, - batch_id=self._my_mix_batch_id, + batch_id=batch_id, ) diff --git a/music_assistant/providers/kion_music/streaming.py b/music_assistant/providers/kion_music/streaming.py index b98c520ff1..746ef003e6 100644 --- a/music_assistant/providers/kion_music/streaming.py +++ b/music_assistant/providers/kion_music/streaming.py @@ -2,14 +2,27 @@ from __future__ import annotations +import asyncio +from collections.abc import AsyncGenerator from typing import TYPE_CHECKING, Any +import aiohttp +from aiohttp import ClientPayloadError, ServerDisconnectedError +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from music_assistant_models.enums import ContentType, StreamType 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, RADIO_TRACK_ID_SEP +from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER + +from .constants import ( + CONF_QUALITY, + QUALITY_EFFICIENT, + QUALITY_HIGH, + QUALITY_SUPERB, + RADIO_TRACK_ID_SEP, +) if TYPE_CHECKING: from yandex_music import DownloadInfo @@ -17,6 +30,19 @@ from .provider import KionMusicProvider +# Encrypted-stream tuning constants +_CHUNK_SIZE = 16384 # smaller than default 65536 for faster first-byte after retry +_STREAM_TIMEOUT = aiohttp.ClientTimeout(total=None, sock_read=30) +# Kion CDN drops TCP every ~6-7 MB per connection (observed via live traffic capture). +# By capping each Range request to 4 MB we stay well below that limit, so CDN drops +# should never occur during normal windowed playback. +_RANGE_WINDOW = 4 * 1024 * 1024 # 4 MB — must be a multiple of AES block size (16) +# Flat short delays for any residual TCP drops (network glitches within a 4 MB window) +_TCP_DROP_DELAYS = (0.5, 1.0, 2.0) +# Exponential delays for true network stalls (read timeout) +_STALL_DELAYS = (2.0, 4.0, 8.0) + + class KionMusicStreamingManager: """Manages KION Music streaming operations.""" @@ -51,9 +77,13 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: quality = self.provider.config.get_value(CONF_QUALITY) quality_str = str(quality) if quality is not None else None preferred_normalized = (quality_str or "").strip().lower() - want_lossless = ( - QUALITY_LOSSLESS in preferred_normalized or preferred_normalized == QUALITY_LOSSLESS - ) + + # Check for superb (lossless) quality + want_lossless = preferred_normalized in (QUALITY_SUPERB, "superb") + + # Backward compatibility: also check old "lossless" value (exact match) + if preferred_normalized == "lossless": + want_lossless = True # When user wants lossless, try get-file-info first (FLAC; download-info often MP3 only) if want_lossless: @@ -62,25 +92,51 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: if file_info: url = file_info.get("url") codec = file_info.get("codec") or "" + needs_decryption = file_info.get("needs_decryption", False) + if url and codec.lower() in ("flac", "flac-mp4"): - content_type = self._get_content_type(codec) + audio_format = self._build_audio_format(codec) + + # Handle encrypted URLs from encraw transport + if needs_decryption and "key" in file_info: + self.logger.info( + "Streaming encrypted %s for track %s - will decrypt on-the-fly", + codec, + track_id, + ) + # Return StreamType.CUSTOM for streaming decryption. + # can_seek=False: provider always streams from position 0; + # allow_seek=True: ffmpeg handles seek with -ss input flag. + return StreamDetails( + item_id=item_id, + provider=self.provider.instance_id, + audio_format=audio_format, + stream_type=StreamType.CUSTOM, + duration=track.duration, + data={ + "encrypted_url": url, + "decryption_key": file_info["key"], + "codec": codec, + }, + can_seek=False, + allow_seek=True, + ) + # Unencrypted URL, use directly self.logger.debug( - "Stream selected for track %s via get-file-info: codec=%s", + "Unencrypted stream for track %s: codec=%s", item_id, codec, ) return StreamDetails( item_id=item_id, provider=self.provider.instance_id, - audio_format=AudioFormat( - content_type=content_type, - bit_rate=0, - ), + audio_format=audio_format, stream_type=StreamType.HTTP, duration=track.duration, path=url, can_seek=True, allow_seek=True, + expiration=50, # get-file-info URLs expire; force MA to re-fetch ) # Default: use /tracks/.../download-info and select best quality @@ -109,40 +165,33 @@ async def get_stream_details(self, item_id: str) -> StreamDetails: getattr(selected_info, "bitrate_in_kbps", None), ) - content_type = self._get_content_type(selected_info.codec) bitrate = selected_info.bitrate_in_kbps or 0 return StreamDetails( item_id=item_id, provider=self.provider.instance_id, - audio_format=AudioFormat( - content_type=content_type, - bit_rate=bitrate, - ), + audio_format=self._build_audio_format(selected_info.codec, bit_rate=bitrate), stream_type=StreamType.HTTP, duration=track.duration, path=selected_info.direct_link, can_seek=True, allow_seek=True, + expiration=50, # download-info direct links expire after ~60s ) def _select_best_quality( self, download_infos: list[Any], preferred_quality: str | None ) -> DownloadInfo | None: - """Select the best quality download info. + """Select the best quality download info based on user preference. :param download_infos: List of DownloadInfo objects. - :param preferred_quality: User's preferred quality (e.g. "lossless" or "Lossless (FLAC)"). + :param preferred_quality: User's quality preference (efficient/high/balanced/superb). :return: Best matching DownloadInfo or None. """ if not download_infos: return None - # Normalize so we accept "lossless", "Lossless (FLAC)", etc. preferred_normalized = (preferred_quality or "").strip().lower() - want_lossless = ( - QUALITY_LOSSLESS in preferred_normalized or preferred_normalized == QUALITY_LOSSLESS - ) # Sort by bitrate descending sorted_infos = sorted( @@ -151,35 +200,399 @@ def _select_best_quality( reverse=True, ) - # If user wants lossless, prefer flac-mp4 then flac (API formats ~2025) - if want_lossless: + # Superb: Prefer FLAC (backward compatibility with "lossless") + if preferred_normalized in {QUALITY_SUPERB, "lossless"}: + # Note: flac-mp4 typically comes from get-file-info API, not download-info, + # but we check here for forward compatibility in case the API changes. for codec in ("flac-mp4", "flac"): for info in sorted_infos: if info.codec and info.codec.lower() == codec: return info self.logger.warning( - "Lossless (FLAC) requested but no FLAC in API response for this " - "track; using best available" + "Superb quality (FLAC) requested but not available; using best available" + ) + return sorted_infos[0] + + # Efficient: Prefer lowest bitrate AAC/MP3 + if preferred_normalized == QUALITY_EFFICIENT: + # Sort ascending for lowest bitrate + sorted_infos_asc = sorted( + download_infos, + key=lambda x: x.bitrate_in_kbps or 999, ) + # Prefer AAC for efficiency, then MP3 (include MP4 container variants) + for codec in ("aac-mp4", "aac", "he-aac-mp4", "he-aac", "mp3"): + for info in sorted_infos_asc: + if info.codec and info.codec.lower() == codec: + return info + return sorted_infos_asc[0] + + # High: Prefer high bitrate MP3 (~320kbps) + if preferred_normalized == QUALITY_HIGH: + # Look for MP3 with bitrate >= 256kbps + high_quality_mp3 = [ + info + for info in sorted_infos + if info.codec + and info.codec.lower() == "mp3" + and info.bitrate_in_kbps + and info.bitrate_in_kbps >= 256 + ] + if high_quality_mp3: + return high_quality_mp3[0] # Already sorted by bitrate descending + + # Fallback: any MP3 available (highest bitrate) + for info in sorted_infos: + if info.codec and info.codec.lower() == "mp3": + return info + + # If no MP3, use highest available (excluding FLAC) + for info in sorted_infos: + if info.codec and info.codec.lower() not in ("flac", "flac-mp4"): + return info - # Return highest bitrate + # Last resort: highest available + return sorted_infos[0] + + # Balanced (default): Prefer ~192kbps AAC, or medium quality MP3 + # Look for bitrate around 192kbps (within range 128-256) + balanced_infos = [ + info + for info in sorted_infos + if info.bitrate_in_kbps and 128 <= info.bitrate_in_kbps <= 256 + ] + if balanced_infos: + # Prefer AAC over MP3 at similar bitrate (include MP4 container variants) + for codec in ("aac-mp4", "aac", "he-aac-mp4", "he-aac", "mp3"): + for info in balanced_infos: + if info.codec and info.codec.lower() == codec: + return info + return balanced_infos[0] + + # Fallback to highest available if no balanced option return sorted_infos[0] if sorted_infos else None - def _get_content_type(self, codec: str | None) -> ContentType: - """Determine content type from codec string. + def _get_content_type(self, codec: str | None) -> tuple[ContentType, ContentType]: + """Determine container and codec type from Kion API codec string. - :param codec: Codec string from KION API. - :return: ContentType enum value. + Kion API returns codec strings like "flac-mp4" (FLAC in MP4 container), + "aac-mp4" (AAC in MP4 container), or plain "flac", "mp3", "aac". + + :param codec: Codec string from Kion API. + :return: Tuple of (content_type/container, codec_type). """ if not codec: - return ContentType.UNKNOWN + return ContentType.UNKNOWN, ContentType.UNKNOWN codec_lower = codec.lower() - if codec_lower in ("flac", "flac-mp4"): - return ContentType.FLAC + + # MP4 container variants: codec is inside an MP4 container + if codec_lower == "flac-mp4": + return ContentType.MP4, ContentType.FLAC + if codec_lower in ("aac-mp4", "he-aac-mp4"): + return ContentType.MP4, ContentType.AAC + + # Plain single-codec formats: codec is implied by content_type, no separate codec_type + if codec_lower == "flac": + return ContentType.FLAC, ContentType.UNKNOWN if codec_lower in ("mp3", "mpeg"): - return ContentType.MP3 - if codec_lower in ("aac", "aac-mp4", "he-aac", "he-aac-mp4"): - return ContentType.AAC + return ContentType.MP3, ContentType.UNKNOWN + if codec_lower in ("aac", "he-aac"): + return ContentType.AAC, ContentType.UNKNOWN + + return ContentType.UNKNOWN, ContentType.UNKNOWN + + def _get_audio_params(self, codec: str | None) -> tuple[int, int]: + """Return (sample_rate, bit_depth) defaults based on codec string. + + The Kion get-file-info API does not return sample rate or bit depth, + so we use codec-based defaults. These values help the core select the + correct PCM output format and avoid unnecessary resampling. + + :param codec: Codec string from Kion API (e.g. "flac-mp4", "flac", "mp3"). + :return: Tuple of (sample_rate, bit_depth). + """ + if codec and codec.lower() == "flac-mp4": + return 48000, 24 + # CD-quality defaults for all other codecs + return 44100, 16 + + def _build_audio_format(self, codec: str | None, bit_rate: int = 0) -> AudioFormat: + """Build AudioFormat with content type and codec-based audio params. + + :param codec: Codec string from Kion API (e.g. "flac-mp4", "flac", "mp3"). + :param bit_rate: Bitrate in kbps (0 for variable/unknown). + :return: Configured AudioFormat instance. + """ + content_type, codec_type = self._get_content_type(codec) + sample_rate, bit_depth = self._get_audio_params(codec) + return AudioFormat( + content_type=content_type, + codec_type=codec_type, + bit_rate=bit_rate, + sample_rate=sample_rate, + bit_depth=bit_depth, + ) + + async def _refresh_encrypted_url( + self, + track_item_id: str, + current_url: str, + current_key_hex: str, + http_status: int, + bytes_yielded: int, + attempt: int, + max_retries: int, + ) -> tuple[str, str] | None: + """Re-fetch an expired encrypted stream URL. + + Called when the CDN responds with 4xx (URL expired or access revoked). + + :return: (new_url, new_key_hex) on success, or None if retries exhausted. + """ + if attempt >= max_retries: + return None + raw_track_id = self._track_id_from_item_id(track_item_id) + self.logger.warning( + "Encrypted stream URL expired (HTTP %d) at %d bytes (attempt %d/%d) — re-fetching", + http_status, + bytes_yielded, + attempt + 1, + max_retries, + ) + token = BYPASS_THROTTLER.set(True) + try: + file_info = await self.client.get_track_file_info_lossless(raw_track_id) + finally: + BYPASS_THROTTLER.reset(token) + if file_info and file_info.get("url"): + return file_info["url"], file_info.get("key", current_key_hex) + return None + + async def _decrypt_response_stream( + self, + response: Any, + key_bytes: bytes, + block_size: int, + bytes_delivered: int, + ) -> AsyncGenerator[bytes, None]: + """Decrypt one HTTP response and yield plaintext chunks. + + Aligns the AES-CTR counter to the correct block for resumption. + If the server ignores a Range header (200 instead of 206), resets the + counter to 0 and skips the already-delivered prefix transparently. + + :param response: aiohttp ClientResponse (open context manager). + :param key_bytes: Raw AES key bytes. + :param block_size: AES block size (16 for CTR mode). + :param bytes_delivered: Total plaintext bytes already sent to the caller. + :return: Async generator yielding decrypted audio bytes. + """ + block_start = (bytes_delivered // block_size) * block_size + block_skip = bytes_delivered - block_start + + if block_start > 0 and response.status == 200: + self.logger.warning( + "Server ignored Range header at %d bytes (200 instead of 206)" + " — restarting decrypt from position 0, skipping %d already-sent bytes", + block_start, + bytes_delivered, + ) + block_skip = bytes_delivered + block_num = (0).to_bytes(block_size, "big") + else: + block_num = (block_start // block_size).to_bytes(block_size, "big") + + decryptor = Cipher(algorithms.AES(key_bytes), modes.CTR(block_num)).decryptor() + carry_skip = block_skip + async for chunk in response.content.iter_chunked(_CHUNK_SIZE): + decrypted = decryptor.update(chunk) + if carry_skip > 0: + skip = min(carry_skip, len(decrypted)) + decrypted = decrypted[skip:] + carry_skip -= skip + if decrypted: + yield decrypted + final = decryptor.finalize() + if final: + yield final + + def _handle_stream_error( + self, + err: Exception, + attempt: int, + max_retries: int, + bytes_yielded: int, + delays: tuple[float, ...], + label: str, + ) -> tuple[int, float]: + """Increment retry counter, log a warning, or raise if retries are exhausted. + + :param err: The exception that caused the retry. + :param attempt: Current retry attempt count (0-based). + :param max_retries: Maximum number of retries allowed. + :param bytes_yielded: Bytes delivered so far (for log context). + :param delays: Backoff delay sequence to pick from. + :param label: Short verb describing the failure (e.g. "dropped", "stalled"). + :return: (new_attempt, retry_delay) tuple when retrying. + :raises MediaNotFoundError: When attempt count exceeds max_retries. + """ + delay = delays[min(attempt, len(delays) - 1)] + attempt += 1 + if attempt <= max_retries: + self.logger.warning( + "Encrypted stream %s at %d bytes (attempt %d/%d) — retrying", + label, + bytes_yielded, + attempt, + max_retries, + ) + return attempt, delay + raise MediaNotFoundError(f"Encrypted stream {label} after retries were exhausted") from err + + @staticmethod + def _is_content_range_eof(headers: Any, window_end: int) -> bool: + """Return True when Content-Range indicates *window_end* reached the last file byte. + + Parses ``Content-Range: bytes start-end/total`` and checks whether + ``window_end >= total - 1``. Returns False on any malformed header so + the caller falls back to the next window safely. + """ + content_range = headers.get("Content-Range", "") + if not content_range.startswith("bytes "): + return False + try: + _, range_spec = content_range.split(" ", 1) + _, total_str = range_spec.split("/", 1) + total_str = total_str.strip() + return total_str.isdigit() and window_end >= int(total_str) - 1 + except ValueError: + return False + + async def get_audio_stream( # noqa: PLR0915 + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Return the audio stream for the provider item with on-the-fly decryption. + + Downloads and decrypts the encrypted stream in windowed Range requests of + _RANGE_WINDOW bytes each. Kion CDN drops TCP every ~6-7 MB per connection; + keeping each request at 4 MB prevents that limit from being reached. + + On connection drop (ClientPayloadError, ServerDisconnectedError), the current + window is retried with a flat short backoff (0.5s/1.0s/2.0s). + On read stall (asyncio.TimeoutError), the current window is retried with + exponential backoff (2s/4s/8s). + On URL expiry (HTTP 4xx), re-fetches the URL and resumes from bytes_yielded. + Up to max_retries retries per window; the retry counter resets on each + successful window so long tracks get the same protection as short ones. + + If the server ignores a Range header (returns 200 instead of 206), the decryptor + is reset to position 0 so decryption stays consistent with the restarted byte stream. + + :param streamdetails: Stream details containing encrypted URL and key. + :param seek_position: Always 0 (seeking delegated to ffmpeg via allow_seek=True). + :return: Async generator yielding decrypted audio bytes. + """ + encrypted_url: str = streamdetails.data["encrypted_url"] + track_item_id: str = streamdetails.item_id + key_hex: str = streamdetails.data["decryption_key"] + try: + key_bytes = bytes.fromhex(key_hex) + except ValueError as exc: + raise MediaNotFoundError("Invalid decryption key format") from exc + if len(key_bytes) not in (16, 24, 32): + raise MediaNotFoundError(f"Unsupported AES key length: {len(key_bytes)} bytes") + + block_size = 16 # AES-CTR block size in bytes + max_retries = 6 + bytes_yielded = 0 # total decrypted bytes delivered to caller + attempt = 0 # retry counter; resets to 0 after each successful window + retry_delay: float = 0.0 + + while True: + if attempt > 0: + await asyncio.sleep(retry_delay) + + block_start = (bytes_yielded // block_size) * block_size + window_end = block_start + _RANGE_WINDOW - 1 + headers = {"Range": f"bytes={block_start}-{window_end}"} + + try: + async with self.mass.http_session.get( + encrypted_url, headers=headers, timeout=_STREAM_TIMEOUT + ) as response: + if response.status in (401, 403, 410): + # URL expired — re-fetch via helper and retry + refreshed = await self._refresh_encrypted_url( + track_item_id, + encrypted_url, + key_hex, + response.status, + bytes_yielded, + attempt, + max_retries, + ) + if refreshed is None: + raise MediaNotFoundError( + f"Encrypted stream URL expired (HTTP {response.status}) " + "after retries exhausted" + ) + encrypted_url, key_hex = refreshed + try: + key_bytes = bytes.fromhex(key_hex) + except ValueError as err: + raise MediaNotFoundError( + "Invalid decryption key format after URL refresh" + ) from err + retry_delay = 0.0 + attempt += 1 # consume one retry slot, same as TCP-drop path + continue + if response.status == 416: + return # Range Not Satisfiable — file size is exact window multiple + try: + response.raise_for_status() + except Exception as err: + raise MediaNotFoundError( + f"Failed to fetch encrypted stream: {err}" + ) from err + + bytes_before = bytes_yielded + # block_skip = bytes re-downloaded for AES-block alignment. + # Needed below to compute actual HTTP bytes received. + block_skip = bytes_before - block_start + async for chunk in self._decrypt_response_stream( + response, key_bytes, block_size, bytes_yielded + ): + bytes_yielded += len(chunk) + yield chunk + + # window complete — check if EOF + window_got = bytes_yielded - bytes_before + # received = actual HTTP bytes the server sent for this Range + # request. window_got alone understates the window when + # block_skip > 0 (reconnect at a non-AES-block boundary): + # the decryptor skips block_skip bytes, so window_got would be + # smaller than _RANGE_WINDOW even for a full server response, + # causing premature stream termination without this correction. + received = window_got + block_skip + if response.status == 200 or received < _RANGE_WINDOW: + return # full file received or last partial window + # Exact-boundary guard: if file size is an exact multiple of + # _RANGE_WINDOW the size check above won't catch EOF. + # Use Content-Range to confirm no bytes remain. + if self._is_content_range_eof(response.headers, window_end): + return + # more data expected: advance to next window + attempt = 0 + retry_delay = 0.0 - return ContentType.UNKNOWN + except asyncio.CancelledError: + raise # propagate cancellation immediately, do not retry + except (ClientPayloadError, ServerDisconnectedError) as err: + attempt, retry_delay = self._handle_stream_error( + err, attempt, max_retries, bytes_yielded, _TCP_DROP_DELAYS, "dropped" + ) + except TimeoutError as err: + attempt, retry_delay = self._handle_stream_error( + err, attempt, max_retries, bytes_yielded, _STALL_DELAYS, "stalled" + ) diff --git a/requirements_all.txt b/requirements_all.txt index d4cdf145a5..041e0eecc8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -88,7 +88,7 @@ uv>=0.8.0 websocket-client==1.9.0 xmltodict==1.0.4 ya-passport-auth==1.3.0 -yandex-music==2.2.0 +yandex-music==3.0.0 ytmusicapi==1.11.5 zeroconf==0.148.0 zvuk-music[async]==0.6.1 diff --git a/tests/providers/kion_music/__snapshots__/test_parsers.ambr b/tests/providers/kion_music/__snapshots__/test_parsers.ambr index 6325fe57c2..7f0e70fb1a 100644 --- a/tests/providers/kion_music/__snapshots__/test_parsers.ambr +++ b/tests/providers/kion_music/__snapshots__/test_parsers.ambr @@ -54,7 +54,7 @@ 'item_id': '300', 'provider_domain': 'kion_music', 'provider_instance': 'kion_music_instance', - 'url': 'https://music.mts.ru/album/300', + 'url': 'https://music.yandex.ru/album/300', }), ]), 'sort_name': 'test album', @@ -116,7 +116,7 @@ 'item_id': '100', 'provider_domain': 'kion_music', 'provider_instance': 'kion_music_instance', - 'url': 'https://music.mts.ru/artist/100', + 'url': 'https://music.yandex.ru/artist/100', }), ]), 'sort_name': 'test artist', @@ -184,7 +184,7 @@ 'item_id': '200', 'provider_domain': 'kion_music', 'provider_instance': 'kion_music_instance', - 'url': 'https://music.mts.ru/artist/200', + 'url': 'https://music.yandex.ru/artist/200', }), ]), 'sort_name': 'artist with cover', @@ -248,7 +248,7 @@ 'item_id': '12345:3', 'provider_domain': 'kion_music', 'provider_instance': 'kion_music_instance', - 'url': 'https://music.mts.ru/users/12345/playlists/3', + 'url': 'https://music.yandex.ru/users/12345/playlists/3', }), ]), 'sort_name': 'my playlist', @@ -315,7 +315,7 @@ 'item_id': '99999:1', 'provider_domain': 'kion_music', 'provider_instance': 'kion_music_instance', - 'url': 'https://music.mts.ru/users/99999/playlists/1', + 'url': 'https://music.yandex.ru/users/99999/playlists/1', }), ]), 'sort_name': 'shared playlist', @@ -385,7 +385,7 @@ 'item_id': '400', 'provider_domain': 'kion_music', 'provider_instance': 'kion_music_instance', - 'url': 'https://music.mts.ru/track/400', + 'url': 'https://music.yandex.ru/track/400', }), ]), 'sort_name': 'test track', @@ -458,7 +458,7 @@ 'item_id': '20', 'provider_domain': 'kion_music', 'provider_instance': 'kion_music_instance', - 'url': 'https://music.mts.ru/album/20', + 'url': 'https://music.yandex.ru/album/20', }), ]), 'sort_name': 'track album', @@ -519,7 +519,7 @@ 'item_id': '10', 'provider_domain': 'kion_music', 'provider_instance': 'kion_music_instance', - 'url': 'https://music.mts.ru/artist/10', + 'url': 'https://music.yandex.ru/artist/10', }), ]), 'sort_name': 'track artist', @@ -588,7 +588,7 @@ 'item_id': '500', 'provider_domain': 'kion_music', 'provider_instance': 'kion_music_instance', - 'url': 'https://music.mts.ru/track/500', + 'url': 'https://music.yandex.ru/track/500', }), ]), 'sort_name': 'track with album', diff --git a/tests/providers/kion_music/conftest.py b/tests/providers/kion_music/conftest.py index df752920fb..8509616da1 100644 --- a/tests/providers/kion_music/conftest.py +++ b/tests/providers/kion_music/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any import pytest from music_assistant_models.enums import MediaType @@ -32,6 +33,14 @@ def get_item_mapping(self, media_type: MediaType | str, key: str, name: str) -> ) +class _StubConfig: + """Minimal config stub for streaming tests.""" + + def get_value(self, key: str, default: Any = None) -> Any: + """Return default value for any key.""" + return default + + class StreamingProviderStub: """Minimal provider stub for streaming tests (no Mock). @@ -41,6 +50,7 @@ class StreamingProviderStub: domain = "kion_music" instance_id = "kion_music_instance" logger = logging.getLogger("kion_music_test_streaming") + config = _StubConfig() def __init__(self) -> None: """Initialize stub with minimal client.""" @@ -48,6 +58,10 @@ def __init__(self) -> None: self.mass = type("MassStub", (), {})() self._warning_count = 0 + async def get_track(self, prov_track_id: str) -> None: + """Stub — not used by streaming unit tests.""" + return + def _count_warning(self, *args: object, **kwargs: object) -> None: """Track warning calls for test assertions.""" self._warning_count += 1 @@ -88,6 +102,7 @@ class StreamingProviderStubWithTracking: domain = "kion_music" instance_id = "kion_music_instance" + config = _StubConfig() def __init__(self) -> None: """Initialize stub with tracking logger.""" @@ -95,6 +110,10 @@ def __init__(self) -> None: self.mass = type("MassStub", (), {})() self.logger = TrackingLogger() + async def get_track(self, prov_track_id: str) -> None: + """Stub — not used by streaming unit tests.""" + return + # Minimal client-like object for kion_music de_json (library requires client, not None) DE_JSON_CLIENT = type("ClientStub", (), {"report_unknown_fields": False})() diff --git a/tests/providers/kion_music/test_api_client.py b/tests/providers/kion_music/test_api_client.py index 953c78f18d..7ef350db86 100644 --- a/tests/providers/kion_music/test_api_client.py +++ b/tests/providers/kion_music/test_api_client.py @@ -5,7 +5,6 @@ from unittest import mock import pytest -from music_assistant_models.errors import ResourceTemporarilyUnavailable from yandex_music.exceptions import NetworkError from music_assistant.providers.kion_music.api_client import KionMusicClient @@ -14,8 +13,8 @@ @pytest.fixture def client() -> KionMusicClient: - """Return a KionMusicClient with a fake token.""" - return KionMusicClient("fake_token") + """Return a KionMusicClient with a fake token and explicit base URL.""" + return KionMusicClient("fake_token", base_url=DEFAULT_BASE_URL) async def test_connect_sets_base_url(client: KionMusicClient) -> None: @@ -74,36 +73,3 @@ async def test_get_liked_albums_batch_fallback_on_network_error( assert len(result) == 1 assert result[0].id == 1 - - -async def test_get_tracks_retry_on_network_error_then_success( - client: KionMusicClient, -) -> None: - """Test that get_tracks retries once on NetworkError and succeeds.""" - mock_client = mock.AsyncMock() - client._client = mock_client - client._user_id = 1 - - track = type("Track", (), {"id": 1})() - mock_client.tracks = mock.AsyncMock(side_effect=[NetworkError("timeout"), [track]]) - - result = await client.get_tracks(["1"]) - - assert len(result) == 1 - assert mock_client.tracks.call_count == 2 - - -async def test_get_tracks_retry_on_network_error_both_fail( - client: KionMusicClient, -) -> None: - """Test that get_tracks raises ResourceTemporarilyUnavailable when retry fails.""" - mock_client = mock.AsyncMock() - client._client = mock_client - client._user_id = 1 - - mock_client.tracks = mock.AsyncMock(side_effect=NetworkError("timeout")) - - with pytest.raises(ResourceTemporarilyUnavailable): - await client.get_tracks(["1"]) - - assert mock_client.tracks.call_count == 2 diff --git a/tests/providers/kion_music/test_integration.py b/tests/providers/kion_music/test_integration.py deleted file mode 100644 index e3e969b726..0000000000 --- a/tests/providers/kion_music/test_integration.py +++ /dev/null @@ -1,354 +0,0 @@ -"""Integration tests for the KION Music provider with in-process Music Assistant.""" - -from __future__ import annotations - -import json -import pathlib -from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING, Any, cast -from unittest import mock - -import pytest -from music_assistant_models.enums import ContentType, MediaType, StreamType -from yandex_music import Album as YandexAlbum -from yandex_music import Artist as YandexArtist -from yandex_music import Playlist as YandexPlaylist -from yandex_music import Track as YandexTrack - -from music_assistant.mass import MusicAssistant -from music_assistant.models.music_provider import MusicProvider -from tests.common import wait_for_sync_completion - -if TYPE_CHECKING: - from music_assistant_models.config_entries import ProviderConfig - -FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" -_DE_JSON_CLIENT = type("ClientStub", (), {"report_unknown_fields": False})() - - -def _load_json(path: pathlib.Path) -> dict[str, Any]: - """Load JSON fixture.""" - with open(path) as f: - return cast("dict[str, Any]", json.load(f)) - - -def _load_kion_objects() -> tuple[Any, Any, Any, Any]: - """Load Artist, Album, Track, Playlist from fixtures for mock client.""" - artist = YandexArtist.de_json( - _load_json(FIXTURES_DIR / "artists" / "minimal.json"), _DE_JSON_CLIENT - ) - album = YandexAlbum.de_json( - _load_json(FIXTURES_DIR / "albums" / "minimal.json"), _DE_JSON_CLIENT - ) - track = YandexTrack.de_json( - _load_json(FIXTURES_DIR / "tracks" / "minimal.json"), _DE_JSON_CLIENT - ) - playlist = YandexPlaylist.de_json( - _load_json(FIXTURES_DIR / "playlists" / "minimal.json"), _DE_JSON_CLIENT - ) - return artist, album, track, playlist - - -def _make_search_result(track: Any, album: Any, artist: Any, playlist: Any) -> Any: - """Build a Search-like object with .tracks.results, .albums.results, etc.""" - return type( - "Search", - (), - { - "tracks": type("TracksResult", (), {"results": [track]})(), - "albums": type("AlbumsResult", (), {"results": [album]})(), - "artists": type("ArtistsResult", (), {"results": [artist]})(), - "playlists": type("PlaylistsResult", (), {"results": [playlist]})(), - }, - )() - - -def _make_download_info( - codec: str = "mp3", - direct_link: str = "https://example.com/kion_track.mp3", - bitrate_in_kbps: int = 320, -) -> Any: - """Build DownloadInfo-like object for streaming.""" - return type( - "DownloadInfo", - (), - { - "direct_link": direct_link, - "codec": codec, - "bitrate_in_kbps": bitrate_in_kbps, - }, - )() - - -@pytest.fixture -async def kion_music_provider( - mass: MusicAssistant, -) -> AsyncGenerator[ProviderConfig, None]: - """Configure KION Music provider with mocked API client and add to mass.""" - artist, album, track, playlist = _load_kion_objects() - search_result = _make_search_result(track, album, artist, playlist) - download_info = _make_download_info() - - # Album with volumes for get_album_tracks - album_with_volumes = type( - "AlbumWithVolumes", - (), - { - "id": album.id, - "title": album.title, - "volumes": [[track]], - "artists": album.artists if hasattr(album, "artists") else [], - "year": getattr(album, "year", None), - "release_date": getattr(album, "release_date", None), - "genre": getattr(album, "genre", None), - "cover_uri": getattr(album, "cover_uri", None), - "og_image": getattr(album, "og_image", None), - "type": getattr(album, "type", "album"), - "available": getattr(album, "available", True), - }, - )() - - with mock.patch( - "music_assistant.providers.kion_music.provider.KionMusicClient" - ) as mock_client_class: - mock_client = mock.AsyncMock() - mock_client_class.return_value = mock_client - - mock_client.connect = mock.AsyncMock(return_value=True) - mock_client.user_id = 12345 - - mock_client.get_liked_tracks = mock.AsyncMock(return_value=[]) - mock_client.get_liked_albums = mock.AsyncMock(return_value=[]) - mock_client.get_liked_artists = mock.AsyncMock(return_value=[]) - mock_client.get_user_playlists = mock.AsyncMock(return_value=[playlist]) - - mock_client.search = mock.AsyncMock(return_value=search_result) - mock_client.get_track = mock.AsyncMock(return_value=track) - mock_client.get_tracks = mock.AsyncMock(return_value=[track]) - mock_client.get_album = mock.AsyncMock(return_value=album) - mock_client.get_album_with_tracks = mock.AsyncMock(return_value=album_with_volumes) - mock_client.get_artist = mock.AsyncMock(return_value=artist) - mock_client.get_artist_albums = mock.AsyncMock(return_value=[album]) - mock_client.get_artist_tracks = mock.AsyncMock(return_value=[track]) - mock_client.get_playlist = mock.AsyncMock(return_value=playlist) - mock_client.get_track_download_info = mock.AsyncMock(return_value=[download_info]) - - async with wait_for_sync_completion(mass): - config = await mass.config.save_provider_config( - "kion_music", - {"token": "mock_kion_token", "quality": "high"}, - ) - await mass.music.start_sync() - - yield config - - -@pytest.fixture -async def kion_music_provider_lossless( - mass: MusicAssistant, -) -> AsyncGenerator[ProviderConfig, None]: - """Configure KION Music with quality=lossless and mock returning MP3 + FLAC.""" - artist, album, track, playlist = _load_kion_objects() - search_result = _make_search_result(track, album, artist, playlist) - mp3_info = _make_download_info( - codec="mp3", - direct_link="https://example.com/kion_track.mp3", - bitrate_in_kbps=320, - ) - flac_info = _make_download_info( - codec="flac", - direct_link="https://example.com/kion_track.flac", - bitrate_in_kbps=0, - ) - download_infos = [mp3_info, flac_info] - - album_with_volumes = type( - "AlbumWithVolumes", - (), - { - "id": album.id, - "title": album.title, - "volumes": [[track]], - "artists": album.artists if hasattr(album, "artists") else [], - "year": getattr(album, "year", None), - "release_date": getattr(album, "release_date", None), - "genre": getattr(album, "genre", None), - "cover_uri": getattr(album, "cover_uri", None), - "og_image": getattr(album, "og_image", None), - "type": getattr(album, "type", "album"), - "available": getattr(album, "available", True), - }, - )() - - with mock.patch( - "music_assistant.providers.kion_music.provider.KionMusicClient" - ) as mock_client_class: - mock_client = mock.AsyncMock() - mock_client_class.return_value = mock_client - - mock_client.connect = mock.AsyncMock(return_value=True) - mock_client.user_id = 12345 - - mock_client.get_liked_tracks = mock.AsyncMock(return_value=[]) - mock_client.get_liked_albums = mock.AsyncMock(return_value=[]) - mock_client.get_liked_artists = mock.AsyncMock(return_value=[]) - mock_client.get_user_playlists = mock.AsyncMock(return_value=[playlist]) - - mock_client.search = mock.AsyncMock(return_value=search_result) - mock_client.get_track = mock.AsyncMock(return_value=track) - mock_client.get_tracks = mock.AsyncMock(return_value=[track]) - mock_client.get_album = mock.AsyncMock(return_value=album) - mock_client.get_album_with_tracks = mock.AsyncMock(return_value=album_with_volumes) - mock_client.get_artist = mock.AsyncMock(return_value=artist) - mock_client.get_artist_albums = mock.AsyncMock(return_value=[album]) - mock_client.get_artist_tracks = mock.AsyncMock(return_value=[track]) - mock_client.get_playlist = mock.AsyncMock(return_value=playlist) - mock_client.get_track_file_info_lossless = mock.AsyncMock(return_value=None) - mock_client.get_track_download_info = mock.AsyncMock(return_value=download_infos) - - async with wait_for_sync_completion(mass): - config = await mass.config.save_provider_config( - "kion_music", - {"token": "mock_kion_token", "quality": "lossless"}, - ) - await mass.music.start_sync() - - yield config - - -def _get_kion_provider(mass: MusicAssistant) -> MusicProvider | None: - """Get KION Music provider instance from mass.""" - for provider in mass.music.providers: - if provider.domain == "kion_music": - return provider - return None - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_registration_and_sync(mass: MusicAssistant) -> None: - """Test that provider is registered and sync completes.""" - prov = _get_kion_provider(mass) - assert prov is not None - assert prov.domain == "kion_music" - assert prov.instance_id - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_search(mass: MusicAssistant) -> None: - """Test search returns results from kion_music.""" - results = await mass.music.search("test query", [MediaType.TRACK], limit=5) - kion_tracks = [t for t in results.tracks if t.provider and "kion_music" in t.provider] - assert len(kion_tracks) >= 0 - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_get_artist(mass: MusicAssistant) -> None: - """Test getting artist by id.""" - prov = _get_kion_provider(mass) - assert prov is not None - artist = await prov.get_artist("100") - assert artist is not None - assert artist.name - assert artist.provider == prov.instance_id - assert artist.item_id == "100" - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_get_album(mass: MusicAssistant) -> None: - """Test getting album by id.""" - prov = _get_kion_provider(mass) - assert prov is not None - album = await prov.get_album("300") - assert album is not None - assert album.name - assert album.provider == prov.instance_id - assert album.item_id == "300" - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_get_track(mass: MusicAssistant) -> None: - """Test getting track by id.""" - prov = _get_kion_provider(mass) - assert prov is not None - track = await prov.get_track("400") - assert track is not None - assert track.name - assert track.provider == prov.instance_id - assert track.item_id == "400" - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_get_album_tracks(mass: MusicAssistant) -> None: - """Test getting album tracks.""" - prov = _get_kion_provider(mass) - assert prov is not None - tracks = await prov.get_album_tracks("300") - assert isinstance(tracks, list) - assert len(tracks) >= 0 - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_get_playlist_tracks(mass: MusicAssistant) -> None: - """Test getting playlist tracks.""" - prov = _get_kion_provider(mass) - assert prov is not None - tracks = await prov.get_playlist_tracks("12345:3", page=0) - assert isinstance(tracks, list) - assert len(tracks) >= 0 - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_get_playlist_tracks_page_gt_zero_returns_empty(mass: MusicAssistant) -> None: - """Test that page > 0 returns empty list (no server-side pagination).""" - prov = _get_kion_provider(mass) - assert prov is not None - tracks = await prov.get_playlist_tracks("12345:3", page=1) - assert tracks == [] - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_get_stream_details(mass: MusicAssistant) -> None: - """Test stream details retrieval.""" - prov = _get_kion_provider(mass) - assert prov is not None - stream_details = await prov.get_stream_details("400", MediaType.TRACK) - assert stream_details is not None - assert stream_details.stream_type == StreamType.HTTP - assert stream_details.path == "https://example.com/kion_track.mp3" - - -@pytest.mark.usefixtures("kion_music_provider_lossless") -async def test_get_stream_details_returns_flac_when_lossless_selected( - mass: MusicAssistant, -) -> None: - """When quality=lossless and API returns MP3+FLAC, stream details use FLAC.""" - prov = _get_kion_provider(mass) - assert prov is not None - stream_details = await prov.get_stream_details("400", MediaType.TRACK) - assert stream_details is not None - assert stream_details.audio_format.content_type == ContentType.FLAC - assert stream_details.path == "https://example.com/kion_track.flac" - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_library_items(mass: MusicAssistant) -> None: - """Test library artists, albums, tracks, playlists.""" - prov = _get_kion_provider(mass) - assert prov is not None - instance_id = prov.instance_id - - artists = await mass.music.artists.library_items() - kion_artists = [a for a in artists if a.provider == instance_id] - assert len(kion_artists) >= 0 - - albums = await mass.music.albums.library_items() - kion_albums = [a for a in albums if a.provider == instance_id] - assert len(kion_albums) >= 0 - - tracks = await mass.music.tracks.library_items() - kion_tracks = [t for t in tracks if t.provider == instance_id] - assert len(kion_tracks) >= 0 - - playlists = await mass.music.playlists.library_items() - kion_playlists = [p for p in playlists if p.provider == instance_id] - assert len(kion_playlists) >= 0 diff --git a/tests/providers/kion_music/test_my_mix.py b/tests/providers/kion_music/test_my_mix.py deleted file mode 100644 index ac054cebf5..0000000000 --- a/tests/providers/kion_music/test_my_mix.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Tests for My Mix (Мой Микс) browse and rotor feedback helpers.""" - -from __future__ import annotations - -from music_assistant.providers.kion_music.constants import ( - RADIO_TRACK_ID_SEP, - ROTOR_STATION_MY_MIX, -) -from music_assistant.providers.kion_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_MIX}") == ( - "12345", - ROTOR_STATION_MY_MIX, - ) - assert _parse_radio_item_id("99@user:custom") == ("99", "user:custom") diff --git a/tests/providers/kion_music/test_parsers.py b/tests/providers/kion_music/test_parsers.py index 62f1829bb2..c9e7bb8370 100644 --- a/tests/providers/kion_music/test_parsers.py +++ b/tests/providers/kion_music/test_parsers.py @@ -78,7 +78,7 @@ def test_parse_artist(example: pathlib.Path, provider_stub: ProviderStub) -> Non assert result.provider == provider_stub.instance_id assert len(result.provider_mappings) == 1 mapping = next(iter(result.provider_mappings)) - assert f"music.mts.ru/artist/{artist_obj.id}" in (mapping.url or "") + assert f"music.yandex.ru/artist/{artist_obj.id}" in (mapping.url or "") def test_parse_artist_with_cover(provider_stub: ProviderStub) -> None: @@ -105,7 +105,7 @@ def test_parse_album(example: pathlib.Path, provider_stub: ProviderStub) -> None assert result.name assert result.provider == provider_stub.instance_id mapping = next(iter(result.provider_mappings)) - assert f"music.mts.ru/album/{album_obj.id}" in (mapping.url or "") + assert f"music.yandex.ru/album/{album_obj.id}" in (mapping.url or "") if album_obj.year: assert result.year == album_obj.year @@ -120,7 +120,7 @@ def test_parse_track(example: pathlib.Path, provider_stub: ProviderStub) -> None assert result.name assert result.duration == (track_obj.duration_ms or 0) // 1000 mapping = next(iter(result.provider_mappings)) - assert f"music.mts.ru/track/{track_obj.id}" in (mapping.url or "") + assert f"music.yandex.ru/track/{track_obj.id}" in (mapping.url or "") def test_parse_track_with_artist_and_album(provider_stub: ProviderStub) -> None: @@ -152,7 +152,7 @@ def test_parse_playlist(example: pathlib.Path, provider_stub: ProviderStub) -> N assert result.item_id == f"{owner_id}:{kind}" assert result.name == (playlist_obj.title or "Unknown Playlist") mapping = next(iter(result.provider_mappings)) - assert f"music.mts.ru/users/{owner_id}/playlists/{kind}" in (mapping.url or "") + assert f"music.yandex.ru/users/{owner_id}/playlists/{kind}" in (mapping.url or "") def test_parse_playlist_editable(provider_stub: ProviderStub) -> None: diff --git a/tests/providers/kion_music/test_streaming.py b/tests/providers/kion_music/test_streaming.py index d712c4f0c4..ca9a3571c6 100644 --- a/tests/providers/kion_music/test_streaming.py +++ b/tests/providers/kion_music/test_streaming.py @@ -2,15 +2,21 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +import asyncio +from typing import TYPE_CHECKING, Any, cast import pytest +from aiohttp import ClientPayloadError from music_assistant_models.enums import ContentType +from music_assistant_models.errors import MediaNotFoundError +from music_assistant_models.media_items import AudioFormat +from music_assistant_models.streamdetails import StreamDetails -from music_assistant.providers.kion_music.constants import QUALITY_HIGH, QUALITY_LOSSLESS +from music_assistant.providers.kion_music.constants import QUALITY_HIGH, QUALITY_SUPERB from music_assistant.providers.kion_music.streaming import KionMusicStreamingManager if TYPE_CHECKING: + from music_assistant.providers.kion_music.provider import KionMusicProvider from tests.providers.kion_music.conftest import ( StreamingProviderStub, StreamingProviderStubWithTracking, @@ -39,7 +45,7 @@ def streaming_manager( streaming_provider_stub: StreamingProviderStub, ) -> KionMusicStreamingManager: """Create streaming manager with real stub (no Mock).""" - return KionMusicStreamingManager(streaming_provider_stub) # type: ignore[arg-type] + return KionMusicStreamingManager(cast("KionMusicProvider", streaming_provider_stub)) @pytest.fixture @@ -47,7 +53,9 @@ def streaming_manager_with_tracking( streaming_provider_stub_with_tracking: StreamingProviderStubWithTracking, ) -> KionMusicStreamingManager: """Create streaming manager with tracking logger for assertions.""" - return KionMusicStreamingManager(streaming_provider_stub_with_tracking) # type: ignore[arg-type] + return KionMusicStreamingManager( + cast("KionMusicProvider", streaming_provider_stub_with_tracking) + ) def test_select_best_quality_lossless_returns_flac( @@ -58,7 +66,7 @@ def test_select_best_quality_lossless_returns_flac( flac = _make_download_info("flac", 0, "https://example.com/track.flac") download_infos = [mp3, flac] - result = streaming_manager._select_best_quality(download_infos, QUALITY_LOSSLESS) + result = streaming_manager._select_best_quality(download_infos, QUALITY_SUPERB) assert result is not None assert result.codec == "flac" @@ -80,39 +88,11 @@ def test_select_best_quality_high_returns_highest_bitrate( assert result.bitrate_in_kbps == 320 -def test_select_best_quality_label_lossless_flac_returns_flac( - streaming_manager: KionMusicStreamingManager, -) -> None: - """When preferred_quality is UI label 'Lossless (FLAC)', FLAC is selected.""" - mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3") - flac = _make_download_info("flac", 0, "https://example.com/track.flac") - download_infos = [mp3, flac] - - result = streaming_manager._select_best_quality(download_infos, "Lossless (FLAC)") - - assert result is not None - assert result.codec == "flac" - - -def test_select_best_quality_lossless_no_flac_returns_fallback( - streaming_manager_with_tracking: KionMusicStreamingManager, -) -> None: - """When lossless requested but no FLAC in list, returns best available (fallback).""" - mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3") - download_infos = [mp3] - - result = streaming_manager_with_tracking._select_best_quality(download_infos, QUALITY_LOSSLESS) - - assert result is not None - assert result.codec == "mp3" - assert streaming_manager_with_tracking.provider.logger._warning_count == 1 # type: ignore[attr-defined] - - def test_select_best_quality_empty_list_returns_none( streaming_manager: KionMusicStreamingManager, ) -> None: """Empty download_infos returns None.""" - result = streaming_manager._select_best_quality([], QUALITY_LOSSLESS) + result = streaming_manager._select_best_quality([], QUALITY_SUPERB) assert result is None @@ -134,6 +114,68 @@ def test_select_best_quality_none_preferred_returns_highest_bitrate( def test_get_content_type_flac_mp4_returns_flac( streaming_manager: KionMusicStreamingManager, ) -> None: - """flac-mp4 codec from get-file-info is mapped to ContentType.FLAC.""" - assert streaming_manager._get_content_type("flac-mp4") == ContentType.FLAC - assert streaming_manager._get_content_type("FLAC-MP4") == ContentType.FLAC + """flac-mp4 codec from get-file-info is mapped to MP4 container and FLAC codec.""" + content_type = streaming_manager._get_content_type("flac-mp4") + assert content_type[0] == ContentType.MP4 + assert content_type[1] == ContentType.FLAC + content_type_upper = streaming_manager._get_content_type("FLAC-MP4") + assert content_type_upper[0] == ContentType.MP4 + assert content_type_upper[1] == ContentType.FLAC + + +def _make_stream_details( + decryption_key: str, encrypted_url: str = "https://example.com/enc.flac" +) -> StreamDetails: + """Build a minimal StreamDetails for get_audio_stream tests.""" + return StreamDetails( + provider="kion_music_instance", + item_id="test_track", + audio_format=AudioFormat(content_type=ContentType.FLAC), + data={"encrypted_url": encrypted_url, "decryption_key": decryption_key}, + ) + + +async def test_get_audio_stream_retries_on_payload_error_then_raises( + streaming_manager: KionMusicStreamingManager, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ClientPayloadError causes retries; raises MediaNotFoundError after max retries.""" + get_audio_stream = getattr(streaming_manager, "get_audio_stream", None) + if get_audio_stream is None: + pytest.skip("get_audio_stream not available in this provider version") + + async def _no_sleep(_: float) -> None: + pass + + monkeypatch.setattr(asyncio, "sleep", _no_sleep) + + class _DroppingContent: + async def iter_chunked(self, n: int) -> Any: + raise ClientPayloadError("Connection dropped") + yield b"" # type: ignore[unreachable] # makes this an async generator + + class _DroppingResponse: + status = 200 + content = _DroppingContent() + + def raise_for_status(self) -> None: + pass + + class _DroppingContext: + async def __aenter__(self) -> _DroppingResponse: + return _DroppingResponse() + + async def __aexit__(self, *args: object) -> None: + pass + + class _FakeHttpSession: + def get(self, url: str, headers: Any = None, **kwargs: Any) -> _DroppingContext: + return _DroppingContext() + + streaming_manager_mass: Any = streaming_manager.mass + streaming_manager_mass.http_session = _FakeHttpSession() + streamdetails = _make_stream_details(decryption_key="00" * 16) # valid 16-byte AES key + + with pytest.raises(MediaNotFoundError, match="retries were exhausted"): + async for _ in get_audio_stream(streamdetails): + pass From 70247609709527cad35ef06a8feb752a91184716 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 11:20:59 +0000 Subject: [PATCH 22/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.0 --- .../yandex_music/test_integration.py | 460 ------------------ 1 file changed, 460 deletions(-) delete mode 100644 tests/providers/yandex_music/test_integration.py diff --git a/tests/providers/yandex_music/test_integration.py b/tests/providers/yandex_music/test_integration.py deleted file mode 100644 index a4e56da6ca..0000000000 --- a/tests/providers/yandex_music/test_integration.py +++ /dev/null @@ -1,460 +0,0 @@ -"""Integration tests for the Yandex Music provider with in-process Music Assistant.""" - -from __future__ import annotations - -import json -import pathlib -from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING, Any, cast -from unittest import mock - -import pytest -from music_assistant_models.enums import ContentType, MediaType, StreamType -from music_assistant_models.errors import ResourceTemporarilyUnavailable -from yandex_music import Album as YandexAlbum -from yandex_music import Artist as YandexArtist -from yandex_music import Playlist as YandexPlaylist -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: - from music_assistant_models.config_entries import ProviderConfig - -FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" -_DE_JSON_CLIENT = type("ClientStub", (), {"report_unknown_fields": False})() - - -def _load_json(path: pathlib.Path) -> dict[str, Any]: - """Load JSON fixture.""" - with open(path) as f: - return cast("dict[str, Any]", json.load(f)) - - -def _load_yandex_objects() -> tuple[Any, Any, Any, Any]: - """Load Yandex Artist, Album, Track, Playlist from fixtures for mock client.""" - artist = YandexArtist.de_json( - _load_json(FIXTURES_DIR / "artists" / "minimal.json"), _DE_JSON_CLIENT - ) - album = YandexAlbum.de_json( - _load_json(FIXTURES_DIR / "albums" / "minimal.json"), _DE_JSON_CLIENT - ) - track = YandexTrack.de_json( - _load_json(FIXTURES_DIR / "tracks" / "minimal.json"), _DE_JSON_CLIENT - ) - playlist = YandexPlaylist.de_json( - _load_json(FIXTURES_DIR / "playlists" / "minimal.json"), _DE_JSON_CLIENT - ) - return artist, album, track, playlist - - -def _make_search_result(track: Any, album: Any, artist: Any, playlist: Any) -> Any: - """Build a Search-like object with .tracks.results, .albums.results, etc.""" - return type( - "Search", - (), - { - "tracks": type("TracksResult", (), {"results": [track]})(), - "albums": type("AlbumsResult", (), {"results": [album]})(), - "artists": type("ArtistsResult", (), {"results": [artist]})(), - "playlists": type("PlaylistsResult", (), {"results": [playlist]})(), - }, - )() - - -def _make_download_info( - codec: str = "mp3", - direct_link: str = "https://example.com/yandex_track.mp3", - bitrate_in_kbps: int = 320, -) -> Any: - """Build DownloadInfo-like object for streaming.""" - return type( - "DownloadInfo", - (), - { - "direct_link": direct_link, - "codec": codec, - "bitrate_in_kbps": bitrate_in_kbps, - }, - )() - - -@pytest.fixture -async def yandex_music_provider( - mass: MusicAssistant, -) -> AsyncGenerator[ProviderConfig, None]: - """Configure Yandex Music provider with mocked API client and add to mass.""" - artist, album, track, playlist = _load_yandex_objects() - search_result = _make_search_result(track, album, artist, playlist) - download_info = _make_download_info() - - # Album with volumes for get_album_tracks - album_with_volumes = type( - "AlbumWithVolumes", - (), - { - "id": album.id, - "title": album.title, - "volumes": [[track]], - "artists": album.artists if hasattr(album, "artists") else [], - "year": getattr(album, "year", None), - "release_date": getattr(album, "release_date", None), - "genre": getattr(album, "genre", None), - "cover_uri": getattr(album, "cover_uri", None), - "og_image": getattr(album, "og_image", None), - "type": getattr(album, "type", "album"), - "available": getattr(album, "available", True), - }, - )() - - with mock.patch( - "music_assistant.providers.yandex_music.provider.YandexMusicClient" - ) as mock_client_class: - mock_client = mock.AsyncMock() - mock_client_class.return_value = mock_client - - mock_client.connect = mock.AsyncMock(return_value=True) - mock_client.user_id = 12345 - - mock_client.get_liked_tracks = mock.AsyncMock(return_value=[]) - mock_client.get_liked_albums = mock.AsyncMock(return_value=[]) - mock_client.get_liked_artists = mock.AsyncMock(return_value=[]) - mock_client.get_user_playlists = mock.AsyncMock(return_value=[playlist]) - - mock_client.search = mock.AsyncMock(return_value=search_result) - mock_client.get_track = mock.AsyncMock(return_value=track) - mock_client.get_tracks = mock.AsyncMock(return_value=[track]) - mock_client.get_album = mock.AsyncMock(return_value=album) - mock_client.get_album_with_tracks = mock.AsyncMock(return_value=album_with_volumes) - mock_client.get_artist = mock.AsyncMock(return_value=artist) - mock_client.get_artist_albums = mock.AsyncMock(return_value=[album]) - mock_client.get_artist_tracks = mock.AsyncMock(return_value=[track]) - mock_client.get_playlist = mock.AsyncMock(return_value=playlist) - mock_client.get_track_file_info = mock.AsyncMock( - return_value={ - "url": "https://example.com/yandex_track.mp3", - "codec": "mp3", - "bitrate_in_kbps": 320, - "needs_decryption": False, - } - ) - mock_client.get_track_download_info = mock.AsyncMock(return_value=[download_info]) - mock_client.get_track_lyrics = mock.AsyncMock(return_value=(None, False)) - mock_client.get_track_lyrics_from_track = mock.AsyncMock(return_value=(None, False)) - - async with wait_for_sync_completion(mass): - config = await mass.config.save_provider_config( - "yandex_music", - {"token": "mock_yandex_token", "quality": "high"}, - ) - await mass.music.start_sync() - - yield config - - -@pytest.fixture -async def yandex_music_provider_lossless( - mass: MusicAssistant, -) -> AsyncGenerator[ProviderConfig, None]: - """Configure Yandex Music with quality=lossless and mock returning MP3 + FLAC.""" - artist, album, track, playlist = _load_yandex_objects() - search_result = _make_search_result(track, album, artist, playlist) - mp3_info = _make_download_info( - codec="mp3", - direct_link="https://example.com/yandex_track.mp3", - bitrate_in_kbps=320, - ) - flac_info = _make_download_info( - codec="flac", - direct_link="https://example.com/yandex_track.flac", - bitrate_in_kbps=0, - ) - download_infos = [mp3_info, flac_info] - - album_with_volumes = type( - "AlbumWithVolumes", - (), - { - "id": album.id, - "title": album.title, - "volumes": [[track]], - "artists": album.artists if hasattr(album, "artists") else [], - "year": getattr(album, "year", None), - "release_date": getattr(album, "release_date", None), - "genre": getattr(album, "genre", None), - "cover_uri": getattr(album, "cover_uri", None), - "og_image": getattr(album, "og_image", None), - "type": getattr(album, "type", "album"), - "available": getattr(album, "available", True), - }, - )() - - with mock.patch( - "music_assistant.providers.yandex_music.provider.YandexMusicClient" - ) as mock_client_class: - mock_client = mock.AsyncMock() - mock_client_class.return_value = mock_client - - mock_client.connect = mock.AsyncMock(return_value=True) - mock_client.user_id = 12345 - - mock_client.get_liked_tracks = mock.AsyncMock(return_value=[]) - mock_client.get_liked_albums = mock.AsyncMock(return_value=[]) - mock_client.get_liked_artists = mock.AsyncMock(return_value=[]) - mock_client.get_user_playlists = mock.AsyncMock(return_value=[playlist]) - - mock_client.search = mock.AsyncMock(return_value=search_result) - mock_client.get_track = mock.AsyncMock(return_value=track) - mock_client.get_tracks = mock.AsyncMock(return_value=[track]) - mock_client.get_album = mock.AsyncMock(return_value=album) - mock_client.get_album_with_tracks = mock.AsyncMock(return_value=album_with_volumes) - mock_client.get_artist = mock.AsyncMock(return_value=artist) - mock_client.get_artist_albums = mock.AsyncMock(return_value=[album]) - mock_client.get_artist_tracks = mock.AsyncMock(return_value=[track]) - mock_client.get_playlist = mock.AsyncMock(return_value=playlist) - mock_client.get_track_file_info = mock.AsyncMock( - return_value={ - "url": "https://example.com/yandex_track.flac", - "codec": "flac", - "bitrate_in_kbps": 0, - "needs_decryption": False, - } - ) - mock_client.get_track_download_info = mock.AsyncMock(return_value=download_infos) - mock_client.get_track_lyrics = mock.AsyncMock(return_value=(None, False)) - mock_client.get_track_lyrics_from_track = mock.AsyncMock(return_value=(None, False)) - - async with wait_for_sync_completion(mass): - config = await mass.config.save_provider_config( - "yandex_music", - {"token": "mock_yandex_token", "quality": "lossless"}, - ) - await mass.music.start_sync() - - yield config - - -def _get_yandex_provider(mass: MusicAssistant) -> MusicProvider | None: - """Get Yandex Music provider instance from mass.""" - for provider in mass.music.providers: - if provider.domain == "yandex_music": - return provider - return None - - -@pytest.mark.usefixtures("yandex_music_provider") -async def test_registration_and_sync(mass: MusicAssistant) -> None: - """Test that provider is registered and sync completes.""" - prov = _get_yandex_provider(mass) - assert prov is not None - assert prov.domain == "yandex_music" - assert prov.instance_id - - -@pytest.mark.usefixtures("yandex_music_provider") -async def test_search(mass: MusicAssistant) -> None: - """Test search returns results from yandex_music.""" - results = await mass.music.search("test query", [MediaType.TRACK], limit=5) - yandex_tracks = [t for t in results.tracks if t.provider and "yandex_music" in t.provider] - assert len(yandex_tracks) >= 0 - - -@pytest.mark.usefixtures("yandex_music_provider") -async def test_get_artist(mass: MusicAssistant) -> None: - """Test getting artist by id.""" - prov = _get_yandex_provider(mass) - assert prov is not None - artist = await prov.get_artist("100") - assert artist is not None - assert artist.name - assert artist.provider == prov.instance_id - assert artist.item_id == "100" - - -@pytest.mark.usefixtures("yandex_music_provider") -async def test_get_album(mass: MusicAssistant) -> None: - """Test getting album by id.""" - prov = _get_yandex_provider(mass) - assert prov is not None - album = await prov.get_album("300") - assert album is not None - assert album.name - assert album.provider == prov.instance_id - assert album.item_id == "300" - - -@pytest.mark.usefixtures("yandex_music_provider") -async def test_get_track(mass: MusicAssistant) -> None: - """Test getting track by id.""" - prov = _get_yandex_provider(mass) - assert prov is not None - track = await prov.get_track("400") - assert track is not None - assert track.name - assert track.provider == prov.instance_id - assert track.item_id == "400" - - -@pytest.mark.usefixtures("yandex_music_provider") -async def test_get_album_tracks(mass: MusicAssistant) -> None: - """Test getting album tracks.""" - prov = _get_yandex_provider(mass) - assert prov is not None - tracks = await prov.get_album_tracks("300") - assert isinstance(tracks, list) - assert len(tracks) >= 0 - - -@pytest.mark.usefixtures("yandex_music_provider") -async def test_get_playlist_tracks(mass: MusicAssistant) -> None: - """Test getting playlist tracks.""" - prov = _get_yandex_provider(mass) - assert prov is not None - tracks = await prov.get_playlist_tracks("12345:3", page=0) - assert isinstance(tracks, list) - assert len(tracks) >= 0 - - -@pytest.mark.usefixtures("yandex_music_provider") -async def test_get_stream_details(mass: MusicAssistant) -> None: - """Test stream details retrieval.""" - prov = _get_yandex_provider(mass) - assert prov is not None - stream_details = await prov.get_stream_details("400", MediaType.TRACK) - assert stream_details is not None - assert stream_details.stream_type == StreamType.CUSTOM - assert stream_details.data["url"] == "https://example.com/yandex_track.mp3" - - -@pytest.mark.usefixtures("yandex_music_provider_lossless") -async def test_get_stream_details_returns_flac_when_lossless_selected( - mass: MusicAssistant, -) -> None: - """When quality=lossless and API returns MP3+FLAC, stream details use FLAC.""" - prov = _get_yandex_provider(mass) - assert prov is not None - stream_details = await prov.get_stream_details("400", MediaType.TRACK) - assert stream_details is not None - assert stream_details.audio_format.content_type == ContentType.FLAC - assert stream_details.data["url"] == "https://example.com/yandex_track.flac" - - -@pytest.mark.usefixtures("yandex_music_provider") -async def test_library_items(mass: MusicAssistant) -> None: - """Test library artists, albums, tracks, playlists.""" - prov = _get_yandex_provider(mass) - assert prov is not None - instance_id = prov.instance_id - - artists = await mass.music.artists.library_items() - yandex_artists = [a for a in artists if a.provider == instance_id] - assert len(yandex_artists) >= 0 - - albums = await mass.music.albums.library_items() - yandex_albums = [a for a in albums if a.provider == instance_id] - assert len(yandex_albums) >= 0 - - tracks = await mass.music.tracks.library_items() - yandex_tracks = [t for t in tracks if t.provider == instance_id] - assert len(yandex_tracks) >= 0 - - playlists = await mass.music.playlists.library_items() - yandex_playlists = [p for p in playlists if p.provider == instance_id] - assert len(yandex_playlists) >= 0 - - -@pytest.mark.usefixtures("yandex_music_provider") -async def test_browse(mass: MusicAssistant) -> None: - """Test browse root and subpaths.""" - prov = _get_yandex_provider(mass) - assert prov is not None - base_path = f"{prov.instance_id}://" - 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) - assert artists_items is not None - assert isinstance(artists_items, (list, tuple)) - - -# -- Playlist edge-case tests -------------------------------------------------- - - -@pytest.mark.usefixtures("yandex_music_provider") -async def test_get_playlist_tracks_page_gt_zero_returns_empty(mass: MusicAssistant) -> None: - """Page > 0 returns empty list (Yandex returns all tracks in one call).""" - prov = _get_yandex_provider(mass) - assert prov is not None - # Use a different playlist ID to avoid cache collision with test_get_playlist_tracks - result = await prov.get_playlist_tracks("12345:99", page=1) - assert result == [] - - -@pytest.mark.usefixtures("yandex_music_provider") -async def test_get_playlist_tracks_fetch_tracks_async_fallback(mass: MusicAssistant) -> None: - """When playlist.tracks is None but track_count > 0, fetch_tracks_async is used.""" - prov = _get_yandex_provider(mass) - assert prov is not None - - _, _, track, _ = _load_yandex_objects() - - # Build a playlist object with tracks=None and track_count=5 - track_short = type("TrackShort", (), {"track_id": 400, "id": 400})() - playlist_no_tracks = type( - "Playlist", - (), - { - "owner": type("Owner", (), {"uid": 12345})(), - "kind": 77, - "title": "Fallback Playlist", - "tracks": None, - "track_count": 5, - "fetch_tracks_async": mock.AsyncMock(return_value=[track_short]), - }, - )() - - prov.client.get_playlist = mock.AsyncMock(return_value=playlist_no_tracks) # type: ignore[attr-defined] - prov.client.get_tracks = mock.AsyncMock(return_value=[track]) # type: ignore[attr-defined] - - result = await prov.get_playlist_tracks("12345:77", page=0) - assert isinstance(result, list) - assert len(result) >= 1 - playlist_no_tracks.fetch_tracks_async.assert_awaited_once() - - -@pytest.mark.usefixtures("yandex_music_provider") -async def test_get_playlist_tracks_empty_batch_raises(mass: MusicAssistant) -> None: - """Empty batch result from get_tracks raises ResourceTemporarilyUnavailable.""" - prov = _get_yandex_provider(mass) - assert prov is not None - - # Build a playlist with tracks that have track_ids - track_short = type("TrackShort", (), {"track_id": 400, "id": 400})() - playlist_with_tracks = type( - "Playlist", - (), - { - "owner": type("Owner", (), {"uid": 12345})(), - "kind": 88, - "title": "Batch Fail Playlist", - "tracks": [track_short], - "track_count": 1, - }, - )() - - prov.client.get_playlist = mock.AsyncMock(return_value=playlist_with_tracks) # type: ignore[attr-defined] - prov.client.get_tracks = mock.AsyncMock(return_value=[]) # type: ignore[attr-defined] - - with pytest.raises(ResourceTemporarilyUnavailable): - await prov.get_playlist_tracks("12345:88", page=0) From 99f76c76d08839cf08c58254cd0cdb0883b00bd9 Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Mon, 20 Apr 2026 14:32:57 +0300 Subject: [PATCH 23/54] Remove kion_music provider from yandex_music PR branch Kion provider got synced onto this branch by mistake and should live on upstream/kion_music instead. --- .../providers/kion_music/__init__.py | 145 - .../providers/kion_music/api_client.py | 1140 -------- .../providers/kion_music/constants.py | 346 --- music_assistant/providers/kion_music/icon.svg | 12 - .../providers/kion_music/icon_monochrome.svg | 6 - .../providers/kion_music/manifest.json | 18 - .../providers/kion_music/parsers.py | 398 --- .../providers/kion_music/provider.py | 2512 ----------------- .../providers/kion_music/streaming.py | 598 ---- tests/providers/kion_music/__init__.py | 1 - .../__snapshots__/test_parsers.ambr | 600 ---- tests/providers/kion_music/conftest.py | 137 - .../kion_music/fixtures/albums/minimal.json | 8 - .../kion_music/fixtures/artists/minimal.json | 4 - .../fixtures/artists/with_cover.json | 8 - .../fixtures/playlists/minimal.json | 9 - .../fixtures/playlists/other_user.json | 10 - .../kion_music/fixtures/tracks/minimal.json | 8 - .../tracks/with_artist_and_album.json | 19 - tests/providers/kion_music/test_api_client.py | 75 - tests/providers/kion_music/test_parsers.py | 247 -- tests/providers/kion_music/test_streaming.py | 181 -- 22 files changed, 6482 deletions(-) delete mode 100644 music_assistant/providers/kion_music/__init__.py delete mode 100644 music_assistant/providers/kion_music/api_client.py delete mode 100644 music_assistant/providers/kion_music/constants.py delete mode 100644 music_assistant/providers/kion_music/icon.svg delete mode 100644 music_assistant/providers/kion_music/icon_monochrome.svg delete mode 100644 music_assistant/providers/kion_music/manifest.json delete mode 100644 music_assistant/providers/kion_music/parsers.py delete mode 100644 music_assistant/providers/kion_music/provider.py delete mode 100644 music_assistant/providers/kion_music/streaming.py delete mode 100644 tests/providers/kion_music/__init__.py delete mode 100644 tests/providers/kion_music/__snapshots__/test_parsers.ambr delete mode 100644 tests/providers/kion_music/conftest.py delete mode 100644 tests/providers/kion_music/fixtures/albums/minimal.json delete mode 100644 tests/providers/kion_music/fixtures/artists/minimal.json delete mode 100644 tests/providers/kion_music/fixtures/artists/with_cover.json delete mode 100644 tests/providers/kion_music/fixtures/playlists/minimal.json delete mode 100644 tests/providers/kion_music/fixtures/playlists/other_user.json delete mode 100644 tests/providers/kion_music/fixtures/tracks/minimal.json delete mode 100644 tests/providers/kion_music/fixtures/tracks/with_artist_and_album.json delete mode 100644 tests/providers/kion_music/test_api_client.py delete mode 100644 tests/providers/kion_music/test_parsers.py delete mode 100644 tests/providers/kion_music/test_streaming.py diff --git a/music_assistant/providers/kion_music/__init__.py b/music_assistant/providers/kion_music/__init__.py deleted file mode 100644 index 30093d2b6e..0000000000 --- a/music_assistant/providers/kion_music/__init__.py +++ /dev/null @@ -1,145 +0,0 @@ -"""KION Music provider support for Music Assistant.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, cast - -from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType -from music_assistant_models.enums import ConfigEntryType, ProviderFeature - -from .constants import ( - CONF_ACTION_CLEAR_AUTH, - CONF_BASE_URL, - CONF_LIKED_TRACKS_MAX_TRACKS, - CONF_MY_WAVE_MAX_TRACKS, - CONF_QUALITY, - CONF_TOKEN, - DEFAULT_BASE_URL, - QUALITY_BALANCED, - QUALITY_EFFICIENT, - QUALITY_HIGH, - QUALITY_SUPERB, -) -from .provider import KionMusicProvider - -if TYPE_CHECKING: - from music_assistant_models.config_entries import ProviderConfig - from music_assistant_models.provider import ProviderManifest - - from music_assistant.mass import MusicAssistant - from music_assistant.models import ProviderInstanceType - -SUPPORTED_FEATURES = { - ProviderFeature.LIBRARY_ARTISTS, - ProviderFeature.LIBRARY_ALBUMS, - ProviderFeature.LIBRARY_TRACKS, - ProviderFeature.LIBRARY_PLAYLISTS, - ProviderFeature.ARTIST_ALBUMS, - ProviderFeature.ARTIST_TOPTRACKS, - ProviderFeature.SEARCH, - ProviderFeature.LIBRARY_ARTISTS_EDIT, - ProviderFeature.LIBRARY_ALBUMS_EDIT, - ProviderFeature.LIBRARY_TRACKS_EDIT, - ProviderFeature.BROWSE, - ProviderFeature.SIMILAR_TRACKS, - ProviderFeature.RECOMMENDATIONS, - ProviderFeature.LYRICS, -} - - -async def setup( - mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig -) -> ProviderInstanceType: - """Initialize provider(instance) with given configuration.""" - return KionMusicProvider(mass, manifest, config, SUPPORTED_FEATURES) - - -async def get_config_entries( - mass: MusicAssistant, # noqa: ARG001 - instance_id: str | None = None, # noqa: ARG001 - action: str | None = None, - values: dict[str, ConfigValueType] | None = None, -) -> tuple[ConfigEntry, ...]: - """Return Config entries to setup this provider.""" - if values is None: - values = {} - - # Handle clear auth action - if action == CONF_ACTION_CLEAR_AUTH: - values[CONF_TOKEN] = None - - # Check if user is authenticated - is_authenticated = bool(values.get(CONF_TOKEN)) - - return ( - # Authentication - ConfigEntry( - key=CONF_TOKEN, - type=ConfigEntryType.SECURE_STRING, - label="KION Music Token", - description="Enter your KION Music OAuth token. " - "See the documentation for how to obtain it.", - required=True, - hidden=is_authenticated, - value=cast("str", values.get(CONF_TOKEN)) if values else None, - ), - ConfigEntry( - key=CONF_ACTION_CLEAR_AUTH, - type=ConfigEntryType.ACTION, - label="Reset authentication", - description="Clear the current authentication details.", - action=CONF_ACTION_CLEAR_AUTH, - hidden=not is_authenticated, - ), - # Quality - ConfigEntry( - key=CONF_QUALITY, - type=ConfigEntryType.STRING, - label="Audio quality", - description="Select preferred audio quality.", - options=[ - ConfigValueOption("Efficient (AAC ~64kbps)", QUALITY_EFFICIENT), - ConfigValueOption("Balanced (AAC ~192kbps)", QUALITY_BALANCED), - ConfigValueOption("High (MP3 ~320kbps)", QUALITY_HIGH), - ConfigValueOption("Superb (FLAC Lossless)", QUALITY_SUPERB), - ], - default_value=QUALITY_BALANCED, - ), - # My Mix maximum tracks (advanced) - ConfigEntry( - key=CONF_MY_WAVE_MAX_TRACKS, - type=ConfigEntryType.INTEGER, - label="My Mix maximum tracks", - description="Maximum number of tracks to fetch for My Mix playlist. " - "Lower values load faster but provide fewer tracks. Default: 150.", - range=(10, 1000), - default_value=150, - required=False, - advanced=True, - ), - # Liked Tracks maximum tracks (advanced) - ConfigEntry( - key=CONF_LIKED_TRACKS_MAX_TRACKS, - type=ConfigEntryType.INTEGER, - label="Liked Tracks maximum tracks", - description="Maximum number of tracks to show in Liked Tracks virtual playlist. " - "Higher values may significantly increase load time. " - "Lower values load faster. Default: 500.", - range=(50, 2000), - default_value=500, - required=False, - advanced=True, - ), - # API Base URL (advanced) - ConfigEntry( - key=CONF_BASE_URL, - type=ConfigEntryType.STRING, - label="API Base URL", - description="API endpoint base URL. " - "Only change if KION Music changes their API endpoint. " - f"Default: {DEFAULT_BASE_URL}", - default_value=DEFAULT_BASE_URL, - required=False, - advanced=True, - ), - ) diff --git a/music_assistant/providers/kion_music/api_client.py b/music_assistant/providers/kion_music/api_client.py deleted file mode 100644 index c3f830387e..0000000000 --- a/music_assistant/providers/kion_music/api_client.py +++ /dev/null @@ -1,1140 +0,0 @@ -"""API client wrapper for KION Music.""" - -from __future__ import annotations - -import asyncio -import base64 -import hashlib -import hmac -import logging -import re -import time -from collections.abc import Awaitable, Callable -from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any, TypeVar, cast - -from music_assistant_models.errors import ( - LoginFailed, - ProviderUnavailableError, - ResourceTemporarilyUnavailable, -) -from yandex_music import Album as YandexAlbum -from yandex_music import Artist as YandexArtist -from yandex_music import ClientAsync, MixLink, Search, TrackShort -from yandex_music import Playlist as YandexPlaylist -from yandex_music import Track as YandexTrack -from yandex_music.exceptions import BadRequestError, NetworkError, UnauthorizedError -from yandex_music.utils.sign_request import DEFAULT_SIGN_KEY - -from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER, Throttler - -if TYPE_CHECKING: - from yandex_music import DownloadInfo - from yandex_music.feed.feed import Feed - from yandex_music.landing.chart_info import ChartInfo - from yandex_music.landing.landing import Landing - from yandex_music.landing.landing_list import LandingList - from yandex_music.rotor.dashboard import Dashboard - from yandex_music.rotor.station_result import StationResult - -from .constants import DEFAULT_LIMIT, ROTOR_STATION_MY_MIX - -# get-file-info with quality=lossless returns FLAC; default /tracks/.../download-info often does not -# Prefer flac-mp4/aac-mp4 (Kion API moved to these formats around 2025) -GET_FILE_INFO_CODECS = "flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4" - -LOGGER = logging.getLogger(__name__) - -_T = TypeVar("_T") - - -class KionMusicClient: - """Wrapper around kion-music-api ClientAsync.""" - - def __init__(self, token: str, base_url: str | None = None) -> None: - """Initialize the KION Music client. - - :param token: KION Music OAuth token. - :param base_url: Optional API base URL (defaults to KION Music API). - """ - self._token = token - self._base_url = base_url - self._client: ClientAsync | None = None - self._user_id: int | None = None - self._last_reconnect_at: float = -30.0 # allow first reconnect immediately - self._reconnect_lock = asyncio.Lock() - self._throttler = Throttler(rate_limit=5, period=1.0) - - @property - def user_id(self) -> int: - """Return the user ID.""" - if self._user_id is None: - raise ProviderUnavailableError("Client not initialized, call connect() first") - return self._user_id - - async def connect(self) -> bool: - """Initialize the client and verify token validity. - - :return: True if connection was successful. - :raises LoginFailed: If the token is invalid. - """ - try: - self._client = await ClientAsync(self._token, base_url=self._base_url).init() - if self._client.me is None or self._client.me.account is None: - raise LoginFailed("Failed to get account info") - self._user_id = self._client.me.account.uid - LOGGER.debug("Connected to KION Music as user %s", self._user_id) - return True - except (UnauthorizedError, BadRequestError) as err: - raise LoginFailed("Invalid KION Music token") from err - except NetworkError as err: - msg = "Network error connecting to KION Music" - raise ResourceTemporarilyUnavailable(msg) from err - - async def disconnect(self) -> None: - """Disconnect the client.""" - self._client = None - self._user_id = None - - async def _ensure_connected(self) -> ClientAsync: - """Ensure the client is connected, attempting reconnect if needed.""" - if self._client is not None: - return self._client - async with self._reconnect_lock: - # Re-check after acquiring lock — another task may have connected already - if self._client is not None: - return self._client # type: ignore[unreachable] - LOGGER.info("Client disconnected, attempting to reconnect...") - try: - await self.connect() - except LoginFailed: - raise - except Exception as err: - raise ProviderUnavailableError("Client not connected and reconnect failed") from err - return cast("ClientAsync", 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) and not self._is_rate_limit_error(err): - return True - msg = str(err).lower() - return "disconnect" in msg or "connection" in msg or "timeout" in msg - - def _is_rate_limit_error(self, err: Exception) -> bool: - """Return True if the exception indicates a rate-limit response from Kion.""" - if not isinstance(err, NetworkError): - return False - msg = str(err).lower() - return "429" in msg or "too many requests" in msg or "rate limit" in msg - - async def _reconnect(self) -> None: - """Disconnect and connect again to recover from Server disconnected / connection errors. - - Enforces a 30-second cooldown between reconnect attempts to avoid hammering Kion - and triggering rate limiting. A lock ensures concurrent callers don't bypass the cooldown. - """ - async with self._reconnect_lock: - now = time.monotonic() - if now - self._last_reconnect_at < 30.0: - raise ProviderUnavailableError("Reconnect cooldown active, skipping") - self._last_reconnect_at = now - await self.disconnect() - await self.connect() - - async def _call_with_retry(self, func: Callable[[ClientAsync], Awaitable[_T]]) -> _T: - """Execute an async API call with throttling and one reconnect attempt on connection error. - - :param func: Async callable that takes a ClientAsync and returns a result. - :return: The result of the API call. - """ - if not BYPASS_THROTTLER.get(): - await self._throttler.acquire() - client = await self._ensure_connected() - try: - return await func(client) - except Exception as err: - if self._is_rate_limit_error(err): - raise ResourceTemporarilyUnavailable( - "KION Music rate limit", backoff_time=60 - ) from err - if not self._is_connection_error(err): - raise - LOGGER.warning("Connection error, reconnecting and retrying: %s", err) - try: - await self._reconnect() - except Exception as recon_err: - raise ProviderUnavailableError("Reconnect failed") from recon_err - client = cast("ClientAsync", self._client) - return await func(client) - - async def _call_no_retry(self, func: Callable[[ClientAsync], Awaitable[_T]]) -> _T: - """Execute an async API call without reconnect retry on call failure. - - Used for fire-and-forget calls (e.g. rotor feedback) where a failed request - should be silently dropped rather than triggering a reconnect cycle that - could cause rate limiting. Note: _ensure_connected() is still called to - establish the initial connection if needed; only the reconnect-on-error - path is skipped. - - :param func: Async callable that takes a ClientAsync and returns a result. - :return: The result of the API call. - """ - if not BYPASS_THROTTLER.get(): - await self._throttler.acquire() - client = await self._ensure_connected() - return await func(client) - - # 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_MIX 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). - """ - try: - result = await self._call_with_retry( - lambda c: c.rotor_station_tracks(station_id, settings2=True, queue=queue) - ) - except BadRequestError as err: - LOGGER.warning("Error fetching rotor station %s tracks: %s", station_id, err) - return ([], None) - except (NetworkError, ProviderUnavailableError) as err: - LOGGER.warning("Error fetching rotor station tracks: %s", err) - return ([], None) - - 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) - try: - full_tracks = await self.get_tracks(track_ids) - except ResourceTemporarilyUnavailable as err: - LOGGER.warning("Error fetching rotor station track details: %s", err) - return ([], result.batch_id if result else None) - 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) - - async def get_my_wave_tracks( - self, queue: str | int | None = None - ) -> tuple[list[YandexTrack], str | None]: - """Get tracks from the My Mix 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_MIX, 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 Mix recommendations. - - Used to report radioStarted, trackStarted, trackFinished, skip so that - Kion can improve subsequent recommendations. - - :param station_id: Station ID (e.g. ROTOR_STATION_MY_MIX). - :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. - """ - payload: dict[str, Any] = { - "type": feedback_type, - "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"), - } - if feedback_type == "radioStarted": - payload["from"] = "KionMusicDesktopAppWindows" - 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 - - async def _post(c: ClientAsync) -> bool: - url = f"{c.base_url}/rotor/station/{station_id}/feedback" - await c._request.post(url, payload) - return True - - try: - result = await self._call_no_retry(_post) - LOGGER.debug( - "Rotor feedback %s track_id=%s total_played_seconds=%s", - feedback_type, - track_id, - total_played_seconds, - ) - return result - except BadRequestError as err: - LOGGER.warning("Rotor feedback %s failed: %s", feedback_type, err) - return False - except (NetworkError, ProviderUnavailableError) as err: - LOGGER.warning("Rotor feedback %s failed: %s", feedback_type, err) - return False - - # Library methods - - async def get_liked_tracks(self) -> list[TrackShort]: - """Get user's liked tracks sorted by timestamp (most recent first). - - :return: List of liked track objects sorted in reverse chronological order. - """ - try: - result = await self._call_with_retry(lambda c: c.users_likes_tracks()) - if result is None: - return [] - tracks = result.tracks or [] - # Sort by timestamp in descending order (most recently liked first) - # TrackShort objects have a timestamp field containing the date the track was liked - return sorted( - tracks, - key=lambda t: getattr(t, "timestamp", datetime.min.replace(tzinfo=UTC)), - reverse=True, - ) - except BadRequestError as err: - LOGGER.error("Error fetching liked tracks: %s", err) - raise ResourceTemporarilyUnavailable("Failed to fetch liked tracks") from err - except (NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error fetching liked tracks: %s", err) - raise ResourceTemporarilyUnavailable("Failed to fetch liked tracks") from err - - async def get_liked_albums(self, batch_size: int = 50) -> list[YandexAlbum]: - """Get user's liked albums with full details (including cover art). - - The users_likes_albums endpoint returns minimal album data without - cover_uri, so we fetch full album details in batches afterwards. - - :return: List of liked album objects with full details. - """ - try: - result = await self._call_with_retry(lambda c: c.users_likes_albums()) - except BadRequestError as err: - LOGGER.error("Error fetching liked albums: %s", err) - raise ResourceTemporarilyUnavailable("Failed to fetch liked albums") from err - except (NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error fetching liked albums: %s", err) - raise ResourceTemporarilyUnavailable("Failed to fetch liked albums") from err - - if result is None: - return [] - album_ids = [ - str(like.album.id) for like in result if like.album is not None and like.album.id - ] - if not album_ids: - return [] - # Fetch full album details in batches to get cover_uri and other metadata - full_albums: list[YandexAlbum] = [] - for i in range(0, len(album_ids), batch_size): - batch = album_ids[i : i + batch_size] - try: - batch_result = await self._call_with_retry( - lambda c, _b=batch: c.albums(_b) # type: ignore[misc] - ) - if batch_result: - full_albums.extend(batch_result) - except (BadRequestError, NetworkError, ProviderUnavailableError) as batch_err: - LOGGER.warning("Error fetching album details batch: %s", batch_err) - # Fall back to minimal data for this batch - batch_set = set(batch) - for like in result: - if like.album is not None and like.album.id and str(like.album.id) in batch_set: - full_albums.append(like.album) - return full_albums - - async def get_liked_artists(self) -> list[YandexArtist]: - """Get user's liked artists. - - :return: List of liked artist objects. - """ - try: - result = await self._call_with_retry(lambda c: c.users_likes_artists()) - if result is None: - return [] - return [like.artist for like in result if like.artist is not None] - except BadRequestError as err: - LOGGER.error("Error fetching liked artists: %s", err) - raise ResourceTemporarilyUnavailable("Failed to fetch liked artists") from err - except (NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error fetching liked artists: %s", err) - raise ResourceTemporarilyUnavailable("Failed to fetch liked artists") from err - - async def get_user_playlists(self) -> list[YandexPlaylist]: - """Get user's playlists. - - :return: List of playlist objects. - """ - try: - result = await self._call_with_retry(lambda c: c.users_playlists_list()) - if result is None: - return [] - return list(result) - except BadRequestError as err: - LOGGER.error("Error fetching playlists: %s", err) - raise ResourceTemporarilyUnavailable("Failed to fetch playlists") from err - except (NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error fetching playlists: %s", err) - raise ResourceTemporarilyUnavailable("Failed to fetch playlists") from err - - async def get_liked_playlists(self) -> list[YandexPlaylist]: - """Get user's liked/saved editorial playlists. - - :return: List of liked playlist objects. - """ - try: - result = await self._call_with_retry(lambda c: c.users_likes_playlists()) - if result is None: - return [] - playlists = [] - for like in result: - if like.playlist is not None: - playlists.append(like.playlist) - return playlists - except BadRequestError as err: - LOGGER.error("Error fetching liked playlists: %s", err) - raise ResourceTemporarilyUnavailable("Failed to fetch liked playlists") from err - except (NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error fetching liked playlists: %s", err) - raise ResourceTemporarilyUnavailable("Failed to fetch liked playlists") from err - - # Search - - async def search( - self, - query: str, - search_type: str = "all", - limit: int = DEFAULT_LIMIT, - ) -> Search | None: - """Search for tracks, albums, artists, or playlists. - - :param query: Search query string. - :param search_type: Type of search ('all', 'track', 'album', 'artist', 'playlist'). - :param limit: Maximum number of results per type. - :return: Search results object. - """ - try: - return await self._call_with_retry( - lambda c: c.search(query, type_=search_type, page=0, nocorrect=False) - ) - except BadRequestError as err: - LOGGER.error("Search error: %s", err) - raise ResourceTemporarilyUnavailable("Search failed") from err - except (NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Search error: %s", err) - raise ResourceTemporarilyUnavailable("Search failed") from err - - # Get single items - - async def get_track(self, track_id: str) -> YandexTrack | None: - """Get a single track by ID. - - :param track_id: Track ID. - :return: Track object or None if not found. - """ - try: - tracks = await self._call_with_retry(lambda c: c.tracks([track_id])) - return tracks[0] if tracks else None - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error fetching track %s: %s", track_id, err) - return None - - async def get_track_lyrics(self, track_id: str) -> tuple[str | None, bool]: - """Get lyrics for a track. - - Fetches lyrics from KION Music API. Returns the lyrics text and whether - it's in synced LRC format (with timestamps) or plain text. - - Note: This method fetches the track first to check lyrics_available. If you - already have the YandexTrack object, use get_track_lyrics_from_track() to - avoid a redundant API call. - - :param track_id: Track ID. - :return: Tuple of (lyrics_text, is_synced). Returns (None, False) if unavailable. - """ - try: - tracks = await self._call_with_retry(lambda c: c.tracks([track_id])) - if not tracks: - return None, False - - return await self.get_track_lyrics_from_track(tracks[0]) - - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.debug("Error fetching lyrics for track %s: %s", track_id, err) - return None, False - except Exception as err: - # Catch any other errors (e.g., geo-restrictions, API changes) - LOGGER.debug("Unexpected error fetching lyrics for track %s: %s", track_id, err) - return None, False - - async def get_track_lyrics_from_track(self, track: YandexTrack) -> tuple[str | None, bool]: - """Get lyrics for an already-fetched track. - - Avoids the extra tracks([track_id]) API call when the YandexTrack object - is already available. - - :param track: YandexTrack object (already fetched). - :return: Tuple of (lyrics_text, is_synced). Returns (None, False) if unavailable. - """ - track_id = getattr(track, "id", None) or getattr(track, "track_id", "unknown") - try: - if not getattr(track, "lyrics_available", False): - LOGGER.debug("Lyrics not available for track %s", track_id) - return None, False - - track_lyrics = await track.get_lyrics_async() - if not track_lyrics: - LOGGER.debug("Failed to get lyrics metadata for track %s", track_id) - return None, False - - lyrics_text = await track_lyrics.fetch_lyrics_async() - if not lyrics_text: - return None, False - - # Check if it's LRC format (synced lyrics have timestamps like [00:12.34]) - # Use re.search without ^ so metadata lines like [ar:Artist] don't prevent detection - is_synced = bool(re.search(r"\[\d{1,2}:\d{1,2}(?:\.\d{2,3})?\]", lyrics_text)) - return lyrics_text, is_synced - - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.debug("Error fetching lyrics for track %s: %s", track_id, err) - return None, False - except Exception as err: - # Catch any other errors (e.g., geo-restrictions, API changes) - LOGGER.debug("Unexpected error fetching lyrics for track %s: %s", track_id, err) - return None, False - - async def get_tracks(self, track_ids: list[str]) -> list[YandexTrack]: - """Get multiple tracks by IDs. - - :param track_ids: List of track IDs. - :return: List of track objects. - :raises ResourceTemporarilyUnavailable: On network errors after retry. - """ - try: - result = await self._call_with_retry(lambda c: c.tracks(track_ids)) - return result or [] - except BadRequestError as err: - LOGGER.error("Error fetching tracks: %s", err) - return [] - except (NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error fetching tracks (retry failed): %s", err) - raise ResourceTemporarilyUnavailable("Failed to fetch tracks") from err - - async def get_album(self, album_id: str) -> YandexAlbum | None: - """Get a single album by ID. - - :param album_id: Album ID. - :return: Album object or None if not found. - """ - try: - albums = await self._call_with_retry(lambda c: c.albums([album_id])) - return albums[0] if albums else None - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error fetching album %s: %s", album_id, err) - return None - - async def get_album_with_tracks(self, album_id: str) -> YandexAlbum | None: - """Get an album with its tracks. - - Uses the same semantics as the web client: albums/{id}/with-tracks - with resumeStream, richTracks, withListeningFinished when the library - passes them through. - - :param album_id: Album ID. - :return: Album object with tracks or None if not found. - """ - - async def _fetch(c: ClientAsync) -> YandexAlbum | None: - try: - return await c.albums_with_tracks( - album_id, - resumeStream=True, - richTracks=True, - withListeningFinished=True, - ) - except TypeError: - # Older kion-music may not accept these kwargs - return await c.albums_with_tracks(album_id) - - try: - return await self._call_with_retry(_fetch) - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error fetching album with tracks %s: %s", album_id, err) - return None - - async def get_artist(self, artist_id: str) -> YandexArtist | None: - """Get a single artist by ID. - - :param artist_id: Artist ID. - :return: Artist object or None if not found. - """ - try: - artists = await self._call_with_retry(lambda c: c.artists([artist_id])) - return artists[0] if artists else None - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error fetching artist %s: %s", artist_id, err) - return None - - async def get_artist_albums( - self, artist_id: str, limit: int = DEFAULT_LIMIT - ) -> list[YandexAlbum]: - """Get artist's albums. - - :param artist_id: Artist ID. - :param limit: Maximum number of albums. - :return: List of album objects. - """ - try: - result = await self._call_with_retry( - lambda c: c.artists_direct_albums(artist_id, page=0, page_size=limit) - ) - if result is None: - return [] - return result.albums or [] - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error fetching artist albums %s: %s", artist_id, err) - return [] - - async def get_artist_tracks( - self, artist_id: str, limit: int = DEFAULT_LIMIT - ) -> list[YandexTrack]: - """Get artist's top tracks. - - :param artist_id: Artist ID. - :param limit: Maximum number of tracks. - :return: List of track objects. - """ - try: - result = await self._call_with_retry( - lambda c: c.artists_tracks(artist_id, page=0, page_size=limit) - ) - if result is None: - return [] - return result.tracks or [] - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error fetching artist tracks %s: %s", artist_id, err) - return [] - - async def get_playlist(self, user_id: str, playlist_id: str) -> YandexPlaylist | None: - """Get a playlist by ID. - - :param user_id: User ID (owner of the playlist). - :param playlist_id: Playlist ID (kind). - :return: Playlist object or None if not found. - :raises ResourceTemporarilyUnavailable: On network errors. - """ - try: - result = await self._call_with_retry( - lambda c: c.users_playlists(kind=int(playlist_id), user_id=user_id) - ) - if isinstance(result, list): - return result[0] if result else None - return result - except BadRequestError as err: - LOGGER.error("Error fetching playlist %s/%s: %s", user_id, playlist_id, err) - return None - except (NetworkError, ProviderUnavailableError) as err: - LOGGER.warning("Network error fetching playlist %s/%s: %s", user_id, playlist_id, err) - raise ResourceTemporarilyUnavailable("Failed to fetch playlist") from err - - # Streaming - - async def get_track_download_info( - self, track_id: str, get_direct_links: bool = True - ) -> list[DownloadInfo]: - """Get download info for a track. - - :param track_id: Track ID. - :param get_direct_links: Whether to get direct download links. - :return: List of download info objects. - """ - try: - result = await self._call_with_retry( - lambda c: c.tracks_download_info(track_id, get_direct_links=get_direct_links) - ) - return result or [] - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error fetching download info for track %s: %s", track_id, err) - return [] - - async def get_track_file_info_lossless(self, track_id: str) -> dict[str, Any] | None: - """Request lossless stream via get-file-info (quality=lossless). - - The /tracks/{id}/download-info endpoint often returns only MP3; get-file-info - with quality=lossless and codecs=flac,... returns FLAC when available. - - Uses manual sign calculation matching kion-music-downloader-realflac. - Uses _call_with_retry for automatic reconnection on transient failures. - - :param track_id: Track ID. - :return: Parsed downloadInfo dict (url, codec, urls, ...) or None on error. - """ - - def _build_signed_params(client: ClientAsync) -> tuple[str, dict[str, Any]]: - """Build URL and signed params using current client and timestamp. - - Called on each attempt by _call_with_retry, so the HMAC signature - is recomputed with a fresh timestamp on every retry. - """ - timestamp = int(time.time()) - params = { - "ts": timestamp, - "trackId": track_id, - "quality": "lossless", - "codecs": GET_FILE_INFO_CODECS, - "transports": "encraw", - } - # Build sign string explicitly matching Kion API specification: - # concatenate ts + trackId + quality + codecs (commas stripped) + transports. - # Comma stripping matches kion-music-downloader-realflac reference implementation - # (see get_file_info signing in that project). - codecs_for_sign = GET_FILE_INFO_CODECS.replace(",", "") - param_string = f"{timestamp}{track_id}lossless{codecs_for_sign}encraw" - hmac_sign = hmac.new( - DEFAULT_SIGN_KEY.encode(), - param_string.encode(), - hashlib.sha256, - ) - # SHA-256 (32 bytes) -> base64 = 44 chars with "=" padding. - # Kion API expects exactly 43 chars (one "=" removed). - # Matches kion-music-downloader-realflac reference implementation. - params["sign"] = base64.b64encode(hmac_sign.digest()).decode().rstrip("=") - url = f"{client.base_url}/get-file-info" - return url, params - - def _parse_file_info_result(raw: dict[str, Any] | None) -> dict[str, Any] | None: - if not raw or not isinstance(raw, dict): - return None - # yandex-music v3 no longer normalises camelCase keys inside - # Response.result, so /get-file-info returns "downloadInfo" as-is. - download_info = raw.get("download_info") or raw.get("downloadInfo") - if not download_info or not download_info.get("url"): - return None - - result = cast("dict[str, Any]", download_info) - - if "key" in download_info: - result["needs_decryption"] = True - LOGGER.debug( - "Encrypted URL received for track %s, will require decryption", - track_id, - ) - else: - result["needs_decryption"] = False - - return result - - async def _do_request(c: ClientAsync) -> dict[str, Any] | None: - url, params = _build_signed_params(c) - return await c._request.get(url, params=params) # type: ignore[no-any-return] - - try: - result = await self._call_with_retry(_do_request) - parsed = _parse_file_info_result(result) - if parsed: - LOGGER.debug( - "get-file-info lossless for track %s: Success, codec=%s", - track_id, - parsed.get("codec"), - ) - return parsed - except (BadRequestError, NetworkError) as err: - LOGGER.debug( - "get-file-info lossless for track %s: %s %s", - track_id, - type(err).__name__, - getattr(err, "message", str(err)) or repr(err), - ) - except UnauthorizedError as err: - LOGGER.debug( - "get-file-info lossless for track %s: UnauthorizedError %s", - track_id, - getattr(err, "message", str(err)) or repr(err), - ) - except Exception as err: - LOGGER.warning( - "get-file-info lossless for track %s: Unexpected error: %s", - track_id, - err, - exc_info=True, - ) - - return None - - # Discovery / recommendations - - async def get_feed(self) -> Feed | None: - """Get personalized feed with generated playlists (Playlist of the Day, etc.). - - :return: Feed object with generated_playlists, or None on error. - """ - try: - return await self._call_with_retry(lambda c: c.feed()) - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.debug("Error fetching feed: %s", err) - return None - - async def get_chart(self, chart_option: str = "") -> ChartInfo | None: - """Get chart data. - - :param chart_option: Optional chart variant (e.g. 'world', 'russia'). - :return: ChartInfo object or None on error. - """ - try: - return await self._call_with_retry(lambda c: c.chart(chart_option)) - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.debug("Error fetching chart: %s", err) - return None - - async def get_new_releases(self) -> LandingList | None: - """Get new album releases. - - :return: LandingList with new_releases (list of album IDs) or None on error. - """ - try: - return await self._call_with_retry(lambda c: c.new_releases()) - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.debug("Error fetching new releases: %s", err) - return None - - async def get_new_playlists(self) -> LandingList | None: - """Get new editorial playlists. - - :return: LandingList with new_playlists (list of PlaylistId) or None on error. - """ - try: - return await self._call_with_retry(lambda c: c.new_playlists()) - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.debug("Error fetching new playlists: %s", err) - return None - - async def get_albums(self, album_ids: list[str]) -> list[YandexAlbum]: - """Get multiple albums by IDs. - - :param album_ids: List of album IDs. - :return: List of album objects. - """ - try: - result = await self._call_with_retry(lambda c: c.albums(album_ids)) - return result or [] - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.debug("Error fetching albums: %s", err) - return [] - - async def get_playlists(self, playlist_ids: list[str]) -> list[YandexPlaylist]: - """Get multiple playlists by IDs (format: 'uid:kind'). - - :param playlist_ids: List of playlist IDs in 'uid:kind' format. - :return: List of playlist objects. - """ - try: - result = await self._call_with_retry(lambda c: c.playlists_list(playlist_ids)) - return result or [] - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.debug("Error fetching playlists: %s", err) - return [] - - async def get_tag_playlists(self, tag_id: str) -> list[YandexPlaylist]: - """Get playlists for a specific tag (mood, era, activity, genre, etc.). - - Tags are used for curated collections like 'chill', '80s', 'workout', 'rock', etc. - The API returns playlist IDs which are then fetched in full. - - :param tag_id: Tag identifier (e.g. 'chill', '80s', 'workout', 'rock'). - :return: List of playlist objects with full details. - """ - try: - tag_result = await self._call_with_retry(lambda c: c.tags(tag_id)) - if not tag_result or not tag_result.ids: - LOGGER.debug("No playlists found for tag: %s", tag_id) - return [] - - # Convert PlaylistId objects to 'uid:kind' format - playlist_ids = [f"{pid.uid}:{pid.kind}" for pid in tag_result.ids] - - # Fetch full playlist details - return await self.get_playlists(playlist_ids) - except BadRequestError as err: - LOGGER.debug("Tag %s not found: %s", tag_id, err) - return [] - except (NetworkError, ProviderUnavailableError) as err: - LOGGER.debug("Error fetching tag %s playlists: %s", tag_id, err) - return [] - - async def get_landing_tags(self) -> list[tuple[str, str]]: - """Discover available tag slugs from the landing mixes block. - - Uses the landing("mixes") API which returns MixLink entities - containing tag URLs (e.g., /tag/chill/) and display titles. - Filters out editorial post entries (/post/ URLs) which have no playlists. - - :return: List of (tag_slug, title) tuples for real tag entries only. - """ - try: - landing: Landing | None = await self._call_with_retry(lambda c: c.landing("mixes")) - if not landing or not landing.blocks: - return [] - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.debug("Error fetching landing tags: %s", err) - return [] - - tags: list[tuple[str, str]] = [] - for block in landing.blocks: - if not block.entities: - continue - for entity in block.entities: - if entity.type == "mix-link" and isinstance(entity.data, MixLink): - url = entity.data.url # e.g., "/tag/chill/" or "/post/..." - # Filter out editorial posts — only include /tag/ URLs - if not url.startswith("/tag/"): - continue - slug = url.strip("/").split("/")[-1] - if slug: - tags.append((slug, entity.data.title)) - return tags - - async def get_mixes_waves(self) -> list[dict[str, Any]] | None: - """Get AI Wave Set stations from /landing-blocks/mixes-waves endpoint. - - Returns structured mix data with categories and station items, each - containing station_id, title, seeds, and visual metadata. - - :return: List of mix category dicts, or None on error. - """ - return await self._get_landing_waves("mixes-waves") - - async def get_waves_landing(self) -> list[dict[str, Any]] | None: - """Get featured wave stations from /landing-blocks/waves endpoint. - - Returns Kion-curated wave categories with station items — the "Волны" - landing page content, separate from the full rotor/stations/list and from - the AI mixes-waves sets. - - :return: List of wave category dicts, or None on error. - """ - return await self._get_landing_waves("waves") - - async def _get_landing_waves(self, block: str) -> list[dict[str, Any]] | None: - """Fetch wave categories from a /landing-blocks/ endpoint. - - Note: Response keys are auto-converted from camelCase to snake_case - by the kion-music library's JSON parser. - - :param block: Block name, e.g. 'waves' or 'mixes-waves'. - :return: List of wave category dicts, or None on error. - """ - - async def _get(c: ClientAsync) -> dict[str, Any]: - url = f"{c.base_url}/landing-blocks/{block}" - return await c._request.get(url) # type: ignore[no-any-return] - - try: - result = await self._call_with_retry(_get) - if result and isinstance(result, dict): - waves = result.get("waves", []) - LOGGER.debug( - "landing-blocks/%s returned %d categories", - block, - len(waves) if isinstance(waves, list) else -1, - ) - return waves if isinstance(waves, list) else [] - return None - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.debug("Error fetching landing-blocks/%s: %s", block, err) - return None - - async def get_wave_stations( - self, language: str | None = None - ) -> list[tuple[str, str, str, str | None]]: - """Get available rotor wave stations grouped by category. - - Calls rotor_stations_list() — equivalent to the rotor/stations/list API endpoint. - Filters out personal stations (type 'user') since My Mix is handled separately. - - :param language: Language for station names (e.g. 'ru', 'en'). Defaults to API default. - :return: List of (station_id, category, name, image_url) tuples, - e.g. ('genre:rock', 'genre', 'Рок', 'https://...'). - """ - try: - results: list[StationResult] = await self._call_with_retry( - lambda c: c.rotor_stations_list(language) - ) - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.warning("Error fetching wave stations: %s", err) - return [] - - stations: list[tuple[str, str, str, str | None]] = [] - for result in results or []: - station = result.station - if station is None or station.id is None: - continue - category = station.id.type - tag = station.id.tag - if not category or not tag: - continue - if category in ("user", "local-language"): - # Skip personal stations (My Mix is handled separately) - # and local-language stations (Kion returns overlapping tracks across them) - continue - station_id = f"{category}:{tag}" - name = station.name or result.rup_title or tag - image_url: str | None = None - raw_url = station.full_image_url or (station.icon.image_url if station.icon else None) - if raw_url: - # Kion avatar URIs use '%%' as a size placeholder; replace it with - # the desired size. If no placeholder, append the size as a suffix - # since these URLs return HTTP 400 without a size component. - if not raw_url.startswith("http"): - raw_url = f"https://{raw_url}" - if "%%" in raw_url: - image_url = raw_url.replace("%%", "400x400") - else: - image_url = f"{raw_url}/400x400" - stations.append((station_id, category, name, image_url)) - return stations - - async def get_dashboard_stations(self) -> list[tuple[str, str, str | None]]: - """Get personalized recommended stations for the current user. - - Calls rotor_stations_dashboard() — returns user-specific stations based - on listening history, unlike rotor_stations_list() which is non-personalized. - - :return: List of (station_id, name, image_url) tuples, - e.g. ('genre:rock', 'Рок', 'https://...'). - """ - try: - dashboard: Dashboard | None = await self._call_with_retry( - lambda c: c.rotor_stations_dashboard() - ) - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.warning("Error fetching dashboard stations: %s", err) - return [] - - if not dashboard or not dashboard.stations: - return [] - - stations: list[tuple[str, str, str | None]] = [] - for result in dashboard.stations: - station = result.station - if station is None or station.id is None: - continue - category = station.id.type - tag = station.id.tag - if not category or not tag: - continue - if category == "user": - continue - station_id = f"{category}:{tag}" - name = station.name or result.rup_title or tag - image_url: str | None = None - raw_url = station.full_image_url or (station.icon.image_url if station.icon else None) - if raw_url: - if not raw_url.startswith("http"): - raw_url = f"https://{raw_url}" - if "%%" in raw_url: - image_url = raw_url.replace("%%", "400x400") - else: - image_url = f"{raw_url}/400x400" - stations.append((station_id, name, image_url)) - return stations - - # Library modifications - - async def like_track(self, track_id: str) -> bool: - """Add a track to liked tracks. - - :param track_id: Track ID to like. - :return: True if successful. - """ - try: - result = await self._call_with_retry(lambda c: c.users_likes_tracks_add(track_id)) - return result is not None - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error liking track %s: %s", track_id, err) - return False - - async def unlike_track(self, track_id: str) -> bool: - """Remove a track from liked tracks. - - :param track_id: Track ID to unlike. - :return: True if successful. - """ - try: - result = await self._call_with_retry(lambda c: c.users_likes_tracks_remove(track_id)) - return result is not None - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error unliking track %s: %s", track_id, err) - return False - - async def like_album(self, album_id: str) -> bool: - """Add an album to liked albums. - - :param album_id: Album ID to like. - :return: True if successful. - """ - try: - result = await self._call_with_retry(lambda c: c.users_likes_albums_add(album_id)) - return result is not None - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error liking album %s: %s", album_id, err) - return False - - async def unlike_album(self, album_id: str) -> bool: - """Remove an album from liked albums. - - :param album_id: Album ID to unlike. - :return: True if successful. - """ - try: - result = await self._call_with_retry(lambda c: c.users_likes_albums_remove(album_id)) - return result is not None - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error unliking album %s: %s", album_id, err) - return False - - async def like_artist(self, artist_id: str) -> bool: - """Add an artist to liked artists. - - :param artist_id: Artist ID to like. - :return: True if successful. - """ - try: - result = await self._call_with_retry(lambda c: c.users_likes_artists_add(artist_id)) - return result is not None - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error liking artist %s: %s", artist_id, err) - return False - - async def unlike_artist(self, artist_id: str) -> bool: - """Remove an artist from liked artists. - - :param artist_id: Artist ID to unlike. - :return: True if successful. - """ - try: - result = await self._call_with_retry(lambda c: c.users_likes_artists_remove(artist_id)) - return result is not None - except (BadRequestError, NetworkError, ProviderUnavailableError) as err: - LOGGER.error("Error unliking artist %s: %s", artist_id, err) - return False diff --git a/music_assistant/providers/kion_music/constants.py b/music_assistant/providers/kion_music/constants.py deleted file mode 100644 index a597d8aa68..0000000000 --- a/music_assistant/providers/kion_music/constants.py +++ /dev/null @@ -1,346 +0,0 @@ -"""Constants for the KION Music provider.""" - -from __future__ import annotations - -from typing import Final - -# Configuration Keys -CONF_TOKEN = "token" -CONF_QUALITY = "quality" -CONF_BASE_URL = "base_url" - -# Actions -CONF_ACTION_AUTH = "auth" -CONF_ACTION_CLEAR_AUTH = "clear_auth" - -# Labels -LABEL_TOKEN = "token_label" -LABEL_AUTH_INSTRUCTIONS = "auth_instructions_label" - -# API defaults -DEFAULT_LIMIT: Final[int] = 50 -DEFAULT_BASE_URL: Final[str] = "https://api.music.yandex.net" -WEB_BASE_URL: Final[str] = "https://music.yandex.ru" - -# Quality options (matching reference implementation) -QUALITY_EFFICIENT = "efficient" # Low quality, efficient bandwidth (~64kbps AAC) -QUALITY_BALANCED = "balanced" # Medium quality, balanced performance (~192kbps AAC) -QUALITY_HIGH = "high" # High quality, lossy (~320kbps MP3) -QUALITY_SUPERB = "superb" # Highest quality, lossless (FLAC) - -# Configuration keys for My Mix behavior (kept) -CONF_MY_WAVE_MAX_TRACKS: Final[str] = "my_wave_max_tracks" - -# Configuration keys for Liked Tracks behavior (kept) -CONF_LIKED_TRACKS_MAX_TRACKS: Final[str] = "liked_tracks_max_tracks" - -# Hardcoded default values for removed config entries -MY_WAVE_BATCH_SIZE: Final[int] = 3 -TRACK_BATCH_SIZE: Final[int] = 50 -DISCOVERY_INITIAL_TRACKS: Final[int] = 20 -BROWSE_INITIAL_TRACKS: Final[int] = 15 - -# Image sizes -IMAGE_SIZE_SMALL = "200x200" -IMAGE_SIZE_MEDIUM = "400x400" -IMAGE_SIZE_LARGE = "1000x1000" - -# Locale-aware provider display names for owner normalization -PROVIDER_DISPLAY_NAME_RU: Final[str] = "KION Music" -PROVIDER_DISPLAY_NAME_EN: Final[str] = "KION Music" - -# Known API-returned system owner name variants (all locales/capitalizations) -# All entries are lowercase; compare with owner_name.lower() for case-insensitive lookup -YANDEX_SYSTEM_OWNER_NAMES: Final[frozenset[str]] = frozenset( - { - "кион музыка", - "кион.музыка", - "kion.music", - "kionmusic", - "kion music", - } -) - -# ID separators -PLAYLIST_ID_SPLITTER: Final[str] = ":" - -# Rotor (radio) station identifiers -ROTOR_STATION_MY_MIX: Final[str] = "user:onyourwave" - -# Virtual playlist ID for My Mix (used in get_playlist / get_playlist_tracks; not owner_id:kind) -MY_WAVE_PLAYLIST_ID: Final[str] = "my_wave" - -# Virtual playlist ID for Liked Tracks -LIKED_TRACKS_PLAYLIST_ID: Final[str] = "liked_tracks" - -# Composite item_id for My Mix 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": "Мои плейлисты", - "feed": "Для вас", - "chart": "Чарт", - "new_releases": "Новинки", - "new_playlists": "Новые плейлисты", - # Picks & Mixes - "picks": "Подборки", - "mixes": "Миксы", - "mood": "Настроение", - "activity": "Активность", - "era": "Эпоха", - "genres": "Жанры", - # Mood tags - "chill": "Расслабляющее", - "sad": "Грустное", - "romantic": "Романтическое", - "party": "Вечеринка", - "relax": "Релакс", - # Activity tags - "workout": "Тренировка", - "focus": "Концентрация", - "morning": "Утро", - "evening": "Вечер", - "driving": "В дороге", # noqa: RUF001 - # Era tags - "80s": "80-е", # noqa: RUF001 - "90s": "90-е", # noqa: RUF001 - "2000s": "2000-е", # noqa: RUF001 - "retro": "Ретро", - # Genre tags - "rock": "Рок", - "jazz": "Джаз", - "classical": "Классика", - "electronic": "Электроника", - "rnb": "R&B", - "hiphop": "Хип-хоп", - "top": "Топ", - "newbies": "По жанру", - # Landing-discovered tags - "in the mood": "В настроение", # noqa: RUF001 - "background": "Послушать фоном", - # Seasonal tags - "winter": "Зима", - "summer": "Лето", - "autumn": "Осень", - "spring": "Весна", - "newyear": "Новый год", - # Liked Tracks - "liked_tracks": "Мне нравится", - # Discovery - "top_picks": "Топ подборки", - "mood_mix": "Настроение", - "activity_mix": "Активность", - "seasonal_mix": "Сезонное", - # Top-level browse groups - "for_you": "Для вас", - "collection": "Коллекция", - # Waves / Radio (rotor station categories) - "waves": "Радио", - "radio": "Радио", - "my_waves": "Персональные", - "my_waves_set": "AI Сеты", - "waves_landing": "Избранные миксы", - "genre": "Жанры", - "epoch": "Эпоха", - "local": "Местное", -} -BROWSE_NAMES_EN: Final[dict[str, str]] = { - "my_wave": "My Mix", - "artists": "My Artists", - "albums": "My Albums", - "tracks": "My Favorites", - "playlists": "My Playlists", - "feed": "Made for You", - "chart": "Chart", - "new_releases": "New Releases", - "new_playlists": "New Playlists", - # Picks & Mixes - "picks": "Picks", - "mixes": "Mixes", - "mood": "Mood", - "activity": "Activity", - "era": "Era", - "genres": "Genres", - # Mood tags - "chill": "Chill", - "sad": "Sad", - "romantic": "Romantic", - "party": "Party", - "relax": "Relax", - # Activity tags - "workout": "Workout", - "focus": "Focus", - "morning": "Morning", - "evening": "Evening", - "driving": "Driving", - # Era tags - "80s": "80s", - "90s": "90s", - "2000s": "2000s", - "retro": "Retro", - # Genre tags - "rock": "Rock", - "jazz": "Jazz", - "classical": "Classical", - "electronic": "Electronic", - "rnb": "R&B", - "hiphop": "Hip-Hop", - "top": "Top", - "newbies": "By Genre", - # Landing-discovered tags - "in the mood": "In the Mood", - "background": "Background", - # Seasonal tags - "winter": "Winter", - "summer": "Summer", - "autumn": "Autumn", - "spring": "Spring", - "newyear": "New Year", - # Liked Tracks - "liked_tracks": "My Favorites", - # Discovery - "top_picks": "Top Picks", - "mood_mix": "Mood Mix", - "activity_mix": "Activity Mix", - "seasonal_mix": "Seasonal", - # Top-level browse groups - "for_you": "For You", - "collection": "Collection", - # Waves / Radio (rotor station categories) - "waves": "Radio", - "radio": "Radio", - "my_waves": "Personal", - "my_waves_set": "AI Mix Sets", - "waves_landing": "Featured Mixes", - "genre": "Genres", - "epoch": "Era", - "local": "Local", -} - -# Tag categories for Picks and Recommendations -# Used by _get_valid_tags_for_category to validate tags at runtime. -TAG_CATEGORY_MOOD: Final[list[str]] = [ - "chill", - "sad", - "romantic", - "party", - "relax", - "in the mood", -] -TAG_CATEGORY_ACTIVITY: Final[list[str]] = [ - "workout", - "focus", - "morning", - "evening", - "driving", - "background", -] -TAG_CATEGORY_ERA: Final[list[str]] = ["80s", "90s", "2000s", "retro"] -TAG_CATEGORY_GENRES: Final[list[str]] = [ - "rock", - "jazz", - "classical", - "electronic", - "rnb", - "hiphop", - "top", - "newbies", -] - -# Tag slug -> display category mapping -# Used to categorize dynamically discovered tags into browse folders. -# Tags not in this mapping default to "mood" category. -TAG_SLUG_CATEGORY: Final[dict[str, str]] = { - # Mood - "chill": "mood", - "sad": "mood", - "romantic": "mood", - "party": "mood", - "relax": "mood", - "in the mood": "mood", - # Activity - "workout": "activity", - "focus": "activity", - "morning": "activity", - "evening": "activity", - "driving": "activity", - "background": "activity", - # Era - "80s": "era", - "90s": "era", - "2000s": "era", - "retro": "era", - # Genres - "rock": "genres", - "jazz": "genres", - "classical": "genres", - "electronic": "genres", - "rnb": "genres", - "hiphop": "genres", - "top": "genres", - "newbies": "genres", - # Seasonal (for mixes) - "winter": "seasonal", - "spring": "seasonal", - "summer": "seasonal", - "autumn": "seasonal", - "newyear": "seasonal", -} - -# Preferred tag order within categories (discovered tags sorted by this) -TAG_CATEGORY_ORDER: Final[dict[str, list[str]]] = { - "mood": ["chill", "sad", "romantic", "party", "relax", "in the mood"], - "activity": ["workout", "focus", "morning", "evening", "driving", "background"], - "era": ["80s", "90s", "2000s", "retro"], - "genres": ["rock", "jazz", "classical", "electronic", "rnb", "hiphop", "top", "newbies"], -} - -# Seasonal tags mapped to months (month number -> tag) -TAG_SEASONAL_MAP: Final[dict[int, str]] = { - 1: "winter", # January - 2: "winter", # February - 3: "spring", # March (validated at runtime; falls back to autumn if unavailable) - 4: "spring", # April - 5: "spring", # May - 6: "summer", # June - 7: "summer", # July - 8: "summer", # August - 9: "autumn", # September - 10: "autumn", # October - 11: "autumn", # November - 12: "winter", # December -} - -# Tags for Mixes (seasonal collections) -TAG_MIXES: Final[list[str]] = ["winter", "spring", "summer", "autumn", "newyear"] - -# Waves by tag (rotor stations) — canonical ID is "waves", "radio" is an alias -WAVES_FOLDER_ID: Final[str] = "waves" -RADIO_FOLDER_ID: Final[str] = "radio" - -# Personalized waves subfolder (rotor/stations/dashboard) -MY_WAVES_FOLDER_ID: Final[str] = "my_waves" - -# AI Mix Sets subfolder (from /landing-blocks/mixes-waves) -MY_WAVES_SET_FOLDER_ID: Final[str] = "my_waves_set" - -# Featured Mixes subfolder inside Radio (from /landing-blocks/waves) -WAVES_LANDING_FOLDER_ID: Final[str] = "waves_landing" - -# Top-level browse group folders -FOR_YOU_FOLDER_ID: Final[str] = "for_you" -COLLECTION_FOLDER_ID: Final[str] = "collection" - -# Preferred display order for wave categories (rotor station types) -WAVE_CATEGORY_DISPLAY_ORDER: Final[list[str]] = [ - "genre", - "mood", - "activity", - "epoch", - "local", -] diff --git a/music_assistant/providers/kion_music/icon.svg b/music_assistant/providers/kion_music/icon.svg deleted file mode 100644 index f2031622ef..0000000000 --- a/music_assistant/providers/kion_music/icon.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/music_assistant/providers/kion_music/icon_monochrome.svg b/music_assistant/providers/kion_music/icon_monochrome.svg deleted file mode 100644 index 3f37201ecb..0000000000 --- a/music_assistant/providers/kion_music/icon_monochrome.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/music_assistant/providers/kion_music/manifest.json b/music_assistant/providers/kion_music/manifest.json deleted file mode 100644 index 0d25ab7a20..0000000000 --- a/music_assistant/providers/kion_music/manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "type": "music", - "domain": "kion_music", - "stage": "beta", - "name": "KION Music", - "description": "Stream music, personalized recommendations, and lossless FLAC from KION Music — including My Wave radio and library sync.", - "codeowners": [ - "@TrudenBoy" - ], - "credits": [ - "[yandex-music-api](https://github.com/MarshalX/yandex-music-api)" - ], - "documentation": "https://music-assistant.io/music-providers/kion-music/", - "requirements": [ - "yandex-music==3.0.0" - ], - "multi_instance": true -} diff --git a/music_assistant/providers/kion_music/parsers.py b/music_assistant/providers/kion_music/parsers.py deleted file mode 100644 index 121f5f6b19..0000000000 --- a/music_assistant/providers/kion_music/parsers.py +++ /dev/null @@ -1,398 +0,0 @@ -"""Parsers for KION Music API responses.""" - -from __future__ import annotations - -from contextlib import suppress -from datetime import datetime -from typing import TYPE_CHECKING - -from music_assistant_models.enums import ( - AlbumType, - ContentType, - ImageType, -) -from music_assistant_models.errors import InvalidDataError -from music_assistant_models.media_items import ( - Album, - Artist, - AudioFormat, - MediaItemImage, - Playlist, - ProviderMapping, - Track, - UniqueList, -) - -from music_assistant.helpers.util import parse_title_and_version - -from .constants import ( - IMAGE_SIZE_LARGE, - PROVIDER_DISPLAY_NAME_EN, - PROVIDER_DISPLAY_NAME_RU, - WEB_BASE_URL, - YANDEX_SYSTEM_OWNER_NAMES, -) - -if TYPE_CHECKING: - from yandex_music import Album as YandexAlbum - from yandex_music import Artist as YandexArtist - from yandex_music import Playlist as YandexPlaylist - from yandex_music import Track as YandexTrack - - from .provider import KionMusicProvider - - -def get_canonical_provider_name(provider: KionMusicProvider) -> str: - """Return the locale-aware canonical display name for the KION Music system account. - - :param provider: The KION Music provider instance. - :return: Localized provider display name. - """ - with suppress(Exception): - locale = (provider.mass.metadata.locale or "en_US").lower() - if locale.startswith("ru"): - return PROVIDER_DISPLAY_NAME_RU - return PROVIDER_DISPLAY_NAME_EN - - -def _get_image_url(cover_uri: str | None, size: str = IMAGE_SIZE_LARGE) -> str | None: - """Convert Kion cover URI to full URL. - - :param cover_uri: Kion cover URI template. - :param size: Image size (e.g., '1000x1000'). - :return: Full image URL or None. - """ - if not cover_uri: - return None - # Cover URIs come in format "avatars.kion.net/get-music-content/xxx/yyy/%%" - # Replace %% with the desired size - return f"https://{cover_uri.replace('%%', size)}" - - -def parse_artist(provider: KionMusicProvider, artist_obj: YandexArtist) -> Artist: - """Parse Kion artist object to MA Artist model. - - :param provider: The KION Music provider instance. - :param artist_obj: Kion artist object. - :return: Music Assistant Artist model. - """ - if artist_obj.id is None: - raise InvalidDataError("Yandex artist missing id") - artist_id = str(artist_obj.id) - artist = Artist( - item_id=artist_id, - provider=provider.instance_id, - name=artist_obj.name or "Unknown Artist", - provider_mappings={ - ProviderMapping( - item_id=artist_id, - provider_domain=provider.domain, - provider_instance=provider.instance_id, - url=f"{WEB_BASE_URL}/artist/{artist_id}", - ) - }, - ) - - # Add image if available - if artist_obj.cover: - image_url = _get_image_url(artist_obj.cover.uri) - if image_url: - artist.metadata.images = UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=provider.instance_id, - remotely_accessible=True, - ) - ] - ) - elif artist_obj.og_image: - image_url = _get_image_url(artist_obj.og_image) - if image_url: - artist.metadata.images = UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=provider.instance_id, - remotely_accessible=True, - ) - ] - ) - - return artist - - -def parse_album(provider: KionMusicProvider, album_obj: YandexAlbum) -> Album: - """Parse Kion album object to MA Album model. - - :param provider: The KION Music provider instance. - :param album_obj: Kion album object. - :return: Music Assistant Album model. - """ - if album_obj.id is None: - raise InvalidDataError("Yandex album missing id") - name, version = parse_title_and_version( - album_obj.title or "Unknown Album", - album_obj.version or None, - ) - album_id = str(album_obj.id) - - # Determine availability - available = album_obj.available or False - - album = Album( - item_id=album_id, - provider=provider.instance_id, - name=name, - version=version, - provider_mappings={ - ProviderMapping( - item_id=album_id, - provider_domain=provider.domain, - provider_instance=provider.instance_id, - audio_format=AudioFormat( - content_type=ContentType.UNKNOWN, - ), - url=f"{WEB_BASE_URL}/album/{album_id}", - available=available, - ) - }, - ) - - # Parse artists - various_artist_album = False - if album_obj.artists: - for artist in album_obj.artists: - if artist.name and artist.name.lower() in ("various artists", "сборник"): - various_artist_album = True - album.artists.append(parse_artist(provider, artist)) - - # Determine album type - album_type_str = album_obj.type or "album" - if album_type_str == "compilation" or various_artist_album: - album.album_type = AlbumType.COMPILATION - elif album_type_str == "single": - album.album_type = AlbumType.SINGLE - else: - album.album_type = AlbumType.ALBUM - - # Parse year - if album_obj.year: - album.year = album_obj.year - if album_obj.release_date: - with suppress(ValueError): - album.metadata.release_date = datetime.fromisoformat(album_obj.release_date) - - # Parse metadata - if album_obj.genre: - album.metadata.genres = {album_obj.genre} - - # Add cover image - if album_obj.cover_uri: - image_url = _get_image_url(album_obj.cover_uri) - if image_url: - album.metadata.images = UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=provider.instance_id, - remotely_accessible=True, - ) - ] - ) - elif album_obj.og_image: - image_url = _get_image_url(album_obj.og_image) - if image_url: - album.metadata.images = UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=provider.instance_id, - remotely_accessible=True, - ) - ] - ) - - return album - - -def parse_track( - provider: KionMusicProvider, - track_obj: YandexTrack, - lyrics: str | None = None, - lyrics_synced: bool = False, -) -> Track: - """Parse Kion track object to MA Track model. - - :param provider: The KION Music provider instance. - :param track_obj: Kion track object. - :param lyrics: Optional lyrics text. - :param lyrics_synced: Whether lyrics are in synced LRC format. - :return: Music Assistant Track model. - """ - if track_obj.id is None: - raise InvalidDataError("Yandex track missing id") - name, version = parse_title_and_version( - track_obj.title or "Unknown Track", - track_obj.version or None, - ) - track_id = str(track_obj.id) - - # Determine availability - available = track_obj.available or False - - # Duration is in milliseconds in Kion API - duration = (track_obj.duration_ms or 0) // 1000 - - track = Track( - item_id=track_id, - provider=provider.instance_id, - name=name, - version=version, - duration=duration, - provider_mappings={ - ProviderMapping( - item_id=track_id, - provider_domain=provider.domain, - provider_instance=provider.instance_id, - audio_format=AudioFormat( - content_type=ContentType.UNKNOWN, - ), - url=f"{WEB_BASE_URL}/track/{track_id}", - available=available, - ) - }, - ) - - # Parse artists - if track_obj.artists: - track.artists = UniqueList() - for artist in track_obj.artists: - track.artists.append(parse_artist(provider, artist)) - - # Parse album (full data so album gets cover art in the library) - if track_obj.albums and len(track_obj.albums) > 0: - album_obj = track_obj.albums[0] - track.album = parse_album(provider, album_obj) - # Also set track image from album cover if available - if album_obj.cover_uri: - image_url = _get_image_url(album_obj.cover_uri) - if image_url: - track.metadata.images = UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=provider.instance_id, - remotely_accessible=True, - ) - ] - ) - - # Parse external IDs - if track_obj.real_id: - # real_id can be used as an identifier - pass - - # Metadata - if track_obj.content_warning: - track.metadata.explicit = track_obj.content_warning == "explicit" - - # Lyrics - if lyrics: - if lyrics_synced: - track.metadata.lrc_lyrics = lyrics - else: - track.metadata.lyrics = lyrics - - return track - - -def parse_playlist( - provider: KionMusicProvider, playlist_obj: YandexPlaylist, owner_name: str | None = None -) -> Playlist: - """Parse Kion playlist object to MA Playlist model. - - :param provider: The KION Music provider instance. - :param playlist_obj: Kion playlist object. - :param owner_name: Optional owner name override. - :return: Music Assistant Playlist model. - """ - # Playlist ID in Kion is a combination of owner uid and playlist kind - owner_id = str(playlist_obj.owner.uid) if playlist_obj.owner else str(provider.client.user_id) - playlist_kind = str(playlist_obj.kind) - playlist_id = f"{owner_id}:{playlist_kind}" - - # Determine if editable (user owns the playlist) - is_editable = owner_id == str(provider.client.user_id) - - # Get owner name - if owner_name is None: - if playlist_obj.owner and playlist_obj.owner.name: - owner_name = playlist_obj.owner.name - elif is_editable: - owner_name = "Me" - else: - owner_name = get_canonical_provider_name(provider) - - # Normalize all known system account name variants to locale-aware canonical form - if owner_name and owner_name.lower() in YANDEX_SYSTEM_OWNER_NAMES: - owner_name = get_canonical_provider_name(provider) - - playlist = Playlist( - item_id=playlist_id, - provider=provider.instance_id, - name=playlist_obj.title or "Unknown Playlist", - owner=owner_name, - provider_mappings={ - ProviderMapping( - item_id=playlist_id, - provider_domain=provider.domain, - provider_instance=provider.instance_id, - url=f"{WEB_BASE_URL}/users/{owner_id}/playlists/{playlist_kind}", - is_unique=is_editable, - ) - }, - is_editable=is_editable, - ) - - # Metadata - if playlist_obj.description: - playlist.metadata.description = playlist_obj.description - - # Add cover image - if playlist_obj.cover: - # Cover can be CoverImage or a string - cover = playlist_obj.cover - if hasattr(cover, "uri") and cover.uri: - image_url = _get_image_url(cover.uri) - if image_url: - playlist.metadata.images = UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=provider.instance_id, - remotely_accessible=True, - ) - ] - ) - elif playlist_obj.og_image: - image_url = _get_image_url(playlist_obj.og_image) - if image_url: - playlist.metadata.images = UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=provider.instance_id, - remotely_accessible=True, - ) - ] - ) - - return playlist diff --git a/music_assistant/providers/kion_music/provider.py b/music_assistant/providers/kion_music/provider.py deleted file mode 100644 index f59fcf27e0..0000000000 --- a/music_assistant/providers/kion_music/provider.py +++ /dev/null @@ -1,2512 +0,0 @@ -"""KION Music provider implementation.""" - -from __future__ import annotations - -import asyncio -import logging -import random -from collections.abc import AsyncGenerator, Sequence -from datetime import UTC, datetime -from io import BytesIO -from typing import TYPE_CHECKING, Any - -from music_assistant_models.enums import ImageType, MediaType, ProviderFeature -from music_assistant_models.errors import ( - InvalidDataError, - LoginFailed, - MediaNotFoundError, - ProviderUnavailableError, - ResourceTemporarilyUnavailable, -) -from music_assistant_models.media_items import ( - Album, - Artist, - BrowseFolder, - ItemMapping, - MediaItemImage, - MediaItemType, - Playlist, - ProviderMapping, - RecommendationFolder, - SearchResults, - Track, - UniqueList, -) -from PIL import Image as PilImage - -from music_assistant.controllers.cache import use_cache -from music_assistant.models.music_provider import MusicProvider - -from .api_client import KionMusicClient -from .constants import ( - BROWSE_INITIAL_TRACKS, - BROWSE_NAMES_EN, - BROWSE_NAMES_RU, - COLLECTION_FOLDER_ID, - CONF_BASE_URL, - CONF_LIKED_TRACKS_MAX_TRACKS, - CONF_MY_WAVE_MAX_TRACKS, - CONF_TOKEN, - DEFAULT_BASE_URL, - DISCOVERY_INITIAL_TRACKS, - FOR_YOU_FOLDER_ID, - IMAGE_SIZE_MEDIUM, - LIKED_TRACKS_PLAYLIST_ID, - MY_WAVE_BATCH_SIZE, - MY_WAVE_PLAYLIST_ID, - MY_WAVES_FOLDER_ID, - MY_WAVES_SET_FOLDER_ID, - PLAYLIST_ID_SPLITTER, - RADIO_FOLDER_ID, - RADIO_TRACK_ID_SEP, - ROTOR_STATION_MY_MIX, - TAG_CATEGORY_ACTIVITY, - TAG_CATEGORY_ERA, - TAG_CATEGORY_GENRES, - TAG_CATEGORY_MOOD, - TAG_CATEGORY_ORDER, - TAG_MIXES, - TAG_SEASONAL_MAP, - TAG_SLUG_CATEGORY, - TRACK_BATCH_SIZE, - WAVE_CATEGORY_DISPLAY_ORDER, - WAVES_FOLDER_ID, - WAVES_LANDING_FOLDER_ID, -) -from .parsers import ( - _get_image_url as get_image_url, -) -from .parsers import ( - get_canonical_provider_name, - parse_album, - parse_artist, - parse_playlist, - parse_track, -) -from .streaming import KionMusicStreamingManager - -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 Mix 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 _WaveState: - """Per-station mutable state for rotor wave playback.""" - - def __init__(self) -> None: - self.batch_id: str | None = None - self.last_track_id: str | None = None - self.seen_track_ids: set[str] = set() - self.radio_started_sent: bool = False - self.lock: asyncio.Lock = asyncio.Lock() - - -class KionMusicProvider(MusicProvider): - """Implementation of a KION Music MusicProvider.""" - - _client: KionMusicClient | None = None - _streaming: KionMusicStreamingManager | 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 - _my_wave_seen_track_ids: set[str] # Track IDs seen in current My Mix session - _my_wave_lock: asyncio.Lock # Protects My Mix mutable state - _wave_states: dict[str, _WaveState] # Per-station state for tagged wave stations - _wave_bg_colors: dict[str, str] # image_url -> hex bg color for transparent covers - - @property - def client(self) -> KionMusicClient: - """Return the KION Music client.""" - if self._client is None: - raise ProviderUnavailableError("Provider not initialized") - return self._client - - @property - def streaming(self) -> KionMusicStreamingManager: - """Return the streaming manager.""" - if self._streaming is None: - 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") - self.logger.debug("Locale detection: locale=%s, use_russian=%s", locale, use_russian) - except Exception as err: - self.logger.debug("Locale detection failed: %s", err) - 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) - if not token: - raise LoginFailed("No KION Music token provided") - - base_url = self.config.get_value(CONF_BASE_URL, DEFAULT_BASE_URL) - self._client = KionMusicClient(str(token), base_url=str(base_url)) - await self._client.connect() - # Suppress kion_music library DEBUG dumps (full API request/response JSON) - logging.getLogger("yandex_music").setLevel(self.logger.level + 10) - self._streaming = KionMusicStreamingManager(self) - # Initialize My Mix duplicate tracking - self._my_wave_seen_track_ids = set() - self._my_wave_lock = asyncio.Lock() - # Initialize per-station wave state dict - self._wave_states = {} - self._wave_bg_colors = {} - self.logger.info("Successfully connected to KION Music") - - async def unload(self, is_removed: bool = False) -> None: - """Handle unload/close of the provider. - - :param is_removed: Whether the provider is being removed. - """ - if self._client: - await self._client.disconnect() - self._client = None - self._streaming = None - await super().unload(is_removed) - - def get_item_mapping(self, media_type: MediaType | str, key: str, name: str) -> ItemMapping: - """Create a generic item mapping. - - :param media_type: The media type. - :param key: The item ID. - :param name: The item name. - :return: An ItemMapping instance. - """ - if isinstance(media_type, str): - media_type = MediaType(media_type) - return ItemMapping( - media_type=media_type, - item_id=key, - provider=self.instance_id, - name=name, - ) - - async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Browse provider items with locale-based folder names. - - Root level shows My Mix (personalised radio), For You (picks & mixes), - Collection (liked tracks/albums/artists/playlists), Radio (rotor stations - by genre/mood/activity/era/local) and AI Mix Sets. Names are in Russian - when MA locale is ru_*, otherwise in English. My Mix 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://waves). - """ - 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: - async with self._my_wave_lock: - return await self._browse_my_wave(path, sub_subpath) - - # For You folder (picks + mixes) - if subpath == FOR_YOU_FOLDER_ID: - return await self._browse_for_you(path, path_parts) - - # Collection folder (library items) - if subpath == COLLECTION_FOLDER_ID: - return await self._browse_collection(path) - - # Handle picks/ path (mood, activity, era, genres) - if subpath == "picks": - return await self._browse_picks(path, path_parts) - - # Handle mixes/ path (seasonal collections) - if subpath == "mixes": - return await self._browse_mixes(path, path_parts) - - # Handle waves/ and radio/ paths (rotor stations by genre/mood/activity) - if subpath in (WAVES_FOLDER_ID, RADIO_FOLDER_ID): - return await self._browse_waves(path, path_parts) - - # Handle my_waves_set/ path (AI Mix Sets from /landing-blocks/mixes-waves) - if subpath == MY_WAVES_SET_FOLDER_ID: - return await self._browse_vibe_sets(path, path_parts) - - # Handle waves_landing/ path (Featured Mixes from /landing-blocks/waves) - if subpath == WAVES_LANDING_FOLDER_ID: - return await self._browse_waves_landing(path, path_parts) - - # Handle direct tag subpath (when folder is played by URI, the full path - # "picks/category/tag" is lost and only the tag slug arrives as subpath). - # Skip the API call for standard top-level folders that are never tag slugs. - _known_folders = { - "artists", - "albums", - "tracks", - "playlists", - LIKED_TRACKS_PLAYLIST_ID, - WAVES_FOLDER_ID, - RADIO_FOLDER_ID, - MY_WAVES_FOLDER_ID, - MY_WAVES_SET_FOLDER_ID, - WAVES_LANDING_FOLDER_ID, - FOR_YOU_FOLDER_ID, - COLLECTION_FOLDER_ID, - } - if subpath and subpath not in _known_folders: - # Handle direct wave station_id (e.g. "activity:workout") passed when - # MA plays a wave station folder using its item_id as the path subpath. - # Station IDs have format "category:tag" where category is non-numeric. - if ":" in subpath: - cat_part = subpath.split(":", 1)[0] - if not cat_part.isdigit(): - return await self._browse_wave_station(subpath) - - discovered_tags = await self._get_discovered_tag_slugs() - if subpath in discovered_tags: - return await self._get_tag_playlists_as_browse(subpath) - - if subpath: - return await super().browse(path) - - names = self._get_browse_names() - - folders: list[BrowseFolder] = [] - base = path if path.endswith("//") else path.rstrip("/") + "/" - # My Mix folder (always enabled — Яндекс «Мой микс») - 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, - ) - ) - # For You folder — Picks + Mixes (Яндекс «Для вас») - folders.append( - BrowseFolder( - item_id=FOR_YOU_FOLDER_ID, - provider=self.instance_id, - path=f"{base}{FOR_YOU_FOLDER_ID}", - name=names.get(FOR_YOU_FOLDER_ID, "For You"), - is_playable=False, - ) - ) - # Collection folder — library items (Яндекс «Коллекция») - has_library = any( - f in self.supported_features - for f in ( - ProviderFeature.LIBRARY_ARTISTS, - ProviderFeature.LIBRARY_ALBUMS, - ProviderFeature.LIBRARY_TRACKS, - ProviderFeature.LIBRARY_PLAYLISTS, - ) - ) - if has_library: - folders.append( - BrowseFolder( - item_id=COLLECTION_FOLDER_ID, - provider=self.instance_id, - path=f"{base}{COLLECTION_FOLDER_ID}", - name=names.get(COLLECTION_FOLDER_ID, "Collection"), - is_playable=False, - ) - ) - # Radio folder — rotor stations (Яндекс волны, renamed to Radio) - folders.append( - BrowseFolder( - item_id=RADIO_FOLDER_ID, - provider=self.instance_id, - path=f"{base}{RADIO_FOLDER_ID}", - name=names.get(RADIO_FOLDER_ID, "Radio"), - is_playable=False, - ) - ) - # AI Mix Sets — parametric stations from /landing-blocks/mixes-waves - folders.append( - BrowseFolder( - item_id=MY_WAVES_SET_FOLDER_ID, - provider=self.instance_id, - path=f"{base}{MY_WAVES_SET_FOLDER_ID}", - name=names.get(MY_WAVES_SET_FOLDER_ID, "AI Mix Sets"), - is_playable=False, - ) - ) - if len(folders) == 1: - return await self.browse(folders[0].path) - return folders - - async def _browse_my_wave( - self, path: str, sub_subpath: str | None - ) -> list[Track | BrowseFolder]: - """Browse My Mix tracks (must be called under _my_wave_lock). - - :param path: Full browse path. - :param sub_subpath: Sub-path part ('next' for load more, or track_id cursor). - :return: List of Track and optional BrowseFolder for "Load more". - """ - max_tracks_config = int( - self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type] - ) - batch_size_config = MY_WAVE_BATCH_SIZE - - # Effective limit on tracks to collect for this call: - # initial browse is capped to BROWSE_INITIAL_TRACKS to avoid marking - # extra tracks as "seen" that are never shown to the user. - effective_limit = min( - BROWSE_INITIAL_TRACKS if sub_subpath != "next" else max_tracks_config, - max_tracks_config, - ) - - # Root my_wave: fetch up to batch_size_config batches so Play adds more tracks. - # "Load more" always uses single next batch. - max_batches = batch_size_config if sub_subpath != "next" else 1 - - # Reset seen tracks on fresh browse (not "load more") - if sub_subpath != "next": - self._my_wave_seen_track_ids = set() - - 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 - total_track_count = 0 - - for _ in range(max_batches): - if total_track_count >= effective_limit: - break - - 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: - sent = await self.client.send_rotor_station_feedback( - ROTOR_STATION_MY_MIX, - "radioStarted", - batch_id=batch_id, - ) - if sent: - self._my_wave_radio_started_sent = True - first_track_id_this_batch = None - for yt in yandex_tracks: - if total_track_count >= effective_limit: - break - - track = self._parse_my_wave_track(yt, self._my_wave_seen_track_ids) - if track is None: - continue - all_tracks.append(track) - total_track_count += 1 - - track_id = track.item_id.split(RADIO_TRACK_ID_SEP, 1)[0] - if first_track_id_this_batch is None: - first_track_id_this_batch = track_id - - if first_track_id_this_batch is not None: - self._my_wave_last_track_id = first_track_id_this_batch - if ( - first_track_id_this_batch is None - or not batch_id - or not yandex_tracks - or total_track_count >= effective_limit - ): - break - queue = first_track_id_this_batch - - # Only show "Load more" if we haven't reached the limit and there's more data - if last_batch_id and total_track_count < max_tracks_config: - names = self._get_browse_names() - next_name = "Ещё" if names == 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 - - def _parse_my_wave_track(self, yt: Any, seen_ids: set[str]) -> Track | None: - """Parse a Kion track into a My Mix Track with composite item_id. - - Extracts the track_id, checks for duplicates in the seen_ids set, - sets composite item_id (track_id@station_id), and updates provider_mappings. - Callers using shared state must hold _my_wave_lock. - - :param yt: Kion track object from rotor station response. - :param seen_ids: Set of already-seen track IDs to check and update. - :return: Parsed Track with composite item_id, or None if duplicate/invalid. - """ - try: - t = parse_track(self, yt) - except InvalidDataError as err: - self.logger.debug("Error parsing My Mix track: %s", err) - return None - - track_id = str(yt.id) if hasattr(yt, "id") and yt.id else getattr(yt, "track_id", None) - if not track_id: - return t - - if track_id in seen_ids: - self.logger.debug("Skipping duplicate My Mix track: %s", track_id) - return None - - seen_ids.add(track_id) - t.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_MIX}" - for pm in t.provider_mappings: - if pm.provider_instance == self.instance_id: - pm.item_id = t.item_id - break - return t - - @use_cache(3600) - async def _validate_tag(self, tag_slug: str) -> bool: - """Check if a tag has playlists by calling client.get_tag_playlists(). - - :param tag_slug: Tag identifier (e.g. 'chill', '80s'). - :return: True if the tag has at least one playlist. - """ - try: - playlists = await self.client.get_tag_playlists(tag_slug) - return len(playlists) > 0 - except Exception as err: - self.logger.debug("Tag validation failed for %s: %s", tag_slug, err) - return False - - @use_cache(3600) - async def _get_valid_tags_for_category(self, category: str) -> list[str]: - """Get validated tags for a category (only those with playlists). - - Combines hardcoded tags from the category lists with any landing-discovered - tags, validates each by calling client.tags(), and returns only those with - playlists. - - :param category: Category name ('mood', 'activity', 'era', 'genres'). - :return: List of valid tag slugs. - """ - category_lists: dict[str, list[str]] = { - "mood": list(TAG_CATEGORY_MOOD), - "activity": list(TAG_CATEGORY_ACTIVITY), - "era": list(TAG_CATEGORY_ERA), - "genres": list(TAG_CATEGORY_GENRES), - } - tags = category_lists.get(category, []) - - # Add landing-discovered tags for this category - try: - landing_tags = await self.client.get_landing_tags() - for slug, _title in landing_tags: - cat = TAG_SLUG_CATEGORY.get(slug, "mood") - if cat == category and slug not in tags: - tags.append(slug) - except Exception as err: - self.logger.debug("Landing tag discovery failed: %s", err) - - # Validate tags in parallel with bounded concurrency - sem = asyncio.Semaphore(8) - - async def _check(tag: str) -> str | None: - async with sem: - return tag if await self._validate_tag(tag) else None - - results = await asyncio.gather(*[_check(tag) for tag in tags]) - return [tag for tag in results if tag is not None] - - @use_cache(3600) - async def _get_discovered_tags(self, locale: str) -> list[tuple[str, str]]: - """Get all available tags by combining hardcoded tags with landing discovery. - - Starts with all hardcoded tags from category lists, adds landing-discovered - tags, validates each via client.tags(), and returns only those with playlists. - Results are cached for 1 hour. The locale parameter is included in the cache - key so that a locale change invalidates the cached result. - - :param locale: Current metadata locale (used as part of cache key). - :return: List of (slug, title) tuples for tags that have playlists. - """ - names = self._get_browse_names() - - # Collect all hardcoded tags (non-seasonal) - all_tags: dict[str, str] = {} - for slug, cat in TAG_SLUG_CATEGORY.items(): - if cat != "seasonal": - all_tags[slug] = names.get(slug, slug.title()) - - # Add landing-discovered tags - try: - landing_tags = await self.client.get_landing_tags() - for slug, title in landing_tags: - if slug not in all_tags: - all_tags[slug] = title - except Exception as err: - self.logger.debug("Failed to discover tags from landing API: %s", err) - - # Validate tags in parallel with bounded concurrency - sem = asyncio.Semaphore(8) - - async def _check(slug: str) -> bool: - async with sem: - return await self._validate_tag(slug) - - tag_items = list(all_tags.items()) - results = await asyncio.gather(*[_check(slug) for slug, _ in tag_items]) - return [ - (slug, title) for (slug, title), valid in zip(tag_items, results, strict=True) if valid - ] - - async def _get_discovered_tag_slugs(self) -> set[str]: - """Get set of all valid tag slugs (cached). - - :return: Set of tag slug strings that have playlists. - """ - discovered = await self._get_discovered_tags(self.mass.metadata.locale or "en_US") - return {slug for slug, _title in discovered} - - async def _browse_for_you( - self, path: str, path_parts: list[str] - ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Browse «For You» folder — shows Picks and Mixes sub-folders. - - :param path: Full browse path. - :param path_parts: Split path parts after ://. - :return: List of sub-folders (Picks, Mixes). - """ - names = self._get_browse_names() - # Strip the for_you segment to build child paths that route to picks/mixes - # Path format: ...//for_you → child paths should be ...//picks, ...//mixes - # We build base from the root (before for_you) by dropping the last segment. - base_parts = path.split("//", 1) - root_base = (base_parts[0] + "//") if len(base_parts) > 1 else path.rstrip("/") + "/" - - if len(path_parts) == 1: - return [ - BrowseFolder( - item_id="picks", - provider=self.instance_id, - path=f"{root_base}picks", - name=names.get("picks", "Picks"), - is_playable=False, - ), - BrowseFolder( - item_id="mixes", - provider=self.instance_id, - path=f"{root_base}mixes", - name=names.get("mixes", "Mixes"), - is_playable=False, - ), - ] - # Deeper path: delegate to picks or mixes handler via canonical paths - return await super().browse(path) - - async def _browse_collection( - self, path: str - ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Browse «Collection» folder — shows library sub-folders (tracks/artists/albums/playlists). - - :param path: Full browse path. - :return: List of library sub-folders. - """ - names = self._get_browse_names() - base_parts = path.split("//", 1) - root_base = (base_parts[0] + "//") if len(base_parts) > 1 else path.rstrip("/") + "/" - - folders: list[BrowseFolder] = [] - if ProviderFeature.LIBRARY_TRACKS in self.supported_features: - folders.append( - BrowseFolder( - item_id="tracks", - provider=self.instance_id, - path=f"{root_base}tracks", - name=names["tracks"], - is_playable=True, - ) - ) - if ProviderFeature.LIBRARY_ARTISTS in self.supported_features: - folders.append( - BrowseFolder( - item_id="artists", - provider=self.instance_id, - path=f"{root_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"{root_base}albums", - name=names["albums"], - is_playable=True, - ) - ) - if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features: - folders.append( - BrowseFolder( - item_id="playlists", - provider=self.instance_id, - path=f"{root_base}playlists", - name=names["playlists"], - is_playable=True, - ) - ) - return folders - - async def _browse_picks( - self, path: str, path_parts: list[str] - ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Browse picks folder using hardcoded tags validated against the API. - - Tags are sourced from hardcoded category lists and landing API discovery, - then validated via client.tags() to ensure they have playlists. - Only categories with at least one valid tag are shown. - - :param path: Full browse path. - :param path_parts: Split path parts after ://. - :return: List of folders or playlists. - """ - names = self._get_browse_names() - base = path.rstrip("/") + "/" - - # Get validated tags - discovered = await self._get_discovered_tags(self.mass.metadata.locale or "en_US") - - # Categorize valid tags - categorized: dict[str, list[tuple[str, str]]] = {} - for slug, title in discovered: - cat = TAG_SLUG_CATEGORY.get(slug, "mood") - # Skip seasonal tags — they belong in mixes, not picks - if cat == "seasonal": - continue - categorized.setdefault(cat, []).append((slug, title)) - - # Sort tags within each category by preferred order - for cat, cat_tags in categorized.items(): - order = TAG_CATEGORY_ORDER.get(cat, []) - order_map = {s: i for i, s in enumerate(order)} - cat_tags.sort(key=lambda t: order_map.get(t[0], len(order))) - - # picks/ - show category folders (only those with valid tags) - if len(path_parts) == 1: - category_display_order = ["mood", "activity", "era", "genres"] - folders: list[BrowseFolder] = [] - for cat in category_display_order: - if cat in categorized: - folders.append( - BrowseFolder( - item_id=cat, - provider=self.instance_id, - path=f"{base}{cat}", - name=names.get(cat, cat.title()), - is_playable=False, - ) - ) - # Show any extra categories not in the standard order - for cat in categorized: - if cat not in category_display_order: - folders.append( - BrowseFolder( - item_id=cat, - provider=self.instance_id, - path=f"{base}{cat}", - name=names.get(cat, cat.title()), - is_playable=False, - ) - ) - return folders - - category: str | None = path_parts[1] if len(path_parts) > 1 else None - tag: str | None = path_parts[2] if len(path_parts) > 2 else None - - self.logger.debug( - "Browse picks: path=%s, category=%s, tag=%s", - path, - category, - tag, - ) - - # picks/category/ - show valid tag folders for this category - if category and not tag: - category_tags = categorized.get(category, []) - folders = [] - for slug, title in category_tags: - folders.append( - BrowseFolder( - item_id=slug, - provider=self.instance_id, - path=f"{base}{slug}", - name=names.get(slug, title), - is_playable=False, - ) - ) - self.logger.debug("Returning %d tag folders for category %s", len(folders), category) - return folders - - # picks/category/tag - show playlists for the tag - if tag: - discovered_slugs = {slug for slug, _ in discovered} - if tag in discovered_slugs: - self.logger.debug("Fetching playlists for tag: %s", tag) - return await self._get_tag_playlists_as_browse(tag) - - self.logger.debug("No match found, returning empty list") - return [] - - async def _browse_mixes( - self, path: str, path_parts: list[str] - ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Browse mixes folder (seasonal collections) using hardcoded tags. - - Uses TAG_MIXES directly and validates each tag via client.tags() - to check if it has playlists. Does not depend on landing API discovery. - - :param path: Full browse path. - :param path_parts: Split path parts after ://. - :return: List of folders or playlists. - """ - names = self._get_browse_names() - base = path.rstrip("/") + "/" - - # Validate seasonal tags in parallel (no landing dependency) - sem = asyncio.Semaphore(5) - - async def _check(tag: str) -> str | None: - async with sem: - return tag if await self._validate_tag(tag) else None - - results = await asyncio.gather(*[_check(t) for t in TAG_MIXES]) - available_mixes = [t for t in results if t is not None] - - # mixes/ - show seasonal folders (only valid ones) - if len(path_parts) == 1: - folders = [] - for t in available_mixes: - folders.append( - BrowseFolder( - item_id=t, - provider=self.instance_id, - path=f"{base}{t}", - name=names.get(t, t.title()), - is_playable=False, - ) - ) - return folders - - # mixes/tag - show playlists for the tag - tag = path_parts[1] if len(path_parts) > 1 else None - if tag and tag in TAG_MIXES: - return await self._get_tag_playlists_as_browse(tag) - - return [] - - def _get_wave_state(self, station_id: str) -> _WaveState: - """Get or create per-station wave state. - - :param station_id: Rotor station ID (e.g. 'genre:rock', 'mood:chill'). - :return: _WaveState instance for this station. - """ - return self._wave_states.setdefault(station_id, _WaveState()) - - async def _browse_waves( - self, path: str, path_parts: list[str] - ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Browse waves folder (rotor stations by genre/mood/activity/epoch/local). - - Fetches available stations from the Kion rotor API and groups them by category. - - :param path: Full browse path. - :param path_parts: Split path parts after ://. - :return: List of folders or tracks. - """ - names = self._get_browse_names() - base = path.rstrip("/") + "/" - - locale = (self.mass.metadata.locale or "en_US").lower() - language = "ru" if locale.startswith("ru") else "en" - - all_stations = await self.client.get_wave_stations(language) - - # Group stations by category, preserving image_url - categorized: dict[str, list[tuple[str, str, str | None]]] = {} - for station_id, cat_key, name, image_url in all_stations: - categorized.setdefault(cat_key, []).append((station_id, name, image_url)) - - # waves/ — show category folders - if len(path_parts) == 1: - folders: list[BrowseFolder] = [] - # Personalized "My Mixes" first — only show if dashboard returns stations - dashboard_stations = await self._get_dashboard_stations_cached() - if dashboard_stations: - folders.append( - BrowseFolder( - item_id=MY_WAVES_FOLDER_ID, - provider=self.instance_id, - path=f"{base}{MY_WAVES_FOLDER_ID}", - name=names.get(MY_WAVES_FOLDER_ID, "My Mixes"), - is_playable=False, - ) - ) - # Featured Mixes — only show if landing-blocks/waves returns data - waves_landing = await self._get_waves_landing_cached() - if waves_landing: - folders.append( - BrowseFolder( - item_id=WAVES_LANDING_FOLDER_ID, - provider=self.instance_id, - path=f"{base}{WAVES_LANDING_FOLDER_ID}", - name=names.get(WAVES_LANDING_FOLDER_ID, "Featured Mixes"), - is_playable=False, - ) - ) - for cat in WAVE_CATEGORY_DISPLAY_ORDER: - if cat in categorized: - folders.append( - BrowseFolder( - item_id=cat, - provider=self.instance_id, - path=f"{base}{cat}", - name=names.get(cat, cat.title()), - is_playable=False, - ) - ) - # Append any categories returned by API that aren't in the predefined order - for cat in categorized: - if cat not in WAVE_CATEGORY_DISPLAY_ORDER: - folders.append( - BrowseFolder( - item_id=cat, - provider=self.instance_id, - path=f"{base}{cat}", - name=names.get(cat, cat.title()), - is_playable=False, - ) - ) - return folders - - category: str | None = path_parts[1] if len(path_parts) > 1 else None - tag: str | None = path_parts[2] if len(path_parts) > 2 else None - - # waves/my_waves/ — show personalized stations from dashboard - if category == MY_WAVES_FOLDER_ID and not tag: - return await self._browse_my_waves_stations(path) - - # waves/waves_landing/... — redirect to Featured Mixes browse - if category == WAVES_LANDING_FOLDER_ID: - return await self._browse_waves_landing(path, path_parts[1:]) - - # waves/my_waves/[/next] — play a specific personal station - # The full station_id has format "genre:allrock", not "my_waves:allrock". - # Resolve by matching against dashboard stations cache. - if category == MY_WAVES_FOLDER_ID and tag: - dashboard_stations = await self._get_dashboard_stations_cached() - for sid, _, _ in dashboard_stations: - sid_tag = sid.split(":", 1)[1] if ":" in sid else sid - if sid_tag == tag: - return await self._browse_wave_station(sid, path=path) - # Fallback: try tag as direct station_id (e.g. "genre:allrock" passed verbatim) - if ":" in tag: - return await self._browse_wave_station(tag, path=path) - return [] - - # waves// — show station folders with artwork - if category and not tag: - cat_stations = categorized.get(category, []) - folders = [] - for station_id, station_name, image_url in cat_stations: - tag_part = station_id.split(":", 1)[1] if ":" in station_id else station_id - station_image: MediaItemImage | None = None - if image_url: - station_image = MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=self.instance_id, - remotely_accessible=True, - ) - folders.append( - BrowseFolder( - item_id=station_id, - provider=self.instance_id, - path=f"{base}{tag_part}", - name=station_name, - is_playable=True, - image=station_image, - ) - ) - return folders - - # waves//[/next] — stream tracks from rotor station - if category and tag: - station_id = f"{category}:{tag}" - return await self._browse_wave_station(station_id, path=path) - - return [] - - @use_cache(600) - async def _get_dashboard_stations_cached(self) -> list[tuple[str, str, str | None]]: - """Get personalized dashboard stations, cached for 10 minutes. - - :return: List of (station_id, name, image_url) tuples. - """ - return await self.client.get_dashboard_stations() - - async def _browse_my_waves_stations(self, path: str) -> list[BrowseFolder]: - """Browse personalized wave stations from rotor/stations/dashboard. - - Names are resolved from the non-personalized station list so that - stations show their actual genre/mood name (e.g. "Рок") rather than - the generic "Мой микс" label that the dashboard API returns. - - :param path: Full browse path (used to build sub-paths). - :return: List of playable BrowseFolder items, one per station. - """ - stations = await self._get_dashboard_stations_cached() - - # Build a name map from the non-personalized list for proper localized names. - locale = (self.mass.metadata.locale or "en_US").lower() - language = "ru" if locale.startswith("ru") else "en" - all_stations = await self.client.get_wave_stations(language) - station_name_map: dict[str, str] = {sid: name for sid, _, name, _ in all_stations} - - base = path.rstrip("/") + "/" - folders: list[BrowseFolder] = [] - for station_id, fallback_name, image_url in stations: - # Use full station_id (e.g. "genre:rock") in path to avoid collisions - # when two stations share the same tag but differ by category. - # The routing fallback (if ":" in tag) handles this correctly. - name = station_name_map.get(station_id, fallback_name) - station_image: MediaItemImage | None = None - if image_url: - station_image = MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=self.instance_id, - remotely_accessible=True, - ) - folders.append( - BrowseFolder( - item_id=station_id, - provider=self.instance_id, - path=f"{base}{station_id}", - name=name, - is_playable=True, - image=station_image, - ) - ) - return folders - - async def _browse_wave_station( - self, station_id: str, path: str = "" - ) -> list[Track | BrowseFolder]: - """Browse a rotor wave station and return tracks. - - Fetches tracks from the rotor station, deduplicates within the current session, - and sends radioStarted feedback on first call. Appends a "Load more" BrowseFolder - at the end so MA can continue fetching the next batch automatically (radio mode). - - :param station_id: Rotor station ID (e.g. 'genre:rock', 'mood:chill'). - :param path: Current browse path, used to construct the "Load more" next path. - :return: List of Track objects with composite item_id (track_id@station_id), - followed by a "Load more" BrowseFolder if more tracks are available. - """ - state = self._get_wave_state(station_id) - async with state.lock: - max_tracks = int( - self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type] - ) - - self.logger.debug( - "Browse wave station: station_id=%s path=%s last_track_id=%s", - station_id, - path, - state.last_track_id, - ) - yandex_tracks, batch_id = await self.client.get_rotor_station_tracks( - station_id, queue=state.last_track_id - ) - if batch_id: - state.batch_id = batch_id - - if not state.radio_started_sent and yandex_tracks: - sent = await self.client.send_rotor_station_feedback( - station_id, - "radioStarted", - batch_id=batch_id, - ) - if sent: - state.radio_started_sent = True - - tracks: list[Track] = [] - first_track_id: str | None = None - for yt in yandex_tracks: - if len(state.seen_track_ids) >= max_tracks: - break - track = self._parse_my_wave_track(yt, state.seen_track_ids) - if track is None: - continue - # Override station_id in composite item_id to reflect this specific station - old_item_id = track.item_id - track_id = old_item_id.split(RADIO_TRACK_ID_SEP, 1)[0] - track.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{station_id}" - # Keep provider mappings in sync with the new item_id - for pm in getattr(track, "provider_mappings", []): - if ( - getattr(pm, "item_id", None) == old_item_id - and getattr(pm, "provider_instance", None) == self.instance_id - ): - pm.item_id = track.item_id - if first_track_id is None: - first_track_id = track_id - tracks.append(track) - - if first_track_id is not None: - state.last_track_id = first_track_id - - self.logger.debug( - "Wave station %s returned %d tracks: %s", - station_id, - len(tracks), - [t.item_id.split(RADIO_TRACK_ID_SEP, 1)[0] for t in tracks[:5]], - ) - result: list[Track | BrowseFolder] = list(tracks) - - # Append "Load more" sentinel so MA knows to call browse again for next batch. - # This mirrors the My Mix mechanism and enables continuous radio playback. - if tracks and len(state.seen_track_ids) < max_tracks and path: - names = self._get_browse_names() - next_name = "Ещё" if names == BROWSE_NAMES_RU else "Load more" - # Append /next to the current path (same pattern as _browse_my_wave). - # This makes each "Load more" path unique (e.g. /next/next/next...) - # so MA never serves a cached result for subsequent presses. - result.append( - BrowseFolder( - item_id="next", - provider=self.instance_id, - path=f"{path.rstrip('/')}/next", - name=next_name, - is_playable=False, - ) - ) - - return result - - @staticmethod - def _extract_wave_item_cover(item: dict[str, Any]) -> tuple[str | None, str | None]: - """Extract cover URI and background color from a wave/mix item. - - :param item: Wave or mix item dict from the API. - :return: (cover_uri, bg_color) tuple where bg_color is a hex string or None. - """ - agent_uri = item.get("agent", {}).get("cover", {}).get("uri", "") - cover_uri = agent_uri or item.get("compact_image_url") - bg_color = item.get("colors", {}).get("average") - return cover_uri, bg_color - - @use_cache(3600) - async def _get_mixes_waves_cached(self) -> list[dict[str, Any]] | None: - """Get AI Wave Set data from /landing-blocks/mixes-waves, cached for 1 hour. - - :return: List of mix category dicts from the API, or None on error. - """ - return await self.client.get_mixes_waves() - - @use_cache(3600) - async def _get_waves_landing_cached(self) -> list[dict[str, Any]] | None: - """Get Featured Mixes data from /landing-blocks/waves, cached for 1 hour. - - :return: List of wave category dicts from the API, or None on error. - """ - return await self.client.get_waves_landing() - - async def _browse_waves_landing( - self, path: str, path_parts: list[str] - ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Browse Featured Mixes (from /landing-blocks/waves). - - :param path: Full browse path. - :param path_parts: Split path parts after ://. - :return: List of folders or tracks. - """ - waves_data = await self._get_waves_landing_cached() - return await self._browse_wave_categories( - path, path_parts, waves_data or [], WAVES_LANDING_FOLDER_ID - ) - - async def _browse_wave_categories( - self, - path: str, - path_parts: list[str], - categories_data: list[dict[str, Any]], - id_prefix: str, - ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Browse wave-like category folders and their station items. - - Shared logic for both 'my_waves_set' browse trees: - - Level 1 (e.g. my_waves_set/): category folders - - Level 2 (e.g. my_waves_set/ai-sets/): playable station folders with artwork - - Level 3+ (e.g. my_waves_set/ai-sets/genre:rock[/next]): track listing - - :param path: Full browse path. - :param path_parts: Split path parts after ://. - :param categories_data: List of category dicts from the API. - :param id_prefix: Prefix for BrowseFolder item_id (e.g. 'my_waves_set'). - :return: List of folders or tracks. - """ - base = path.rstrip("/") + "/" - - if not categories_data: - return [] - - # Level 1 → category folders - if len(path_parts) == 1: - folders: list[BrowseFolder] = [] - for wave_category in categories_data: - cat_id = wave_category.get("id", "") - cat_title = wave_category.get("title", "") - items = wave_category.get("items", []) - if not items or not cat_id: - continue - display_name = cat_title.capitalize() if cat_title else cat_id.capitalize() - folders.append( - BrowseFolder( - item_id=f"{id_prefix}_{cat_id}", - provider=self.instance_id, - path=f"{base}{cat_id}", - name=display_name, - is_playable=False, - ) - ) - return folders - - category_id = path_parts[1] if len(path_parts) > 1 else None - if not category_id: - return [] - - # Level 3+ → stream tracks from rotor station - if len(path_parts) > 2: - station_id = path_parts[2] - return await self._browse_wave_station(station_id, path=path) - - # Level 2 → playable station folders with artwork - for wave_category in categories_data: - if wave_category.get("id") == category_id: - items = wave_category.get("items", []) - result: list[BrowseFolder] = [] - for item in items: - station_id = item.get("station_id", "") - title = item.get("title", "") - if not station_id or not title: - continue - cover_uri, bg_color = self._extract_wave_item_cover(item) - image: MediaItemImage | None = None - if cover_uri: - if cover_uri.startswith("http"): - img_url: str = cover_uri.replace("%%", IMAGE_SIZE_MEDIUM) - else: - raw = get_image_url(cover_uri) - img_url = "" if raw is None else raw - if img_url: - if bg_color: - # Append bg_color as URL fragment for cache-key uniqueness. - # MA will call resolve_image() to composite the transparent PNG. - if len(self._wave_bg_colors) > 200: - self._wave_bg_colors.clear() - img_url = f"{img_url}#{bg_color.lstrip('#')}" - self._wave_bg_colors[img_url] = bg_color - image = MediaItemImage( - type=ImageType.THUMB, - path=img_url, - provider=self.instance_id, - remotely_accessible=bg_color is None, - ) - result.append( - BrowseFolder( - item_id=station_id, - provider=self.instance_id, - path=f"{base}{station_id}", - name=title, - is_playable=True, - image=image, - ) - ) - return result - - return [] - - async def _browse_vibe_sets( - self, path: str, path_parts: list[str] - ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Browse AI Mix Sets (from /landing-blocks/mixes-waves). - - :param path: Full browse path. - :param path_parts: Split path parts after ://. - :return: List of folders or tracks. - """ - mixes_data = await self._get_mixes_waves_cached() - return await self._browse_wave_categories( - path, path_parts, mixes_data or [], MY_WAVES_SET_FOLDER_ID - ) - - @use_cache(600) - async def _get_tag_playlists_as_browse( - self, tag_id: str - ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Get playlists for a tag and return as browse items. - - :param tag_id: Tag identifier (e.g. 'chill', '80s'). - :return: List of Playlist objects. - """ - self.logger.debug("Fetching playlists for tag: %s", tag_id) - playlists = await self.client.get_tag_playlists(tag_id) - self.logger.debug("Got %d playlists for tag %s", len(playlists), tag_id) - result: list[Playlist] = [] - for playlist in playlists: - try: - result.append(parse_playlist(self, playlist)) - except InvalidDataError as err: - self.logger.debug("Error parsing tag playlist: %s", err) - self.logger.debug("Parsed %d playlists for tag %s", len(result), tag_id) - return result - - # Search - - @use_cache(3600 * 24 * 14) - async def search( - self, search_query: str, media_types: list[MediaType], limit: int = 5 - ) -> SearchResults: - """Perform search on KION Music. - - :param search_query: The search query. - :param media_types: List of media types to search for. - :param limit: Maximum number of results per type. - :return: SearchResults with found items. - """ - result = SearchResults() - - # Determine search type based on requested media types - # Map MediaType to Kion API search type - type_mapping = { - MediaType.TRACK: "track", - MediaType.ALBUM: "album", - MediaType.ARTIST: "artist", - MediaType.PLAYLIST: "playlist", - } - requested_types = [type_mapping[mt] for mt in media_types if mt in type_mapping] - - # Use specific type if only one requested, otherwise search all - search_type = requested_types[0] if len(requested_types) == 1 else "all" - - search_result = await self.client.search(search_query, search_type=search_type, limit=limit) - if not search_result: - return result - - # Parse tracks - if MediaType.TRACK in media_types and search_result.tracks: - for track in search_result.tracks.results[:limit]: - try: - result.tracks = [*result.tracks, parse_track(self, track)] - except InvalidDataError as err: - self.logger.debug("Error parsing track: %s", err) - - # Parse albums - if MediaType.ALBUM in media_types and search_result.albums: - for album in search_result.albums.results[:limit]: - try: - result.albums = [*result.albums, parse_album(self, album)] - except InvalidDataError as err: - self.logger.debug("Error parsing album: %s", err) - - # Parse artists - if MediaType.ARTIST in media_types and search_result.artists: - for artist in search_result.artists.results[:limit]: - try: - result.artists = [*result.artists, parse_artist(self, artist)] - except InvalidDataError as err: - self.logger.debug("Error parsing artist: %s", err) - - # Parse playlists - if MediaType.PLAYLIST in media_types and search_result.playlists: - for playlist in search_result.playlists.results[:limit]: - try: - result.playlists = [*result.playlists, parse_playlist(self, playlist)] - except InvalidDataError as err: - self.logger.debug("Error parsing playlist: %s", err) - - return result - - # Get single items - - @use_cache(3600 * 24 * 30) - async def get_artist(self, prov_artist_id: str) -> Artist: - """Get artist details by ID. - - :param prov_artist_id: The provider artist ID. - :return: Artist object. - :raises MediaNotFoundError: If artist not found. - """ - artist = await self.client.get_artist(prov_artist_id) - if not artist: - raise MediaNotFoundError(f"Artist {prov_artist_id} not found") - return parse_artist(self, artist) - - @use_cache(3600 * 24 * 30) - async def get_album(self, prov_album_id: str) -> Album: - """Get album details by ID. - - :param prov_album_id: The provider album ID. - :return: Album object. - :raises MediaNotFoundError: If album not found. - """ - album = await self.client.get_album(prov_album_id) - if not album: - raise MediaNotFoundError(f"Album {prov_album_id} not found") - return parse_album(self, album) - - async def get_track(self, prov_track_id: str) -> Track: - """Get track details by ID. - - Supports composite item_id (track_id@station_id) for My Mix tracks; - only the track_id part is used for the API. Normalizes the ID before - caching to avoid duplicate cache entries. - - :param prov_track_id: The provider track ID (or track_id@station_id). - :return: Track object. - :raises MediaNotFoundError: If track not found. - """ - track_id, _ = _parse_radio_item_id(prov_track_id) - return await self._get_track_cached(track_id) - - @use_cache(3600 * 24 * 30) - async def _get_track_cached(self, track_id: str) -> Track: - """Get track details by normalized ID (cached). - - :param track_id: Normalized track ID (without station suffix). - :return: Track object. - :raises MediaNotFoundError: If track not found. - """ - yandex_track = await self.client.get_track(track_id) - if not yandex_track: - raise MediaNotFoundError(f"Track {track_id} not found") - - # Use the already-fetched track object to avoid a duplicate API call - lyrics, lyrics_synced = await self.client.get_track_lyrics_from_track(yandex_track) - - return parse_track(self, yandex_track, lyrics=lyrics, lyrics_synced=lyrics_synced) - - async def get_playlist(self, prov_playlist_id: str) -> Playlist: - """Get playlist details by ID. - - Supports virtual playlists MY_WAVE_PLAYLIST_ID (My Mix) and - LIKED_TRACKS_PLAYLIST_ID (Liked Tracks). Real playlists use format "owner_id:kind". - - :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind", - my_wave, or liked_tracks). - :return: Playlist object. - :raises MediaNotFoundError: If playlist not found. - """ - # Virtual playlists - not cached (locale-dependent names) - 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=get_canonical_provider_name(self), - provider_mappings={ - ProviderMapping( - item_id=MY_WAVE_PLAYLIST_ID, - provider_domain=self.domain, - provider_instance=self.instance_id, - is_unique=True, - ) - }, - is_editable=False, - ) - - if prov_playlist_id == LIKED_TRACKS_PLAYLIST_ID: - names = self._get_browse_names() - return Playlist( - item_id=LIKED_TRACKS_PLAYLIST_ID, - provider=self.instance_id, - name=names[LIKED_TRACKS_PLAYLIST_ID], - owner=get_canonical_provider_name(self), - provider_mappings={ - ProviderMapping( - item_id=LIKED_TRACKS_PLAYLIST_ID, - provider_domain=self.domain, - provider_instance=self.instance_id, - is_unique=True, - ) - }, - is_editable=False, - ) - - # Real playlists - use cached method - return await self._get_real_playlist(prov_playlist_id) - - @use_cache(3600 * 24 * 30) - async def _get_real_playlist(self, prov_playlist_id: str) -> Playlist: - """Get real playlist details by ID (cached). - - :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind"). - :return: Playlist object. - :raises MediaNotFoundError: If playlist not found. - """ - # 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) - else: - owner_id = str(self.client.user_id) - kind = prov_playlist_id - - playlist = await self.client.get_playlist(owner_id, kind) - if not playlist: - 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 Mix tracks for virtual playlist (uncached; uses cursor for page > 0). - - Fetches MY_WAVE_BATCH_SIZE Rotor API batches per page call to reduce - the number of round-trips when the player controller paginates through pages. - - :param page: Page number (0 = first batch, 1+ = next batches via queue cursor). - :return: List of Track objects for this page. - """ - async with self._my_wave_lock: - max_tracks_config = int( - self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type] - ) - - # Reset seen tracks on first page - if page == 0: - self._my_wave_seen_track_ids = set() - - queue: str | int | None = None - if page > 0: - queue = self._my_wave_playlist_next_cursor - if not queue: - return [] - - # Check if we've already reached the limit - if len(self._my_wave_seen_track_ids) >= max_tracks_config: - return [] - - tracks: list[Track] = [] - next_cursor: str | None = None - - # Fetch MY_WAVE_BATCH_SIZE Rotor API batches per page to reduce API round-trips - for _ in range(MY_WAVE_BATCH_SIZE): - if len(self._my_wave_seen_track_ids) >= max_tracks_config: - break - - 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: - sent = await self.client.send_rotor_station_feedback( - ROTOR_STATION_MY_MIX, - "radioStarted", - batch_id=batch_id, - ) - if sent: - self._my_wave_radio_started_sent = True - - if not yandex_tracks: - break - - first_track_id_this_batch = None - for yt in yandex_tracks: - if len(self._my_wave_seen_track_ids) >= max_tracks_config: - break - - track = self._parse_my_wave_track(yt, self._my_wave_seen_track_ids) - if track is None: - continue - - tracks.append(track) - track_id = track.item_id.split(RADIO_TRACK_ID_SEP, 1)[0] - if first_track_id_this_batch is None: - first_track_id_this_batch = track_id - - if first_track_id_this_batch is not None: - next_cursor = first_track_id_this_batch - queue = first_track_id_this_batch - else: - # All tracks in this batch were duplicates or failed to parse - break - - # Store cursor for next page call (None clears pagination so next call returns []) - self._my_wave_playlist_next_cursor = next_cursor - return tracks - - async def _get_liked_tracks_playlist_tracks(self, page: int) -> list[Track]: - """Get liked tracks for virtual playlist (sorted in reverse chronological order). - - :param page: Page number (0 = all tracks limited by config, >0 = empty for pagination). - :return: List of Track objects. - """ - # Liked tracks API returns all tracks at once, so only return tracks on page 0 - if page > 0: - return [] - - max_tracks_config = int( - self.config.get_value(CONF_LIKED_TRACKS_MAX_TRACKS) or 500 # type: ignore[arg-type] - ) - - # Fetch liked tracks (already sorted in reverse chronological order by api_client) - track_shorts = await self.client.get_liked_tracks() - if not track_shorts: - self.logger.debug("No liked tracks found") - return [] - - # Apply max tracks limit - track_shorts = track_shorts[:max_tracks_config] - - # Fetch full track details in batches - track_ids = [str(ts.track_id) for ts in track_shorts if ts.track_id] - - batch_size = TRACK_BATCH_SIZE - full_tracks = [] - for i in range(0, len(track_ids), batch_size): - batch_ids = track_ids[i : i + batch_size] - batch_result = await self.client.get_tracks(batch_ids) - full_tracks.extend(batch_result) - - # Create track ID to full track mapping by track ID directly - track_map = {} - for t in full_tracks: - if hasattr(t, "id") and t.id: - track_map[str(t.id)] = t - - # Parse tracks in the original order (reverse chronological) - tracks = [] - for track_id in track_ids: - # track_id may be compound "trackId:albumId", extract base ID for lookup - base_id = track_id.split(":")[0] if ":" in track_id else track_id - found = track_map.get(track_id) or track_map.get(base_id) - if found: - try: - tracks.append(parse_track(self, found)) - except InvalidDataError as err: - self.logger.debug("Error parsing liked track %s: %s", track_id, err) - - self.logger.debug("Liked tracks: fetched %s, parsed %s", len(track_shorts), len(tracks)) - return tracks - - # Get related items - - @use_cache(3600 * 24 * 30) - async def get_album_tracks(self, prov_album_id: str) -> list[Track]: - """Get album tracks. - - :param prov_album_id: The provider album ID. - :return: List of Track objects. - """ - album = await self.client.get_album_with_tracks(prov_album_id) - if not album or not album.volumes: - return [] - - tracks = [] - for volume_index, volume in enumerate(album.volumes): - for track_index, track in enumerate(volume): - try: - parsed_track = parse_track(self, track) - parsed_track.disc_number = volume_index + 1 - parsed_track.track_number = track_index + 1 - tracks.append(parsed_track) - except InvalidDataError as err: - 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 Kion Rotor station for this track. - - Uses rotor station track:{id} so MA radio mode gets Kion 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 - - async def recommendations(self) -> list[RecommendationFolder]: - """Get recommendations with multiple discovery folders. - - Returns My Mix, Feed (Made for You), Chart, New Releases, and - New Playlists sections. - - :return: List of recommendation folders. - """ - folders: list[RecommendationFolder] = [] - - folder = await self._get_my_wave_recommendations() - if folder: - folders.append(folder) - - folder = await self._get_feed_recommendations() - if folder: - folders.append(folder) - - folder = await self._get_chart_recommendations() - if folder: - folders.append(folder) - - folder = await self._get_new_releases_recommendations() - if folder: - folders.append(folder) - - folder = await self._get_new_playlists_recommendations() - if folder: - folders.append(folder) - - # Picks & Mixes recommendations - folder = await self._get_top_picks_recommendations() - if folder: - folders.append(folder) - - # Mood mix: select tag outside cache so rotation actually works - mood_tag = await self._pick_random_tag_for_category("mood") - if mood_tag: - folder = await self._get_mood_mix_recommendations(mood_tag) - if folder: - folders.append(folder) - - # Activity mix: select tag outside cache so rotation actually works - activity_tag = await self._pick_random_tag_for_category("activity") - if activity_tag: - folder = await self._get_activity_mix_recommendations(activity_tag) - if folder: - folders.append(folder) - - folder = await self._get_seasonal_mix_recommendations() - if folder: - folders.append(folder) - - return folders - - @use_cache(600) - async def _get_my_wave_recommendations(self) -> RecommendationFolder | None: - """Get My Mix recommendation folder with personalized tracks. - - :return: RecommendationFolder with My Mix tracks, or None if empty. - """ - max_tracks_config = int( - self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type] - ) - batch_size_config = MY_WAVE_BATCH_SIZE - - seen_track_ids: set[str] = set() - items: list[Track] = [] - queue: str | int | None = None - - for _ in range(batch_size_config): - if len(seen_track_ids) >= max_tracks_config: - break - - yandex_tracks, _ = await self.client.get_my_wave_tracks(queue=queue) - if not yandex_tracks: - break - - first_track_id_this_batch = None - for yt in yandex_tracks: - if len(seen_track_ids) >= max_tracks_config: - break - - track = self._parse_my_wave_track(yt, seen_ids=seen_track_ids) - if track is None: - continue - - items.append(track) - track_id = track.item_id.split(RADIO_TRACK_ID_SEP, 1)[0] - if first_track_id_this_batch is None: - first_track_id_this_batch = track_id - - queue = first_track_id_this_batch - if not queue: - break - - if not items: - return None - - initial_tracks_limit = DISCOVERY_INITIAL_TRACKS - if len(items) > initial_tracks_limit: - items = items[:initial_tracks_limit] - - names = self._get_browse_names() - 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(1800) - async def _get_feed_recommendations(self) -> RecommendationFolder | None: - """Get personalized feed playlists (Playlist of the Day, DejaVu, etc.). - - :return: RecommendationFolder with generated playlists, or None if unavailable. - """ - feed = await self.client.get_feed() - if not feed or not feed.generated_playlists: - return None - items: list[Playlist] = [] - for gen_playlist in feed.generated_playlists: - if gen_playlist.data and gen_playlist.ready: - try: - items.append(parse_playlist(self, gen_playlist.data)) - except InvalidDataError as err: - self.logger.debug("Error parsing feed playlist: %s", err) - if not items: - return None - names = self._get_browse_names() - return RecommendationFolder( - item_id="feed", - provider=self.instance_id, - name=names["feed"], - items=UniqueList(items), - icon="mdi-account-music", - ) - - @use_cache(3600) - async def _get_chart_recommendations(self) -> RecommendationFolder | None: - """Get chart tracks (hot tracks of the month). - - :return: RecommendationFolder with chart tracks, or None if unavailable. - """ - chart_info = await self.client.get_chart() - if not chart_info or not chart_info.chart: - return None - playlist = chart_info.chart - if not playlist.tracks: - return None - # TrackShort objects in chart context have .track (full Track) and .chart (position) - tracks: list[Track] = [] - for track_short in playlist.tracks[:20]: - track_obj = getattr(track_short, "track", None) - if not track_obj: - continue - try: - tracks.append(parse_track(self, track_obj)) - except InvalidDataError as err: - self.logger.debug("Error parsing chart track: %s", err) - if not tracks: - return None - names = self._get_browse_names() - return RecommendationFolder( - item_id="chart", - provider=self.instance_id, - name=names["chart"], - items=UniqueList(tracks), - icon="mdi-chart-line", - ) - - @use_cache(3600) - async def _get_new_releases_recommendations(self) -> RecommendationFolder | None: - """Get new album releases. - - :return: RecommendationFolder with new albums, or None if unavailable. - """ - releases = await self.client.get_new_releases() - if not releases or not releases.new_releases: - return None - # new_releases is a list of album IDs (int) — need to batch-fetch full details - album_ids = [str(aid) for aid in releases.new_releases[:20]] - if not album_ids: - return None - full_albums = await self.client.get_albums(album_ids) - if not full_albums: - return None - albums: list[Album] = [] - for album in full_albums: - try: - albums.append(parse_album(self, album)) - except InvalidDataError as err: - self.logger.debug("Error parsing new release album: %s", err) - if not albums: - return None - names = self._get_browse_names() - return RecommendationFolder( - item_id="new_releases", - provider=self.instance_id, - name=names["new_releases"], - items=UniqueList(albums), - icon="mdi-new-box", - ) - - @use_cache(3600) - async def _get_new_playlists_recommendations(self) -> RecommendationFolder | None: - """Get new editorial playlists. - - :return: RecommendationFolder with new playlists, or None if unavailable. - """ - result = await self.client.get_new_playlists() - if not result or not result.new_playlists: - return None - # new_playlists is a list of PlaylistId objects (uid, kind) — fetch full details - playlist_ids = [ - f"{pid.uid}:{pid.kind}" - for pid in result.new_playlists[:20] - if hasattr(pid, "uid") and hasattr(pid, "kind") - ] - if not playlist_ids: - return None - full_playlists = await self.client.get_playlists(playlist_ids) - if not full_playlists: - return None - playlists: list[Playlist] = [] - for playlist in full_playlists: - try: - playlists.append(parse_playlist(self, playlist)) - except InvalidDataError as err: - self.logger.debug("Error parsing new playlist: %s", err) - if not playlists: - return None - names = self._get_browse_names() - return RecommendationFolder( - item_id="new_playlists", - provider=self.instance_id, - name=names["new_playlists"], - items=UniqueList(playlists), - icon="mdi-playlist-star", - ) - - @use_cache(3600) - async def _get_top_picks_recommendations(self) -> RecommendationFolder | None: - """Get Top Picks recommendation folder (tag: top). - - :return: RecommendationFolder with top playlists, or None if unavailable. - """ - playlists = await self.client.get_tag_playlists("top") - if not playlists: - return None - items: list[Playlist] = [] - for playlist in playlists[:10]: - try: - items.append(parse_playlist(self, playlist)) - except InvalidDataError as err: - self.logger.debug("Error parsing top picks playlist: %s", err) - if not items: - return None - names = self._get_browse_names() - return RecommendationFolder( - item_id="top_picks", - provider=self.instance_id, - name=names.get("top_picks", "Top Picks"), - items=UniqueList(items), - icon="mdi-star", - ) - - async def _pick_random_tag_for_category(self, category: str) -> str | None: - """Pick a random valid tag for a category (not cached — enables rotation). - - :param category: Category name ('mood', 'activity', etc.). - :return: Random tag slug, or None if no valid tags. - """ - valid_tags = await self._get_valid_tags_for_category(category) - if not valid_tags: - return None - return random.choice(valid_tags) - - @use_cache(1800) - async def _get_mood_mix_recommendations(self, mood_tag: str) -> RecommendationFolder | None: - """Get Mood Mix recommendation folder for a specific tag. - - :param mood_tag: Preselected mood tag slug. - :return: RecommendationFolder with mood playlists, or None if unavailable. - """ - playlists = await self.client.get_tag_playlists(mood_tag) - if not playlists: - self.logger.debug("No playlists for mood tag %s, skipping recommendation", mood_tag) - return None - items: list[Playlist] = [] - for playlist in playlists[:8]: - try: - items.append(parse_playlist(self, playlist)) - except InvalidDataError as err: - self.logger.debug("Error parsing mood playlist: %s", err) - if not items: - return None - names = self._get_browse_names() - tag_name = names.get(mood_tag, mood_tag.title()) - return RecommendationFolder( - item_id="mood_mix", - provider=self.instance_id, - name=f"{names.get('mood_mix', 'Mood')}: {tag_name}", - items=UniqueList(items), - icon="mdi-emoticon-outline", - ) - - @use_cache(1800) - async def _get_activity_mix_recommendations( - self, activity_tag: str - ) -> RecommendationFolder | None: - """Get Activity Mix recommendation folder for a specific tag. - - :param activity_tag: Preselected activity tag slug. - :return: RecommendationFolder with activity playlists, or None if unavailable. - """ - playlists = await self.client.get_tag_playlists(activity_tag) - if not playlists: - self.logger.debug( - "No playlists for activity tag %s, skipping recommendation", activity_tag - ) - return None - items: list[Playlist] = [] - for playlist in playlists[:8]: - try: - items.append(parse_playlist(self, playlist)) - except InvalidDataError as err: - self.logger.debug("Error parsing activity playlist: %s", err) - if not items: - return None - names = self._get_browse_names() - tag_name = names.get(activity_tag, activity_tag.title()) - return RecommendationFolder( - item_id="activity_mix", - provider=self.instance_id, - name=f"{names.get('activity_mix', 'Activity')}: {tag_name}", - items=UniqueList(items), - icon="mdi-run", - ) - - @use_cache(3600 * 6) - async def _get_seasonal_mix_recommendations(self) -> RecommendationFolder | None: - """Get Seasonal Mix recommendation folder (based on current month). - - :return: RecommendationFolder with seasonal playlists, or None if unavailable. - """ - # Determine current season tag - current_month = datetime.now(tz=UTC).month - seasonal_tag = TAG_SEASONAL_MAP.get(current_month, "autumn") - - # Validate the seasonal tag; fall back to autumn if not available - if not await self._validate_tag(seasonal_tag): - seasonal_tag = "autumn" - - playlists = await self.client.get_tag_playlists(seasonal_tag) - if not playlists: - return None - items: list[Playlist] = [] - for playlist in playlists[:8]: - try: - items.append(parse_playlist(self, playlist)) - except InvalidDataError as err: - self.logger.debug("Error parsing seasonal playlist: %s", err) - if not items: - return None - names = self._get_browse_names() - tag_name = names.get(seasonal_tag, seasonal_tag.title()) - return RecommendationFolder( - item_id="seasonal_mix", - provider=self.instance_id, - name=f"{names.get('seasonal_mix', 'Seasonal')}: {tag_name}", - items=UniqueList(items), - icon="mdi-weather-sunny", - ) - - @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", - my_wave, or liked_tracks). - :param page: Page number for pagination. - :return: List of Track objects. - """ - self.logger.debug( - "get_playlist_tracks called: prov_playlist_id=%s, page=%s", prov_playlist_id, page - ) - - if prov_playlist_id == MY_WAVE_PLAYLIST_ID: - self.logger.debug("Fetching My Mix tracks") - return await self._get_my_wave_playlist_tracks(page) - - if prov_playlist_id == LIKED_TRACKS_PLAYLIST_ID: - self.logger.debug("Fetching Liked Tracks for virtual playlist") - result = await self._get_liked_tracks_playlist_tracks(page) - self.logger.debug("Liked Tracks playlist returned %s tracks", len(result)) - return result - - # KION 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: - return [] - - # 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) - else: - owner_id = str(self.client.user_id) - kind = prov_playlist_id - - playlist = await self.client.get_playlist(owner_id, kind) - if not playlist: - return [] - - # API sometimes returns playlist without tracks; fetch them explicitly if needed - tracks_list = playlist.tracks or [] - track_count = getattr(playlist, "track_count", None) or 0 - if not tracks_list and track_count > 0: - self.logger.debug( - "Playlist %s/%s: track_count=%s but no tracks in response, " - "calling fetch_tracks_async", - owner_id, - kind, - track_count, - ) - try: - tracks_list = await playlist.fetch_tracks_async() - except Exception as err: - self.logger.warning("fetch_tracks_async failed for %s/%s: %s", owner_id, kind, err) - if not tracks_list: - raise ResourceTemporarilyUnavailable( - "Playlist tracks not available; try again later" - ) - - if not tracks_list: - return [] - - # Kion returns TrackShort objects, we need to fetch full track info - track_ids = [ - str(track.track_id) if hasattr(track, "track_id") else str(track.id) - for track in tracks_list - if track - ] - if not track_ids: - return [] - - # Fetch full track details in batches to avoid timeouts - batch_size = TRACK_BATCH_SIZE - full_tracks = [] - for i in range(0, len(track_ids), batch_size): - batch = track_ids[i : i + batch_size] - batch_result = await self.client.get_tracks(batch) - if not batch_result: - self.logger.warning( - "Received empty result for playlist %s tracks batch %s-%s", - prov_playlist_id, - i, - i + len(batch) - 1, - ) - raise ResourceTemporarilyUnavailable( - "Playlist tracks not fully available; try again later" - ) - full_tracks.extend(batch_result) - - if track_ids and not full_tracks: - raise ResourceTemporarilyUnavailable("Failed to load track details; try again later") - - tracks = [] - for track in full_tracks: - try: - tracks.append(parse_track(self, track)) - except InvalidDataError as err: - self.logger.debug("Error parsing playlist track: %s", err) - return tracks - - @use_cache(3600 * 24 * 7) - async def get_artist_albums(self, prov_artist_id: str) -> list[Album]: - """Get artist's albums. - - :param prov_artist_id: The provider artist ID. - :return: List of Album objects. - """ - albums = await self.client.get_artist_albums(prov_artist_id) - result = [] - for album in albums: - try: - result.append(parse_album(self, album)) - except InvalidDataError as err: - self.logger.debug("Error parsing artist album: %s", err) - return result - - @use_cache(3600 * 24 * 7) - async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]: - """Get artist's top tracks. - - :param prov_artist_id: The provider artist ID. - :return: List of Track objects. - """ - tracks = await self.client.get_artist_tracks(prov_artist_id) - result = [] - for track in tracks: - try: - result.append(parse_track(self, track)) - except InvalidDataError as err: - self.logger.debug("Error parsing artist track: %s", err) - return result - - # Library methods - - async def get_library_artists(self) -> AsyncGenerator[Artist, None]: - """Retrieve library artists from KION Music.""" - artists = await self.client.get_liked_artists() - for artist in artists: - try: - yield parse_artist(self, artist) - except InvalidDataError as err: - self.logger.debug("Error parsing library artist: %s", err) - - async def get_library_albums(self) -> AsyncGenerator[Album, None]: - """Retrieve library albums from KION Music.""" - batch_size = TRACK_BATCH_SIZE - albums = await self.client.get_liked_albums(batch_size=batch_size) - for album in albums: - try: - yield parse_album(self, album) - except InvalidDataError as err: - self.logger.debug("Error parsing library album: %s", err) - - async def get_library_tracks(self) -> AsyncGenerator[Track, None]: - """Retrieve library tracks from KION Music.""" - track_shorts = await self.client.get_liked_tracks() - if not track_shorts: - return - - # Fetch full track details in batches - track_ids = [str(ts.track_id) for ts in track_shorts if ts.track_id] - batch_size = TRACK_BATCH_SIZE - for i in range(0, len(track_ids), batch_size): - batch_ids = track_ids[i : i + batch_size] - full_tracks = await self.client.get_tracks(batch_ids) - for track in full_tracks: - try: - yield parse_track(self, track) - except InvalidDataError as err: - self.logger.debug("Error parsing library track: %s", err) - - async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: - """Retrieve library playlists from KION Music. - - Includes virtual playlists (My Mix and Liked Tracks if enabled), user-created playlists, - and user-liked editorial playlists (returned by a separate API endpoint). - """ - yield await self.get_playlist(MY_WAVE_PLAYLIST_ID) - yield await self.get_playlist(LIKED_TRACKS_PLAYLIST_ID) - seen_ids: set[str] = set() - # User-created playlists - playlists = await self.client.get_user_playlists() - for playlist in playlists: - try: - parsed = parse_playlist(self, playlist) - seen_ids.add(parsed.item_id) - yield parsed - except InvalidDataError as err: - self.logger.debug("Error parsing library playlist: %s", err) - # User-liked editorial playlists (not in users_playlists_list) - liked_playlists = await self.client.get_liked_playlists() - for playlist in liked_playlists: - try: - parsed = parse_playlist(self, playlist) - if parsed.item_id not in seen_ids: - yield parsed - except InvalidDataError as err: - self.logger.debug("Error parsing liked playlist: %s", err) - - # Library edit methods - - async def library_add(self, item: MediaItemType) -> bool: - """Add item to library. - - :param item: The media item to add. - :return: True if successful. - """ - 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(track_id) - if item.media_type == MediaType.ALBUM: - return await self.client.like_album(prov_item_id) - if item.media_type == MediaType.ARTIST: - return await self.client.like_artist(prov_item_id) - return False - - 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 (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(track_id) - if media_type == MediaType.ALBUM: - return await self.client.unlike_album(prov_item_id) - if media_type == MediaType.ARTIST: - return await self.client.unlike_artist(prov_item_id) - return False - - def _get_provider_item_id(self, item: MediaItemType) -> str | None: - """Get provider item ID from media item.""" - for mapping in item.provider_mappings: - if mapping.provider_instance == self.instance_id: - return mapping.item_id - return item.item_id if item.provider == self.instance_id else None - - # Streaming - - async def get_stream_details( - self, item_id: str, media_type: MediaType = MediaType.TRACK - ) -> StreamDetails: - """Get stream details for a track. - - :param item_id: The track ID (or track_id@station_id for My Mix). - :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 get_audio_stream( - self, streamdetails: StreamDetails, seek_position: int = 0 - ) -> AsyncGenerator[bytes, None]: - """Return the audio stream for the provider item. - - This method is called when StreamType.CUSTOM is used, enabling on-the-fly - decryption of encrypted FLAC streams without disk I/O. - - :param streamdetails: Stream details containing encrypted URL and decryption key. - :param seek_position: Seek position in seconds (not supported for encrypted streams). - :return: Async generator yielding decrypted audio chunks. - """ - async for chunk in self.streaming.get_audio_stream(streamdetails, seek_position): - yield chunk - - async def resolve_image(self, path: str) -> str | bytes: - """Resolve wave cover image with background color fill for transparent PNGs. - - If the image URL has an associated background color (stored in _wave_bg_colors), - downloads the PNG from Kion CDN and composites it on a solid color background - using Pillow, returning JPEG bytes. Falls back to the original URL on any error. - - :param path: Image URL (may include #rrggbb fragment used as cache key). - :return: Composited JPEG bytes, or original path string as fallback. - """ - bg_color = self._wave_bg_colors.get(path) - if not bg_color: - return path - - # Strip the #color fragment before fetching the actual image - fetch_url = path.split("#", maxsplit=1)[0] if "#" in path else path - try: - async with self.mass.http_session.get(fetch_url) as resp: - resp.raise_for_status() - raw = await resp.read() - except Exception as err: - self.logger.debug("Failed to fetch wave cover %s: %s", fetch_url, err) - return fetch_url - - def _composite() -> bytes: - bg_clean = bg_color.lstrip("#") - try: - r = int(bg_clean[0:2], 16) - g = int(bg_clean[2:4], 16) - b = int(bg_clean[4:6], 16) - except (ValueError, IndexError): - return raw - fg = PilImage.open(BytesIO(raw)).convert("RGBA") - bg = PilImage.new("RGBA", fg.size, (r, g, b, 255)) - bg.paste(fg, mask=fg) - out = BytesIO() - bg.convert("RGB").save(out, "JPEG", quality=92) - return out.getvalue() - - try: - return await asyncio.to_thread(_composite) - except Exception as err: - self.logger.debug("Wave cover composite failed for %s: %s", fetch_url, err) - return fetch_url - - 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 Mix. - - Sends trackStarted when the track is currently playing (is_playing=True). - trackFinished/skip are sent from on_streamed to use accurate seconds_streamed. - - Also auto-enables "Don't stop the music" for any queue playing a radio track - so that MA refills the queue via get_similar_tracks when < 5 tracks remain. - """ - # Radio feedback always enabled - if media_type != MediaType.TRACK: - return - track_id, station_id = _parse_radio_item_id(prov_item_id) - if not station_id: - return - # Auto-enable "Don't stop the music" on every on_played call for radio tracks. - # Calling on every invocation (not just is_playing=True) ensures it fires even - # for short tracks that finish before the 30-second periodic callback. - self._ensure_dont_stop_the_music(prov_item_id) - if is_playing: - if station_id == ROTOR_STATION_MY_MIX: - batch_id = self._my_wave_batch_id - else: - state = self._wave_states.get(station_id) - batch_id = state.batch_id if state else None - await self.client.send_rotor_station_feedback( - station_id, - "trackStarted", - track_id=track_id, - batch_id=batch_id, - ) - # Remove duplicate call that was under is_playing guard. - # _ensure_dont_stop_the_music is now called unconditionally above. - - def _ensure_dont_stop_the_music(self, prov_item_id: str) -> None: - """Enable 'Don't stop the music' on queues playing this specific radio item. - - Iterates all queues and enables the setting on queues whose current track - mapping matches this exact composite item_id (track_id@station_id) for this - provider instance. - - Also sets queue.radio_source directly to the current track because - enqueued_media_items is empty for BrowseFolder-initiated playback, which - normally prevents MA's auto-fill from triggering. Setting radio_source - directly bypasses that gap so _fill_radio_tracks runs when < 5 tracks remain. - """ - for queue in self.mass.player_queues: - current = queue.current_item - if current is None or current.media_item is None: - continue - item = current.media_item - # Match by provider instance and exact composite item_id - for mapping in getattr(item, "provider_mappings", []): - if ( - mapping.provider_instance == self.instance_id - and mapping.item_id == prov_item_id - ): - # Set radio_source directly so MA's fill mechanism works even when - # the queue was started from a BrowseFolder (enqueued_media_items empty). - if not queue.radio_source and isinstance(item, Track): - queue.radio_source = [item] - if not queue.dont_stop_the_music_enabled: - try: - self.mass.player_queues.set_dont_stop_the_music( - queue.queue_id, dont_stop_the_music_enabled=True - ) - self.logger.info( - "Auto-enabled 'Don't stop the music' for queue %s (radio station)", - queue.display_name, - ) - except Exception as err: - self.logger.debug( - "Could not enable 'Don't stop the music' for queue %s: %s", - queue.display_name, - err, - ) - break - - def _ensure_dont_stop_the_music_for_queue(self, queue_id: str | None) -> None: - """Enable 'Don't stop the music' for a specific queue by ID. - - Faster variant of _ensure_dont_stop_the_music used from on_streamed where - queue_id is available directly, avoiding iteration over all queues. - """ - if not queue_id: - return - queue = self.mass.player_queues.get(queue_id) - if queue is None: - return - current = queue.current_item - if current is None or current.media_item is None: - return - item = current.media_item - for mapping in getattr(item, "provider_mappings", []): - if ( - mapping.provider_instance == self.instance_id - and RADIO_TRACK_ID_SEP in mapping.item_id - ): - if not queue.radio_source and isinstance(item, Track): - queue.radio_source = [item] - if not queue.dont_stop_the_music_enabled: - try: - self.mass.player_queues.set_dont_stop_the_music( - queue_id, dont_stop_the_music_enabled=True - ) - self.logger.info( - "Auto-enabled 'Don't stop the music' for queue %s (radio)", - queue.display_name, - ) - except Exception as err: - self.logger.debug( - "Could not enable 'Don't stop the music' for queue %s: %s", - queue.display_name, - err, - ) - break - - async def on_streamed(self, streamdetails: StreamDetails) -> None: - """Report stream completion for My Mix rotor feedback. - - Sends trackFinished or skip with actual seconds_streamed so Kion - can improve recommendations. - """ - # Radio feedback always enabled - track_id, station_id = _parse_radio_item_id(streamdetails.item_id) - if not station_id: - return - # Also ensure Don't stop the music is active — on_streamed fires even for - # very short tracks and we have queue_id here directly. - self._ensure_dont_stop_the_music_for_queue(streamdetails.queue_id) - 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" - if station_id == ROTOR_STATION_MY_MIX: - batch_id = self._my_wave_batch_id - else: - state = self._wave_states.get(station_id) - batch_id = state.batch_id if state else None - await self.client.send_rotor_station_feedback( - station_id, - feedback_type, - track_id=track_id, - total_played_seconds=seconds, - batch_id=batch_id, - ) diff --git a/music_assistant/providers/kion_music/streaming.py b/music_assistant/providers/kion_music/streaming.py deleted file mode 100644 index 746ef003e6..0000000000 --- a/music_assistant/providers/kion_music/streaming.py +++ /dev/null @@ -1,598 +0,0 @@ -"""Streaming operations for KION Music.""" - -from __future__ import annotations - -import asyncio -from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING, Any - -import aiohttp -from aiohttp import ClientPayloadError, ServerDisconnectedError -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -from music_assistant_models.enums import ContentType, StreamType -from music_assistant_models.errors import MediaNotFoundError -from music_assistant_models.media_items import AudioFormat -from music_assistant_models.streamdetails import StreamDetails - -from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER - -from .constants import ( - CONF_QUALITY, - QUALITY_EFFICIENT, - QUALITY_HIGH, - QUALITY_SUPERB, - RADIO_TRACK_ID_SEP, -) - -if TYPE_CHECKING: - from yandex_music import DownloadInfo - - from .provider import KionMusicProvider - - -# Encrypted-stream tuning constants -_CHUNK_SIZE = 16384 # smaller than default 65536 for faster first-byte after retry -_STREAM_TIMEOUT = aiohttp.ClientTimeout(total=None, sock_read=30) -# Kion CDN drops TCP every ~6-7 MB per connection (observed via live traffic capture). -# By capping each Range request to 4 MB we stay well below that limit, so CDN drops -# should never occur during normal windowed playback. -_RANGE_WINDOW = 4 * 1024 * 1024 # 4 MB — must be a multiple of AES block size (16) -# Flat short delays for any residual TCP drops (network glitches within a 4 MB window) -_TCP_DROP_DELAYS = (0.5, 1.0, 2.0) -# Exponential delays for true network stalls (read timeout) -_STALL_DELAYS = (2.0, 4.0, 8.0) - - -class KionMusicStreamingManager: - """Manages KION Music streaming operations.""" - - def __init__(self, provider: KionMusicProvider) -> None: - """Initialize streaming manager. - - :param provider: The KION Music provider instance. - """ - self.provider = provider - self.client = provider.client - 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 Mix).""" - 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 or composite track_id@station_id for My Mix. - :return: StreamDetails for the track (item_id preserved for on_streamed). - :raises MediaNotFoundError: If stream URL cannot be obtained. - """ - 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") - - quality = self.provider.config.get_value(CONF_QUALITY) - quality_str = str(quality) if quality is not None else None - preferred_normalized = (quality_str or "").strip().lower() - - # Check for superb (lossless) quality - want_lossless = preferred_normalized in (QUALITY_SUPERB, "superb") - - # Backward compatibility: also check old "lossless" value (exact match) - if preferred_normalized == "lossless": - want_lossless = True - - # 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", 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 "" - needs_decryption = file_info.get("needs_decryption", False) - - if url and codec.lower() in ("flac", "flac-mp4"): - audio_format = self._build_audio_format(codec) - - # Handle encrypted URLs from encraw transport - if needs_decryption and "key" in file_info: - self.logger.info( - "Streaming encrypted %s for track %s - will decrypt on-the-fly", - codec, - track_id, - ) - # Return StreamType.CUSTOM for streaming decryption. - # can_seek=False: provider always streams from position 0; - # allow_seek=True: ffmpeg handles seek with -ss input flag. - return StreamDetails( - item_id=item_id, - provider=self.provider.instance_id, - audio_format=audio_format, - stream_type=StreamType.CUSTOM, - duration=track.duration, - data={ - "encrypted_url": url, - "decryption_key": file_info["key"], - "codec": codec, - }, - can_seek=False, - allow_seek=True, - ) - # Unencrypted URL, use directly - self.logger.debug( - "Unencrypted stream for track %s: codec=%s", - item_id, - codec, - ) - return StreamDetails( - item_id=item_id, - provider=self.provider.instance_id, - audio_format=audio_format, - stream_type=StreamType.HTTP, - duration=track.duration, - path=url, - can_seek=True, - allow_seek=True, - expiration=50, # get-file-info URLs expire; force MA to re-fetch - ) - - # Default: use /tracks/.../download-info and select best quality - 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}") - - codecs_available = [ - (getattr(i, "codec", None), getattr(i, "bitrate_in_kbps", None)) for i in download_infos - ] - self.logger.debug( - "Stream quality for track %s: config quality=%s, available codecs=%s", - track_id, - quality_str, - codecs_available, - ) - selected_info = self._select_best_quality(download_infos, quality_str) - - if not selected_info or not selected_info.direct_link: - raise MediaNotFoundError(f"No stream URL available for track {item_id}") - - self.logger.debug( - "Stream selected for track %s: codec=%s, bitrate=%s", - track_id, - getattr(selected_info, "codec", None), - getattr(selected_info, "bitrate_in_kbps", None), - ) - - bitrate = selected_info.bitrate_in_kbps or 0 - - return StreamDetails( - item_id=item_id, - provider=self.provider.instance_id, - audio_format=self._build_audio_format(selected_info.codec, bit_rate=bitrate), - stream_type=StreamType.HTTP, - duration=track.duration, - path=selected_info.direct_link, - can_seek=True, - allow_seek=True, - expiration=50, # download-info direct links expire after ~60s - ) - - def _select_best_quality( - self, download_infos: list[Any], preferred_quality: str | None - ) -> DownloadInfo | None: - """Select the best quality download info based on user preference. - - :param download_infos: List of DownloadInfo objects. - :param preferred_quality: User's quality preference (efficient/high/balanced/superb). - :return: Best matching DownloadInfo or None. - """ - if not download_infos: - return None - - preferred_normalized = (preferred_quality or "").strip().lower() - - # Sort by bitrate descending - sorted_infos = sorted( - download_infos, - key=lambda x: x.bitrate_in_kbps or 0, - reverse=True, - ) - - # Superb: Prefer FLAC (backward compatibility with "lossless") - if preferred_normalized in {QUALITY_SUPERB, "lossless"}: - # Note: flac-mp4 typically comes from get-file-info API, not download-info, - # but we check here for forward compatibility in case the API changes. - for codec in ("flac-mp4", "flac"): - for info in sorted_infos: - if info.codec and info.codec.lower() == codec: - return info - self.logger.warning( - "Superb quality (FLAC) requested but not available; using best available" - ) - return sorted_infos[0] - - # Efficient: Prefer lowest bitrate AAC/MP3 - if preferred_normalized == QUALITY_EFFICIENT: - # Sort ascending for lowest bitrate - sorted_infos_asc = sorted( - download_infos, - key=lambda x: x.bitrate_in_kbps or 999, - ) - # Prefer AAC for efficiency, then MP3 (include MP4 container variants) - for codec in ("aac-mp4", "aac", "he-aac-mp4", "he-aac", "mp3"): - for info in sorted_infos_asc: - if info.codec and info.codec.lower() == codec: - return info - return sorted_infos_asc[0] - - # High: Prefer high bitrate MP3 (~320kbps) - if preferred_normalized == QUALITY_HIGH: - # Look for MP3 with bitrate >= 256kbps - high_quality_mp3 = [ - info - for info in sorted_infos - if info.codec - and info.codec.lower() == "mp3" - and info.bitrate_in_kbps - and info.bitrate_in_kbps >= 256 - ] - if high_quality_mp3: - return high_quality_mp3[0] # Already sorted by bitrate descending - - # Fallback: any MP3 available (highest bitrate) - for info in sorted_infos: - if info.codec and info.codec.lower() == "mp3": - return info - - # If no MP3, use highest available (excluding FLAC) - for info in sorted_infos: - if info.codec and info.codec.lower() not in ("flac", "flac-mp4"): - return info - - # Last resort: highest available - return sorted_infos[0] - - # Balanced (default): Prefer ~192kbps AAC, or medium quality MP3 - # Look for bitrate around 192kbps (within range 128-256) - balanced_infos = [ - info - for info in sorted_infos - if info.bitrate_in_kbps and 128 <= info.bitrate_in_kbps <= 256 - ] - if balanced_infos: - # Prefer AAC over MP3 at similar bitrate (include MP4 container variants) - for codec in ("aac-mp4", "aac", "he-aac-mp4", "he-aac", "mp3"): - for info in balanced_infos: - if info.codec and info.codec.lower() == codec: - return info - return balanced_infos[0] - - # Fallback to highest available if no balanced option - return sorted_infos[0] if sorted_infos else None - - def _get_content_type(self, codec: str | None) -> tuple[ContentType, ContentType]: - """Determine container and codec type from Kion API codec string. - - Kion API returns codec strings like "flac-mp4" (FLAC in MP4 container), - "aac-mp4" (AAC in MP4 container), or plain "flac", "mp3", "aac". - - :param codec: Codec string from Kion API. - :return: Tuple of (content_type/container, codec_type). - """ - if not codec: - return ContentType.UNKNOWN, ContentType.UNKNOWN - - codec_lower = codec.lower() - - # MP4 container variants: codec is inside an MP4 container - if codec_lower == "flac-mp4": - return ContentType.MP4, ContentType.FLAC - if codec_lower in ("aac-mp4", "he-aac-mp4"): - return ContentType.MP4, ContentType.AAC - - # Plain single-codec formats: codec is implied by content_type, no separate codec_type - if codec_lower == "flac": - return ContentType.FLAC, ContentType.UNKNOWN - if codec_lower in ("mp3", "mpeg"): - return ContentType.MP3, ContentType.UNKNOWN - if codec_lower in ("aac", "he-aac"): - return ContentType.AAC, ContentType.UNKNOWN - - return ContentType.UNKNOWN, ContentType.UNKNOWN - - def _get_audio_params(self, codec: str | None) -> tuple[int, int]: - """Return (sample_rate, bit_depth) defaults based on codec string. - - The Kion get-file-info API does not return sample rate or bit depth, - so we use codec-based defaults. These values help the core select the - correct PCM output format and avoid unnecessary resampling. - - :param codec: Codec string from Kion API (e.g. "flac-mp4", "flac", "mp3"). - :return: Tuple of (sample_rate, bit_depth). - """ - if codec and codec.lower() == "flac-mp4": - return 48000, 24 - # CD-quality defaults for all other codecs - return 44100, 16 - - def _build_audio_format(self, codec: str | None, bit_rate: int = 0) -> AudioFormat: - """Build AudioFormat with content type and codec-based audio params. - - :param codec: Codec string from Kion API (e.g. "flac-mp4", "flac", "mp3"). - :param bit_rate: Bitrate in kbps (0 for variable/unknown). - :return: Configured AudioFormat instance. - """ - content_type, codec_type = self._get_content_type(codec) - sample_rate, bit_depth = self._get_audio_params(codec) - return AudioFormat( - content_type=content_type, - codec_type=codec_type, - bit_rate=bit_rate, - sample_rate=sample_rate, - bit_depth=bit_depth, - ) - - async def _refresh_encrypted_url( - self, - track_item_id: str, - current_url: str, - current_key_hex: str, - http_status: int, - bytes_yielded: int, - attempt: int, - max_retries: int, - ) -> tuple[str, str] | None: - """Re-fetch an expired encrypted stream URL. - - Called when the CDN responds with 4xx (URL expired or access revoked). - - :return: (new_url, new_key_hex) on success, or None if retries exhausted. - """ - if attempt >= max_retries: - return None - raw_track_id = self._track_id_from_item_id(track_item_id) - self.logger.warning( - "Encrypted stream URL expired (HTTP %d) at %d bytes (attempt %d/%d) — re-fetching", - http_status, - bytes_yielded, - attempt + 1, - max_retries, - ) - token = BYPASS_THROTTLER.set(True) - try: - file_info = await self.client.get_track_file_info_lossless(raw_track_id) - finally: - BYPASS_THROTTLER.reset(token) - if file_info and file_info.get("url"): - return file_info["url"], file_info.get("key", current_key_hex) - return None - - async def _decrypt_response_stream( - self, - response: Any, - key_bytes: bytes, - block_size: int, - bytes_delivered: int, - ) -> AsyncGenerator[bytes, None]: - """Decrypt one HTTP response and yield plaintext chunks. - - Aligns the AES-CTR counter to the correct block for resumption. - If the server ignores a Range header (200 instead of 206), resets the - counter to 0 and skips the already-delivered prefix transparently. - - :param response: aiohttp ClientResponse (open context manager). - :param key_bytes: Raw AES key bytes. - :param block_size: AES block size (16 for CTR mode). - :param bytes_delivered: Total plaintext bytes already sent to the caller. - :return: Async generator yielding decrypted audio bytes. - """ - block_start = (bytes_delivered // block_size) * block_size - block_skip = bytes_delivered - block_start - - if block_start > 0 and response.status == 200: - self.logger.warning( - "Server ignored Range header at %d bytes (200 instead of 206)" - " — restarting decrypt from position 0, skipping %d already-sent bytes", - block_start, - bytes_delivered, - ) - block_skip = bytes_delivered - block_num = (0).to_bytes(block_size, "big") - else: - block_num = (block_start // block_size).to_bytes(block_size, "big") - - decryptor = Cipher(algorithms.AES(key_bytes), modes.CTR(block_num)).decryptor() - carry_skip = block_skip - async for chunk in response.content.iter_chunked(_CHUNK_SIZE): - decrypted = decryptor.update(chunk) - if carry_skip > 0: - skip = min(carry_skip, len(decrypted)) - decrypted = decrypted[skip:] - carry_skip -= skip - if decrypted: - yield decrypted - final = decryptor.finalize() - if final: - yield final - - def _handle_stream_error( - self, - err: Exception, - attempt: int, - max_retries: int, - bytes_yielded: int, - delays: tuple[float, ...], - label: str, - ) -> tuple[int, float]: - """Increment retry counter, log a warning, or raise if retries are exhausted. - - :param err: The exception that caused the retry. - :param attempt: Current retry attempt count (0-based). - :param max_retries: Maximum number of retries allowed. - :param bytes_yielded: Bytes delivered so far (for log context). - :param delays: Backoff delay sequence to pick from. - :param label: Short verb describing the failure (e.g. "dropped", "stalled"). - :return: (new_attempt, retry_delay) tuple when retrying. - :raises MediaNotFoundError: When attempt count exceeds max_retries. - """ - delay = delays[min(attempt, len(delays) - 1)] - attempt += 1 - if attempt <= max_retries: - self.logger.warning( - "Encrypted stream %s at %d bytes (attempt %d/%d) — retrying", - label, - bytes_yielded, - attempt, - max_retries, - ) - return attempt, delay - raise MediaNotFoundError(f"Encrypted stream {label} after retries were exhausted") from err - - @staticmethod - def _is_content_range_eof(headers: Any, window_end: int) -> bool: - """Return True when Content-Range indicates *window_end* reached the last file byte. - - Parses ``Content-Range: bytes start-end/total`` and checks whether - ``window_end >= total - 1``. Returns False on any malformed header so - the caller falls back to the next window safely. - """ - content_range = headers.get("Content-Range", "") - if not content_range.startswith("bytes "): - return False - try: - _, range_spec = content_range.split(" ", 1) - _, total_str = range_spec.split("/", 1) - total_str = total_str.strip() - return total_str.isdigit() and window_end >= int(total_str) - 1 - except ValueError: - return False - - async def get_audio_stream( # noqa: PLR0915 - self, streamdetails: StreamDetails, seek_position: int = 0 - ) -> AsyncGenerator[bytes, None]: - """Return the audio stream for the provider item with on-the-fly decryption. - - Downloads and decrypts the encrypted stream in windowed Range requests of - _RANGE_WINDOW bytes each. Kion CDN drops TCP every ~6-7 MB per connection; - keeping each request at 4 MB prevents that limit from being reached. - - On connection drop (ClientPayloadError, ServerDisconnectedError), the current - window is retried with a flat short backoff (0.5s/1.0s/2.0s). - On read stall (asyncio.TimeoutError), the current window is retried with - exponential backoff (2s/4s/8s). - On URL expiry (HTTP 4xx), re-fetches the URL and resumes from bytes_yielded. - Up to max_retries retries per window; the retry counter resets on each - successful window so long tracks get the same protection as short ones. - - If the server ignores a Range header (returns 200 instead of 206), the decryptor - is reset to position 0 so decryption stays consistent with the restarted byte stream. - - :param streamdetails: Stream details containing encrypted URL and key. - :param seek_position: Always 0 (seeking delegated to ffmpeg via allow_seek=True). - :return: Async generator yielding decrypted audio bytes. - """ - encrypted_url: str = streamdetails.data["encrypted_url"] - track_item_id: str = streamdetails.item_id - key_hex: str = streamdetails.data["decryption_key"] - try: - key_bytes = bytes.fromhex(key_hex) - except ValueError as exc: - raise MediaNotFoundError("Invalid decryption key format") from exc - if len(key_bytes) not in (16, 24, 32): - raise MediaNotFoundError(f"Unsupported AES key length: {len(key_bytes)} bytes") - - block_size = 16 # AES-CTR block size in bytes - max_retries = 6 - bytes_yielded = 0 # total decrypted bytes delivered to caller - attempt = 0 # retry counter; resets to 0 after each successful window - retry_delay: float = 0.0 - - while True: - if attempt > 0: - await asyncio.sleep(retry_delay) - - block_start = (bytes_yielded // block_size) * block_size - window_end = block_start + _RANGE_WINDOW - 1 - headers = {"Range": f"bytes={block_start}-{window_end}"} - - try: - async with self.mass.http_session.get( - encrypted_url, headers=headers, timeout=_STREAM_TIMEOUT - ) as response: - if response.status in (401, 403, 410): - # URL expired — re-fetch via helper and retry - refreshed = await self._refresh_encrypted_url( - track_item_id, - encrypted_url, - key_hex, - response.status, - bytes_yielded, - attempt, - max_retries, - ) - if refreshed is None: - raise MediaNotFoundError( - f"Encrypted stream URL expired (HTTP {response.status}) " - "after retries exhausted" - ) - encrypted_url, key_hex = refreshed - try: - key_bytes = bytes.fromhex(key_hex) - except ValueError as err: - raise MediaNotFoundError( - "Invalid decryption key format after URL refresh" - ) from err - retry_delay = 0.0 - attempt += 1 # consume one retry slot, same as TCP-drop path - continue - if response.status == 416: - return # Range Not Satisfiable — file size is exact window multiple - try: - response.raise_for_status() - except Exception as err: - raise MediaNotFoundError( - f"Failed to fetch encrypted stream: {err}" - ) from err - - bytes_before = bytes_yielded - # block_skip = bytes re-downloaded for AES-block alignment. - # Needed below to compute actual HTTP bytes received. - block_skip = bytes_before - block_start - async for chunk in self._decrypt_response_stream( - response, key_bytes, block_size, bytes_yielded - ): - bytes_yielded += len(chunk) - yield chunk - - # window complete — check if EOF - window_got = bytes_yielded - bytes_before - # received = actual HTTP bytes the server sent for this Range - # request. window_got alone understates the window when - # block_skip > 0 (reconnect at a non-AES-block boundary): - # the decryptor skips block_skip bytes, so window_got would be - # smaller than _RANGE_WINDOW even for a full server response, - # causing premature stream termination without this correction. - received = window_got + block_skip - if response.status == 200 or received < _RANGE_WINDOW: - return # full file received or last partial window - # Exact-boundary guard: if file size is an exact multiple of - # _RANGE_WINDOW the size check above won't catch EOF. - # Use Content-Range to confirm no bytes remain. - if self._is_content_range_eof(response.headers, window_end): - return - # more data expected: advance to next window - attempt = 0 - retry_delay = 0.0 - - except asyncio.CancelledError: - raise # propagate cancellation immediately, do not retry - except (ClientPayloadError, ServerDisconnectedError) as err: - attempt, retry_delay = self._handle_stream_error( - err, attempt, max_retries, bytes_yielded, _TCP_DROP_DELAYS, "dropped" - ) - except TimeoutError as err: - attempt, retry_delay = self._handle_stream_error( - err, attempt, max_retries, bytes_yielded, _STALL_DELAYS, "stalled" - ) diff --git a/tests/providers/kion_music/__init__.py b/tests/providers/kion_music/__init__.py deleted file mode 100644 index 40d4adaec2..0000000000 --- a/tests/providers/kion_music/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for KION Music provider.""" diff --git a/tests/providers/kion_music/__snapshots__/test_parsers.ambr b/tests/providers/kion_music/__snapshots__/test_parsers.ambr deleted file mode 100644 index 7f0e70fb1a..0000000000 --- a/tests/providers/kion_music/__snapshots__/test_parsers.ambr +++ /dev/null @@ -1,600 +0,0 @@ -# serializer version: 1 -# name: test_parse_album_snapshot[minimal] - dict({ - 'album_type': 'album', - 'artists': list([ - ]), - 'date_added': None, - 'external_ids': list([ - ]), - 'favorite': False, - 'is_playable': True, - 'item_id': '300', - 'media_type': 'album', - 'metadata': dict({ - 'chapters': None, - 'copyright': None, - 'description': None, - 'explicit': None, - 'genres': None, - 'grouping': None, - 'images': None, - 'label': None, - 'languages': None, - 'last_refresh': None, - 'links': None, - 'lrc_lyrics': None, - 'lyrics': None, - 'mood': None, - 'performers': None, - 'popularity': None, - 'preview': None, - 'release_date': None, - 'review': None, - 'style': None, - }), - 'name': 'Test Album', - 'position': None, - 'provider': 'kion_music_instance', - 'provider_mappings': list([ - dict({ - 'audio_format': dict({ - 'bit_depth': 16, - 'bit_rate': 0, - 'channels': 2, - 'codec_type': '?', - 'content_type': '?', - 'output_format_str': '?', - 'sample_rate': 44100, - }), - 'available': True, - 'details': None, - 'in_library': None, - 'is_unique': None, - 'item_id': '300', - 'provider_domain': 'kion_music', - 'provider_instance': 'kion_music_instance', - 'url': 'https://music.yandex.ru/album/300', - }), - ]), - 'sort_name': 'test album', - 'translation_key': None, - 'uri': 'kion_music_instance://album/300', - 'version': '', - 'year': 2020, - }) -# --- -# name: test_parse_artist_snapshot[minimal] - dict({ - 'date_added': None, - 'external_ids': list([ - ]), - 'favorite': False, - 'is_playable': True, - 'item_id': '100', - 'media_type': 'artist', - 'metadata': dict({ - 'chapters': None, - 'copyright': None, - 'description': None, - 'explicit': None, - 'genres': None, - 'grouping': None, - 'images': None, - 'label': None, - 'languages': None, - 'last_refresh': None, - 'links': None, - 'lrc_lyrics': None, - 'lyrics': None, - 'mood': None, - 'performers': None, - 'popularity': None, - 'preview': None, - 'release_date': None, - 'review': None, - 'style': None, - }), - 'name': 'Test Artist', - 'position': None, - 'provider': 'kion_music_instance', - 'provider_mappings': list([ - dict({ - 'audio_format': dict({ - 'bit_depth': 16, - 'bit_rate': 0, - 'channels': 2, - 'codec_type': '?', - 'content_type': '?', - 'output_format_str': '?', - 'sample_rate': 44100, - }), - 'available': True, - 'details': None, - 'in_library': None, - 'is_unique': None, - 'item_id': '100', - 'provider_domain': 'kion_music', - 'provider_instance': 'kion_music_instance', - 'url': 'https://music.yandex.ru/artist/100', - }), - ]), - 'sort_name': 'test artist', - 'translation_key': None, - 'uri': 'kion_music_instance://artist/100', - 'version': '', - }) -# --- -# name: test_parse_artist_snapshot[with_cover] - dict({ - 'date_added': None, - 'external_ids': list([ - ]), - 'favorite': False, - 'is_playable': True, - 'item_id': '200', - 'media_type': 'artist', - 'metadata': dict({ - 'chapters': None, - 'copyright': None, - 'description': None, - 'explicit': None, - 'genres': None, - 'grouping': None, - 'images': list([ - dict({ - 'path': 'https://avatars.yandex.net/get-music-content/xxx/yyy/1000x1000', - 'provider': 'kion_music_instance', - 'remotely_accessible': True, - 'type': 'thumb', - }), - ]), - 'label': None, - 'languages': None, - 'last_refresh': None, - 'links': None, - 'lrc_lyrics': None, - 'lyrics': None, - 'mood': None, - 'performers': None, - 'popularity': None, - 'preview': None, - 'release_date': None, - 'review': None, - 'style': None, - }), - 'name': 'Artist With Cover', - 'position': None, - 'provider': 'kion_music_instance', - 'provider_mappings': list([ - dict({ - 'audio_format': dict({ - 'bit_depth': 16, - 'bit_rate': 0, - 'channels': 2, - 'codec_type': '?', - 'content_type': '?', - 'output_format_str': '?', - 'sample_rate': 44100, - }), - 'available': True, - 'details': None, - 'in_library': None, - 'is_unique': None, - 'item_id': '200', - 'provider_domain': 'kion_music', - 'provider_instance': 'kion_music_instance', - 'url': 'https://music.yandex.ru/artist/200', - }), - ]), - 'sort_name': 'artist with cover', - 'translation_key': None, - 'uri': 'kion_music_instance://artist/200', - 'version': '', - }) -# --- -# name: test_parse_playlist_snapshot[minimal] - dict({ - 'date_added': None, - 'external_ids': list([ - ]), - 'favorite': False, - 'is_dynamic': False, - 'is_editable': True, - 'is_playable': True, - 'item_id': '12345:3', - 'media_type': 'playlist', - 'metadata': dict({ - 'chapters': None, - 'copyright': None, - 'description': None, - 'explicit': None, - 'genres': None, - 'grouping': None, - 'images': None, - 'label': None, - 'languages': None, - 'last_refresh': None, - 'links': None, - 'lrc_lyrics': None, - 'lyrics': None, - 'mood': None, - 'performers': None, - 'popularity': None, - 'preview': None, - 'release_date': None, - 'review': None, - 'style': None, - }), - 'name': 'My Playlist', - 'owner': 'Me', - 'position': None, - 'provider': 'kion_music_instance', - 'provider_mappings': list([ - dict({ - 'audio_format': dict({ - 'bit_depth': 16, - 'bit_rate': 0, - 'channels': 2, - 'codec_type': '?', - 'content_type': '?', - 'output_format_str': '?', - 'sample_rate': 44100, - }), - 'available': True, - 'details': None, - 'in_library': None, - 'is_unique': True, - 'item_id': '12345:3', - 'provider_domain': 'kion_music', - 'provider_instance': 'kion_music_instance', - 'url': 'https://music.yandex.ru/users/12345/playlists/3', - }), - ]), - 'sort_name': 'my playlist', - 'supported_mediatypes': list([ - 'track', - ]), - 'translation_key': None, - 'uri': 'kion_music_instance://playlist/12345:3', - 'version': '', - }) -# --- -# name: test_parse_playlist_snapshot[other_user] - dict({ - 'date_added': None, - 'external_ids': list([ - ]), - 'favorite': False, - 'is_dynamic': False, - 'is_editable': False, - 'is_playable': True, - 'item_id': '99999:1', - 'media_type': 'playlist', - 'metadata': dict({ - 'chapters': None, - 'copyright': None, - 'description': 'A shared playlist', - 'explicit': None, - 'genres': None, - 'grouping': None, - 'images': None, - 'label': None, - 'languages': None, - 'last_refresh': None, - 'links': None, - 'lrc_lyrics': None, - 'lyrics': None, - 'mood': None, - 'performers': None, - 'popularity': None, - 'preview': None, - 'release_date': None, - 'review': None, - 'style': None, - }), - 'name': 'Shared Playlist', - 'owner': 'Other User', - 'position': None, - 'provider': 'kion_music_instance', - 'provider_mappings': list([ - dict({ - 'audio_format': dict({ - 'bit_depth': 16, - 'bit_rate': 0, - 'channels': 2, - 'codec_type': '?', - 'content_type': '?', - 'output_format_str': '?', - 'sample_rate': 44100, - }), - 'available': True, - 'details': None, - 'in_library': None, - 'is_unique': False, - 'item_id': '99999:1', - 'provider_domain': 'kion_music', - 'provider_instance': 'kion_music_instance', - 'url': 'https://music.yandex.ru/users/99999/playlists/1', - }), - ]), - 'sort_name': 'shared playlist', - 'supported_mediatypes': list([ - 'track', - ]), - 'translation_key': None, - 'uri': 'kion_music_instance://playlist/99999:1', - 'version': '', - }) -# --- -# name: test_parse_track_snapshot[minimal] - dict({ - 'album': None, - 'artists': list([ - ]), - 'date_added': None, - 'disc_number': 0, - 'duration': 180, - 'external_ids': list([ - ]), - 'favorite': False, - 'is_playable': True, - 'item_id': '400', - 'last_played': 0, - 'media_type': 'track', - 'metadata': dict({ - 'chapters': None, - 'copyright': None, - 'description': None, - 'explicit': None, - 'genres': None, - 'grouping': None, - 'images': None, - 'label': None, - 'languages': None, - 'last_refresh': None, - 'links': None, - 'lrc_lyrics': None, - 'lyrics': None, - 'mood': None, - 'performers': None, - 'popularity': None, - 'preview': None, - 'release_date': None, - 'review': None, - 'style': None, - }), - 'name': 'Test Track', - 'position': None, - 'provider': 'kion_music_instance', - 'provider_mappings': list([ - dict({ - 'audio_format': dict({ - 'bit_depth': 16, - 'bit_rate': 0, - 'channels': 2, - 'codec_type': '?', - 'content_type': '?', - 'output_format_str': '?', - 'sample_rate': 44100, - }), - 'available': True, - 'details': None, - 'in_library': None, - 'is_unique': None, - 'item_id': '400', - 'provider_domain': 'kion_music', - 'provider_instance': 'kion_music_instance', - 'url': 'https://music.yandex.ru/track/400', - }), - ]), - 'sort_name': 'test track', - 'track_number': 0, - 'translation_key': None, - 'uri': 'kion_music_instance://track/400', - 'version': '', - }) -# --- -# name: test_parse_track_snapshot[with_artist_and_album] - dict({ - 'album': dict({ - 'album_type': 'album', - 'artists': list([ - ]), - 'date_added': None, - 'external_ids': list([ - ]), - 'favorite': False, - 'is_playable': True, - 'item_id': '20', - 'media_type': 'album', - 'metadata': dict({ - 'chapters': None, - 'copyright': None, - 'description': None, - 'explicit': None, - 'genres': None, - 'grouping': None, - 'images': list([ - dict({ - 'path': 'https://avatars.yandex.net/get-music-content/aaa/bbb/1000x1000', - 'provider': 'kion_music_instance', - 'remotely_accessible': True, - 'type': 'thumb', - }), - ]), - 'label': None, - 'languages': None, - 'last_refresh': None, - 'links': None, - 'lrc_lyrics': None, - 'lyrics': None, - 'mood': None, - 'performers': None, - 'popularity': None, - 'preview': None, - 'release_date': None, - 'review': None, - 'style': None, - }), - 'name': 'Track Album', - 'position': None, - 'provider': 'kion_music_instance', - 'provider_mappings': list([ - dict({ - 'audio_format': dict({ - 'bit_depth': 16, - 'bit_rate': 0, - 'channels': 2, - 'codec_type': '?', - 'content_type': '?', - 'output_format_str': '?', - 'sample_rate': 44100, - }), - 'available': False, - 'details': None, - 'in_library': None, - 'is_unique': None, - 'item_id': '20', - 'provider_domain': 'kion_music', - 'provider_instance': 'kion_music_instance', - 'url': 'https://music.yandex.ru/album/20', - }), - ]), - 'sort_name': 'track album', - 'translation_key': None, - 'uri': 'kion_music_instance://album/20', - 'version': '', - 'year': None, - }), - 'artists': list([ - dict({ - 'date_added': None, - 'external_ids': list([ - ]), - 'favorite': False, - 'is_playable': True, - 'item_id': '10', - 'media_type': 'artist', - 'metadata': dict({ - 'chapters': None, - 'copyright': None, - 'description': None, - 'explicit': None, - 'genres': None, - 'grouping': None, - 'images': None, - 'label': None, - 'languages': None, - 'last_refresh': None, - 'links': None, - 'lrc_lyrics': None, - 'lyrics': None, - 'mood': None, - 'performers': None, - 'popularity': None, - 'preview': None, - 'release_date': None, - 'review': None, - 'style': None, - }), - 'name': 'Track Artist', - 'position': None, - 'provider': 'kion_music_instance', - 'provider_mappings': list([ - dict({ - 'audio_format': dict({ - 'bit_depth': 16, - 'bit_rate': 0, - 'channels': 2, - 'codec_type': '?', - 'content_type': '?', - 'output_format_str': '?', - 'sample_rate': 44100, - }), - 'available': True, - 'details': None, - 'in_library': None, - 'is_unique': None, - 'item_id': '10', - 'provider_domain': 'kion_music', - 'provider_instance': 'kion_music_instance', - 'url': 'https://music.yandex.ru/artist/10', - }), - ]), - 'sort_name': 'track artist', - 'translation_key': None, - 'uri': 'kion_music_instance://artist/10', - 'version': '', - }), - ]), - 'date_added': None, - 'disc_number': 0, - 'duration': 240, - 'external_ids': list([ - ]), - 'favorite': False, - 'is_playable': True, - 'item_id': '500', - 'last_played': 0, - 'media_type': 'track', - 'metadata': dict({ - 'chapters': None, - 'copyright': None, - 'description': None, - 'explicit': None, - 'genres': None, - 'grouping': None, - 'images': list([ - dict({ - 'path': 'https://avatars.yandex.net/get-music-content/aaa/bbb/1000x1000', - 'provider': 'kion_music_instance', - 'remotely_accessible': True, - 'type': 'thumb', - }), - ]), - 'label': None, - 'languages': None, - 'last_refresh': None, - 'links': None, - 'lrc_lyrics': None, - 'lyrics': None, - 'mood': None, - 'performers': None, - 'popularity': None, - 'preview': None, - 'release_date': None, - 'review': None, - 'style': None, - }), - 'name': 'Track With Album', - 'position': None, - 'provider': 'kion_music_instance', - 'provider_mappings': list([ - dict({ - 'audio_format': dict({ - 'bit_depth': 16, - 'bit_rate': 0, - 'channels': 2, - 'codec_type': '?', - 'content_type': '?', - 'output_format_str': '?', - 'sample_rate': 44100, - }), - 'available': True, - 'details': None, - 'in_library': None, - 'is_unique': None, - 'item_id': '500', - 'provider_domain': 'kion_music', - 'provider_instance': 'kion_music_instance', - 'url': 'https://music.yandex.ru/track/500', - }), - ]), - 'sort_name': 'track with album', - 'track_number': 0, - 'translation_key': None, - 'uri': 'kion_music_instance://track/500', - 'version': '', - }) -# --- diff --git a/tests/providers/kion_music/conftest.py b/tests/providers/kion_music/conftest.py deleted file mode 100644 index 8509616da1..0000000000 --- a/tests/providers/kion_music/conftest.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Shared fixtures and stubs for KION Music provider tests.""" - -from __future__ import annotations - -import logging -from typing import Any - -import pytest -from music_assistant_models.enums import MediaType -from music_assistant_models.media_items import ItemMapping - - -class ProviderStub: - """Minimal provider-like object for parser tests (no Mock). - - Provides the minimal interface needed by parse_* functions. - """ - - domain = "kion_music" - instance_id = "kion_music_instance" - - def __init__(self) -> None: - """Initialize stub with minimal client.""" - self.client = type("ClientStub", (), {"user_id": 12345})() - - def get_item_mapping(self, media_type: MediaType | str, key: str, name: str) -> ItemMapping: - """Return ItemMapping for the given media type, key and name.""" - return ItemMapping( - media_type=MediaType(media_type) if isinstance(media_type, str) else media_type, - item_id=key, - provider=self.instance_id, - name=name, - ) - - -class _StubConfig: - """Minimal config stub for streaming tests.""" - - def get_value(self, key: str, default: Any = None) -> Any: - """Return default value for any key.""" - return default - - -class StreamingProviderStub: - """Minimal provider stub for streaming tests (no Mock). - - Provides the minimal interface needed by KionMusicStreamingManager. - """ - - domain = "kion_music" - instance_id = "kion_music_instance" - logger = logging.getLogger("kion_music_test_streaming") - config = _StubConfig() - - def __init__(self) -> None: - """Initialize stub with minimal client.""" - self.client = type("ClientStub", (), {"user_id": 12345})() - self.mass = type("MassStub", (), {})() - self._warning_count = 0 - - async def get_track(self, prov_track_id: str) -> None: - """Stub — not used by streaming unit tests.""" - return - - def _count_warning(self, *args: object, **kwargs: object) -> None: - """Track warning calls for test assertions.""" - self._warning_count += 1 - - -class TrackingLogger: - """Logger that tracks calls for test assertions without using Mock.""" - - def __init__(self) -> None: - """Initialize with empty call counters.""" - self._debug_count = 0 - self._info_count = 0 - self._warning_count = 0 - self._error_count = 0 - - def debug(self, *args: object, **kwargs: object) -> None: - """Track debug calls.""" - self._debug_count += 1 - - def info(self, *args: object, **kwargs: object) -> None: - """Track info calls.""" - self._info_count += 1 - - def warning(self, *args: object, **kwargs: object) -> None: - """Track warning calls.""" - self._warning_count += 1 - - def error(self, *args: object, **kwargs: object) -> None: - """Track error calls.""" - self._error_count += 1 - - -class StreamingProviderStubWithTracking: - """Provider stub with tracking logger for assertions. - - Use this when you need to verify logging behavior. - """ - - domain = "kion_music" - instance_id = "kion_music_instance" - config = _StubConfig() - - def __init__(self) -> None: - """Initialize stub with tracking logger.""" - self.client = type("ClientStub", (), {"user_id": 12345})() - self.mass = type("MassStub", (), {})() - self.logger = TrackingLogger() - - async def get_track(self, prov_track_id: str) -> None: - """Stub — not used by streaming unit tests.""" - return - - -# Minimal client-like object for kion_music de_json (library requires client, not None) -DE_JSON_CLIENT = type("ClientStub", (), {"report_unknown_fields": False})() - - -@pytest.fixture -def provider_stub() -> ProviderStub: - """Return a real provider stub (no Mock).""" - return ProviderStub() - - -@pytest.fixture -def streaming_provider_stub() -> StreamingProviderStub: - """Return a streaming provider stub (no Mock).""" - return StreamingProviderStub() - - -@pytest.fixture -def streaming_provider_stub_with_tracking() -> StreamingProviderStubWithTracking: - """Return a streaming provider stub with tracking logger.""" - return StreamingProviderStubWithTracking() diff --git a/tests/providers/kion_music/fixtures/albums/minimal.json b/tests/providers/kion_music/fixtures/albums/minimal.json deleted file mode 100644 index ec8a82c57b..0000000000 --- a/tests/providers/kion_music/fixtures/albums/minimal.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": 300, - "title": "Test Album", - "available": true, - "artists": [], - "type": "album", - "year": 2020 -} diff --git a/tests/providers/kion_music/fixtures/artists/minimal.json b/tests/providers/kion_music/fixtures/artists/minimal.json deleted file mode 100644 index 06296f0a5c..0000000000 --- a/tests/providers/kion_music/fixtures/artists/minimal.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "id": 100, - "name": "Test Artist" -} diff --git a/tests/providers/kion_music/fixtures/artists/with_cover.json b/tests/providers/kion_music/fixtures/artists/with_cover.json deleted file mode 100644 index ef6c49ae3f..0000000000 --- a/tests/providers/kion_music/fixtures/artists/with_cover.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": 200, - "name": "Artist With Cover", - "cover": { - "type": "from-og-image", - "uri": "avatars.yandex.net/get-music-content/xxx/yyy/%%" - } -} diff --git a/tests/providers/kion_music/fixtures/playlists/minimal.json b/tests/providers/kion_music/fixtures/playlists/minimal.json deleted file mode 100644 index 6e77c679c1..0000000000 --- a/tests/providers/kion_music/fixtures/playlists/minimal.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "owner": { - "uid": 12345, - "name": "Me", - "login": "me" - }, - "kind": 3, - "title": "My Playlist" -} diff --git a/tests/providers/kion_music/fixtures/playlists/other_user.json b/tests/providers/kion_music/fixtures/playlists/other_user.json deleted file mode 100644 index 60fba828f8..0000000000 --- a/tests/providers/kion_music/fixtures/playlists/other_user.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "owner": { - "uid": 99999, - "name": "Other User", - "login": "other_user" - }, - "kind": 1, - "title": "Shared Playlist", - "description": "A shared playlist" -} diff --git a/tests/providers/kion_music/fixtures/tracks/minimal.json b/tests/providers/kion_music/fixtures/tracks/minimal.json deleted file mode 100644 index 4aed92b417..0000000000 --- a/tests/providers/kion_music/fixtures/tracks/minimal.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": 400, - "title": "Test Track", - "available": true, - "duration_ms": 180000, - "artists": [], - "albums": [] -} diff --git a/tests/providers/kion_music/fixtures/tracks/with_artist_and_album.json b/tests/providers/kion_music/fixtures/tracks/with_artist_and_album.json deleted file mode 100644 index 2211d3e285..0000000000 --- a/tests/providers/kion_music/fixtures/tracks/with_artist_and_album.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": 500, - "title": "Track With Album", - "available": true, - "duration_ms": 240000, - "artists": [ - { - "id": 10, - "name": "Track Artist" - } - ], - "albums": [ - { - "id": 20, - "title": "Track Album", - "cover_uri": "avatars.yandex.net/get-music-content/aaa/bbb/%%" - } - ] -} diff --git a/tests/providers/kion_music/test_api_client.py b/tests/providers/kion_music/test_api_client.py deleted file mode 100644 index 7ef350db86..0000000000 --- a/tests/providers/kion_music/test_api_client.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Unit tests for the KION Music API client.""" - -from __future__ import annotations - -from unittest import mock - -import pytest -from yandex_music.exceptions import NetworkError - -from music_assistant.providers.kion_music.api_client import KionMusicClient -from music_assistant.providers.kion_music.constants import DEFAULT_BASE_URL - - -@pytest.fixture -def client() -> KionMusicClient: - """Return a KionMusicClient with a fake token and explicit base URL.""" - return KionMusicClient("fake_token", base_url=DEFAULT_BASE_URL) - - -async def test_connect_sets_base_url(client: KionMusicClient) -> None: - """Verify connect() passes DEFAULT_BASE_URL to ClientAsync.""" - with mock.patch("music_assistant.providers.kion_music.api_client.ClientAsync") as mock_cls: - mock_instance = mock.AsyncMock() - mock_instance.me = type("Me", (), {"account": type("Account", (), {"uid": 42})()})() - mock_instance.init = mock.AsyncMock(return_value=mock_instance) - mock_cls.return_value = mock_instance - - result = await client.connect() - - assert result is True - mock_cls.assert_called_once_with("fake_token", base_url=DEFAULT_BASE_URL) - - -async def test_get_liked_albums_batching(client: KionMusicClient) -> None: - """Test that liked albums are fetched in batches of 50.""" - mock_client = mock.AsyncMock() - client._client = mock_client - client._user_id = 1 - - # Create 60 likes so we get 2 batches - likes = [] - for i in range(60): - like = type("Like", (), {"album": type("Album", (), {"id": i + 1})()})() - likes.append(like) - - mock_client.users_likes_albums = mock.AsyncMock(return_value=likes) - - batch1 = [type("Album", (), {"id": i + 1})() for i in range(50)] - batch2 = [type("Album", (), {"id": i + 51})() for i in range(10)] - mock_client.albums = mock.AsyncMock(side_effect=[batch1, batch2]) - - result = await client.get_liked_albums() - - assert len(result) == 60 - assert mock_client.albums.call_count == 2 - - -async def test_get_liked_albums_batch_fallback_on_network_error( - client: KionMusicClient, -) -> None: - """Test fallback to minimal data when batch fetch fails.""" - mock_client = mock.AsyncMock() - client._client = mock_client - client._user_id = 1 - - album_obj = type("Album", (), {"id": 1})() - likes = [type("Like", (), {"album": album_obj})()] - - mock_client.users_likes_albums = mock.AsyncMock(return_value=likes) - mock_client.albums = mock.AsyncMock(side_effect=NetworkError("timeout")) - - result = await client.get_liked_albums() - - assert len(result) == 1 - assert result[0].id == 1 diff --git a/tests/providers/kion_music/test_parsers.py b/tests/providers/kion_music/test_parsers.py deleted file mode 100644 index c9e7bb8370..0000000000 --- a/tests/providers/kion_music/test_parsers.py +++ /dev/null @@ -1,247 +0,0 @@ -"""Test we can parse KION Music API objects into Music Assistant models.""" - -from __future__ import annotations - -import json -import pathlib -from typing import TYPE_CHECKING, Any, cast - -import pytest -from yandex_music import Album as YandexAlbum -from yandex_music import Artist as YandexArtist -from yandex_music import Playlist as YandexPlaylist -from yandex_music import Track as YandexTrack - -from music_assistant.providers.kion_music.parsers import ( - parse_album, - parse_artist, - parse_playlist, - parse_track, -) -from music_assistant.providers.kion_music.provider import KionMusicProvider - -from .conftest import DE_JSON_CLIENT - -if TYPE_CHECKING: - from syrupy.assertion import SnapshotAssertion - - from .conftest import ProviderStub - -FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" -ARTIST_FIXTURES = list(FIXTURES_DIR.glob("artists/*.json")) -ALBUM_FIXTURES = list(FIXTURES_DIR.glob("albums/*.json")) -TRACK_FIXTURES = list(FIXTURES_DIR.glob("tracks/*.json")) -PLAYLIST_FIXTURES = list(FIXTURES_DIR.glob("playlists/*.json")) - - -def _load_json(path: pathlib.Path) -> dict[str, Any]: - """Load JSON fixture.""" - with open(path) as f: - return cast("dict[str, Any]", json.load(f)) - - -def _artist_from_fixture(path: pathlib.Path) -> YandexArtist | None: - """Deserialize KION Artist from fixture JSON.""" - data = _load_json(path) - return YandexArtist.de_json(data, DE_JSON_CLIENT) - - -def _album_from_fixture(path: pathlib.Path) -> YandexAlbum | None: - """Deserialize KION Album from fixture JSON.""" - data = _load_json(path) - return YandexAlbum.de_json(data, DE_JSON_CLIENT) - - -def _track_from_fixture(path: pathlib.Path) -> YandexTrack | None: - """Deserialize KION Track from fixture JSON.""" - data = _load_json(path) - return YandexTrack.de_json(data, DE_JSON_CLIENT) - - -def _playlist_from_fixture(path: pathlib.Path) -> YandexPlaylist | None: - """Deserialize KION Playlist from fixture JSON.""" - data = _load_json(path) - return YandexPlaylist.de_json(data, DE_JSON_CLIENT) - - -# provider_stub fixture is provided by conftest.py - - -@pytest.mark.parametrize("example", ARTIST_FIXTURES, ids=lambda val: val.stem) -def test_parse_artist(example: pathlib.Path, provider_stub: ProviderStub) -> None: - """Test we can parse artists from fixture JSON.""" - artist_obj = _artist_from_fixture(example) - assert artist_obj is not None - result = parse_artist(cast("KionMusicProvider", provider_stub), artist_obj) - assert result.item_id == str(artist_obj.id) - assert result.name == (artist_obj.name or "Unknown Artist") - assert result.provider == provider_stub.instance_id - assert len(result.provider_mappings) == 1 - mapping = next(iter(result.provider_mappings)) - assert f"music.yandex.ru/artist/{artist_obj.id}" in (mapping.url or "") - - -def test_parse_artist_with_cover(provider_stub: ProviderStub) -> None: - """Test parsing artist with cover image.""" - path = FIXTURES_DIR / "artists" / "with_cover.json" - artist_obj = _artist_from_fixture(path) - assert artist_obj is not None - result = parse_artist(cast("KionMusicProvider", provider_stub), artist_obj) - assert result.item_id == "200" - assert result.name == "Artist With Cover" - if artist_obj.cover and artist_obj.cover.uri: - assert result.metadata.images is not None - assert len(result.metadata.images) == 1 - assert "avatars.yandex.net" in (result.metadata.images[0].path or "") - - -@pytest.mark.parametrize("example", ALBUM_FIXTURES, ids=lambda val: val.stem) -def test_parse_album(example: pathlib.Path, provider_stub: ProviderStub) -> None: - """Test we can parse albums from fixture JSON.""" - album_obj = _album_from_fixture(example) - assert album_obj is not None - result = parse_album(cast("KionMusicProvider", provider_stub), album_obj) - assert result.item_id == str(album_obj.id) - assert result.name - assert result.provider == provider_stub.instance_id - mapping = next(iter(result.provider_mappings)) - assert f"music.yandex.ru/album/{album_obj.id}" in (mapping.url or "") - if album_obj.year: - assert result.year == album_obj.year - - -@pytest.mark.parametrize("example", TRACK_FIXTURES, ids=lambda val: val.stem) -def test_parse_track(example: pathlib.Path, provider_stub: ProviderStub) -> None: - """Test we can parse tracks from fixture JSON.""" - track_obj = _track_from_fixture(example) - assert track_obj is not None - result = parse_track(cast("KionMusicProvider", provider_stub), track_obj) - assert result.item_id == str(track_obj.id) - assert result.name - assert result.duration == (track_obj.duration_ms or 0) // 1000 - mapping = next(iter(result.provider_mappings)) - assert f"music.yandex.ru/track/{track_obj.id}" in (mapping.url or "") - - -def test_parse_track_with_artist_and_album(provider_stub: ProviderStub) -> None: - """Test parsing track with artist and album.""" - path = FIXTURES_DIR / "tracks" / "with_artist_and_album.json" - track_obj = _track_from_fixture(path) - assert track_obj is not None - result = parse_track(cast("KionMusicProvider", provider_stub), track_obj) - assert result.item_id == "500" - if track_obj.artists: - assert len(result.artists) >= 1 - assert result.artists[0].name == "Track Artist" - if track_obj.albums: - assert result.album is not None - assert result.album.item_id == "20" - assert result.album.name == "Track Album" - - -@pytest.mark.parametrize("example", PLAYLIST_FIXTURES, ids=lambda val: val.stem) -def test_parse_playlist(example: pathlib.Path, provider_stub: ProviderStub) -> None: - """Test we can parse playlists from fixture JSON.""" - playlist_obj = _playlist_from_fixture(example) - assert playlist_obj is not None - result = parse_playlist(cast("KionMusicProvider", provider_stub), playlist_obj) - owner_id = ( - str(playlist_obj.owner.uid) if playlist_obj.owner else str(provider_stub.client.user_id) - ) - kind = str(playlist_obj.kind) - assert result.item_id == f"{owner_id}:{kind}" - assert result.name == (playlist_obj.title or "Unknown Playlist") - mapping = next(iter(result.provider_mappings)) - assert f"music.yandex.ru/users/{owner_id}/playlists/{kind}" in (mapping.url or "") - - -def test_parse_playlist_editable(provider_stub: ProviderStub) -> None: - """Test parsing own playlist (editable).""" - path = FIXTURES_DIR / "playlists" / "minimal.json" - playlist_obj = _playlist_from_fixture(path) - assert playlist_obj is not None - result = parse_playlist(cast("KionMusicProvider", provider_stub), playlist_obj) - assert result.owner == "Me" - assert result.is_editable is True - - -def test_parse_playlist_other_user(provider_stub: ProviderStub) -> None: - """Test parsing playlist owned by another user.""" - path = FIXTURES_DIR / "playlists" / "other_user.json" - playlist_obj = _playlist_from_fixture(path) - assert playlist_obj is not None - result = parse_playlist(cast("KionMusicProvider", provider_stub), playlist_obj) - assert result.item_id == "99999:1" - assert result.name == "Shared Playlist" - assert result.owner == "Other User" - assert result.is_editable is False - assert result.metadata.description == "A shared playlist" - - -# --- Snapshot tests --- - - -def _sort_for_snapshot(parsed: dict[str, Any]) -> dict[str, Any]: - """Sort lists in parsed dict for deterministic snapshot comparison.""" - if parsed.get("external_ids"): - parsed["external_ids"] = sorted(parsed["external_ids"]) - if "metadata" in parsed and isinstance(parsed["metadata"], dict): - if parsed["metadata"].get("genres"): - parsed["metadata"]["genres"] = sorted(parsed["metadata"]["genres"]) - return parsed - - -@pytest.mark.parametrize("example", ARTIST_FIXTURES, ids=lambda val: val.stem) -def test_parse_artist_snapshot( - example: pathlib.Path, - provider_stub: ProviderStub, - snapshot: SnapshotAssertion, -) -> None: - """Snapshot test for artist parsing.""" - artist_obj = _artist_from_fixture(example) - assert artist_obj is not None - result = parse_artist(cast("KionMusicProvider", provider_stub), artist_obj) - parsed = _sort_for_snapshot(result.to_dict()) - assert snapshot == parsed - - -@pytest.mark.parametrize("example", ALBUM_FIXTURES, ids=lambda val: val.stem) -def test_parse_album_snapshot( - example: pathlib.Path, - provider_stub: ProviderStub, - snapshot: SnapshotAssertion, -) -> None: - """Snapshot test for album parsing.""" - album_obj = _album_from_fixture(example) - assert album_obj is not None - result = parse_album(cast("KionMusicProvider", provider_stub), album_obj) - parsed = _sort_for_snapshot(result.to_dict()) - assert snapshot == parsed - - -@pytest.mark.parametrize("example", TRACK_FIXTURES, ids=lambda val: val.stem) -def test_parse_track_snapshot( - example: pathlib.Path, - provider_stub: ProviderStub, - snapshot: SnapshotAssertion, -) -> None: - """Snapshot test for track parsing.""" - track_obj = _track_from_fixture(example) - assert track_obj is not None - result = parse_track(cast("KionMusicProvider", provider_stub), track_obj) - parsed = _sort_for_snapshot(result.to_dict()) - assert snapshot == parsed - - -@pytest.mark.parametrize("example", PLAYLIST_FIXTURES, ids=lambda val: val.stem) -def test_parse_playlist_snapshot( - example: pathlib.Path, - provider_stub: ProviderStub, - snapshot: SnapshotAssertion, -) -> None: - """Snapshot test for playlist parsing.""" - playlist_obj = _playlist_from_fixture(example) - assert playlist_obj is not None - result = parse_playlist(cast("KionMusicProvider", provider_stub), playlist_obj) - parsed = _sort_for_snapshot(result.to_dict()) - assert snapshot == parsed diff --git a/tests/providers/kion_music/test_streaming.py b/tests/providers/kion_music/test_streaming.py deleted file mode 100644 index ca9a3571c6..0000000000 --- a/tests/providers/kion_music/test_streaming.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Unit tests for KION Music streaming quality selection.""" - -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING, Any, cast - -import pytest -from aiohttp import ClientPayloadError -from music_assistant_models.enums import ContentType -from music_assistant_models.errors import MediaNotFoundError -from music_assistant_models.media_items import AudioFormat -from music_assistant_models.streamdetails import StreamDetails - -from music_assistant.providers.kion_music.constants import QUALITY_HIGH, QUALITY_SUPERB -from music_assistant.providers.kion_music.streaming import KionMusicStreamingManager - -if TYPE_CHECKING: - from music_assistant.providers.kion_music.provider import KionMusicProvider - from tests.providers.kion_music.conftest import ( - StreamingProviderStub, - StreamingProviderStubWithTracking, - ) - - -def _make_download_info( - codec: str, - bitrate_in_kbps: int, - direct_link: str = "https://example.com/track", -) -> Any: - """Build DownloadInfo-like object.""" - return type( - "DownloadInfo", - (), - { - "codec": codec, - "bitrate_in_kbps": bitrate_in_kbps, - "direct_link": direct_link, - }, - )() - - -@pytest.fixture -def streaming_manager( - streaming_provider_stub: StreamingProviderStub, -) -> KionMusicStreamingManager: - """Create streaming manager with real stub (no Mock).""" - return KionMusicStreamingManager(cast("KionMusicProvider", streaming_provider_stub)) - - -@pytest.fixture -def streaming_manager_with_tracking( - streaming_provider_stub_with_tracking: StreamingProviderStubWithTracking, -) -> KionMusicStreamingManager: - """Create streaming manager with tracking logger for assertions.""" - return KionMusicStreamingManager( - cast("KionMusicProvider", streaming_provider_stub_with_tracking) - ) - - -def test_select_best_quality_lossless_returns_flac( - streaming_manager: KionMusicStreamingManager, -) -> None: - """When preferred_quality is 'lossless' and list has MP3 and FLAC, FLAC is selected.""" - mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3") - flac = _make_download_info("flac", 0, "https://example.com/track.flac") - download_infos = [mp3, flac] - - result = streaming_manager._select_best_quality(download_infos, QUALITY_SUPERB) - - assert result is not None - assert result.codec == "flac" - assert result.direct_link == "https://example.com/track.flac" - - -def test_select_best_quality_high_returns_highest_bitrate( - streaming_manager: KionMusicStreamingManager, -) -> None: - """When preferred is 'high' and list has MP3 and FLAC, highest bitrate is selected.""" - mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3") - flac = _make_download_info("flac", 0, "https://example.com/track.flac") - download_infos = [mp3, flac] - - result = streaming_manager._select_best_quality(download_infos, QUALITY_HIGH) - - assert result is not None - assert result.codec == "mp3" - assert result.bitrate_in_kbps == 320 - - -def test_select_best_quality_empty_list_returns_none( - streaming_manager: KionMusicStreamingManager, -) -> None: - """Empty download_infos returns None.""" - result = streaming_manager._select_best_quality([], QUALITY_SUPERB) - assert result is None - - -def test_select_best_quality_none_preferred_returns_highest_bitrate( - streaming_manager: KionMusicStreamingManager, -) -> None: - """When preferred_quality is None, returns highest bitrate.""" - mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3") - flac = _make_download_info("flac", 0, "https://example.com/track.flac") - download_infos = [mp3, flac] - - result = streaming_manager._select_best_quality(download_infos, None) - - assert result is not None - assert result.codec == "mp3" - assert result.bitrate_in_kbps == 320 - - -def test_get_content_type_flac_mp4_returns_flac( - streaming_manager: KionMusicStreamingManager, -) -> None: - """flac-mp4 codec from get-file-info is mapped to MP4 container and FLAC codec.""" - content_type = streaming_manager._get_content_type("flac-mp4") - assert content_type[0] == ContentType.MP4 - assert content_type[1] == ContentType.FLAC - content_type_upper = streaming_manager._get_content_type("FLAC-MP4") - assert content_type_upper[0] == ContentType.MP4 - assert content_type_upper[1] == ContentType.FLAC - - -def _make_stream_details( - decryption_key: str, encrypted_url: str = "https://example.com/enc.flac" -) -> StreamDetails: - """Build a minimal StreamDetails for get_audio_stream tests.""" - return StreamDetails( - provider="kion_music_instance", - item_id="test_track", - audio_format=AudioFormat(content_type=ContentType.FLAC), - data={"encrypted_url": encrypted_url, "decryption_key": decryption_key}, - ) - - -async def test_get_audio_stream_retries_on_payload_error_then_raises( - streaming_manager: KionMusicStreamingManager, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """ClientPayloadError causes retries; raises MediaNotFoundError after max retries.""" - get_audio_stream = getattr(streaming_manager, "get_audio_stream", None) - if get_audio_stream is None: - pytest.skip("get_audio_stream not available in this provider version") - - async def _no_sleep(_: float) -> None: - pass - - monkeypatch.setattr(asyncio, "sleep", _no_sleep) - - class _DroppingContent: - async def iter_chunked(self, n: int) -> Any: - raise ClientPayloadError("Connection dropped") - yield b"" # type: ignore[unreachable] # makes this an async generator - - class _DroppingResponse: - status = 200 - content = _DroppingContent() - - def raise_for_status(self) -> None: - pass - - class _DroppingContext: - async def __aenter__(self) -> _DroppingResponse: - return _DroppingResponse() - - async def __aexit__(self, *args: object) -> None: - pass - - class _FakeHttpSession: - def get(self, url: str, headers: Any = None, **kwargs: Any) -> _DroppingContext: - return _DroppingContext() - - streaming_manager_mass: Any = streaming_manager.mass - streaming_manager_mass.http_session = _FakeHttpSession() - streamdetails = _make_stream_details(decryption_key="00" * 16) # valid 16-byte AES key - - with pytest.raises(MediaNotFoundError, match="retries were exhausted"): - async for _ in get_audio_stream(streamdetails): - pass From 66acb7b8c0bbb0640503fb916aebecd206fa10f3 Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Mon, 20 Apr 2026 14:35:04 +0300 Subject: [PATCH 24/54] Restore kion_music to upstream/dev state --- .../providers/kion_music/__init__.py | 111 +++ .../providers/kion_music/api_client.py | 677 ++++++++++++++ .../providers/kion_music/constants.py | 71 ++ music_assistant/providers/kion_music/icon.svg | 12 + .../providers/kion_music/icon_monochrome.svg | 6 + .../providers/kion_music/manifest.json | 11 + .../providers/kion_music/parsers.py | 354 +++++++ .../providers/kion_music/provider.py | 881 ++++++++++++++++++ .../providers/kion_music/streaming.py | 185 ++++ tests/providers/kion_music/__init__.py | 1 + .../__snapshots__/test_parsers.ambr | 600 ++++++++++++ tests/providers/kion_music/conftest.py | 118 +++ .../kion_music/fixtures/albums/minimal.json | 8 + .../kion_music/fixtures/artists/minimal.json | 4 + .../fixtures/artists/with_cover.json | 8 + .../fixtures/playlists/minimal.json | 9 + .../fixtures/playlists/other_user.json | 10 + .../kion_music/fixtures/tracks/minimal.json | 8 + .../tracks/with_artist_and_album.json | 19 + tests/providers/kion_music/test_api_client.py | 109 +++ .../providers/kion_music/test_integration.py | 354 +++++++ tests/providers/kion_music/test_my_mix.py | 24 + tests/providers/kion_music/test_parsers.py | 247 +++++ tests/providers/kion_music/test_streaming.py | 139 +++ 24 files changed, 3966 insertions(+) create mode 100644 music_assistant/providers/kion_music/__init__.py create mode 100644 music_assistant/providers/kion_music/api_client.py create mode 100644 music_assistant/providers/kion_music/constants.py create mode 100644 music_assistant/providers/kion_music/icon.svg create mode 100644 music_assistant/providers/kion_music/icon_monochrome.svg create mode 100644 music_assistant/providers/kion_music/manifest.json create mode 100644 music_assistant/providers/kion_music/parsers.py create mode 100644 music_assistant/providers/kion_music/provider.py create mode 100644 music_assistant/providers/kion_music/streaming.py create mode 100644 tests/providers/kion_music/__init__.py create mode 100644 tests/providers/kion_music/__snapshots__/test_parsers.ambr create mode 100644 tests/providers/kion_music/conftest.py create mode 100644 tests/providers/kion_music/fixtures/albums/minimal.json create mode 100644 tests/providers/kion_music/fixtures/artists/minimal.json create mode 100644 tests/providers/kion_music/fixtures/artists/with_cover.json create mode 100644 tests/providers/kion_music/fixtures/playlists/minimal.json create mode 100644 tests/providers/kion_music/fixtures/playlists/other_user.json create mode 100644 tests/providers/kion_music/fixtures/tracks/minimal.json create mode 100644 tests/providers/kion_music/fixtures/tracks/with_artist_and_album.json create mode 100644 tests/providers/kion_music/test_api_client.py create mode 100644 tests/providers/kion_music/test_integration.py create mode 100644 tests/providers/kion_music/test_my_mix.py create mode 100644 tests/providers/kion_music/test_parsers.py create mode 100644 tests/providers/kion_music/test_streaming.py diff --git a/music_assistant/providers/kion_music/__init__.py b/music_assistant/providers/kion_music/__init__.py new file mode 100644 index 0000000000..3beab037f9 --- /dev/null +++ b/music_assistant/providers/kion_music/__init__.py @@ -0,0 +1,111 @@ +"""KION Music provider support for Music Assistant.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType +from music_assistant_models.enums import ConfigEntryType, ProviderFeature + +from .constants import ( + CONF_ACTION_CLEAR_AUTH, + CONF_BASE_URL, + CONF_QUALITY, + CONF_TOKEN, + DEFAULT_BASE_URL, + QUALITY_HIGH, + QUALITY_LOSSLESS, +) +from .provider import KionMusicProvider + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ProviderConfig + from music_assistant_models.provider import ProviderManifest + + from music_assistant.mass import MusicAssistant + from music_assistant.models import ProviderInstanceType + + +SUPPORTED_FEATURES = { + ProviderFeature.LIBRARY_ARTISTS, + ProviderFeature.LIBRARY_ALBUMS, + ProviderFeature.LIBRARY_TRACKS, + ProviderFeature.LIBRARY_PLAYLISTS, + ProviderFeature.ARTIST_ALBUMS, + ProviderFeature.ARTIST_TOPTRACKS, + ProviderFeature.SEARCH, + ProviderFeature.LIBRARY_ARTISTS_EDIT, + ProviderFeature.LIBRARY_ALBUMS_EDIT, + ProviderFeature.LIBRARY_TRACKS_EDIT, + ProviderFeature.BROWSE, + ProviderFeature.SIMILAR_TRACKS, + ProviderFeature.RECOMMENDATIONS, +} + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + return KionMusicProvider(mass, manifest, config, SUPPORTED_FEATURES) + + +async def get_config_entries( + mass: MusicAssistant, # noqa: ARG001 + instance_id: str | None = None, # noqa: ARG001 + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, +) -> tuple[ConfigEntry, ...]: + """Return Config entries to setup this provider.""" + if values is None: + values = {} + + # Handle clear auth action + if action == CONF_ACTION_CLEAR_AUTH: + values[CONF_TOKEN] = None + + # Check if user is authenticated + is_authenticated = bool(values.get(CONF_TOKEN)) + + return ( + ConfigEntry( + key=CONF_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="KION Music Token", + description="Enter your KION Music OAuth token. " + "See the documentation for how to obtain it.", + required=True, + hidden=is_authenticated, + value=cast("str", values.get(CONF_TOKEN)) if values else None, + ), + ConfigEntry( + key=CONF_ACTION_CLEAR_AUTH, + type=ConfigEntryType.ACTION, + label="Reset authentication", + description="Clear the current authentication details.", + action=CONF_ACTION_CLEAR_AUTH, + hidden=not is_authenticated, + ), + ConfigEntry( + key=CONF_QUALITY, + type=ConfigEntryType.STRING, + label="Audio quality", + description="Select preferred audio quality.", + options=[ + ConfigValueOption("High (320 kbps)", QUALITY_HIGH), + ConfigValueOption("Lossless (FLAC)", QUALITY_LOSSLESS), + ], + default_value=QUALITY_HIGH, + ), + ConfigEntry( + key=CONF_BASE_URL, + type=ConfigEntryType.STRING, + label="API Base URL", + description="API endpoint base URL. " + "Only change if KION Music changes their API endpoint. " + "Default: https://music.mts.ru/ya_proxy_api", + default_value=DEFAULT_BASE_URL, + required=False, + advanced=True, + ), + ) diff --git a/music_assistant/providers/kion_music/api_client.py b/music_assistant/providers/kion_music/api_client.py new file mode 100644 index 0000000000..a5a5646578 --- /dev/null +++ b/music_assistant/providers/kion_music/api_client.py @@ -0,0 +1,677 @@ +"""API client wrapper for KION Music.""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any, cast + +from music_assistant_models.errors import ( + LoginFailed, + ProviderUnavailableError, + ResourceTemporarilyUnavailable, +) +from yandex_music import Album as YandexAlbum +from yandex_music import Artist as YandexArtist +from yandex_music import ClientAsync, Search, TrackShort +from yandex_music import Playlist as YandexPlaylist +from yandex_music import Track as YandexTrack +from yandex_music.exceptions import BadRequestError, NetworkError, UnauthorizedError +from yandex_music.utils.sign_request import get_sign_request + +if TYPE_CHECKING: + from yandex_music import DownloadInfo + +from .constants import DEFAULT_BASE_URL, DEFAULT_LIMIT, ROTOR_FEEDBACK_FROM, ROTOR_STATION_MY_MIX + +# get-file-info with quality=lossless returns FLAC; default /tracks/.../download-info often does not +# Prefer flac-mp4/aac-mp4 (KION API moved to these formats around 2025) +GET_FILE_INFO_CODECS = "flac-mp4,flac,aac-mp4,aac,he-aac,mp3,he-aac-mp4" + +LOGGER = logging.getLogger(__name__) + + +class KionMusicClient: + """Wrapper around yandex-music-api ClientAsync.""" + + def __init__(self, token: str, base_url: str | None = None) -> None: + """Initialize the KION Music client. + + :param token: KION Music OAuth token. + :param base_url: Optional API base URL (defaults to KION Music API). + """ + self._token = token + self._base_url = base_url or DEFAULT_BASE_URL + self._client: ClientAsync | None = None + self._user_id: int | None = None + + @property + def user_id(self) -> int: + """Return the user ID.""" + if self._user_id is None: + raise ProviderUnavailableError("Client not initialized, call connect() first") + return self._user_id + + async def connect(self) -> bool: + """Initialize the client and verify token validity. + + :return: True if connection was successful. + :raises LoginFailed: If the token is invalid. + """ + try: + self._client = await ClientAsync(self._token, base_url=self._base_url).init() + if self._client.me is None or self._client.me.account is None: + raise LoginFailed("Failed to get account info") + self._user_id = self._client.me.account.uid + LOGGER.debug("Connected to KION Music as user %s", self._user_id) + return True + except UnauthorizedError as err: + raise LoginFailed("Invalid KION Music token") from err + except NetworkError as err: + msg = "Network error connecting to KION Music" + raise ResourceTemporarilyUnavailable(msg) from err + + async def disconnect(self) -> None: + """Disconnect the client.""" + self._client = None + self._user_id = None + + def _ensure_connected(self) -> ClientAsync: + """Ensure the client is connected and return it.""" + if self._client is None: + 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_MIX 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_mix_tracks( + self, queue: str | int | None = None + ) -> tuple[list[YandexTrack], str | None]: + """Get tracks from the My Mix (Мой Микс) 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_MIX, 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 Mix recommendations. + + Used to report radioStarted, trackStarted, trackFinished, skip so that + the service can improve subsequent recommendations. + + :param station_id: Station ID (e.g. ROTOR_STATION_MY_MIX). + :param feedback_type: One of 'radioStarted', 'trackStarted', 'trackFinished', 'skip'. + :param batch_id: Optional batch ID from the last get_my_mix_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"] = ROTOR_FEEDBACK_FROM + 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]: + """Get user's liked tracks. + + :return: List of liked track objects. + """ + client = self._ensure_connected() + try: + result = await client.users_likes_tracks() + if result is None: + return [] + return result.tracks or [] + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error fetching liked tracks: %s", err) + raise ResourceTemporarilyUnavailable("Failed to fetch liked tracks") from err + + async def get_liked_albums(self, batch_size: int = 50) -> list[YandexAlbum]: + """Get user's liked albums with full details (including cover art). + + The users_likes_albums endpoint returns minimal album data without + cover_uri, so we fetch full album details in batches afterwards. + + :return: List of liked album objects with full details. + """ + client = self._ensure_connected() + try: + result = await client.users_likes_albums() + if result is None: + return [] + album_ids = [ + str(like.album.id) for like in result if like.album is not None and like.album.id + ] + if not album_ids: + return [] + # Fetch full album details in batches to get cover_uri and other metadata + # batch_size is now a parameter with default 50 + full_albums: list[YandexAlbum] = [] + for i in range(0, len(album_ids), batch_size): + batch = album_ids[i : i + batch_size] + try: + batch_result = await client.albums(batch) + if batch_result: + full_albums.extend(batch_result) + except (BadRequestError, NetworkError) as batch_err: + LOGGER.warning("Error fetching album details batch: %s", batch_err) + # Fall back to minimal data for this batch + batch_set = set(batch) + for like in result: + if ( + like.album is not None + and like.album.id + and str(like.album.id) in batch_set + ): + full_albums.append(like.album) + return full_albums + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error fetching liked albums: %s", err) + raise ResourceTemporarilyUnavailable("Failed to fetch liked albums") from err + + async def get_liked_artists(self) -> list[YandexArtist]: + """Get user's liked artists. + + :return: List of liked artist objects. + """ + client = self._ensure_connected() + try: + result = await client.users_likes_artists() + if result is None: + return [] + return [like.artist for like in result if like.artist is not None] + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error fetching liked artists: %s", err) + raise ResourceTemporarilyUnavailable("Failed to fetch liked artists") from err + + async def get_user_playlists(self) -> list[YandexPlaylist]: + """Get user's playlists. + + :return: List of playlist objects. + """ + client = self._ensure_connected() + try: + result = await client.users_playlists_list() + if result is None: + return [] + return list(result) + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error fetching playlists: %s", err) + raise ResourceTemporarilyUnavailable("Failed to fetch playlists") from err + + # Search + + async def search( + self, + query: str, + search_type: str = "all", + limit: int = DEFAULT_LIMIT, + ) -> Search | None: + """Search for tracks, albums, artists, or playlists. + + :param query: Search query string. + :param search_type: Type of search ('all', 'track', 'album', 'artist', 'playlist'). + :param limit: Maximum number of results per type. + :return: Search results object. + """ + client = self._ensure_connected() + try: + return await client.search(query, type_=search_type, page=0, nocorrect=False) + except (BadRequestError, NetworkError) as err: + LOGGER.error("Search error: %s", err) + raise ResourceTemporarilyUnavailable("Search failed") from err + + # Get single items + + async def get_track(self, track_id: str) -> YandexTrack | None: + """Get a single track by ID. + + :param track_id: Track ID. + :return: Track object or None if not found. + """ + client = self._ensure_connected() + try: + tracks = await client.tracks([track_id]) + return tracks[0] if tracks else None + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error fetching track %s: %s", track_id, err) + return None + + async def get_tracks(self, track_ids: list[str]) -> list[YandexTrack]: + """Get multiple tracks by IDs. + + :param track_ids: List of track IDs. + :return: List of track objects. + :raises ResourceTemporarilyUnavailable: On network errors after retry. + """ + client = self._ensure_connected() + try: + result = await client.tracks(track_ids) + return result or [] + except NetworkError as err: + # Retry once on network errors (timeout, disconnect, etc.) + LOGGER.warning("Network error fetching tracks, retrying once: %s", err) + try: + result = await client.tracks(track_ids) + return result or [] + except NetworkError as retry_err: + LOGGER.error("Error fetching tracks (retry failed): %s", retry_err) + raise ResourceTemporarilyUnavailable("Failed to fetch tracks") from retry_err + except BadRequestError as err: + LOGGER.error("Error fetching tracks: %s", err) + return [] + + async def get_album(self, album_id: str) -> YandexAlbum | None: + """Get a single album by ID. + + :param album_id: Album ID. + :return: Album object or None if not found. + """ + client = self._ensure_connected() + try: + albums = await client.albums([album_id]) + return albums[0] if albums else None + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error fetching album %s: %s", album_id, err) + return None + + async def get_album_with_tracks(self, album_id: str) -> YandexAlbum | None: + """Get an album with its tracks. + + Uses the same semantics as the web client: albums/{id}/with-tracks + with resumeStream, richTracks, withListeningFinished when the library + passes them through. + + :param album_id: Album ID. + :return: Album object with tracks or None if not found. + """ + client = self._ensure_connected() + try: + return await client.albums_with_tracks( + album_id, + resumeStream=True, + richTracks=True, + withListeningFinished=True, + ) + except TypeError: + # Older yandex-music may not accept these kwargs + return await client.albums_with_tracks(album_id) + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error fetching album with tracks %s: %s", album_id, err) + return None + + async def get_artist(self, artist_id: str) -> YandexArtist | None: + """Get a single artist by ID. + + :param artist_id: Artist ID. + :return: Artist object or None if not found. + """ + client = self._ensure_connected() + try: + artists = await client.artists([artist_id]) + return artists[0] if artists else None + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error fetching artist %s: %s", artist_id, err) + return None + + async def get_artist_albums( + self, artist_id: str, limit: int = DEFAULT_LIMIT + ) -> list[YandexAlbum]: + """Get artist's albums. + + :param artist_id: Artist ID. + :param limit: Maximum number of albums. + :return: List of album objects. + """ + client = self._ensure_connected() + try: + result = await client.artists_direct_albums(artist_id, page=0, page_size=limit) + if result is None: + return [] + return result.albums or [] + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error fetching artist albums %s: %s", artist_id, err) + return [] + + async def get_artist_tracks( + self, artist_id: str, limit: int = DEFAULT_LIMIT + ) -> list[YandexTrack]: + """Get artist's top tracks. + + :param artist_id: Artist ID. + :param limit: Maximum number of tracks. + :return: List of track objects. + """ + client = self._ensure_connected() + try: + result = await client.artists_tracks(artist_id, page=0, page_size=limit) + if result is None: + return [] + return result.tracks or [] + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error fetching artist tracks %s: %s", artist_id, err) + return [] + + async def get_playlist(self, user_id: str, playlist_id: str) -> YandexPlaylist | None: + """Get a playlist by ID. + + :param user_id: User ID (owner of the playlist). + :param playlist_id: Playlist ID (kind). + :return: Playlist object or None if not found. + :raises ResourceTemporarilyUnavailable: On network errors. + """ + client = self._ensure_connected() + try: + result = await client.users_playlists(kind=int(playlist_id), user_id=user_id) + if isinstance(result, list): + return result[0] if result else None + return result + except NetworkError as err: + LOGGER.warning("Network error fetching playlist %s/%s: %s", user_id, playlist_id, err) + raise ResourceTemporarilyUnavailable("Failed to fetch playlist") from err + except BadRequestError as err: + LOGGER.error("Error fetching playlist %s/%s: %s", user_id, playlist_id, err) + return None + + # Streaming + + async def get_track_download_info( + self, track_id: str, get_direct_links: bool = True + ) -> list[DownloadInfo]: + """Get download info for a track. + + :param track_id: Track ID. + :param get_direct_links: Whether to get direct download links. + :return: List of download info objects. + """ + client = self._ensure_connected() + try: + result = await client.tracks_download_info(track_id, get_direct_links=get_direct_links) + return result or [] + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error fetching download info for track %s: %s", track_id, err) + return [] + + async def get_track_file_info_lossless(self, track_id: str) -> dict[str, Any] | None: + """Request lossless stream via get-file-info (quality=lossless). + + The /tracks/{id}/download-info endpoint often returns only MP3; get-file-info + with quality=lossless and codecs=flac,... returns FLAC when available. + + Includes retry with reconnect on transient connection errors so that a + momentary disconnect does not silently fall back to lossy quality. + + :param track_id: Track ID. + :return: Parsed downloadInfo dict (url, codec, urls, ...) or None on error. + """ + + def _parse_file_info_result(raw: dict[str, Any] | None) -> dict[str, Any] | None: + if not raw or not isinstance(raw, dict): + return None + download_info = raw.get("download_info") + if not download_info or not download_info.get("url"): + return None + return cast("dict[str, Any]", download_info) + + for attempt in range(2): + client = self._ensure_connected() + sign = get_sign_request(track_id) + base_params = { + "ts": sign.timestamp, + "trackId": track_id, + "quality": "lossless", + "codecs": GET_FILE_INFO_CODECS, + "sign": sign.value, + } + + url = f"{client.base_url}/get-file-info" + params_encraw = {**base_params, "transports": "encraw"} + try: + result = await client.request.get(url, params=params_encraw) + return _parse_file_info_result(result) + except UnauthorizedError as err: + LOGGER.debug( + "get-file-info lossless for track %s (transports=encraw): %s %s", + track_id, + type(err).__name__, + getattr(err, "message", str(err)) or repr(err), + ) + LOGGER.debug( + "If you have KION Music Plus and this track has lossless, " + "try a token from the web client (music.mts.ru)." + ) + params_raw = {**base_params, "transports": "raw"} + try: + result = await client.request.get(url, params=params_raw) + return _parse_file_info_result(result) + except (BadRequestError, NetworkError, UnauthorizedError) as retry_err: + LOGGER.debug( + "get-file-info lossless for track %s (transports=raw): %s %s", + track_id, + type(retry_err).__name__, + getattr(retry_err, "message", str(retry_err)) or repr(retry_err), + ) + return None + except BadRequestError as err: + LOGGER.debug( + "get-file-info lossless for track %s: %s %s", + track_id, + type(err).__name__, + getattr(err, "message", str(err)) or repr(err), + ) + return None + except (NetworkError, Exception) as err: + if attempt == 0 and self._is_connection_error(err): + LOGGER.warning( + "Connection error on get-file-info lossless for track %s, reconnecting: %s", + track_id, + err, + ) + try: + await self._reconnect() + except Exception as recon_err: + LOGGER.debug("Reconnect failed: %s", recon_err) + return None + else: + LOGGER.debug( + "get-file-info lossless for track %s: %s %s", + track_id, + type(err).__name__, + getattr(err, "message", str(err)) or repr(err), + ) + return None + return None + + # Library modifications + + async def like_track(self, track_id: str) -> bool: + """Add a track to liked tracks. + + :param track_id: Track ID to like. + :return: True if successful. + """ + client = self._ensure_connected() + try: + result = await client.users_likes_tracks_add(track_id) + return result is not None + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error liking track %s: %s", track_id, err) + return False + + async def unlike_track(self, track_id: str) -> bool: + """Remove a track from liked tracks. + + :param track_id: Track ID to unlike. + :return: True if successful. + """ + client = self._ensure_connected() + try: + result = await client.users_likes_tracks_remove(track_id) + return result is not None + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error unliking track %s: %s", track_id, err) + return False + + async def like_album(self, album_id: str) -> bool: + """Add an album to liked albums. + + :param album_id: Album ID to like. + :return: True if successful. + """ + client = self._ensure_connected() + try: + result = await client.users_likes_albums_add(album_id) + return result is not None + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error liking album %s: %s", album_id, err) + return False + + async def unlike_album(self, album_id: str) -> bool: + """Remove an album from liked albums. + + :param album_id: Album ID to unlike. + :return: True if successful. + """ + client = self._ensure_connected() + try: + result = await client.users_likes_albums_remove(album_id) + return result is not None + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error unliking album %s: %s", album_id, err) + return False + + async def like_artist(self, artist_id: str) -> bool: + """Add an artist to liked artists. + + :param artist_id: Artist ID to like. + :return: True if successful. + """ + client = self._ensure_connected() + try: + result = await client.users_likes_artists_add(artist_id) + return result is not None + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error liking artist %s: %s", artist_id, err) + return False + + async def unlike_artist(self, artist_id: str) -> bool: + """Remove an artist from liked artists. + + :param artist_id: Artist ID to unlike. + :return: True if successful. + """ + client = self._ensure_connected() + try: + result = await client.users_likes_artists_remove(artist_id) + return result is not None + except (BadRequestError, NetworkError) as err: + LOGGER.error("Error unliking artist %s: %s", artist_id, err) + return False diff --git a/music_assistant/providers/kion_music/constants.py b/music_assistant/providers/kion_music/constants.py new file mode 100644 index 0000000000..70b8a805f4 --- /dev/null +++ b/music_assistant/providers/kion_music/constants.py @@ -0,0 +1,71 @@ +"""Constants for the KION Music provider.""" + +from __future__ import annotations + +from typing import Final + +# Configuration Keys +CONF_TOKEN = "token" +CONF_QUALITY = "quality" +CONF_BASE_URL = "base_url" + +# Actions +CONF_ACTION_AUTH = "auth" +CONF_ACTION_CLEAR_AUTH = "clear_auth" + +# Labels +LABEL_TOKEN = "token_label" +LABEL_AUTH_INSTRUCTIONS = "auth_instructions_label" + +# API defaults +DEFAULT_LIMIT: Final[int] = 50 +DEFAULT_BASE_URL: Final[str] = "https://music.mts.ru/ya_proxy_api" + +# Quality options +QUALITY_HIGH = "high" +QUALITY_LOSSLESS = "lossless" + +# Default tuning values for My Mix / browse / discovery behaviour +MY_MIX_MAX_TRACKS: Final[int] = 150 +MY_MIX_BATCH_SIZE: Final[int] = 3 +TRACK_BATCH_SIZE: Final[int] = 50 +DISCOVERY_INITIAL_TRACKS: Final[int] = 20 +BROWSE_INITIAL_TRACKS: Final[int] = 15 + +# Image sizes +IMAGE_SIZE_SMALL = "200x200" +IMAGE_SIZE_MEDIUM = "400x400" +IMAGE_SIZE_LARGE = "1000x1000" + +# ID separators +PLAYLIST_ID_SPLITTER: Final[str] = ":" + +# Rotor (radio) station identifiers +ROTOR_STATION_MY_MIX: Final[str] = "user:onyourwave" + +# Client identifier for rotor radioStarted feedback. +# The API expects a "from" field identifying the client; the desktop app +# identifier ensures the rotor API returns proper recommendations. +ROTOR_FEEDBACK_FROM: Final[str] = "YandexMusicDesktopAppWindows" + +# Virtual playlist ID for My Mix (used in get_playlist / get_playlist_tracks; not owner_id:kind) +MY_MIX_PLAYLIST_ID: Final[str] = "my_mix" + +# Composite item_id for My Mix 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_mix": "Мой Микс", + "artists": "Мои исполнители", + "albums": "Мои альбомы", + "tracks": "Мне нравится", + "playlists": "Мои плейлисты", +} +BROWSE_NAMES_EN: Final[dict[str, str]] = { + "my_mix": "My Mix", + "artists": "My Artists", + "albums": "My Albums", + "tracks": "My Favorites", + "playlists": "My Playlists", +} diff --git a/music_assistant/providers/kion_music/icon.svg b/music_assistant/providers/kion_music/icon.svg new file mode 100644 index 0000000000..f2031622ef --- /dev/null +++ b/music_assistant/providers/kion_music/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/music_assistant/providers/kion_music/icon_monochrome.svg b/music_assistant/providers/kion_music/icon_monochrome.svg new file mode 100644 index 0000000000..3f37201ecb --- /dev/null +++ b/music_assistant/providers/kion_music/icon_monochrome.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/music_assistant/providers/kion_music/manifest.json b/music_assistant/providers/kion_music/manifest.json new file mode 100644 index 0000000000..9b5cdc2d44 --- /dev/null +++ b/music_assistant/providers/kion_music/manifest.json @@ -0,0 +1,11 @@ +{ + "type": "music", + "domain": "kion_music", + "stage": "beta", + "name": "KION Music", + "description": "Stream music from KION Music (MTS) service.", + "codeowners": ["@TrudenBoy"], + "documentation": "https://music-assistant.io/music-providers/kion-music/", + "requirements": ["yandex-music==2.2.0"], + "multi_instance": true +} diff --git a/music_assistant/providers/kion_music/parsers.py b/music_assistant/providers/kion_music/parsers.py new file mode 100644 index 0000000000..8588ae6714 --- /dev/null +++ b/music_assistant/providers/kion_music/parsers.py @@ -0,0 +1,354 @@ +"""Parsers for KION Music API responses.""" + +from __future__ import annotations + +from contextlib import suppress +from datetime import datetime +from typing import TYPE_CHECKING + +from music_assistant_models.enums import ( + AlbumType, + ContentType, + ImageType, +) +from music_assistant_models.media_items import ( + Album, + Artist, + AudioFormat, + MediaItemImage, + Playlist, + ProviderMapping, + Track, + UniqueList, +) + +from music_assistant.helpers.util import parse_title_and_version + +from .constants import IMAGE_SIZE_LARGE + +if TYPE_CHECKING: + from yandex_music import Album as YandexAlbum + from yandex_music import Artist as YandexArtist + from yandex_music import Playlist as YandexPlaylist + from yandex_music import Track as YandexTrack + + from .provider import KionMusicProvider + + +def _get_image_url(cover_uri: str | None, size: str = IMAGE_SIZE_LARGE) -> str | None: + """Convert cover URI to full URL. + + :param cover_uri: Cover URI template. + :param size: Image size (e.g., '1000x1000'). + :return: Full image URL or None. + """ + if not cover_uri: + return None + # Cover URIs come in format "avatars.yandex.net/get-music-content/xxx/yyy/%%" + # Replace %% with the desired size + return f"https://{cover_uri.replace('%%', size)}" + + +def parse_artist(provider: KionMusicProvider, artist_obj: YandexArtist) -> Artist: + """Parse a KION Music artist object to MA Artist model. + + :param provider: The KION Music provider instance. + :param artist_obj: API artist object. + :return: Music Assistant Artist model. + """ + artist_id = str(artist_obj.id) + artist = Artist( + item_id=artist_id, + provider=provider.instance_id, + name=artist_obj.name or "Unknown Artist", + provider_mappings={ + ProviderMapping( + item_id=artist_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + url=f"https://music.mts.ru/artist/{artist_id}", + ) + }, + ) + + # Add image if available + if artist_obj.cover: + image_url = _get_image_url(artist_obj.cover.uri) + if image_url: + artist.metadata.images = UniqueList( + [ + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=provider.instance_id, + remotely_accessible=True, + ) + ] + ) + elif artist_obj.og_image: + image_url = _get_image_url(artist_obj.og_image) + if image_url: + artist.metadata.images = UniqueList( + [ + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=provider.instance_id, + remotely_accessible=True, + ) + ] + ) + + return artist + + +def parse_album(provider: KionMusicProvider, album_obj: YandexAlbum) -> Album: + """Parse a KION Music album object to MA Album model. + + :param provider: The KION Music provider instance. + :param album_obj: API album object. + :return: Music Assistant Album model. + """ + name, version = parse_title_and_version( + album_obj.title or "Unknown Album", + album_obj.version or None, + ) + album_id = str(album_obj.id) + + # Determine availability + available = album_obj.available or False + + album = Album( + item_id=album_id, + provider=provider.instance_id, + name=name, + version=version, + provider_mappings={ + ProviderMapping( + item_id=album_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + audio_format=AudioFormat( + content_type=ContentType.UNKNOWN, + ), + url=f"https://music.mts.ru/album/{album_id}", + available=available, + ) + }, + ) + + # Parse artists + various_artist_album = False + if album_obj.artists: + for artist in album_obj.artists: + if artist.name and artist.name.lower() in ("various artists", "сборник"): + various_artist_album = True + album.artists.append(parse_artist(provider, artist)) + + # Determine album type + album_type_str = album_obj.type or "album" + if album_type_str == "compilation" or various_artist_album: + album.album_type = AlbumType.COMPILATION + elif album_type_str == "single": + album.album_type = AlbumType.SINGLE + else: + album.album_type = AlbumType.ALBUM + + # Parse year + if album_obj.year: + album.year = album_obj.year + if album_obj.release_date: + with suppress(ValueError): + album.metadata.release_date = datetime.fromisoformat(album_obj.release_date) + + # Parse metadata + if album_obj.genre: + album.metadata.genres = {album_obj.genre} + + # Add cover image + if album_obj.cover_uri: + image_url = _get_image_url(album_obj.cover_uri) + if image_url: + album.metadata.images = UniqueList( + [ + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=provider.instance_id, + remotely_accessible=True, + ) + ] + ) + elif album_obj.og_image: + image_url = _get_image_url(album_obj.og_image) + if image_url: + album.metadata.images = UniqueList( + [ + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=provider.instance_id, + remotely_accessible=True, + ) + ] + ) + + return album + + +def parse_track(provider: KionMusicProvider, track_obj: YandexTrack) -> Track: + """Parse a KION Music track object to MA Track model. + + :param provider: The KION Music provider instance. + :param track_obj: API track object. + :return: Music Assistant Track model. + """ + name, version = parse_title_and_version( + track_obj.title or "Unknown Track", + track_obj.version or None, + ) + track_id = str(track_obj.id) + + # Determine availability + available = track_obj.available or False + + # Duration is in milliseconds in KION API + duration = (track_obj.duration_ms or 0) // 1000 + + track = Track( + item_id=track_id, + provider=provider.instance_id, + name=name, + version=version, + duration=duration, + provider_mappings={ + ProviderMapping( + item_id=track_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + audio_format=AudioFormat( + content_type=ContentType.UNKNOWN, + ), + url=f"https://music.mts.ru/track/{track_id}", + available=available, + ) + }, + ) + + # Parse artists + if track_obj.artists: + track.artists = UniqueList() + for artist in track_obj.artists: + track.artists.append(parse_artist(provider, artist)) + + # Parse album (full data so album gets cover art in the library) + if track_obj.albums and len(track_obj.albums) > 0: + album_obj = track_obj.albums[0] + track.album = parse_album(provider, album_obj) + # Also set track image from album cover if available + if album_obj.cover_uri: + image_url = _get_image_url(album_obj.cover_uri) + if image_url: + track.metadata.images = UniqueList( + [ + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=provider.instance_id, + remotely_accessible=True, + ) + ] + ) + + # Parse external IDs + if track_obj.real_id: + # real_id can be used as an identifier + pass + + # Metadata + if track_obj.content_warning: + track.metadata.explicit = track_obj.content_warning == "explicit" + + return track + + +def parse_playlist( + provider: KionMusicProvider, playlist_obj: YandexPlaylist, owner_name: str | None = None +) -> Playlist: + """Parse a KION Music playlist object to MA Playlist model. + + :param provider: The KION Music provider instance. + :param playlist_obj: API playlist object. + :param owner_name: Optional owner name override. + :return: Music Assistant Playlist model. + """ + # Playlist ID is a combination of owner uid and playlist kind + owner_id = str(playlist_obj.owner.uid) if playlist_obj.owner else str(provider.client.user_id) + playlist_kind = str(playlist_obj.kind) + playlist_id = f"{owner_id}:{playlist_kind}" + + # Determine if editable (user owns the playlist) + is_editable = owner_id == str(provider.client.user_id) + + # Get owner name + if owner_name is None: + if playlist_obj.owner and playlist_obj.owner.name: + owner_name = playlist_obj.owner.name + elif is_editable: + owner_name = "Me" + else: + owner_name = "KION Music" + + playlist = Playlist( + item_id=playlist_id, + provider=provider.instance_id, + name=playlist_obj.title or "Unknown Playlist", + owner=owner_name, + provider_mappings={ + ProviderMapping( + item_id=playlist_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + url=f"https://music.mts.ru/users/{owner_id}/playlists/{playlist_kind}", + is_unique=is_editable, + ) + }, + is_editable=is_editable, + ) + + # Metadata + if playlist_obj.description: + playlist.metadata.description = playlist_obj.description + + # Add cover image + if playlist_obj.cover: + # Cover can be CoverImage or a string + cover = playlist_obj.cover + if hasattr(cover, "uri") and cover.uri: + image_url = _get_image_url(cover.uri) + if image_url: + playlist.metadata.images = UniqueList( + [ + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=provider.instance_id, + remotely_accessible=True, + ) + ] + ) + elif playlist_obj.og_image: + image_url = _get_image_url(playlist_obj.og_image) + if image_url: + playlist.metadata.images = UniqueList( + [ + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=provider.instance_id, + remotely_accessible=True, + ) + ] + ) + + return playlist diff --git a/music_assistant/providers/kion_music/provider.py b/music_assistant/providers/kion_music/provider.py new file mode 100644 index 0000000000..60c8d296f8 --- /dev/null +++ b/music_assistant/providers/kion_music/provider.py @@ -0,0 +1,881 @@ +"""KION Music provider implementation.""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from music_assistant_models.enums import MediaType, ProviderFeature +from music_assistant_models.errors import ( + InvalidDataError, + LoginFailed, + MediaNotFoundError, + ProviderUnavailableError, + ResourceTemporarilyUnavailable, +) +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 KionMusicClient +from .constants import ( + BROWSE_INITIAL_TRACKS, + BROWSE_NAMES_EN, + BROWSE_NAMES_RU, + CONF_BASE_URL, + CONF_TOKEN, + DEFAULT_BASE_URL, + DISCOVERY_INITIAL_TRACKS, + MY_MIX_BATCH_SIZE, + MY_MIX_MAX_TRACKS, + MY_MIX_PLAYLIST_ID, + PLAYLIST_ID_SPLITTER, + RADIO_TRACK_ID_SEP, + ROTOR_STATION_MY_MIX, + TRACK_BATCH_SIZE, +) +from .parsers import parse_album, parse_artist, parse_playlist, parse_track +from .streaming import KionMusicStreamingManager + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + 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 Mix 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 KionMusicProvider(MusicProvider): + """Implementation of a KION Music MusicProvider.""" + + _client: KionMusicClient | None = None + _streaming: KionMusicStreamingManager | None = None + _my_mix_batch_id: str | None = None + _my_mix_last_track_id: str | None = None # last track id for "Load more" (API queue param) + _my_mix_playlist_next_cursor: str | None = None # first_track_id for next playlist page + _my_mix_radio_started_sent: bool = False + _my_mix_seen_track_ids: set[str] # Track IDs seen in current My Mix session + + @property + def client(self) -> KionMusicClient: + """Return the KION Music client.""" + if self._client is None: + raise ProviderUnavailableError("Provider not initialized") + return self._client + + @property + def streaming(self) -> KionMusicStreamingManager: + """Return the streaming manager.""" + if self._streaming is None: + 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) + if not token: + raise LoginFailed("No KION Music token provided") + + base_url = self.config.get_value(CONF_BASE_URL, DEFAULT_BASE_URL) + self._client = KionMusicClient(str(token), base_url=str(base_url)) + await self._client.connect() + # Suppress yandex_music library DEBUG dumps (full API request/response JSON) + logging.getLogger("yandex_music").setLevel(self.logger.level + 10) + self._streaming = KionMusicStreamingManager(self) + # Initialize My Mix duplicate tracking + self._my_mix_seen_track_ids = set() + self.logger.info("Successfully connected to KION Music") + + async def unload(self, is_removed: bool = False) -> None: + """Handle unload/close of the provider. + + :param is_removed: Whether the provider is being removed. + """ + if self._client: + await self._client.disconnect() + self._client = None + self._streaming = None + await super().unload(is_removed) + + def get_item_mapping(self, media_type: MediaType | str, key: str, name: str) -> ItemMapping: + """Create a generic item mapping. + + :param media_type: The media type. + :param key: The item ID. + :param name: The item name. + :return: An ItemMapping instance. + """ + if isinstance(media_type, str): + media_type = MediaType(media_type) + return ItemMapping( + media_type=media_type, + item_id=key, + provider=self.instance_id, + name=name, + ) + + async def _fetch_my_mix_tracks( + self, + *, + max_tracks: int = MY_MIX_MAX_TRACKS, + max_batches: int = MY_MIX_BATCH_SIZE, + initial_queue: str | int | None = None, + seen_track_ids: set[str] | None = None, + ) -> tuple[list[Track], str | None, str | None, set[str]]: + """Fetch My Mix tracks with de-duplication and radio feedback. + + :param max_tracks: Maximum number of tracks to return. + :param max_batches: Maximum number of API batch calls. + :param initial_queue: Optional track ID for API pagination. + :param seen_track_ids: Already-seen track IDs for de-duplication. + :return: (tracks, last_batch_id, last_first_track_id, updated_seen_ids). + """ + if seen_track_ids is None: + seen_track_ids = set() + + tracks: list[Track] = [] + last_batch_id: str | None = None + last_first_track_id: str | None = None + queue: str | int | None = initial_queue + + for _ in range(max_batches): + if len(tracks) >= max_tracks: + break + + yandex_tracks, batch_id = await self.client.get_my_mix_tracks(queue=queue) + if batch_id: + self._my_mix_batch_id = batch_id + last_batch_id = batch_id + if not self._my_mix_radio_started_sent and yandex_tracks: + self._my_mix_radio_started_sent = True + await self.client.send_rotor_station_feedback( + ROTOR_STATION_MY_MIX, + "radioStarted", + batch_id=batch_id, + ) + first_track_id_this_batch: str | None = None + for yt in yandex_tracks: + if len(tracks) >= max_tracks: + break + 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 track_id in seen_track_ids: + self.logger.debug("Skipping duplicate My Mix track: %s", track_id) + continue + seen_track_ids.add(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_MIX}" + 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 Mix track: %s", err) + if first_track_id_this_batch is not None: + last_first_track_id = first_track_id_this_batch + if not batch_id or not yandex_tracks or len(tracks) >= max_tracks: + break + queue = first_track_id_this_batch + + return (tracks, last_batch_id, last_first_track_id, seen_track_ids) + + async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse provider items with locale-based folder names and My Mix. + + Root level shows My Mix, artists, albums, liked tracks, playlists. Names + are in Russian when MA locale is ru_*, otherwise in English. My Mix + 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_MIX_PLAYLIST_ID: + max_batches = MY_MIX_BATCH_SIZE if sub_subpath != "next" else 1 + + if sub_subpath != "next": + self._my_mix_seen_track_ids = set() + + queue: str | int | None = None + if sub_subpath == "next": + queue = self._my_mix_last_track_id + elif sub_subpath: + queue = sub_subpath + + ( + fetched, + last_batch_id, + last_first_track_id, + self._my_mix_seen_track_ids, + ) = await self._fetch_my_mix_tracks( + max_batches=max_batches, + initial_queue=queue, + seen_track_ids=self._my_mix_seen_track_ids, + ) + if last_first_track_id is not None: + self._my_mix_last_track_id = last_first_track_id + + all_tracks: list[Track | BrowseFolder] = list(fetched) + + # Apply initial tracks limit if not in "load more" mode + if sub_subpath != "next": + if len(all_tracks) > BROWSE_INITIAL_TRACKS: + all_tracks = all_tracks[:BROWSE_INITIAL_TRACKS] + + # Only show "Load more" if we haven't reached the limit and there's more data + if last_batch_id and len(fetched) < MY_MIX_MAX_TRACKS: + names = self._get_browse_names() + next_name = "Ещё" if names == 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_MIX_PLAYLIST_ID, + provider=self.instance_id, + path=f"{base}{MY_MIX_PLAYLIST_ID}", + name=names[MY_MIX_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) + async def search( + self, search_query: str, media_types: list[MediaType], limit: int = 5 + ) -> SearchResults: + """Perform search on KION Music. + + :param search_query: The search query. + :param media_types: List of media types to search for. + :param limit: Maximum number of results per type. + :return: SearchResults with found items. + """ + result = SearchResults() + + # Determine search type based on requested media types + # Map MediaType to KION API search type + type_mapping = { + MediaType.TRACK: "track", + MediaType.ALBUM: "album", + MediaType.ARTIST: "artist", + MediaType.PLAYLIST: "playlist", + } + requested_types = [type_mapping[mt] for mt in media_types if mt in type_mapping] + + # Use specific type if only one requested, otherwise search all + search_type = requested_types[0] if len(requested_types) == 1 else "all" + + search_result = await self.client.search(search_query, search_type=search_type, limit=limit) + if not search_result: + return result + + # Parse tracks + if MediaType.TRACK in media_types and search_result.tracks: + for track in search_result.tracks.results[:limit]: + try: + result.tracks = [*result.tracks, parse_track(self, track)] + except InvalidDataError as err: + self.logger.debug("Error parsing track: %s", err) + + # Parse albums + if MediaType.ALBUM in media_types and search_result.albums: + for album in search_result.albums.results[:limit]: + try: + result.albums = [*result.albums, parse_album(self, album)] + except InvalidDataError as err: + self.logger.debug("Error parsing album: %s", err) + + # Parse artists + if MediaType.ARTIST in media_types and search_result.artists: + for artist in search_result.artists.results[:limit]: + try: + result.artists = [*result.artists, parse_artist(self, artist)] + except InvalidDataError as err: + self.logger.debug("Error parsing artist: %s", err) + + # Parse playlists + if MediaType.PLAYLIST in media_types and search_result.playlists: + for playlist in search_result.playlists.results[:limit]: + try: + result.playlists = [*result.playlists, parse_playlist(self, playlist)] + except InvalidDataError as err: + self.logger.debug("Error parsing playlist: %s", err) + + return result + + # Get single items + + @use_cache(3600 * 24 * 30) + async def get_artist(self, prov_artist_id: str) -> Artist: + """Get artist details by ID. + + :param prov_artist_id: The provider artist ID. + :return: Artist object. + :raises MediaNotFoundError: If artist not found. + """ + artist = await self.client.get_artist(prov_artist_id) + if not artist: + raise MediaNotFoundError(f"Artist {prov_artist_id} not found") + return parse_artist(self, artist) + + @use_cache(3600 * 24 * 30) + async def get_album(self, prov_album_id: str) -> Album: + """Get album details by ID. + + :param prov_album_id: The provider album ID. + :return: Album object. + :raises MediaNotFoundError: If album not found. + """ + album = await self.client.get_album(prov_album_id) + if not album: + raise MediaNotFoundError(f"Album {prov_album_id} not found") + return parse_album(self, album) + + async def get_track(self, prov_track_id: str) -> Track: + """Get track details by ID. + + Supports composite item_id (track_id@station_id) for My Mix tracks; + only the track_id part is used for the API. Normalizes the ID before + caching so that "12345" and "12345@user:onyourwave" share one cache entry. + + :param prov_track_id: The provider track ID (or track_id@station_id). + :return: Track object. + :raises MediaNotFoundError: If track not found. + """ + track_id, _ = _parse_radio_item_id(prov_track_id) + return await self._get_track_cached(track_id) + + @use_cache(3600 * 24 * 30) + async def _get_track_cached(self, track_id: str) -> Track: + """Fetch and cache track details by normalized track ID. + + :param track_id: Plain track ID (no station suffix). + :return: Track object. + :raises MediaNotFoundError: If track not found. + """ + yandex_track = await self.client.get_track(track_id) + if not yandex_track: + raise MediaNotFoundError(f"Track {track_id} not found") + return parse_track(self, yandex_track) + + @use_cache(3600 * 24 * 30) + async def get_playlist(self, prov_playlist_id: str) -> Playlist: + """Get playlist details by ID. + + Supports virtual playlist MY_MIX_PLAYLIST_ID (My Mix). Real playlists + use format "owner_id:kind". + + :param prov_playlist_id: The provider playlist ID (format: "owner_id:kind" or my_mix). + :return: Playlist object. + :raises MediaNotFoundError: If playlist not found. + """ + if prov_playlist_id == MY_MIX_PLAYLIST_ID: + names = self._get_browse_names() + return Playlist( + item_id=MY_MIX_PLAYLIST_ID, + provider=self.instance_id, + name=names[MY_MIX_PLAYLIST_ID], + owner="KION Music", + provider_mappings={ + ProviderMapping( + item_id=MY_MIX_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) + else: + owner_id = str(self.client.user_id) + kind = prov_playlist_id + + playlist = await self.client.get_playlist(owner_id, kind) + if not playlist: + raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found") + return parse_playlist(self, playlist) + + async def _get_my_mix_playlist_tracks(self, page: int) -> list[Track]: + """Get My Mix 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. + """ + if page == 0: + self._my_mix_seen_track_ids = set() + + queue: str | int | None = None + if page > 0: + queue = self._my_mix_playlist_next_cursor + if not queue: + return [] + + if len(self._my_mix_seen_track_ids) >= MY_MIX_MAX_TRACKS: + return [] + + ( + tracks, + _, + last_first_track_id, + self._my_mix_seen_track_ids, + ) = await self._fetch_my_mix_tracks( + max_batches=1, + initial_queue=queue, + seen_track_ids=self._my_mix_seen_track_ids, + ) + if last_first_track_id is not None: + self._my_mix_playlist_next_cursor = last_first_track_id + return tracks + + # Get related items + + @use_cache(3600 * 24 * 30) + async def get_album_tracks(self, prov_album_id: str) -> list[Track]: + """Get album tracks. + + :param prov_album_id: The provider album ID. + :return: List of Track objects. + """ + album = await self.client.get_album_with_tracks(prov_album_id) + if not album or not album.volumes: + return [] + + tracks = [] + for volume_index, volume in enumerate(album.volumes): + for track_index, track in enumerate(volume): + try: + parsed_track = parse_track(self, track) + parsed_track.disc_number = volume_index + 1 + parsed_track.track_number = track_index + 1 + tracks.append(parsed_track) + except InvalidDataError as err: + 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 rotor station for this track. + + Uses rotor station track:{id} so MA radio mode gets 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(600) # Cache for 10 minutes + async def recommendations(self) -> list[RecommendationFolder]: + """Get recommendations; includes My Mix (Мой Микс) as first folder. + + Fetches fresh tracks on each call for discovery experience. + + :return: List of recommendation folders (My Mix with tracks). + """ + items, _, _, _ = await self._fetch_my_mix_tracks( + max_tracks=DISCOVERY_INITIAL_TRACKS, + ) + + names = self._get_browse_names() + return [ + RecommendationFolder( + item_id=MY_MIX_PLAYLIST_ID, + provider=self.instance_id, + name=names[MY_MIX_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" or my_mix). + :param page: Page number for pagination. + :return: List of Track objects. + """ + if prov_playlist_id == MY_MIX_PLAYLIST_ID: + return await self._get_my_mix_playlist_tracks(page) + + # KION 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: + return [] + + # 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) + else: + owner_id = str(self.client.user_id) + kind = prov_playlist_id + + playlist = await self.client.get_playlist(owner_id, kind) + if not playlist: + return [] + + # API sometimes returns playlist without tracks; fetch them explicitly if needed + tracks_list = playlist.tracks or [] + track_count = getattr(playlist, "track_count", None) or 0 + if not tracks_list and track_count > 0: + self.logger.debug( + "Playlist %s/%s: track_count=%s but no tracks in response, " + "calling fetch_tracks_async", + owner_id, + kind, + track_count, + ) + try: + tracks_list = await playlist.fetch_tracks_async() + except Exception as err: + self.logger.warning("fetch_tracks_async failed for %s/%s: %s", owner_id, kind, err) + if not tracks_list: + raise ResourceTemporarilyUnavailable( + "Playlist tracks not available; try again later" + ) + + if not tracks_list: + return [] + + # API returns TrackShort objects, we need to fetch full track info + track_ids = [ + str(track.track_id) if hasattr(track, "track_id") else str(track.id) + for track in tracks_list + if track + ] + if not track_ids: + return [] + + # Fetch full track details in batches to avoid timeouts + full_tracks = [] + for i in range(0, len(track_ids), TRACK_BATCH_SIZE): + batch = track_ids[i : i + TRACK_BATCH_SIZE] + batch_result = await self.client.get_tracks(batch) + if not batch_result: + self.logger.warning( + "Received empty result for playlist %s tracks batch %s-%s", + prov_playlist_id, + i, + i + len(batch) - 1, + ) + raise ResourceTemporarilyUnavailable( + "Playlist tracks not fully available; try again later" + ) + full_tracks.extend(batch_result) + + if track_ids and not full_tracks: + raise ResourceTemporarilyUnavailable("Failed to load track details; try again later") + + tracks = [] + for track in full_tracks: + try: + tracks.append(parse_track(self, track)) + except InvalidDataError as err: + self.logger.debug("Error parsing playlist track: %s", err) + return tracks + + @use_cache(3600 * 24 * 7) + async def get_artist_albums(self, prov_artist_id: str) -> list[Album]: + """Get artist's albums. + + :param prov_artist_id: The provider artist ID. + :return: List of Album objects. + """ + albums = await self.client.get_artist_albums(prov_artist_id) + result = [] + for album in albums: + try: + result.append(parse_album(self, album)) + except InvalidDataError as err: + self.logger.debug("Error parsing artist album: %s", err) + return result + + @use_cache(3600 * 24 * 7) + async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]: + """Get artist's top tracks. + + :param prov_artist_id: The provider artist ID. + :return: List of Track objects. + """ + tracks = await self.client.get_artist_tracks(prov_artist_id) + result = [] + for track in tracks: + try: + result.append(parse_track(self, track)) + except InvalidDataError as err: + self.logger.debug("Error parsing artist track: %s", err) + return result + + # Library methods + + async def get_library_artists(self) -> AsyncGenerator[Artist, None]: + """Retrieve library artists from KION Music.""" + artists = await self.client.get_liked_artists() + for artist in artists: + try: + yield parse_artist(self, artist) + except InvalidDataError as err: + self.logger.debug("Error parsing library artist: %s", err) + + async def get_library_albums(self) -> AsyncGenerator[Album, None]: + """Retrieve library albums from KION Music.""" + albums = await self.client.get_liked_albums(batch_size=TRACK_BATCH_SIZE) + for album in albums: + try: + yield parse_album(self, album) + except InvalidDataError as err: + self.logger.debug("Error parsing library album: %s", err) + + async def get_library_tracks(self) -> AsyncGenerator[Track, None]: + """Retrieve library tracks from KION Music.""" + track_shorts = await self.client.get_liked_tracks() + if not track_shorts: + return + + # Fetch full track details in batches + track_ids = [str(ts.track_id) for ts in track_shorts if ts.track_id] + for i in range(0, len(track_ids), TRACK_BATCH_SIZE): + batch_ids = track_ids[i : i + TRACK_BATCH_SIZE] + full_tracks = await self.client.get_tracks(batch_ids) + for track in full_tracks: + try: + yield parse_track(self, track) + except InvalidDataError as err: + self.logger.debug("Error parsing library track: %s", err) + + async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: + """Retrieve library playlists from KION Music. + + Includes the virtual My Mix playlist first, then user playlists. + """ + yield await self.get_playlist(MY_MIX_PLAYLIST_ID) + playlists = await self.client.get_user_playlists() + for playlist in playlists: + try: + yield parse_playlist(self, playlist) + except InvalidDataError as err: + self.logger.debug("Error parsing library playlist: %s", err) + + # Library edit methods + + async def library_add(self, item: MediaItemType) -> bool: + """Add item to library. + + :param item: The media item to add. + :return: True if successful. + """ + 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(track_id) + if item.media_type == MediaType.ALBUM: + return await self.client.like_album(prov_item_id) + if item.media_type == MediaType.ARTIST: + return await self.client.like_artist(prov_item_id) + return False + + 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 (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(track_id) + if media_type == MediaType.ALBUM: + return await self.client.unlike_album(prov_item_id) + if media_type == MediaType.ARTIST: + return await self.client.unlike_artist(prov_item_id) + return False + + def _get_provider_item_id(self, item: MediaItemType) -> str | None: + """Get provider item ID from media item.""" + for mapping in item.provider_mappings: + if mapping.provider_instance == self.instance_id: + return mapping.item_id + return item.item_id if item.provider == self.instance_id else None + + # Streaming + + async def get_stream_details( + self, item_id: str, media_type: MediaType = MediaType.TRACK + ) -> StreamDetails: + """Get stream details for a track. + + :param item_id: The track ID (or track_id@station_id for My Mix). + :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 Mix. + + 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_mix_batch_id, + ) + + async def on_streamed(self, streamdetails: StreamDetails) -> None: + """Report stream completion for My Mix rotor feedback. + + Sends trackFinished or skip with actual seconds_streamed so the service + 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_mix_batch_id, + ) diff --git a/music_assistant/providers/kion_music/streaming.py b/music_assistant/providers/kion_music/streaming.py new file mode 100644 index 0000000000..b98c520ff1 --- /dev/null +++ b/music_assistant/providers/kion_music/streaming.py @@ -0,0 +1,185 @@ +"""Streaming operations for KION Music.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from music_assistant_models.enums import ContentType, StreamType +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, RADIO_TRACK_ID_SEP + +if TYPE_CHECKING: + from yandex_music import DownloadInfo + + from .provider import KionMusicProvider + + +class KionMusicStreamingManager: + """Manages KION Music streaming operations.""" + + def __init__(self, provider: KionMusicProvider) -> None: + """Initialize streaming manager. + + :param provider: The KION Music provider instance. + """ + self.provider = provider + self.client = provider.client + 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 Mix).""" + 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 or composite track_id@station_id for My Mix. + :return: StreamDetails for the track (item_id preserved for on_streamed). + :raises MediaNotFoundError: If stream URL cannot be obtained. + """ + 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") + + quality = self.provider.config.get_value(CONF_QUALITY) + quality_str = str(quality) if quality is not None else None + preferred_normalized = (quality_str or "").strip().lower() + want_lossless = ( + QUALITY_LOSSLESS in preferred_normalized or preferred_normalized == QUALITY_LOSSLESS + ) + + # 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", 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 "" + if url and codec.lower() in ("flac", "flac-mp4"): + content_type = self._get_content_type(codec) + self.logger.debug( + "Stream selected for track %s via get-file-info: codec=%s", + item_id, + codec, + ) + return StreamDetails( + item_id=item_id, + provider=self.provider.instance_id, + audio_format=AudioFormat( + content_type=content_type, + bit_rate=0, + ), + stream_type=StreamType.HTTP, + duration=track.duration, + path=url, + can_seek=True, + allow_seek=True, + ) + + # Default: use /tracks/.../download-info and select best quality + 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}") + + codecs_available = [ + (getattr(i, "codec", None), getattr(i, "bitrate_in_kbps", None)) for i in download_infos + ] + self.logger.debug( + "Stream quality for track %s: config quality=%s, available codecs=%s", + track_id, + quality_str, + codecs_available, + ) + selected_info = self._select_best_quality(download_infos, quality_str) + + if not selected_info or not selected_info.direct_link: + raise MediaNotFoundError(f"No stream URL available for track {item_id}") + + self.logger.debug( + "Stream selected for track %s: codec=%s, bitrate=%s", + track_id, + getattr(selected_info, "codec", None), + getattr(selected_info, "bitrate_in_kbps", None), + ) + + content_type = self._get_content_type(selected_info.codec) + bitrate = selected_info.bitrate_in_kbps or 0 + + return StreamDetails( + item_id=item_id, + provider=self.provider.instance_id, + audio_format=AudioFormat( + content_type=content_type, + bit_rate=bitrate, + ), + stream_type=StreamType.HTTP, + duration=track.duration, + path=selected_info.direct_link, + can_seek=True, + allow_seek=True, + ) + + def _select_best_quality( + self, download_infos: list[Any], preferred_quality: str | None + ) -> DownloadInfo | None: + """Select the best quality download info. + + :param download_infos: List of DownloadInfo objects. + :param preferred_quality: User's preferred quality (e.g. "lossless" or "Lossless (FLAC)"). + :return: Best matching DownloadInfo or None. + """ + if not download_infos: + return None + + # Normalize so we accept "lossless", "Lossless (FLAC)", etc. + preferred_normalized = (preferred_quality or "").strip().lower() + want_lossless = ( + QUALITY_LOSSLESS in preferred_normalized or preferred_normalized == QUALITY_LOSSLESS + ) + + # Sort by bitrate descending + sorted_infos = sorted( + download_infos, + key=lambda x: x.bitrate_in_kbps or 0, + reverse=True, + ) + + # If user wants lossless, prefer flac-mp4 then flac (API formats ~2025) + if want_lossless: + for codec in ("flac-mp4", "flac"): + for info in sorted_infos: + if info.codec and info.codec.lower() == codec: + return info + self.logger.warning( + "Lossless (FLAC) requested but no FLAC in API response for this " + "track; using best available" + ) + + # Return highest bitrate + return sorted_infos[0] if sorted_infos else None + + def _get_content_type(self, codec: str | None) -> ContentType: + """Determine content type from codec string. + + :param codec: Codec string from KION API. + :return: ContentType enum value. + """ + if not codec: + return ContentType.UNKNOWN + + codec_lower = codec.lower() + if codec_lower in ("flac", "flac-mp4"): + return ContentType.FLAC + if codec_lower in ("mp3", "mpeg"): + return ContentType.MP3 + if codec_lower in ("aac", "aac-mp4", "he-aac", "he-aac-mp4"): + return ContentType.AAC + + return ContentType.UNKNOWN diff --git a/tests/providers/kion_music/__init__.py b/tests/providers/kion_music/__init__.py new file mode 100644 index 0000000000..40d4adaec2 --- /dev/null +++ b/tests/providers/kion_music/__init__.py @@ -0,0 +1 @@ +"""Tests for KION Music provider.""" diff --git a/tests/providers/kion_music/__snapshots__/test_parsers.ambr b/tests/providers/kion_music/__snapshots__/test_parsers.ambr new file mode 100644 index 0000000000..6325fe57c2 --- /dev/null +++ b/tests/providers/kion_music/__snapshots__/test_parsers.ambr @@ -0,0 +1,600 @@ +# serializer version: 1 +# name: test_parse_album_snapshot[minimal] + dict({ + 'album_type': 'album', + 'artists': list([ + ]), + 'date_added': None, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '300', + 'media_type': 'album', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'Test Album', + 'position': None, + 'provider': 'kion_music_instance', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'is_unique': None, + 'item_id': '300', + 'provider_domain': 'kion_music', + 'provider_instance': 'kion_music_instance', + 'url': 'https://music.mts.ru/album/300', + }), + ]), + 'sort_name': 'test album', + 'translation_key': None, + 'uri': 'kion_music_instance://album/300', + 'version': '', + 'year': 2020, + }) +# --- +# name: test_parse_artist_snapshot[minimal] + dict({ + 'date_added': None, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '100', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'Test Artist', + 'position': None, + 'provider': 'kion_music_instance', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'is_unique': None, + 'item_id': '100', + 'provider_domain': 'kion_music', + 'provider_instance': 'kion_music_instance', + 'url': 'https://music.mts.ru/artist/100', + }), + ]), + 'sort_name': 'test artist', + 'translation_key': None, + 'uri': 'kion_music_instance://artist/100', + 'version': '', + }) +# --- +# name: test_parse_artist_snapshot[with_cover] + dict({ + 'date_added': None, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '200', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://avatars.yandex.net/get-music-content/xxx/yyy/1000x1000', + 'provider': 'kion_music_instance', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'Artist With Cover', + 'position': None, + 'provider': 'kion_music_instance', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'is_unique': None, + 'item_id': '200', + 'provider_domain': 'kion_music', + 'provider_instance': 'kion_music_instance', + 'url': 'https://music.mts.ru/artist/200', + }), + ]), + 'sort_name': 'artist with cover', + 'translation_key': None, + 'uri': 'kion_music_instance://artist/200', + 'version': '', + }) +# --- +# name: test_parse_playlist_snapshot[minimal] + dict({ + 'date_added': None, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_dynamic': False, + 'is_editable': True, + 'is_playable': True, + 'item_id': '12345:3', + 'media_type': 'playlist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'My Playlist', + 'owner': 'Me', + 'position': None, + 'provider': 'kion_music_instance', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'is_unique': True, + 'item_id': '12345:3', + 'provider_domain': 'kion_music', + 'provider_instance': 'kion_music_instance', + 'url': 'https://music.mts.ru/users/12345/playlists/3', + }), + ]), + 'sort_name': 'my playlist', + 'supported_mediatypes': list([ + 'track', + ]), + 'translation_key': None, + 'uri': 'kion_music_instance://playlist/12345:3', + 'version': '', + }) +# --- +# name: test_parse_playlist_snapshot[other_user] + dict({ + 'date_added': None, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_dynamic': False, + 'is_editable': False, + 'is_playable': True, + 'item_id': '99999:1', + 'media_type': 'playlist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': 'A shared playlist', + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'Shared Playlist', + 'owner': 'Other User', + 'position': None, + 'provider': 'kion_music_instance', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'is_unique': False, + 'item_id': '99999:1', + 'provider_domain': 'kion_music', + 'provider_instance': 'kion_music_instance', + 'url': 'https://music.mts.ru/users/99999/playlists/1', + }), + ]), + 'sort_name': 'shared playlist', + 'supported_mediatypes': list([ + 'track', + ]), + 'translation_key': None, + 'uri': 'kion_music_instance://playlist/99999:1', + 'version': '', + }) +# --- +# name: test_parse_track_snapshot[minimal] + dict({ + 'album': None, + 'artists': list([ + ]), + 'date_added': None, + 'disc_number': 0, + 'duration': 180, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '400', + 'last_played': 0, + 'media_type': 'track', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'Test Track', + 'position': None, + 'provider': 'kion_music_instance', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'is_unique': None, + 'item_id': '400', + 'provider_domain': 'kion_music', + 'provider_instance': 'kion_music_instance', + 'url': 'https://music.mts.ru/track/400', + }), + ]), + 'sort_name': 'test track', + 'track_number': 0, + 'translation_key': None, + 'uri': 'kion_music_instance://track/400', + 'version': '', + }) +# --- +# name: test_parse_track_snapshot[with_artist_and_album] + dict({ + 'album': dict({ + 'album_type': 'album', + 'artists': list([ + ]), + 'date_added': None, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '20', + 'media_type': 'album', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://avatars.yandex.net/get-music-content/aaa/bbb/1000x1000', + 'provider': 'kion_music_instance', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'Track Album', + 'position': None, + 'provider': 'kion_music_instance', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': False, + 'details': None, + 'in_library': None, + 'is_unique': None, + 'item_id': '20', + 'provider_domain': 'kion_music', + 'provider_instance': 'kion_music_instance', + 'url': 'https://music.mts.ru/album/20', + }), + ]), + 'sort_name': 'track album', + 'translation_key': None, + 'uri': 'kion_music_instance://album/20', + 'version': '', + 'year': None, + }), + 'artists': list([ + dict({ + 'date_added': None, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '10', + 'media_type': 'artist', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'Track Artist', + 'position': None, + 'provider': 'kion_music_instance', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'is_unique': None, + 'item_id': '10', + 'provider_domain': 'kion_music', + 'provider_instance': 'kion_music_instance', + 'url': 'https://music.mts.ru/artist/10', + }), + ]), + 'sort_name': 'track artist', + 'translation_key': None, + 'uri': 'kion_music_instance://artist/10', + 'version': '', + }), + ]), + 'date_added': None, + 'disc_number': 0, + 'duration': 240, + 'external_ids': list([ + ]), + 'favorite': False, + 'is_playable': True, + 'item_id': '500', + 'last_played': 0, + 'media_type': 'track', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'grouping': None, + 'images': list([ + dict({ + 'path': 'https://avatars.yandex.net/get-music-content/aaa/bbb/1000x1000', + 'provider': 'kion_music_instance', + 'remotely_accessible': True, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'Track With Album', + 'position': None, + 'provider': 'kion_music_instance', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 0, + 'channels': 2, + 'codec_type': '?', + 'content_type': '?', + 'output_format_str': '?', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'in_library': None, + 'is_unique': None, + 'item_id': '500', + 'provider_domain': 'kion_music', + 'provider_instance': 'kion_music_instance', + 'url': 'https://music.mts.ru/track/500', + }), + ]), + 'sort_name': 'track with album', + 'track_number': 0, + 'translation_key': None, + 'uri': 'kion_music_instance://track/500', + 'version': '', + }) +# --- diff --git a/tests/providers/kion_music/conftest.py b/tests/providers/kion_music/conftest.py new file mode 100644 index 0000000000..df752920fb --- /dev/null +++ b/tests/providers/kion_music/conftest.py @@ -0,0 +1,118 @@ +"""Shared fixtures and stubs for KION Music provider tests.""" + +from __future__ import annotations + +import logging + +import pytest +from music_assistant_models.enums import MediaType +from music_assistant_models.media_items import ItemMapping + + +class ProviderStub: + """Minimal provider-like object for parser tests (no Mock). + + Provides the minimal interface needed by parse_* functions. + """ + + domain = "kion_music" + instance_id = "kion_music_instance" + + def __init__(self) -> None: + """Initialize stub with minimal client.""" + self.client = type("ClientStub", (), {"user_id": 12345})() + + def get_item_mapping(self, media_type: MediaType | str, key: str, name: str) -> ItemMapping: + """Return ItemMapping for the given media type, key and name.""" + return ItemMapping( + media_type=MediaType(media_type) if isinstance(media_type, str) else media_type, + item_id=key, + provider=self.instance_id, + name=name, + ) + + +class StreamingProviderStub: + """Minimal provider stub for streaming tests (no Mock). + + Provides the minimal interface needed by KionMusicStreamingManager. + """ + + domain = "kion_music" + instance_id = "kion_music_instance" + logger = logging.getLogger("kion_music_test_streaming") + + def __init__(self) -> None: + """Initialize stub with minimal client.""" + self.client = type("ClientStub", (), {"user_id": 12345})() + self.mass = type("MassStub", (), {})() + self._warning_count = 0 + + def _count_warning(self, *args: object, **kwargs: object) -> None: + """Track warning calls for test assertions.""" + self._warning_count += 1 + + +class TrackingLogger: + """Logger that tracks calls for test assertions without using Mock.""" + + def __init__(self) -> None: + """Initialize with empty call counters.""" + self._debug_count = 0 + self._info_count = 0 + self._warning_count = 0 + self._error_count = 0 + + def debug(self, *args: object, **kwargs: object) -> None: + """Track debug calls.""" + self._debug_count += 1 + + def info(self, *args: object, **kwargs: object) -> None: + """Track info calls.""" + self._info_count += 1 + + def warning(self, *args: object, **kwargs: object) -> None: + """Track warning calls.""" + self._warning_count += 1 + + def error(self, *args: object, **kwargs: object) -> None: + """Track error calls.""" + self._error_count += 1 + + +class StreamingProviderStubWithTracking: + """Provider stub with tracking logger for assertions. + + Use this when you need to verify logging behavior. + """ + + domain = "kion_music" + instance_id = "kion_music_instance" + + def __init__(self) -> None: + """Initialize stub with tracking logger.""" + self.client = type("ClientStub", (), {"user_id": 12345})() + self.mass = type("MassStub", (), {})() + self.logger = TrackingLogger() + + +# Minimal client-like object for kion_music de_json (library requires client, not None) +DE_JSON_CLIENT = type("ClientStub", (), {"report_unknown_fields": False})() + + +@pytest.fixture +def provider_stub() -> ProviderStub: + """Return a real provider stub (no Mock).""" + return ProviderStub() + + +@pytest.fixture +def streaming_provider_stub() -> StreamingProviderStub: + """Return a streaming provider stub (no Mock).""" + return StreamingProviderStub() + + +@pytest.fixture +def streaming_provider_stub_with_tracking() -> StreamingProviderStubWithTracking: + """Return a streaming provider stub with tracking logger.""" + return StreamingProviderStubWithTracking() diff --git a/tests/providers/kion_music/fixtures/albums/minimal.json b/tests/providers/kion_music/fixtures/albums/minimal.json new file mode 100644 index 0000000000..ec8a82c57b --- /dev/null +++ b/tests/providers/kion_music/fixtures/albums/minimal.json @@ -0,0 +1,8 @@ +{ + "id": 300, + "title": "Test Album", + "available": true, + "artists": [], + "type": "album", + "year": 2020 +} diff --git a/tests/providers/kion_music/fixtures/artists/minimal.json b/tests/providers/kion_music/fixtures/artists/minimal.json new file mode 100644 index 0000000000..06296f0a5c --- /dev/null +++ b/tests/providers/kion_music/fixtures/artists/minimal.json @@ -0,0 +1,4 @@ +{ + "id": 100, + "name": "Test Artist" +} diff --git a/tests/providers/kion_music/fixtures/artists/with_cover.json b/tests/providers/kion_music/fixtures/artists/with_cover.json new file mode 100644 index 0000000000..ef6c49ae3f --- /dev/null +++ b/tests/providers/kion_music/fixtures/artists/with_cover.json @@ -0,0 +1,8 @@ +{ + "id": 200, + "name": "Artist With Cover", + "cover": { + "type": "from-og-image", + "uri": "avatars.yandex.net/get-music-content/xxx/yyy/%%" + } +} diff --git a/tests/providers/kion_music/fixtures/playlists/minimal.json b/tests/providers/kion_music/fixtures/playlists/minimal.json new file mode 100644 index 0000000000..6e77c679c1 --- /dev/null +++ b/tests/providers/kion_music/fixtures/playlists/minimal.json @@ -0,0 +1,9 @@ +{ + "owner": { + "uid": 12345, + "name": "Me", + "login": "me" + }, + "kind": 3, + "title": "My Playlist" +} diff --git a/tests/providers/kion_music/fixtures/playlists/other_user.json b/tests/providers/kion_music/fixtures/playlists/other_user.json new file mode 100644 index 0000000000..60fba828f8 --- /dev/null +++ b/tests/providers/kion_music/fixtures/playlists/other_user.json @@ -0,0 +1,10 @@ +{ + "owner": { + "uid": 99999, + "name": "Other User", + "login": "other_user" + }, + "kind": 1, + "title": "Shared Playlist", + "description": "A shared playlist" +} diff --git a/tests/providers/kion_music/fixtures/tracks/minimal.json b/tests/providers/kion_music/fixtures/tracks/minimal.json new file mode 100644 index 0000000000..4aed92b417 --- /dev/null +++ b/tests/providers/kion_music/fixtures/tracks/minimal.json @@ -0,0 +1,8 @@ +{ + "id": 400, + "title": "Test Track", + "available": true, + "duration_ms": 180000, + "artists": [], + "albums": [] +} diff --git a/tests/providers/kion_music/fixtures/tracks/with_artist_and_album.json b/tests/providers/kion_music/fixtures/tracks/with_artist_and_album.json new file mode 100644 index 0000000000..2211d3e285 --- /dev/null +++ b/tests/providers/kion_music/fixtures/tracks/with_artist_and_album.json @@ -0,0 +1,19 @@ +{ + "id": 500, + "title": "Track With Album", + "available": true, + "duration_ms": 240000, + "artists": [ + { + "id": 10, + "name": "Track Artist" + } + ], + "albums": [ + { + "id": 20, + "title": "Track Album", + "cover_uri": "avatars.yandex.net/get-music-content/aaa/bbb/%%" + } + ] +} diff --git a/tests/providers/kion_music/test_api_client.py b/tests/providers/kion_music/test_api_client.py new file mode 100644 index 0000000000..953c78f18d --- /dev/null +++ b/tests/providers/kion_music/test_api_client.py @@ -0,0 +1,109 @@ +"""Unit tests for the KION Music API client.""" + +from __future__ import annotations + +from unittest import mock + +import pytest +from music_assistant_models.errors import ResourceTemporarilyUnavailable +from yandex_music.exceptions import NetworkError + +from music_assistant.providers.kion_music.api_client import KionMusicClient +from music_assistant.providers.kion_music.constants import DEFAULT_BASE_URL + + +@pytest.fixture +def client() -> KionMusicClient: + """Return a KionMusicClient with a fake token.""" + return KionMusicClient("fake_token") + + +async def test_connect_sets_base_url(client: KionMusicClient) -> None: + """Verify connect() passes DEFAULT_BASE_URL to ClientAsync.""" + with mock.patch("music_assistant.providers.kion_music.api_client.ClientAsync") as mock_cls: + mock_instance = mock.AsyncMock() + mock_instance.me = type("Me", (), {"account": type("Account", (), {"uid": 42})()})() + mock_instance.init = mock.AsyncMock(return_value=mock_instance) + mock_cls.return_value = mock_instance + + result = await client.connect() + + assert result is True + mock_cls.assert_called_once_with("fake_token", base_url=DEFAULT_BASE_URL) + + +async def test_get_liked_albums_batching(client: KionMusicClient) -> None: + """Test that liked albums are fetched in batches of 50.""" + mock_client = mock.AsyncMock() + client._client = mock_client + client._user_id = 1 + + # Create 60 likes so we get 2 batches + likes = [] + for i in range(60): + like = type("Like", (), {"album": type("Album", (), {"id": i + 1})()})() + likes.append(like) + + mock_client.users_likes_albums = mock.AsyncMock(return_value=likes) + + batch1 = [type("Album", (), {"id": i + 1})() for i in range(50)] + batch2 = [type("Album", (), {"id": i + 51})() for i in range(10)] + mock_client.albums = mock.AsyncMock(side_effect=[batch1, batch2]) + + result = await client.get_liked_albums() + + assert len(result) == 60 + assert mock_client.albums.call_count == 2 + + +async def test_get_liked_albums_batch_fallback_on_network_error( + client: KionMusicClient, +) -> None: + """Test fallback to minimal data when batch fetch fails.""" + mock_client = mock.AsyncMock() + client._client = mock_client + client._user_id = 1 + + album_obj = type("Album", (), {"id": 1})() + likes = [type("Like", (), {"album": album_obj})()] + + mock_client.users_likes_albums = mock.AsyncMock(return_value=likes) + mock_client.albums = mock.AsyncMock(side_effect=NetworkError("timeout")) + + result = await client.get_liked_albums() + + assert len(result) == 1 + assert result[0].id == 1 + + +async def test_get_tracks_retry_on_network_error_then_success( + client: KionMusicClient, +) -> None: + """Test that get_tracks retries once on NetworkError and succeeds.""" + mock_client = mock.AsyncMock() + client._client = mock_client + client._user_id = 1 + + track = type("Track", (), {"id": 1})() + mock_client.tracks = mock.AsyncMock(side_effect=[NetworkError("timeout"), [track]]) + + result = await client.get_tracks(["1"]) + + assert len(result) == 1 + assert mock_client.tracks.call_count == 2 + + +async def test_get_tracks_retry_on_network_error_both_fail( + client: KionMusicClient, +) -> None: + """Test that get_tracks raises ResourceTemporarilyUnavailable when retry fails.""" + mock_client = mock.AsyncMock() + client._client = mock_client + client._user_id = 1 + + mock_client.tracks = mock.AsyncMock(side_effect=NetworkError("timeout")) + + with pytest.raises(ResourceTemporarilyUnavailable): + await client.get_tracks(["1"]) + + assert mock_client.tracks.call_count == 2 diff --git a/tests/providers/kion_music/test_integration.py b/tests/providers/kion_music/test_integration.py new file mode 100644 index 0000000000..e3e969b726 --- /dev/null +++ b/tests/providers/kion_music/test_integration.py @@ -0,0 +1,354 @@ +"""Integration tests for the KION Music provider with in-process Music Assistant.""" + +from __future__ import annotations + +import json +import pathlib +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING, Any, cast +from unittest import mock + +import pytest +from music_assistant_models.enums import ContentType, MediaType, StreamType +from yandex_music import Album as YandexAlbum +from yandex_music import Artist as YandexArtist +from yandex_music import Playlist as YandexPlaylist +from yandex_music import Track as YandexTrack + +from music_assistant.mass import MusicAssistant +from music_assistant.models.music_provider import MusicProvider +from tests.common import wait_for_sync_completion + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ProviderConfig + +FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" +_DE_JSON_CLIENT = type("ClientStub", (), {"report_unknown_fields": False})() + + +def _load_json(path: pathlib.Path) -> dict[str, Any]: + """Load JSON fixture.""" + with open(path) as f: + return cast("dict[str, Any]", json.load(f)) + + +def _load_kion_objects() -> tuple[Any, Any, Any, Any]: + """Load Artist, Album, Track, Playlist from fixtures for mock client.""" + artist = YandexArtist.de_json( + _load_json(FIXTURES_DIR / "artists" / "minimal.json"), _DE_JSON_CLIENT + ) + album = YandexAlbum.de_json( + _load_json(FIXTURES_DIR / "albums" / "minimal.json"), _DE_JSON_CLIENT + ) + track = YandexTrack.de_json( + _load_json(FIXTURES_DIR / "tracks" / "minimal.json"), _DE_JSON_CLIENT + ) + playlist = YandexPlaylist.de_json( + _load_json(FIXTURES_DIR / "playlists" / "minimal.json"), _DE_JSON_CLIENT + ) + return artist, album, track, playlist + + +def _make_search_result(track: Any, album: Any, artist: Any, playlist: Any) -> Any: + """Build a Search-like object with .tracks.results, .albums.results, etc.""" + return type( + "Search", + (), + { + "tracks": type("TracksResult", (), {"results": [track]})(), + "albums": type("AlbumsResult", (), {"results": [album]})(), + "artists": type("ArtistsResult", (), {"results": [artist]})(), + "playlists": type("PlaylistsResult", (), {"results": [playlist]})(), + }, + )() + + +def _make_download_info( + codec: str = "mp3", + direct_link: str = "https://example.com/kion_track.mp3", + bitrate_in_kbps: int = 320, +) -> Any: + """Build DownloadInfo-like object for streaming.""" + return type( + "DownloadInfo", + (), + { + "direct_link": direct_link, + "codec": codec, + "bitrate_in_kbps": bitrate_in_kbps, + }, + )() + + +@pytest.fixture +async def kion_music_provider( + mass: MusicAssistant, +) -> AsyncGenerator[ProviderConfig, None]: + """Configure KION Music provider with mocked API client and add to mass.""" + artist, album, track, playlist = _load_kion_objects() + search_result = _make_search_result(track, album, artist, playlist) + download_info = _make_download_info() + + # Album with volumes for get_album_tracks + album_with_volumes = type( + "AlbumWithVolumes", + (), + { + "id": album.id, + "title": album.title, + "volumes": [[track]], + "artists": album.artists if hasattr(album, "artists") else [], + "year": getattr(album, "year", None), + "release_date": getattr(album, "release_date", None), + "genre": getattr(album, "genre", None), + "cover_uri": getattr(album, "cover_uri", None), + "og_image": getattr(album, "og_image", None), + "type": getattr(album, "type", "album"), + "available": getattr(album, "available", True), + }, + )() + + with mock.patch( + "music_assistant.providers.kion_music.provider.KionMusicClient" + ) as mock_client_class: + mock_client = mock.AsyncMock() + mock_client_class.return_value = mock_client + + mock_client.connect = mock.AsyncMock(return_value=True) + mock_client.user_id = 12345 + + mock_client.get_liked_tracks = mock.AsyncMock(return_value=[]) + mock_client.get_liked_albums = mock.AsyncMock(return_value=[]) + mock_client.get_liked_artists = mock.AsyncMock(return_value=[]) + mock_client.get_user_playlists = mock.AsyncMock(return_value=[playlist]) + + mock_client.search = mock.AsyncMock(return_value=search_result) + mock_client.get_track = mock.AsyncMock(return_value=track) + mock_client.get_tracks = mock.AsyncMock(return_value=[track]) + mock_client.get_album = mock.AsyncMock(return_value=album) + mock_client.get_album_with_tracks = mock.AsyncMock(return_value=album_with_volumes) + mock_client.get_artist = mock.AsyncMock(return_value=artist) + mock_client.get_artist_albums = mock.AsyncMock(return_value=[album]) + mock_client.get_artist_tracks = mock.AsyncMock(return_value=[track]) + mock_client.get_playlist = mock.AsyncMock(return_value=playlist) + mock_client.get_track_download_info = mock.AsyncMock(return_value=[download_info]) + + async with wait_for_sync_completion(mass): + config = await mass.config.save_provider_config( + "kion_music", + {"token": "mock_kion_token", "quality": "high"}, + ) + await mass.music.start_sync() + + yield config + + +@pytest.fixture +async def kion_music_provider_lossless( + mass: MusicAssistant, +) -> AsyncGenerator[ProviderConfig, None]: + """Configure KION Music with quality=lossless and mock returning MP3 + FLAC.""" + artist, album, track, playlist = _load_kion_objects() + search_result = _make_search_result(track, album, artist, playlist) + mp3_info = _make_download_info( + codec="mp3", + direct_link="https://example.com/kion_track.mp3", + bitrate_in_kbps=320, + ) + flac_info = _make_download_info( + codec="flac", + direct_link="https://example.com/kion_track.flac", + bitrate_in_kbps=0, + ) + download_infos = [mp3_info, flac_info] + + album_with_volumes = type( + "AlbumWithVolumes", + (), + { + "id": album.id, + "title": album.title, + "volumes": [[track]], + "artists": album.artists if hasattr(album, "artists") else [], + "year": getattr(album, "year", None), + "release_date": getattr(album, "release_date", None), + "genre": getattr(album, "genre", None), + "cover_uri": getattr(album, "cover_uri", None), + "og_image": getattr(album, "og_image", None), + "type": getattr(album, "type", "album"), + "available": getattr(album, "available", True), + }, + )() + + with mock.patch( + "music_assistant.providers.kion_music.provider.KionMusicClient" + ) as mock_client_class: + mock_client = mock.AsyncMock() + mock_client_class.return_value = mock_client + + mock_client.connect = mock.AsyncMock(return_value=True) + mock_client.user_id = 12345 + + mock_client.get_liked_tracks = mock.AsyncMock(return_value=[]) + mock_client.get_liked_albums = mock.AsyncMock(return_value=[]) + mock_client.get_liked_artists = mock.AsyncMock(return_value=[]) + mock_client.get_user_playlists = mock.AsyncMock(return_value=[playlist]) + + mock_client.search = mock.AsyncMock(return_value=search_result) + mock_client.get_track = mock.AsyncMock(return_value=track) + mock_client.get_tracks = mock.AsyncMock(return_value=[track]) + mock_client.get_album = mock.AsyncMock(return_value=album) + mock_client.get_album_with_tracks = mock.AsyncMock(return_value=album_with_volumes) + mock_client.get_artist = mock.AsyncMock(return_value=artist) + mock_client.get_artist_albums = mock.AsyncMock(return_value=[album]) + mock_client.get_artist_tracks = mock.AsyncMock(return_value=[track]) + mock_client.get_playlist = mock.AsyncMock(return_value=playlist) + mock_client.get_track_file_info_lossless = mock.AsyncMock(return_value=None) + mock_client.get_track_download_info = mock.AsyncMock(return_value=download_infos) + + async with wait_for_sync_completion(mass): + config = await mass.config.save_provider_config( + "kion_music", + {"token": "mock_kion_token", "quality": "lossless"}, + ) + await mass.music.start_sync() + + yield config + + +def _get_kion_provider(mass: MusicAssistant) -> MusicProvider | None: + """Get KION Music provider instance from mass.""" + for provider in mass.music.providers: + if provider.domain == "kion_music": + return provider + return None + + +@pytest.mark.usefixtures("kion_music_provider") +async def test_registration_and_sync(mass: MusicAssistant) -> None: + """Test that provider is registered and sync completes.""" + prov = _get_kion_provider(mass) + assert prov is not None + assert prov.domain == "kion_music" + assert prov.instance_id + + +@pytest.mark.usefixtures("kion_music_provider") +async def test_search(mass: MusicAssistant) -> None: + """Test search returns results from kion_music.""" + results = await mass.music.search("test query", [MediaType.TRACK], limit=5) + kion_tracks = [t for t in results.tracks if t.provider and "kion_music" in t.provider] + assert len(kion_tracks) >= 0 + + +@pytest.mark.usefixtures("kion_music_provider") +async def test_get_artist(mass: MusicAssistant) -> None: + """Test getting artist by id.""" + prov = _get_kion_provider(mass) + assert prov is not None + artist = await prov.get_artist("100") + assert artist is not None + assert artist.name + assert artist.provider == prov.instance_id + assert artist.item_id == "100" + + +@pytest.mark.usefixtures("kion_music_provider") +async def test_get_album(mass: MusicAssistant) -> None: + """Test getting album by id.""" + prov = _get_kion_provider(mass) + assert prov is not None + album = await prov.get_album("300") + assert album is not None + assert album.name + assert album.provider == prov.instance_id + assert album.item_id == "300" + + +@pytest.mark.usefixtures("kion_music_provider") +async def test_get_track(mass: MusicAssistant) -> None: + """Test getting track by id.""" + prov = _get_kion_provider(mass) + assert prov is not None + track = await prov.get_track("400") + assert track is not None + assert track.name + assert track.provider == prov.instance_id + assert track.item_id == "400" + + +@pytest.mark.usefixtures("kion_music_provider") +async def test_get_album_tracks(mass: MusicAssistant) -> None: + """Test getting album tracks.""" + prov = _get_kion_provider(mass) + assert prov is not None + tracks = await prov.get_album_tracks("300") + assert isinstance(tracks, list) + assert len(tracks) >= 0 + + +@pytest.mark.usefixtures("kion_music_provider") +async def test_get_playlist_tracks(mass: MusicAssistant) -> None: + """Test getting playlist tracks.""" + prov = _get_kion_provider(mass) + assert prov is not None + tracks = await prov.get_playlist_tracks("12345:3", page=0) + assert isinstance(tracks, list) + assert len(tracks) >= 0 + + +@pytest.mark.usefixtures("kion_music_provider") +async def test_get_playlist_tracks_page_gt_zero_returns_empty(mass: MusicAssistant) -> None: + """Test that page > 0 returns empty list (no server-side pagination).""" + prov = _get_kion_provider(mass) + assert prov is not None + tracks = await prov.get_playlist_tracks("12345:3", page=1) + assert tracks == [] + + +@pytest.mark.usefixtures("kion_music_provider") +async def test_get_stream_details(mass: MusicAssistant) -> None: + """Test stream details retrieval.""" + prov = _get_kion_provider(mass) + assert prov is not None + stream_details = await prov.get_stream_details("400", MediaType.TRACK) + assert stream_details is not None + assert stream_details.stream_type == StreamType.HTTP + assert stream_details.path == "https://example.com/kion_track.mp3" + + +@pytest.mark.usefixtures("kion_music_provider_lossless") +async def test_get_stream_details_returns_flac_when_lossless_selected( + mass: MusicAssistant, +) -> None: + """When quality=lossless and API returns MP3+FLAC, stream details use FLAC.""" + prov = _get_kion_provider(mass) + assert prov is not None + stream_details = await prov.get_stream_details("400", MediaType.TRACK) + assert stream_details is not None + assert stream_details.audio_format.content_type == ContentType.FLAC + assert stream_details.path == "https://example.com/kion_track.flac" + + +@pytest.mark.usefixtures("kion_music_provider") +async def test_library_items(mass: MusicAssistant) -> None: + """Test library artists, albums, tracks, playlists.""" + prov = _get_kion_provider(mass) + assert prov is not None + instance_id = prov.instance_id + + artists = await mass.music.artists.library_items() + kion_artists = [a for a in artists if a.provider == instance_id] + assert len(kion_artists) >= 0 + + albums = await mass.music.albums.library_items() + kion_albums = [a for a in albums if a.provider == instance_id] + assert len(kion_albums) >= 0 + + tracks = await mass.music.tracks.library_items() + kion_tracks = [t for t in tracks if t.provider == instance_id] + assert len(kion_tracks) >= 0 + + playlists = await mass.music.playlists.library_items() + kion_playlists = [p for p in playlists if p.provider == instance_id] + assert len(kion_playlists) >= 0 diff --git a/tests/providers/kion_music/test_my_mix.py b/tests/providers/kion_music/test_my_mix.py new file mode 100644 index 0000000000..ac054cebf5 --- /dev/null +++ b/tests/providers/kion_music/test_my_mix.py @@ -0,0 +1,24 @@ +"""Tests for My Mix (Мой Микс) browse and rotor feedback helpers.""" + +from __future__ import annotations + +from music_assistant.providers.kion_music.constants import ( + RADIO_TRACK_ID_SEP, + ROTOR_STATION_MY_MIX, +) +from music_assistant.providers.kion_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_MIX}") == ( + "12345", + ROTOR_STATION_MY_MIX, + ) + assert _parse_radio_item_id("99@user:custom") == ("99", "user:custom") diff --git a/tests/providers/kion_music/test_parsers.py b/tests/providers/kion_music/test_parsers.py new file mode 100644 index 0000000000..62f1829bb2 --- /dev/null +++ b/tests/providers/kion_music/test_parsers.py @@ -0,0 +1,247 @@ +"""Test we can parse KION Music API objects into Music Assistant models.""" + +from __future__ import annotations + +import json +import pathlib +from typing import TYPE_CHECKING, Any, cast + +import pytest +from yandex_music import Album as YandexAlbum +from yandex_music import Artist as YandexArtist +from yandex_music import Playlist as YandexPlaylist +from yandex_music import Track as YandexTrack + +from music_assistant.providers.kion_music.parsers import ( + parse_album, + parse_artist, + parse_playlist, + parse_track, +) +from music_assistant.providers.kion_music.provider import KionMusicProvider + +from .conftest import DE_JSON_CLIENT + +if TYPE_CHECKING: + from syrupy.assertion import SnapshotAssertion + + from .conftest import ProviderStub + +FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" +ARTIST_FIXTURES = list(FIXTURES_DIR.glob("artists/*.json")) +ALBUM_FIXTURES = list(FIXTURES_DIR.glob("albums/*.json")) +TRACK_FIXTURES = list(FIXTURES_DIR.glob("tracks/*.json")) +PLAYLIST_FIXTURES = list(FIXTURES_DIR.glob("playlists/*.json")) + + +def _load_json(path: pathlib.Path) -> dict[str, Any]: + """Load JSON fixture.""" + with open(path) as f: + return cast("dict[str, Any]", json.load(f)) + + +def _artist_from_fixture(path: pathlib.Path) -> YandexArtist | None: + """Deserialize KION Artist from fixture JSON.""" + data = _load_json(path) + return YandexArtist.de_json(data, DE_JSON_CLIENT) + + +def _album_from_fixture(path: pathlib.Path) -> YandexAlbum | None: + """Deserialize KION Album from fixture JSON.""" + data = _load_json(path) + return YandexAlbum.de_json(data, DE_JSON_CLIENT) + + +def _track_from_fixture(path: pathlib.Path) -> YandexTrack | None: + """Deserialize KION Track from fixture JSON.""" + data = _load_json(path) + return YandexTrack.de_json(data, DE_JSON_CLIENT) + + +def _playlist_from_fixture(path: pathlib.Path) -> YandexPlaylist | None: + """Deserialize KION Playlist from fixture JSON.""" + data = _load_json(path) + return YandexPlaylist.de_json(data, DE_JSON_CLIENT) + + +# provider_stub fixture is provided by conftest.py + + +@pytest.mark.parametrize("example", ARTIST_FIXTURES, ids=lambda val: val.stem) +def test_parse_artist(example: pathlib.Path, provider_stub: ProviderStub) -> None: + """Test we can parse artists from fixture JSON.""" + artist_obj = _artist_from_fixture(example) + assert artist_obj is not None + result = parse_artist(cast("KionMusicProvider", provider_stub), artist_obj) + assert result.item_id == str(artist_obj.id) + assert result.name == (artist_obj.name or "Unknown Artist") + assert result.provider == provider_stub.instance_id + assert len(result.provider_mappings) == 1 + mapping = next(iter(result.provider_mappings)) + assert f"music.mts.ru/artist/{artist_obj.id}" in (mapping.url or "") + + +def test_parse_artist_with_cover(provider_stub: ProviderStub) -> None: + """Test parsing artist with cover image.""" + path = FIXTURES_DIR / "artists" / "with_cover.json" + artist_obj = _artist_from_fixture(path) + assert artist_obj is not None + result = parse_artist(cast("KionMusicProvider", provider_stub), artist_obj) + assert result.item_id == "200" + assert result.name == "Artist With Cover" + if artist_obj.cover and artist_obj.cover.uri: + assert result.metadata.images is not None + assert len(result.metadata.images) == 1 + assert "avatars.yandex.net" in (result.metadata.images[0].path or "") + + +@pytest.mark.parametrize("example", ALBUM_FIXTURES, ids=lambda val: val.stem) +def test_parse_album(example: pathlib.Path, provider_stub: ProviderStub) -> None: + """Test we can parse albums from fixture JSON.""" + album_obj = _album_from_fixture(example) + assert album_obj is not None + result = parse_album(cast("KionMusicProvider", provider_stub), album_obj) + assert result.item_id == str(album_obj.id) + assert result.name + assert result.provider == provider_stub.instance_id + mapping = next(iter(result.provider_mappings)) + assert f"music.mts.ru/album/{album_obj.id}" in (mapping.url or "") + if album_obj.year: + assert result.year == album_obj.year + + +@pytest.mark.parametrize("example", TRACK_FIXTURES, ids=lambda val: val.stem) +def test_parse_track(example: pathlib.Path, provider_stub: ProviderStub) -> None: + """Test we can parse tracks from fixture JSON.""" + track_obj = _track_from_fixture(example) + assert track_obj is not None + result = parse_track(cast("KionMusicProvider", provider_stub), track_obj) + assert result.item_id == str(track_obj.id) + assert result.name + assert result.duration == (track_obj.duration_ms or 0) // 1000 + mapping = next(iter(result.provider_mappings)) + assert f"music.mts.ru/track/{track_obj.id}" in (mapping.url or "") + + +def test_parse_track_with_artist_and_album(provider_stub: ProviderStub) -> None: + """Test parsing track with artist and album.""" + path = FIXTURES_DIR / "tracks" / "with_artist_and_album.json" + track_obj = _track_from_fixture(path) + assert track_obj is not None + result = parse_track(cast("KionMusicProvider", provider_stub), track_obj) + assert result.item_id == "500" + if track_obj.artists: + assert len(result.artists) >= 1 + assert result.artists[0].name == "Track Artist" + if track_obj.albums: + assert result.album is not None + assert result.album.item_id == "20" + assert result.album.name == "Track Album" + + +@pytest.mark.parametrize("example", PLAYLIST_FIXTURES, ids=lambda val: val.stem) +def test_parse_playlist(example: pathlib.Path, provider_stub: ProviderStub) -> None: + """Test we can parse playlists from fixture JSON.""" + playlist_obj = _playlist_from_fixture(example) + assert playlist_obj is not None + result = parse_playlist(cast("KionMusicProvider", provider_stub), playlist_obj) + owner_id = ( + str(playlist_obj.owner.uid) if playlist_obj.owner else str(provider_stub.client.user_id) + ) + kind = str(playlist_obj.kind) + assert result.item_id == f"{owner_id}:{kind}" + assert result.name == (playlist_obj.title or "Unknown Playlist") + mapping = next(iter(result.provider_mappings)) + assert f"music.mts.ru/users/{owner_id}/playlists/{kind}" in (mapping.url or "") + + +def test_parse_playlist_editable(provider_stub: ProviderStub) -> None: + """Test parsing own playlist (editable).""" + path = FIXTURES_DIR / "playlists" / "minimal.json" + playlist_obj = _playlist_from_fixture(path) + assert playlist_obj is not None + result = parse_playlist(cast("KionMusicProvider", provider_stub), playlist_obj) + assert result.owner == "Me" + assert result.is_editable is True + + +def test_parse_playlist_other_user(provider_stub: ProviderStub) -> None: + """Test parsing playlist owned by another user.""" + path = FIXTURES_DIR / "playlists" / "other_user.json" + playlist_obj = _playlist_from_fixture(path) + assert playlist_obj is not None + result = parse_playlist(cast("KionMusicProvider", provider_stub), playlist_obj) + assert result.item_id == "99999:1" + assert result.name == "Shared Playlist" + assert result.owner == "Other User" + assert result.is_editable is False + assert result.metadata.description == "A shared playlist" + + +# --- Snapshot tests --- + + +def _sort_for_snapshot(parsed: dict[str, Any]) -> dict[str, Any]: + """Sort lists in parsed dict for deterministic snapshot comparison.""" + if parsed.get("external_ids"): + parsed["external_ids"] = sorted(parsed["external_ids"]) + if "metadata" in parsed and isinstance(parsed["metadata"], dict): + if parsed["metadata"].get("genres"): + parsed["metadata"]["genres"] = sorted(parsed["metadata"]["genres"]) + return parsed + + +@pytest.mark.parametrize("example", ARTIST_FIXTURES, ids=lambda val: val.stem) +def test_parse_artist_snapshot( + example: pathlib.Path, + provider_stub: ProviderStub, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot test for artist parsing.""" + artist_obj = _artist_from_fixture(example) + assert artist_obj is not None + result = parse_artist(cast("KionMusicProvider", provider_stub), artist_obj) + parsed = _sort_for_snapshot(result.to_dict()) + assert snapshot == parsed + + +@pytest.mark.parametrize("example", ALBUM_FIXTURES, ids=lambda val: val.stem) +def test_parse_album_snapshot( + example: pathlib.Path, + provider_stub: ProviderStub, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot test for album parsing.""" + album_obj = _album_from_fixture(example) + assert album_obj is not None + result = parse_album(cast("KionMusicProvider", provider_stub), album_obj) + parsed = _sort_for_snapshot(result.to_dict()) + assert snapshot == parsed + + +@pytest.mark.parametrize("example", TRACK_FIXTURES, ids=lambda val: val.stem) +def test_parse_track_snapshot( + example: pathlib.Path, + provider_stub: ProviderStub, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot test for track parsing.""" + track_obj = _track_from_fixture(example) + assert track_obj is not None + result = parse_track(cast("KionMusicProvider", provider_stub), track_obj) + parsed = _sort_for_snapshot(result.to_dict()) + assert snapshot == parsed + + +@pytest.mark.parametrize("example", PLAYLIST_FIXTURES, ids=lambda val: val.stem) +def test_parse_playlist_snapshot( + example: pathlib.Path, + provider_stub: ProviderStub, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot test for playlist parsing.""" + playlist_obj = _playlist_from_fixture(example) + assert playlist_obj is not None + result = parse_playlist(cast("KionMusicProvider", provider_stub), playlist_obj) + parsed = _sort_for_snapshot(result.to_dict()) + assert snapshot == parsed diff --git a/tests/providers/kion_music/test_streaming.py b/tests/providers/kion_music/test_streaming.py new file mode 100644 index 0000000000..d712c4f0c4 --- /dev/null +++ b/tests/providers/kion_music/test_streaming.py @@ -0,0 +1,139 @@ +"""Unit tests for KION Music streaming quality selection.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest +from music_assistant_models.enums import ContentType + +from music_assistant.providers.kion_music.constants import QUALITY_HIGH, QUALITY_LOSSLESS +from music_assistant.providers.kion_music.streaming import KionMusicStreamingManager + +if TYPE_CHECKING: + from tests.providers.kion_music.conftest import ( + StreamingProviderStub, + StreamingProviderStubWithTracking, + ) + + +def _make_download_info( + codec: str, + bitrate_in_kbps: int, + direct_link: str = "https://example.com/track", +) -> Any: + """Build DownloadInfo-like object.""" + return type( + "DownloadInfo", + (), + { + "codec": codec, + "bitrate_in_kbps": bitrate_in_kbps, + "direct_link": direct_link, + }, + )() + + +@pytest.fixture +def streaming_manager( + streaming_provider_stub: StreamingProviderStub, +) -> KionMusicStreamingManager: + """Create streaming manager with real stub (no Mock).""" + return KionMusicStreamingManager(streaming_provider_stub) # type: ignore[arg-type] + + +@pytest.fixture +def streaming_manager_with_tracking( + streaming_provider_stub_with_tracking: StreamingProviderStubWithTracking, +) -> KionMusicStreamingManager: + """Create streaming manager with tracking logger for assertions.""" + return KionMusicStreamingManager(streaming_provider_stub_with_tracking) # type: ignore[arg-type] + + +def test_select_best_quality_lossless_returns_flac( + streaming_manager: KionMusicStreamingManager, +) -> None: + """When preferred_quality is 'lossless' and list has MP3 and FLAC, FLAC is selected.""" + mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3") + flac = _make_download_info("flac", 0, "https://example.com/track.flac") + download_infos = [mp3, flac] + + result = streaming_manager._select_best_quality(download_infos, QUALITY_LOSSLESS) + + assert result is not None + assert result.codec == "flac" + assert result.direct_link == "https://example.com/track.flac" + + +def test_select_best_quality_high_returns_highest_bitrate( + streaming_manager: KionMusicStreamingManager, +) -> None: + """When preferred is 'high' and list has MP3 and FLAC, highest bitrate is selected.""" + mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3") + flac = _make_download_info("flac", 0, "https://example.com/track.flac") + download_infos = [mp3, flac] + + result = streaming_manager._select_best_quality(download_infos, QUALITY_HIGH) + + assert result is not None + assert result.codec == "mp3" + assert result.bitrate_in_kbps == 320 + + +def test_select_best_quality_label_lossless_flac_returns_flac( + streaming_manager: KionMusicStreamingManager, +) -> None: + """When preferred_quality is UI label 'Lossless (FLAC)', FLAC is selected.""" + mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3") + flac = _make_download_info("flac", 0, "https://example.com/track.flac") + download_infos = [mp3, flac] + + result = streaming_manager._select_best_quality(download_infos, "Lossless (FLAC)") + + assert result is not None + assert result.codec == "flac" + + +def test_select_best_quality_lossless_no_flac_returns_fallback( + streaming_manager_with_tracking: KionMusicStreamingManager, +) -> None: + """When lossless requested but no FLAC in list, returns best available (fallback).""" + mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3") + download_infos = [mp3] + + result = streaming_manager_with_tracking._select_best_quality(download_infos, QUALITY_LOSSLESS) + + assert result is not None + assert result.codec == "mp3" + assert streaming_manager_with_tracking.provider.logger._warning_count == 1 # type: ignore[attr-defined] + + +def test_select_best_quality_empty_list_returns_none( + streaming_manager: KionMusicStreamingManager, +) -> None: + """Empty download_infos returns None.""" + result = streaming_manager._select_best_quality([], QUALITY_LOSSLESS) + assert result is None + + +def test_select_best_quality_none_preferred_returns_highest_bitrate( + streaming_manager: KionMusicStreamingManager, +) -> None: + """When preferred_quality is None, returns highest bitrate.""" + mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3") + flac = _make_download_info("flac", 0, "https://example.com/track.flac") + download_infos = [mp3, flac] + + result = streaming_manager._select_best_quality(download_infos, None) + + assert result is not None + assert result.codec == "mp3" + assert result.bitrate_in_kbps == 320 + + +def test_get_content_type_flac_mp4_returns_flac( + streaming_manager: KionMusicStreamingManager, +) -> None: + """flac-mp4 codec from get-file-info is mapped to ContentType.FLAC.""" + assert streaming_manager._get_content_type("flac-mp4") == ContentType.FLAC + assert streaming_manager._get_content_type("FLAC-MP4") == ContentType.FLAC From 8ab1309c23a1d82816fa8de2a10b2873b2672da2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 11:47:54 +0000 Subject: [PATCH 25/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.0 --- music_assistant/providers/yandex_music/auth.py | 2 +- music_assistant/providers/yandex_music/provider.py | 3 ++- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/music_assistant/providers/yandex_music/auth.py b/music_assistant/providers/yandex_music/auth.py index 6a5dbfe053..e86ee024d6 100644 --- a/music_assistant/providers/yandex_music/auth.py +++ b/music_assistant/providers/yandex_music/auth.py @@ -207,7 +207,7 @@ async def perform_device_auth(mass: MusicAssistant, session_id: str) -> tuple[st session.verification_url, session.expires_in, ) - _LOGGER.debug("Device flow user_code issued: %s", session.user_code) + _LOGGER.debug("Device flow user_code issued") page_path = f"{_DEVICE_CODE_PAGE_PATH}/{session_id}" status_path = f"{page_path}/status" diff --git a/music_assistant/providers/yandex_music/provider.py b/music_assistant/providers/yandex_music/provider.py index a34deb5c71..48c0a4efe7 100644 --- a/music_assistant/providers/yandex_music/provider.py +++ b/music_assistant/providers/yandex_music/provider.py @@ -64,6 +64,7 @@ MY_WAVES_SET_FOLDER_ID, PINNED_ITEMS_FOLDER_ID, PLAYLIST_ID_SPLITTER, + QUALITY_BALANCED, RADIO_FOLDER_ID, RADIO_TRACK_ID_SEP, ROTOR_STATION_MY_WAVE, @@ -2538,7 +2539,7 @@ async def get_rotor_station_tracks( def get_quality(self) -> str: """Return the configured audio quality tier (e.g. 'balanced', 'superb').""" - return str(self.config.get_value(CONF_QUALITY) or "").strip().lower() + return str(self.config.get_value(CONF_QUALITY) or QUALITY_BALANCED).strip().lower() async def resolve_image(self, path: str) -> str | bytes: """Resolve wave cover image with background color fill for transparent PNGs. diff --git a/requirements_all.txt b/requirements_all.txt index 041e0eecc8..d4cdf145a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -88,7 +88,7 @@ uv>=0.8.0 websocket-client==1.9.0 xmltodict==1.0.4 ya-passport-auth==1.3.0 -yandex-music==3.0.0 +yandex-music==2.2.0 ytmusicapi==1.11.5 zeroconf==0.148.0 zvuk-music[async]==0.6.1 From c28c7ad298a770477c8398f36ca943a9dc83b7e5 Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Mon, 20 Apr 2026 14:49:46 +0300 Subject: [PATCH 26/54] Bump kion_music yandex-music requirement to 3.0.0 Aligns kion_music's pinned yandex-music version with yandex_music's, so gen_requirements_all produces a stable 3.0.0 pin regardless of which provider directory the script iterates first. Without this, CI's regeneration picked 2.2.0 (kion's) and reverted the bump. Kion has already been validated against yandex-music v3 and will land the same bump on its own PR (upstream/kion_music). --- music_assistant/providers/kion_music/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/kion_music/manifest.json b/music_assistant/providers/kion_music/manifest.json index 9b5cdc2d44..34164af14f 100644 --- a/music_assistant/providers/kion_music/manifest.json +++ b/music_assistant/providers/kion_music/manifest.json @@ -6,6 +6,6 @@ "description": "Stream music from KION Music (MTS) service.", "codeowners": ["@TrudenBoy"], "documentation": "https://music-assistant.io/music-providers/kion-music/", - "requirements": ["yandex-music==2.2.0"], + "requirements": ["yandex-music==3.0.0"], "multi_instance": true } diff --git a/requirements_all.txt b/requirements_all.txt index d4cdf145a5..041e0eecc8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -88,7 +88,7 @@ uv>=0.8.0 websocket-client==1.9.0 xmltodict==1.0.4 ya-passport-auth==1.3.0 -yandex-music==2.2.0 +yandex-music==3.0.0 ytmusicapi==1.11.5 zeroconf==0.148.0 zvuk-music[async]==0.6.1 From 3376fbf43c8d20176b2ae19109b1dcb12aab8cbe Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Mon, 20 Apr 2026 14:52:55 +0300 Subject: [PATCH 27/54] Revert "Bump kion_music yandex-music requirement to 3.0.0" This reverts commit c28c7ad298a770477c8398f36ca943a9dc83b7e5. --- music_assistant/providers/kion_music/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/kion_music/manifest.json b/music_assistant/providers/kion_music/manifest.json index 34164af14f..9b5cdc2d44 100644 --- a/music_assistant/providers/kion_music/manifest.json +++ b/music_assistant/providers/kion_music/manifest.json @@ -6,6 +6,6 @@ "description": "Stream music from KION Music (MTS) service.", "codeowners": ["@TrudenBoy"], "documentation": "https://music-assistant.io/music-providers/kion-music/", - "requirements": ["yandex-music==3.0.0"], + "requirements": ["yandex-music==2.2.0"], "multi_instance": true } diff --git a/requirements_all.txt b/requirements_all.txt index 041e0eecc8..d4cdf145a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -88,7 +88,7 @@ uv>=0.8.0 websocket-client==1.9.0 xmltodict==1.0.4 ya-passport-auth==1.3.0 -yandex-music==3.0.0 +yandex-music==2.2.0 ytmusicapi==1.11.5 zeroconf==0.148.0 zvuk-music[async]==0.6.1 From 0bb21a4c69c8f9ea0afa2f6a1b16734dee891098 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 12:08:30 +0000 Subject: [PATCH 28/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.0 --- music_assistant/providers/yandex_music/api_client.py | 7 ++++++- music_assistant/providers/yandex_music/parsers.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_music/api_client.py b/music_assistant/providers/yandex_music/api_client.py index 71a5d21b82..d6b780db3e 100644 --- a/music_assistant/providers/yandex_music/api_client.py +++ b/music_assistant/providers/yandex_music/api_client.py @@ -858,7 +858,12 @@ async def _do_request(c: ClientAsync) -> dict[str, Any] | None: transport, ) return parsed - except (BadRequestError, NetworkError) as err: + except ( + BadRequestError, + NetworkError, + ProviderUnavailableError, + ResourceTemporarilyUnavailable, + ) as err: LOGGER.debug( "get-file-info for track %s: %s %s", track_id, diff --git a/music_assistant/providers/yandex_music/parsers.py b/music_assistant/providers/yandex_music/parsers.py index 55c8bea743..a7756412ba 100644 --- a/music_assistant/providers/yandex_music/parsers.py +++ b/music_assistant/providers/yandex_music/parsers.py @@ -133,7 +133,7 @@ def parse_artist( artist.metadata.description = description stats = getattr(about, "stats", None) monthly = getattr(stats, "last_month_listeners", None) if stats else None - if monthly: + if monthly is not None: artist.metadata.popularity = max(0, min(100, monthly // 10000)) return artist From bd801d696c29a8cc445883733890d36451ec3468 Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy <139659391+trudenboy@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:41:57 +0300 Subject: [PATCH 29/54] Update ya-passport-auth and yandex-music versions Updated ya-passport-auth and yandex-music versions. --- requirements_all.txt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/requirements_all.txt b/requirements_all.txt index 1481d4b366..041e0eecc8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -87,12 +87,8 @@ unidecode==1.4.0 uv>=0.8.0 websocket-client==1.9.0 xmltodict==1.0.4 -<<<<<<< upstream/yandex_music ya-passport-auth==1.3.0 -======= -ya-passport-auth==1.2.3 ->>>>>>> dev -yandex-music==2.2.0 +yandex-music==3.0.0 ytmusicapi==1.11.5 zeroconf==0.148.0 zvuk-music[async]==0.6.1 From d7b0644e64cfd8db9d06357d97f08693321025c9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 18:00:04 +0000 Subject: [PATCH 30/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.1 --- requirements_all.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_all.txt b/requirements_all.txt index 041e0eecc8..8de605f398 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -87,8 +87,8 @@ unidecode==1.4.0 uv>=0.8.0 websocket-client==1.9.0 xmltodict==1.0.4 -ya-passport-auth==1.3.0 -yandex-music==3.0.0 +ya-passport-auth==1.2.3 +yandex-music==2.2.0 ytmusicapi==1.11.5 zeroconf==0.148.0 zvuk-music[async]==0.6.1 From e4187b902ce8ccd7234f49145c75c41ed5129432 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 20 Apr 2026 19:54:28 +0000 Subject: [PATCH 31/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.1 --- .../providers/yandex_music/auth.py | 19 +++++++- tests/providers/yandex_music/test_auth.py | 46 ++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/music_assistant/providers/yandex_music/auth.py b/music_assistant/providers/yandex_music/auth.py index e86ee024d6..920fc75319 100644 --- a/music_assistant/providers/yandex_music/auth.py +++ b/music_assistant/providers/yandex_music/auth.py @@ -25,11 +25,13 @@ from typing import TYPE_CHECKING from aiohttp import web -from music_assistant_models.errors import LoginFailed +from music_assistant_models.errors import LoginFailed, ResourceTemporarilyUnavailable from ya_passport_auth import Credentials, PassportClient, SecretStr from ya_passport_auth.exceptions import ( DeviceCodeTimeoutError, + NetworkError, QRTimeoutError, + RateLimitedError, YaPassportError, ) @@ -311,10 +313,19 @@ async def perform_qr_auth(mass: MusicAssistant, session_id: str) -> tuple[str, s async def refresh_music_token(x_token: SecretStr) -> SecretStr: - """Exchange an x_token for a fresh music-scoped OAuth token.""" + """Exchange an x_token for a fresh music-scoped OAuth token. + + Distinguishes transient Passport failures (network/rate limiting) from + credential-invalid errors: only the latter raise ``LoginFailed``, so + callers don't clear stored tokens on a Passport blip. + """ try: async with PassportClient.create() as client: return await client.refresh_music_token(x_token) + except (NetworkError, RateLimitedError) as err: + raise ResourceTemporarilyUnavailable( + f"Yandex Passport temporarily unavailable: {err}" + ) from err except YaPassportError as err: raise LoginFailed(f"Failed to refresh music token: {err}") from err @@ -334,6 +345,10 @@ async def refresh_credentials_via_passport( return await client.refresh_credentials( Credentials(x_token=x_token, refresh_token=refresh_token) ) + except (NetworkError, RateLimitedError) as err: + raise ResourceTemporarilyUnavailable( + f"Yandex Passport temporarily unavailable: {err}" + ) from err except YaPassportError as err: raise LoginFailed(f"Failed to refresh credentials: {err}") from err diff --git a/tests/providers/yandex_music/test_auth.py b/tests/providers/yandex_music/test_auth.py index 20bd77ef4b..e55daf122a 100644 --- a/tests/providers/yandex_music/test_auth.py +++ b/tests/providers/yandex_music/test_auth.py @@ -9,7 +9,7 @@ from unittest import mock import pytest -from music_assistant_models.errors import LoginFailed +from music_assistant_models.errors import LoginFailed, ResourceTemporarilyUnavailable from ya_passport_auth import Credentials, DeviceCodeSession, QrSession, SecretStr from ya_passport_auth.exceptions import ( DeviceCodeTimeoutError, @@ -640,6 +640,28 @@ async def test_refresh_music_token_auth_error_raises_login_failed() -> None: await refresh_music_token(SecretStr("bad_x_token")) +@pytest.mark.parametrize( + "exc", + [PassportNetworkError("offline"), RateLimitedError("429")], + ids=["network", "rate_limited"], +) +async def test_refresh_music_token_transient_error_raises_temporarily_unavailable( + exc: Exception, +) -> None: + """Transient Passport failures don't masquerade as LoginFailed.""" + mock_client = mock.AsyncMock() + mock_client.refresh_music_token.side_effect = exc + + with mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(ResourceTemporarilyUnavailable, match="temporarily unavailable"): + await refresh_music_token(SecretStr("my_x_token")) + + # -- validate_x_token ---------------------------------------------------------- @@ -719,3 +741,25 @@ async def test_refresh_credentials_via_passport_error_raises_login_failed() -> N with pytest.raises(LoginFailed, match="Failed to refresh credentials"): await refresh_credentials_via_passport(SecretStr("bad_x"), SecretStr("bad_refresh")) + + +@pytest.mark.parametrize( + "exc", + [PassportNetworkError("offline"), RateLimitedError("429")], + ids=["network", "rate_limited"], +) +async def test_refresh_credentials_via_passport_transient_error_raises_temporarily_unavailable( + exc: Exception, +) -> None: + """Transient Passport failures don't masquerade as LoginFailed.""" + mock_client = mock.AsyncMock() + mock_client.refresh_credentials.side_effect = exc + + with mock.patch( + "music_assistant.providers.yandex_music.auth.PassportClient.create", + ) as mock_create: + mock_create.return_value.__aenter__ = mock.AsyncMock(return_value=mock_client) + mock_create.return_value.__aexit__ = mock.AsyncMock(return_value=False) + + with pytest.raises(ResourceTemporarilyUnavailable, match="temporarily unavailable"): + await refresh_credentials_via_passport(SecretStr("x"), SecretStr("refresh")) From 67f1d51fe62a67ae2871bcb33d891d931b6f20ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Apr 2026 10:16:02 +0000 Subject: [PATCH 32/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.0.1 --- music_assistant/providers/yandex_music/provider.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/music_assistant/providers/yandex_music/provider.py b/music_assistant/providers/yandex_music/provider.py index 48c0a4efe7..8bae1d656e 100644 --- a/music_assistant/providers/yandex_music/provider.py +++ b/music_assistant/providers/yandex_music/provider.py @@ -65,6 +65,7 @@ PINNED_ITEMS_FOLDER_ID, PLAYLIST_ID_SPLITTER, QUALITY_BALANCED, + QUALITY_SUPERB, RADIO_FOLDER_ID, RADIO_TRACK_ID_SEP, ROTOR_STATION_MY_WAVE, @@ -2539,7 +2540,10 @@ async def get_rotor_station_tracks( def get_quality(self) -> str: """Return the configured audio quality tier (e.g. 'balanced', 'superb').""" - return str(self.config.get_value(CONF_QUALITY) or QUALITY_BALANCED).strip().lower() + quality = str(self.config.get_value(CONF_QUALITY) or QUALITY_BALANCED).strip().lower() + if quality == "lossless": + quality = QUALITY_SUPERB + return quality async def resolve_image(self, path: str) -> str | bytes: """Resolve wave cover image with background color fill for transparent PNGs. From 72e1d051708668d1ac9a66e5c5d5105b970fd6bd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Apr 2026 14:01:50 +0000 Subject: [PATCH 33/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.1.0 --- .../providers/yandex_music/__init__.py | 4 + .../providers/yandex_music/api_client.py | 8 +- .../providers/yandex_music/parsers.py | 309 ++++++++++++++++-- .../providers/yandex_music/provider.py | 175 +++++++++- .../fixtures/audiobooks/basic.json | 25 ++ .../fixtures/podcast_episodes/basic.json | 24 ++ .../yandex_music/fixtures/podcasts/basic.json | 25 ++ tests/providers/yandex_music/test_parsers.py | 122 +++++++ 8 files changed, 652 insertions(+), 40 deletions(-) create mode 100644 tests/providers/yandex_music/fixtures/audiobooks/basic.json create mode 100644 tests/providers/yandex_music/fixtures/podcast_episodes/basic.json create mode 100644 tests/providers/yandex_music/fixtures/podcasts/basic.json diff --git a/music_assistant/providers/yandex_music/__init__.py b/music_assistant/providers/yandex_music/__init__.py index f3d56476e0..1326abda17 100644 --- a/music_assistant/providers/yandex_music/__init__.py +++ b/music_assistant/providers/yandex_music/__init__.py @@ -41,12 +41,16 @@ ProviderFeature.LIBRARY_ALBUMS, ProviderFeature.LIBRARY_TRACKS, ProviderFeature.LIBRARY_PLAYLISTS, + ProviderFeature.LIBRARY_PODCASTS, + ProviderFeature.LIBRARY_AUDIOBOOKS, ProviderFeature.ARTIST_ALBUMS, ProviderFeature.ARTIST_TOPTRACKS, ProviderFeature.SEARCH, ProviderFeature.LIBRARY_ARTISTS_EDIT, ProviderFeature.LIBRARY_ALBUMS_EDIT, ProviderFeature.LIBRARY_TRACKS_EDIT, + ProviderFeature.LIBRARY_PODCASTS_EDIT, + ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT, ProviderFeature.BROWSE, ProviderFeature.SIMILAR_TRACKS, ProviderFeature.SIMILAR_ARTISTS, diff --git a/music_assistant/providers/yandex_music/api_client.py b/music_assistant/providers/yandex_music/api_client.py index d6b780db3e..4d83bb5e1b 100644 --- a/music_assistant/providers/yandex_music/api_client.py +++ b/music_assistant/providers/yandex_music/api_client.py @@ -610,9 +610,11 @@ async def get_album_with_tracks(self, album_id: str) -> YandexAlbum | None: return await self._call_with_retry( lambda c: c.albums_with_tracks( album_id, - resumeStream=True, - richTracks=True, - withListeningFinished=True, + params={ + "resumeStream": "true", + "richTracks": "true", + "withListeningFinished": "true", + }, ) ) except (BadRequestError, NetworkError, ProviderUnavailableError) as err: diff --git a/music_assistant/providers/yandex_music/parsers.py b/music_assistant/providers/yandex_music/parsers.py index a7756412ba..006710ab66 100644 --- a/music_assistant/providers/yandex_music/parsers.py +++ b/music_assistant/providers/yandex_music/parsers.py @@ -4,7 +4,7 @@ from contextlib import suppress from datetime import datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from music_assistant_models.enums import ( AlbumType, @@ -15,9 +15,12 @@ from music_assistant_models.media_items import ( Album, Artist, + Audiobook, AudioFormat, MediaItemImage, Playlist, + Podcast, + PodcastEpisode, ProviderMapping, Track, UniqueList, @@ -42,6 +45,32 @@ from .provider import YandexMusicProvider +AlbumKind = Literal["music", "podcast", "audiobook"] + + +def classify_album(album_obj: YandexAlbum) -> AlbumKind: + """Classify a Yandex album as music / podcast / audiobook. + + Checks both ``meta_type`` and ``type`` for the substrings "audiobook" / + "podcast". The more specific "audiobook" signal wins over "podcast" on any + field because Yandex tags audiobooks with ``meta_type="podcast"`` *and* + ``type="audiobook"`` — empirically observed in production libraries. + Values are not documented in the yandex_music SDK. + + :param album_obj: Yandex album object. + :return: One of "music", "podcast", "audiobook". + """ + fields = [ + (getattr(album_obj, "meta_type", None) or "").lower(), + (getattr(album_obj, "type", None) or "").lower(), + ] + if any("audiobook" in f for f in fields): + return "audiobook" + if any("podcast" in f for f in fields): + return "podcast" + return "music" + + def get_canonical_provider_name(provider: YandexMusicProvider) -> str: """Return the locale-aware canonical display name for the Yandex Music system account. @@ -139,6 +168,33 @@ def parse_artist( return artist +def _album_cover_images( + provider: YandexMusicProvider, album_obj: YandexAlbum +) -> UniqueList[MediaItemImage]: + """Build the UniqueList of images for an album-like object. + + Prefers the templated ``cover_uri`` and falls back to ``og_image`` — matches + the selection rules used for podcasts and audiobooks so all album-like + parsers stay in sync. + """ + images: UniqueList[MediaItemImage] = UniqueList() + image_url: str | None = None + if album_obj.cover_uri: + image_url = _get_image_url(album_obj.cover_uri) + elif album_obj.og_image: + image_url = _get_image_url(album_obj.og_image) + if image_url: + images.append( + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=provider.instance_id, + remotely_accessible=True, + ) + ) + return images + + def parse_album(provider: YandexMusicProvider, album_obj: YandexAlbum) -> Album: """Parse Yandex album object to MA Album model. @@ -204,33 +260,9 @@ def parse_album(provider: YandexMusicProvider, album_obj: YandexAlbum) -> Album: if album_obj.genre: album.metadata.genres = {album_obj.genre} - # Add cover image - if album_obj.cover_uri: - image_url = _get_image_url(album_obj.cover_uri) - if image_url: - album.metadata.images = UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=provider.instance_id, - remotely_accessible=True, - ) - ] - ) - elif album_obj.og_image: - image_url = _get_image_url(album_obj.og_image) - if image_url: - album.metadata.images = UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=provider.instance_id, - remotely_accessible=True, - ) - ] - ) + images = _album_cover_images(provider, album_obj) + if images: + album.metadata.images = images return album @@ -411,3 +443,224 @@ def parse_playlist( ) return playlist + + +def parse_podcast(provider: YandexMusicProvider, album_obj: YandexAlbum) -> Podcast: + """Parse Yandex album (meta_type=podcast) to MA Podcast model. + + :param provider: The Yandex Music provider instance. + :param album_obj: Yandex album object classified as a podcast. + :return: Music Assistant Podcast model. + """ + if album_obj.id is None: + raise InvalidDataError("Yandex podcast missing id") + name, _ = parse_title_and_version( + album_obj.title or "Unknown Podcast", + album_obj.version or None, + ) + podcast_id = str(album_obj.id) + available = album_obj.available or False + + # Publisher: prefer labels[0].name; fall back to first artist name + publisher: str | None = None + labels = getattr(album_obj, "labels", None) + if labels: + first = labels[0] + label_name = getattr(first, "name", None) if not isinstance(first, str) else first + if label_name: + publisher = label_name + if not publisher and album_obj.artists: + first_artist = album_obj.artists[0] + if first_artist.name: + publisher = first_artist.name + + podcast = Podcast( + item_id=podcast_id, + provider=provider.instance_id, + name=name, + provider_mappings={ + ProviderMapping( + item_id=podcast_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + audio_format=AudioFormat(content_type=ContentType.UNKNOWN), + url=f"{WEB_BASE_URL}/album/{podcast_id}", + available=available, + ) + }, + publisher=publisher, + total_episodes=album_obj.track_count, + ) + + description = album_obj.description or album_obj.short_description + if description: + podcast.metadata.description = description + if album_obj.content_warning: + podcast.metadata.explicit = album_obj.content_warning == "explicit" + + images = _album_cover_images(provider, album_obj) + if images: + podcast.metadata.images = images + + if album_obj.genre: + podcast.metadata.genres = {album_obj.genre} + else: + podcast.metadata.genres = {"Spoken Word"} + + if album_obj.release_date: + with suppress(ValueError): + podcast.metadata.release_date = datetime.fromisoformat(album_obj.release_date) + + return podcast + + +def parse_podcast_episode( + provider: YandexMusicProvider, + track_obj: YandexTrack, + podcast: Podcast, + position: int = 0, +) -> PodcastEpisode: + """Parse Yandex track (episode of a podcast album) to MA PodcastEpisode. + + :param provider: The Yandex Music provider instance. + :param track_obj: Yandex track object. + :param podcast: Parent Podcast object. + :param position: 1-based episode index (0 if unknown). + :return: Music Assistant PodcastEpisode model. + """ + if track_obj.id is None: + raise InvalidDataError("Yandex podcast episode missing id") + episode_id = str(track_obj.id) + available = track_obj.available or False + duration = (track_obj.duration_ms or 0) // 1000 + + episode_name = track_obj.title or (f"Episode {position}" if position else "Unknown Episode") + episode = PodcastEpisode( + item_id=episode_id, + provider=provider.instance_id, + name=episode_name, + duration=duration, + podcast=podcast, + position=position, + provider_mappings={ + ProviderMapping( + item_id=episode_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + audio_format=AudioFormat(content_type=ContentType.UNKNOWN), + url=f"{WEB_BASE_URL}/track/{episode_id}", + available=available, + ) + }, + ) + + if track_obj.short_description: + episode.metadata.description = track_obj.short_description + if track_obj.content_warning: + episode.metadata.explicit = track_obj.content_warning == "explicit" + + # Track cover → fall back to podcast cover + if track_obj.cover_uri: + image_url = _get_image_url(track_obj.cover_uri) + if image_url: + episode.metadata.images = UniqueList( + [ + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=provider.instance_id, + remotely_accessible=True, + ) + ] + ) + elif track_obj.og_image: + image_url = _get_image_url(track_obj.og_image) + if image_url: + episode.metadata.images = UniqueList( + [ + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=provider.instance_id, + remotely_accessible=True, + ) + ] + ) + if not episode.metadata.images and podcast.metadata.images: + episode.metadata.images = UniqueList(podcast.metadata.images) + + return episode + + +def parse_audiobook(provider: YandexMusicProvider, album_obj: YandexAlbum) -> Audiobook: + """Parse Yandex album (meta_type=audiobook) to MA Audiobook model. + + :param provider: The Yandex Music provider instance. + :param album_obj: Yandex album object classified as an audiobook. + :return: Music Assistant Audiobook model. Chapters and duration are filled + by the provider's get_audiobook() method after loading album tracks. + """ + if album_obj.id is None: + raise InvalidDataError("Yandex audiobook missing id") + name, _ = parse_title_and_version( + album_obj.title or "Unknown Audiobook", + album_obj.version or None, + ) + audiobook_id = str(album_obj.id) + available = album_obj.available or False + + # Publisher: prefer labels[0]; fall back to nothing (authors sit on artists) + publisher: str | None = None + labels = getattr(album_obj, "labels", None) + if labels: + first = labels[0] + label_name = getattr(first, "name", None) if not isinstance(first, str) else first + if label_name: + publisher = label_name + + authors: UniqueList[str] = UniqueList() + if album_obj.artists: + for artist in album_obj.artists: + if artist.name: + authors.append(artist.name) + + audiobook = Audiobook( + item_id=audiobook_id, + provider=provider.instance_id, + name=name, + provider_mappings={ + ProviderMapping( + item_id=audiobook_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + audio_format=AudioFormat(content_type=ContentType.UNKNOWN), + url=f"{WEB_BASE_URL}/album/{audiobook_id}", + available=available, + ) + }, + publisher=publisher, + authors=authors, + narrators=UniqueList(), + duration=0, + ) + + description = album_obj.description or album_obj.short_description + if description: + audiobook.metadata.description = description + if album_obj.content_warning: + audiobook.metadata.explicit = album_obj.content_warning == "explicit" + + images = _album_cover_images(provider, album_obj) + if images: + audiobook.metadata.images = images + + if album_obj.genre: + audiobook.metadata.genres = {album_obj.genre} + else: + audiobook.metadata.genres = {"Spoken Word"} + + if album_obj.release_date: + with suppress(ValueError): + audiobook.metadata.release_date = datetime.fromisoformat(album_obj.release_date) + + return audiobook diff --git a/music_assistant/providers/yandex_music/provider.py b/music_assistant/providers/yandex_music/provider.py index 8bae1d656e..ed5d187c2c 100644 --- a/music_assistant/providers/yandex_music/provider.py +++ b/music_assistant/providers/yandex_music/provider.py @@ -21,11 +21,15 @@ from music_assistant_models.media_items import ( Album, Artist, + Audiobook, BrowseFolder, ItemMapping, + MediaItemChapter, MediaItemImage, MediaItemType, Playlist, + Podcast, + PodcastEpisode, ProviderMapping, RecommendationFolder, SearchResults, @@ -86,16 +90,21 @@ _get_image_url as get_image_url, ) from .parsers import ( + classify_album, get_canonical_provider_name, parse_album, parse_artist, + parse_audiobook, parse_playlist, + parse_podcast, + parse_podcast_episode, parse_track, ) from .streaming import YandexMusicStreamingManager if TYPE_CHECKING: from music_assistant_models.streamdetails import StreamDetails + from yandex_music import Album as YandexAlbum def _parse_radio_item_id(item_id: str) -> tuple[str, str | None]: @@ -137,6 +146,10 @@ class YandexMusicProvider(MusicProvider): _my_wave_lock: asyncio.Lock # Protects My Wave mutable state _wave_states: dict[str, _WaveState] # Per-station state for tagged wave stations _wave_bg_colors: dict[str, str] # image_url -> hex bg color for transparent covers + # Short-lived cache to dedupe the three library syncs (albums/podcasts/audiobooks) + # that all derive from the same liked-albums endpoint. + _liked_albums_cache: tuple[float, list[YandexAlbum]] | None = None + _liked_albums_lock: asyncio.Lock @property def client(self) -> YandexMusicClient: @@ -272,6 +285,8 @@ async def handle_async_init(self) -> None: # Initialize per-station wave state dict self._wave_states = {} self._wave_bg_colors = {} + self._liked_albums_lock = asyncio.Lock() + self._liked_albums_cache = None self.logger.info("Successfully connected to Yandex Music") async def unload(self, is_removed: bool = False) -> None: @@ -1512,6 +1527,7 @@ async def search( MediaType.ALBUM: "album", MediaType.ARTIST: "artist", MediaType.PLAYLIST: "playlist", + MediaType.PODCAST: "podcast", } requested_types = [type_mapping[mt] for mt in media_types if mt in type_mapping] @@ -1554,6 +1570,15 @@ async def search( except InvalidDataError as err: self.logger.debug("Error parsing playlist: %s", err) + # Parse podcasts (Yandex returns them as albums under .podcasts) + podcasts_node = getattr(search_result, "podcasts", None) + if MediaType.PODCAST in media_types and podcasts_node: + for album in podcasts_node.results[:limit]: + try: + result.podcasts = [*result.podcasts, parse_podcast(self, album)] + except InvalidDataError as err: + self.logger.debug("Error parsing podcast: %s", err) + return result # Get single items @@ -1587,6 +1612,87 @@ async def get_album(self, prov_album_id: str) -> Album: raise MediaNotFoundError(f"Album {prov_album_id} not found") return parse_album(self, album) + @use_cache(3600 * 24) + async def get_podcast(self, prov_podcast_id: str) -> Podcast: + """Get podcast details by ID (backed by a Yandex album). + + :param prov_podcast_id: The provider podcast (album) ID. + :return: Podcast object. + :raises MediaNotFoundError: If not found. + """ + album = await self.client.get_album(prov_podcast_id) + if not album: + raise MediaNotFoundError(f"Podcast {prov_podcast_id} not found") + return parse_podcast(self, album) + + async def get_podcast_episodes( + self, prov_podcast_id: str + ) -> AsyncGenerator[PodcastEpisode, None]: + """Iterate podcast episodes for a given podcast (album) ID.""" + album = await self.client.get_album_with_tracks(prov_podcast_id) + if not album: + raise MediaNotFoundError(f"Podcast {prov_podcast_id} not found") + podcast = parse_podcast(self, album) + position = 1 + for disc in album.volumes or []: + for track_obj in disc: + try: + yield parse_podcast_episode(self, track_obj, podcast, position=position) + except InvalidDataError as err: + self.logger.debug("Error parsing podcast episode: %s", err) + position += 1 + + async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode: + """Get a single podcast episode by ID. + + The parent Podcast is reconstructed from the track's parent album. If + the album isn't present on the track, the episode cannot be converted + into a valid MA model and InvalidDataError is raised. + """ + tracks = await self.client.get_tracks([prov_episode_id]) + if not tracks: + raise MediaNotFoundError(f"Podcast episode {prov_episode_id} not found") + track_obj = tracks[0] + if not track_obj.albums: + raise InvalidDataError( + f"Podcast episode {prov_episode_id} is missing parent podcast album data" + ) + podcast = parse_podcast(self, track_obj.albums[0]) + return parse_podcast_episode(self, track_obj, podcast, position=0) + + @use_cache(3600 * 24) + async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook: + """Get audiobook details by ID, including chapters built from tracks. + + :param prov_audiobook_id: The provider audiobook (album) ID. + :return: Audiobook object. + :raises MediaNotFoundError: If not found. + """ + album = await self.client.get_album_with_tracks(prov_audiobook_id) + if not album: + raise MediaNotFoundError(f"Audiobook {prov_audiobook_id} not found") + audiobook = parse_audiobook(self, album) + + chapters: list[MediaItemChapter] = [] + start = 0.0 + pos = 1 + for disc in album.volumes or []: + for track_obj in disc: + dur_s = (track_obj.duration_ms or 0) / 1000.0 + chapters.append( + MediaItemChapter( + position=pos, + name=track_obj.title or f"Chapter {pos}", + start=start, + end=start + dur_s, + ) + ) + start += dur_s + pos += 1 + audiobook.metadata.chapters = chapters + audiobook.duration = int(start) + return audiobook + async def get_track(self, prov_track_id: str) -> Track: """Get track details by ID. @@ -2401,16 +2507,59 @@ async def get_library_artists(self) -> AsyncGenerator[Artist, None]: except InvalidDataError as err: self.logger.debug("Error parsing library artist: %s", err) + async def _get_liked_albums_cached(self, ttl: float = 30.0) -> list[YandexAlbum]: + """Return liked albums with a short in-process TTL cache + lock. + + Albums, podcasts and audiobooks are all derived from the same + ``users/{uid}/likes/albums`` endpoint, so a full library sync would + otherwise trigger three sequential (or concurrent) identical calls. + The lock serializes refreshes so only one request hits the API when + multiple library syncs start together. + """ + async with self._liked_albums_lock: + now = asyncio.get_running_loop().time() + if self._liked_albums_cache is not None: + cached_at, cached = self._liked_albums_cache + if now - cached_at < ttl: + return cached + albums = await self.client.get_liked_albums(batch_size=TRACK_BATCH_SIZE) + self._liked_albums_cache = (now, albums) + return albums + async def get_library_albums(self) -> AsyncGenerator[Album, None]: - """Retrieve library albums from Yandex Music.""" - batch_size = TRACK_BATCH_SIZE - albums = await self.client.get_liked_albums(batch_size=batch_size) - for album in albums: + """Retrieve library albums from Yandex Music. + + Excludes entries classified as podcasts or audiobooks so they don't + duplicate into the Albums library view. + """ + for album in await self._get_liked_albums_cached(): + if classify_album(album) != "music": + continue try: yield parse_album(self, album) except InvalidDataError as err: self.logger.debug("Error parsing library album: %s", err) + async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]: + """Retrieve library podcasts from Yandex Music (filtered liked albums).""" + for album in await self._get_liked_albums_cached(): + if classify_album(album) != "podcast": + continue + try: + yield parse_podcast(self, album) + except InvalidDataError as err: + self.logger.debug("Error parsing library podcast: %s", err) + + async def get_library_audiobooks(self) -> AsyncGenerator[Audiobook, None]: + """Retrieve library audiobooks from Yandex Music (filtered liked albums).""" + for album in await self._get_liked_albums_cached(): + if classify_album(album) != "audiobook": + continue + try: + yield parse_audiobook(self, album) + except InvalidDataError as err: + self.logger.debug("Error parsing library audiobook: %s", err) + async def get_library_tracks(self) -> AsyncGenerator[Track, None]: """Retrieve library tracks from Yandex Music.""" track_shorts = await self.client.get_liked_tracks() @@ -2472,7 +2621,7 @@ async def library_add(self, item: MediaItemType) -> bool: if item.media_type == MediaType.TRACK: return await self.client.like_track(track_id) - if item.media_type == MediaType.ALBUM: + if item.media_type in (MediaType.ALBUM, MediaType.PODCAST, MediaType.AUDIOBOOK): return await self.client.like_album(prov_item_id) if item.media_type == MediaType.ARTIST: return await self.client.like_artist(prov_item_id) @@ -2488,7 +2637,7 @@ async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool track_id, _ = _parse_radio_item_id(prov_item_id) if media_type == MediaType.TRACK: return await self.client.unlike_track(track_id) - if media_type == MediaType.ALBUM: + if media_type in (MediaType.ALBUM, MediaType.PODCAST, MediaType.AUDIOBOOK): return await self.client.unlike_album(prov_item_id) if media_type == MediaType.ARTIST: return await self.client.unlike_artist(prov_item_id) @@ -2506,12 +2655,20 @@ def _get_provider_item_id(self, item: MediaItemType) -> str | None: async def get_stream_details( self, item_id: str, media_type: MediaType = MediaType.TRACK ) -> StreamDetails: - """Get stream details for a track. + """Get stream details for a track or podcast episode. + + A podcast episode is a track underneath the Yandex API, so it flows + through the same streaming path. Audiobook streaming as a single + entity is not implemented (Phase 2). - :param item_id: The track ID (or track_id@station_id for My Wave). - :param media_type: The media type (should be TRACK). + :param item_id: The track / episode ID (or track_id@station_id for My Wave). + :param media_type: The media type. :return: StreamDetails for the track. """ + if media_type == MediaType.AUDIOBOOK: + raise NotImplementedError( + "Audiobook streaming is not yet supported by the Yandex Music provider" + ) return await self.streaming.get_stream_details(item_id) async def get_audio_stream( diff --git a/tests/providers/yandex_music/fixtures/audiobooks/basic.json b/tests/providers/yandex_music/fixtures/audiobooks/basic.json new file mode 100644 index 0000000000..984b1fba9e --- /dev/null +++ b/tests/providers/yandex_music/fixtures/audiobooks/basic.json @@ -0,0 +1,25 @@ +{ + "id": 800, + "title": "Sample Audiobook", + "available": true, + "artists": [ + { + "id": 81, + "name": "Book Author" + }, + { + "id": 82, + "name": "Co-Author" + } + ], + "labels": [ + { + "id": 2, + "name": "Audio Publisher" + } + ], + "type": "audiobook", + "meta_type": "podcast", + "description": "A sample audiobook description.", + "cover_uri": "avatars.yandex.net/get-music-content/ab/cover/%%" +} diff --git a/tests/providers/yandex_music/fixtures/podcast_episodes/basic.json b/tests/providers/yandex_music/fixtures/podcast_episodes/basic.json new file mode 100644 index 0000000000..b3eeb6b564 --- /dev/null +++ b/tests/providers/yandex_music/fixtures/podcast_episodes/basic.json @@ -0,0 +1,24 @@ +{ + "id": 900, + "title": "Episode One", + "available": true, + "duration_ms": 1800000, + "artists": [ + { + "id": 71, + "name": "Podcast Author" + } + ], + "albums": [ + { + "id": 700, + "title": "Sample Podcast", + "type": "podcast", + "meta_type": "podcast", + "cover_uri": "avatars.yandex.net/get-music-content/pod/cover/%%" + } + ], + "short_description": "Episode summary goes here.", + "content_warning": "explicit", + "cover_uri": "avatars.yandex.net/get-music-content/pod/ep1/%%" +} diff --git a/tests/providers/yandex_music/fixtures/podcasts/basic.json b/tests/providers/yandex_music/fixtures/podcasts/basic.json new file mode 100644 index 0000000000..500208831e --- /dev/null +++ b/tests/providers/yandex_music/fixtures/podcasts/basic.json @@ -0,0 +1,25 @@ +{ + "id": 700, + "title": "Sample Podcast", + "available": true, + "artists": [ + { + "id": 71, + "name": "Podcast Author" + } + ], + "labels": [ + { + "id": 1, + "name": "Podcast Studio" + } + ], + "type": "podcast", + "meta_type": "podcast", + "track_count": 42, + "description": "A sample podcast description.", + "short_description": "Short desc", + "content_warning": "explicit", + "cover_uri": "avatars.yandex.net/get-music-content/pod/cover/%%", + "release_date": "2024-03-15T00:00:00+00:00" +} diff --git a/tests/providers/yandex_music/test_parsers.py b/tests/providers/yandex_music/test_parsers.py index 1fd39d4b91..205f8a2507 100644 --- a/tests/providers/yandex_music/test_parsers.py +++ b/tests/providers/yandex_music/test_parsers.py @@ -13,9 +13,13 @@ from yandex_music import Track as YandexTrack from music_assistant.providers.yandex_music.parsers import ( + classify_album, parse_album, parse_artist, + parse_audiobook, parse_playlist, + parse_podcast, + parse_podcast_episode, parse_track, ) from music_assistant.providers.yandex_music.provider import YandexMusicProvider @@ -32,6 +36,8 @@ ALBUM_FIXTURES = list(FIXTURES_DIR.glob("albums/*.json")) TRACK_FIXTURES = list(FIXTURES_DIR.glob("tracks/*.json")) PLAYLIST_FIXTURES = list(FIXTURES_DIR.glob("playlists/*.json")) +PODCAST_FIXTURES = list(FIXTURES_DIR.glob("podcasts/*.json")) +AUDIOBOOK_FIXTURES = list(FIXTURES_DIR.glob("audiobooks/*.json")) def _load_json(path: pathlib.Path) -> dict[str, Any]: @@ -295,3 +301,119 @@ def test_parse_playlist_snapshot( result = parse_playlist(cast("YandexMusicProvider", provider_stub), playlist_obj) parsed = _sort_for_snapshot(result.to_dict()) assert snapshot == parsed + + +# --- classify_album --- + + +@pytest.mark.parametrize( + ("meta_type", "type_", "expected"), + [ + ("podcast", None, "podcast"), + (None, "podcast", "podcast"), + ("Podcast", None, "podcast"), + ("podcast_episode", None, "podcast"), + ("audiobook", None, "audiobook"), + (None, "audiobook", "audiobook"), + ("AUDIOBOOK", None, "audiobook"), + # audiobook wins over podcast on any field — empirically observed: + # Yandex tags audiobooks as meta_type="podcast" + type="audiobook" + ("podcast", "audiobook", "audiobook"), + ("audiobook", "podcast", "audiobook"), + ("audiobook", "music", "audiobook"), + # plain music + (None, None, "music"), + ("music", "album", "music"), + ("", "", "music"), + ], +) +def test_classify_album( + meta_type: str | None, + type_: str | None, + expected: str, +) -> None: + """classify_album maps meta_type/type variants to music/podcast/audiobook.""" + album_obj = YandexAlbum.de_json( + {"id": 1, "title": "x", "meta_type": meta_type, "type": type_}, + DE_JSON_CLIENT, + ) + assert album_obj is not None + assert classify_album(album_obj) == expected + + +# --- Podcast / Audiobook / PodcastEpisode parsers --- + + +@pytest.mark.parametrize("example", PODCAST_FIXTURES, ids=lambda val: val.stem) +def test_parse_podcast(example: pathlib.Path, provider_stub: ProviderStub) -> None: + """parse_podcast extracts basic fields from a podcast-typed album fixture.""" + album_obj = _album_from_fixture(example) + assert album_obj is not None + result = parse_podcast(cast("YandexMusicProvider", provider_stub), album_obj) + assert result.item_id == str(album_obj.id) + assert result.name + assert result.provider == provider_stub.instance_id + mapping = next(iter(result.provider_mappings)) + assert f"music.yandex.ru/album/{album_obj.id}" in (mapping.url or "") + # publisher resolves from labels[0].name when present + if album_obj.labels: + first = album_obj.labels[0] + label_name = first if isinstance(first, str) else getattr(first, "name", None) + if label_name: + assert result.publisher == label_name + if album_obj.track_count is not None: + assert result.total_episodes == album_obj.track_count + + +@pytest.mark.parametrize("example", AUDIOBOOK_FIXTURES, ids=lambda val: val.stem) +def test_parse_audiobook(example: pathlib.Path, provider_stub: ProviderStub) -> None: + """parse_audiobook extracts authors from artists and publisher from labels.""" + album_obj = _album_from_fixture(example) + assert album_obj is not None + result = parse_audiobook(cast("YandexMusicProvider", provider_stub), album_obj) + assert result.item_id == str(album_obj.id) + assert result.name + assert result.duration == 0 # filled in later by get_audiobook() + # authors come from album artists + expected_authors = [a.name for a in (album_obj.artists or []) if a.name] + assert list(result.authors) == expected_authors + assert list(result.narrators) == [] + + +def test_parse_podcast_episode(provider_stub: ProviderStub) -> None: + """parse_podcast_episode links episode to its parent podcast.""" + podcast_album = _album_from_fixture(FIXTURES_DIR / "podcasts" / "basic.json") + assert podcast_album is not None + podcast = parse_podcast(cast("YandexMusicProvider", provider_stub), podcast_album) + + track_obj = _track_from_fixture(FIXTURES_DIR / "podcast_episodes" / "basic.json") + assert track_obj is not None + episode = parse_podcast_episode( + cast("YandexMusicProvider", provider_stub), track_obj, podcast, position=1 + ) + assert episode.item_id == str(track_obj.id) + assert episode.name == track_obj.title + assert episode.position == 1 + assert episode.duration == (track_obj.duration_ms or 0) // 1000 + assert episode.podcast is podcast + mapping = next(iter(episode.provider_mappings)) + assert f"music.yandex.ru/track/{track_obj.id}" in (mapping.url or "") + + +def test_parse_podcast_episode_inherits_podcast_image(provider_stub: ProviderStub) -> None: + """Episode image falls back to parent podcast image when track has none.""" + podcast_album = _album_from_fixture(FIXTURES_DIR / "podcasts" / "basic.json") + assert podcast_album is not None + podcast = parse_podcast(cast("YandexMusicProvider", provider_stub), podcast_album) + # strip cover on the track so the fallback kicks in + track_obj = _track_from_fixture(FIXTURES_DIR / "podcast_episodes" / "basic.json") + assert track_obj is not None + track_obj.cover_uri = None + track_obj.og_image = None + episode = parse_podcast_episode( + cast("YandexMusicProvider", provider_stub), track_obj, podcast, position=1 + ) + assert episode.metadata.images is not None + assert episode.metadata.images == podcast.metadata.images + # Must be a separate list — mutating one shouldn't affect the other. + assert episode.metadata.images is not podcast.metadata.images From e64d8618db4155f053b8e57ee27d062f4a1c7b5d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Apr 2026 14:23:20 +0000 Subject: [PATCH 34/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.1.1 --- music_assistant/providers/yandex_music/provider.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/music_assistant/providers/yandex_music/provider.py b/music_assistant/providers/yandex_music/provider.py index ed5d187c2c..b176e6bcc0 100644 --- a/music_assistant/providers/yandex_music/provider.py +++ b/music_assistant/providers/yandex_music/provider.py @@ -190,6 +190,14 @@ async def _reauth_via_refresh_token( new_creds = await refresh_credentials_via_passport( SecretStr(x_token), SecretStr(refresh_token) ) + except ResourceTemporarilyUnavailable as err2: + # Transient Passport failure — keep creds, let MA retry later + self.logger.warning( + "Credential refresh temporarily unavailable: %s", type(err2).__name__ + ) + raise ProviderUnavailableError( + "Unable to refresh credentials right now. Please try again later." + ) from err2 except LoginFailed as err2: self.logger.warning("Session and refresh tokens are both expired") self._update_config_value(CONF_TOKEN, None, encrypted=True) From 3612ae7b18145323d22f258475aee67d01a6b03e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Apr 2026 14:37:07 +0000 Subject: [PATCH 35/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.1.2 --- music_assistant/providers/yandex_music/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/music_assistant/providers/yandex_music/__init__.py b/music_assistant/providers/yandex_music/__init__.py index 1326abda17..ae133f0342 100644 --- a/music_assistant/providers/yandex_music/__init__.py +++ b/music_assistant/providers/yandex_music/__init__.py @@ -87,6 +87,9 @@ async def get_config_entries( values[CONF_X_TOKEN] = x_token else: values[CONF_X_TOKEN] = None + # QR flow never yields a refresh_token — clear any stale one from a + # prior device-flow login so we don't leave dead credentials behind + values[CONF_REFRESH_TOKEN] = None # Handle Device Flow auth action (yields x_token + refresh_token, # so we get silent auto-refresh on music-token AND x_token expiry) From 681e0ba13302446c4b36432249ea37dc387ec03e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Apr 2026 19:22:23 +0000 Subject: [PATCH 36/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.2.0 --- .../providers/yandex_music/provider.py | 128 ++++++++++++++++-- 1 file changed, 116 insertions(+), 12 deletions(-) diff --git a/music_assistant/providers/yandex_music/provider.py b/music_assistant/providers/yandex_music/provider.py index b176e6bcc0..a369bee2cf 100644 --- a/music_assistant/providers/yandex_music/provider.py +++ b/music_assistant/providers/yandex_music/provider.py @@ -10,7 +10,7 @@ from io import BytesIO from typing import TYPE_CHECKING, Any -from music_assistant_models.enums import ImageType, MediaType, ProviderFeature +from music_assistant_models.enums import ImageType, MediaType, ProviderFeature, StreamType from music_assistant_models.errors import ( InvalidDataError, LoginFailed, @@ -36,6 +36,7 @@ Track, UniqueList, ) +from music_assistant_models.streamdetails import StreamDetails from PIL import Image as PilImage from ya_passport_auth import SecretStr @@ -103,7 +104,6 @@ from .streaming import YandexMusicStreamingManager if TYPE_CHECKING: - from music_assistant_models.streamdetails import StreamDetails from yandex_music import Album as YandexAlbum @@ -2663,37 +2663,141 @@ def _get_provider_item_id(self, item: MediaItemType) -> str | None: async def get_stream_details( self, item_id: str, media_type: MediaType = MediaType.TRACK ) -> StreamDetails: - """Get stream details for a track or podcast episode. + """Get stream details for a track, podcast episode, or audiobook. A podcast episode is a track underneath the Yandex API, so it flows - through the same streaming path. Audiobook streaming as a single - entity is not implemented (Phase 2). + through the same per-track streaming path. An audiobook is an album + with multiple tracks (chapters) — returned as a CUSTOM stream whose + generator concatenates each chapter's bytes in order. - :param item_id: The track / episode ID (or track_id@station_id for My Wave). + :param item_id: The track / episode ID (or track_id@station_id for My Wave), + or the audiobook (album) ID when ``media_type`` is AUDIOBOOK. :param media_type: The media type. - :return: StreamDetails for the track. + :return: StreamDetails for the item. """ if media_type == MediaType.AUDIOBOOK: - raise NotImplementedError( - "Audiobook streaming is not yet supported by the Yandex Music provider" - ) + return await self._get_audiobook_stream_details(item_id) return await self.streaming.get_stream_details(item_id) + async def _get_audiobook_stream_details(self, audiobook_id: str) -> StreamDetails: + """Build StreamDetails for an audiobook as a chapter-concatenated CUSTOM stream. + + Loads the album's tracks, uses the first chapter to establish the audio + format, and stores the per-chapter track-IDs + durations in ``data`` so + ``get_audio_stream`` can iterate them. Seek across chapter boundaries is + handled via ``seek_position`` in ``get_audio_stream``; in-chapter seek is + disabled (Yandex per-chapter seekability varies by codec). + """ + album = await self.client.get_album_with_tracks(audiobook_id) + if not album or not (album.volumes or []): + raise MediaNotFoundError(f"Audiobook {audiobook_id} has no chapters") + + chapter_ids: list[str] = [] + chapter_durations_ms: list[int] = [] + for disc in album.volumes or []: + for track_obj in disc: + chapter_ids.append(str(track_obj.id)) + chapter_durations_ms.append(int(track_obj.duration_ms or 0)) + if not chapter_ids: + raise MediaNotFoundError(f"Audiobook {audiobook_id} has no chapters") + + # Resolve first-chapter format so MA/ffmpeg know what it's decoding + first = await self.streaming.get_stream_details(chapter_ids[0]) + total_duration = sum(chapter_durations_ms) // 1000 + + return StreamDetails( + item_id=audiobook_id, + provider=self.instance_id, + media_type=MediaType.AUDIOBOOK, + audio_format=first.audio_format, + stream_type=StreamType.CUSTOM, + duration=total_duration, + data={ + "chapter_ids": chapter_ids, + "chapter_durations_ms": chapter_durations_ms, + }, + can_seek=False, + allow_seek=True, + ) + async def get_audio_stream( self, streamdetails: StreamDetails, seek_position: int = 0 ) -> AsyncGenerator[bytes, None]: """Return the audio stream for the provider item. - Uses windowed Range-request streaming to prevent Yandex CDN drops. - Handles both raw (direct) and encrypted (encraw) transports. + For tracks and podcast episodes, streams via windowed Range requests + (raw or AES-CTR encrypted). For audiobooks, iterates chapters: each + chapter's bytes are streamed through the per-track path and concatenated. :param streamdetails: Stream details with URL and optional decryption key. :param seek_position: Seek position in seconds (handled by provider for raw transport). :return: Async generator yielding audio chunks. """ + data = streamdetails.data if isinstance(streamdetails.data, dict) else None + if streamdetails.media_type == MediaType.AUDIOBOOK and data and "chapter_ids" in data: + async for chunk in self._stream_audiobook_chapters(data, seek_position): + yield chunk + return async for chunk in self.streaming.get_audio_stream(streamdetails, seek_position): yield chunk + async def _stream_audiobook_chapters( + self, data: dict[str, Any], seek_position: int + ) -> AsyncGenerator[bytes, None]: + """Concatenate per-chapter streams of an audiobook. + + Translates ``seek_position`` into (start_chapter, in_chapter_offset) and + delegates each chapter to the per-track streaming path. Subsequent + chapters start at offset 0. + """ + chapter_ids: list[str] = list(data.get("chapter_ids") or []) + chapter_durations_ms: list[int] = list(data.get("chapter_durations_ms") or []) + if not chapter_ids: + return + + start_idx = 0 + chapter_seek = 0 + if seek_position > 0 and chapter_durations_ms: + accumulated_ms = 0 + seek_ms = seek_position * 1000 + for idx, dur_ms in enumerate(chapter_durations_ms): + if accumulated_ms + dur_ms > seek_ms: + start_idx = idx + chapter_seek = (seek_ms - accumulated_ms) // 1000 + break + accumulated_ms += dur_ms + else: + # seek past end of audiobook — start at last chapter + start_idx = len(chapter_ids) - 1 + chapter_seek = 0 + + for idx in range(start_idx, len(chapter_ids)): + chapter_id = chapter_ids[idx] + offset = chapter_seek if idx == start_idx else 0 + try: + chapter_details = await self.streaming.get_stream_details(chapter_id) + except Exception as err: + self.logger.warning( + "Audiobook chapter %d (%s) stream-details failed: %s", + idx + 1, + chapter_id, + err, + ) + continue + try: + async for chunk in self.streaming.get_audio_stream(chapter_details, offset): + yield chunk + except asyncio.CancelledError: + raise + except Exception as err: + self.logger.warning( + "Audiobook chapter %d (%s) stream failed mid-play: %s", + idx + 1, + chapter_id, + err, + ) + continue + async def get_rotor_station_tracks( self, station_id: str, queue: str | int | None = None ) -> tuple[list[Any], str | None]: From dc9b7cf1098587b22be7ee4c987ea315759eb674 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Apr 2026 19:22:16 +0000 Subject: [PATCH 37/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.2.1 --- .../providers/yandex_music/provider.py | 93 ++++++++++++++----- 1 file changed, 70 insertions(+), 23 deletions(-) diff --git a/music_assistant/providers/yandex_music/provider.py b/music_assistant/providers/yandex_music/provider.py index a369bee2cf..45df2cd526 100644 --- a/music_assistant/providers/yandex_music/provider.py +++ b/music_assistant/providers/yandex_music/provider.py @@ -2684,9 +2684,11 @@ async def _get_audiobook_stream_details(self, audiobook_id: str) -> StreamDetail Loads the album's tracks, uses the first chapter to establish the audio format, and stores the per-chapter track-IDs + durations in ``data`` so - ``get_audio_stream`` can iterate them. Seek across chapter boundaries is - handled via ``seek_position`` in ``get_audio_stream``; in-chapter seek is - disabled (Yandex per-chapter seekability varies by codec). + ``get_audio_stream`` can iterate them. ``can_seek=True`` so MA routes + ``seek_position`` into ``get_audio_stream``, where the provider translates + it into ``(start_chapter, in_chapter_offset)``. In-chapter precision + requires a byte-seekable chapter codec (raw MP3); otherwise the chapter + is restarted from its beginning. """ album = await self.client.get_album_with_tracks(audiobook_id) if not album or not (album.volumes or []): @@ -2716,7 +2718,7 @@ async def _get_audiobook_stream_details(self, audiobook_id: str) -> StreamDetail "chapter_ids": chapter_ids, "chapter_durations_ms": chapter_durations_ms, }, - can_seek=False, + can_seek=True, allow_seek=True, ) @@ -2741,62 +2743,107 @@ async def get_audio_stream( async for chunk in self.streaming.get_audio_stream(streamdetails, seek_position): yield chunk + def _resolve_audiobook_seek( + self, chapter_durations_ms: list[int], seek_position: int, n_chapters: int + ) -> tuple[int, int]: + """Map an audiobook ``seek_position`` (seconds) to (start_idx, chapter_seek).""" + if seek_position <= 0 or not chapter_durations_ms: + return 0, 0 + accumulated_ms = 0 + seek_ms = seek_position * 1000 + for idx, dur_ms in enumerate(chapter_durations_ms): + if accumulated_ms + dur_ms > seek_ms: + return idx, (seek_ms - accumulated_ms) // 1000 + accumulated_ms += dur_ms + # Seek past end — start at last chapter from 0 + return max(n_chapters - 1, 0), 0 + async def _stream_audiobook_chapters( self, data: dict[str, Any], seek_position: int ) -> AsyncGenerator[bytes, None]: """Concatenate per-chapter streams of an audiobook. Translates ``seek_position`` into (start_chapter, in_chapter_offset) and - delegates each chapter to the per-track streaming path. Subsequent - chapters start at offset 0. + delegates each chapter to the per-track streaming path. In-chapter offset + is only applied when the chapter codec is byte-seekable (``can_seek``); + otherwise the chapter is restarted from its beginning. Tracks consecutive + chapter failures and raises ``MediaNotFoundError`` once the threshold is + exceeded, so playback never silently truncates. """ chapter_ids: list[str] = list(data.get("chapter_ids") or []) chapter_durations_ms: list[int] = list(data.get("chapter_durations_ms") or []) if not chapter_ids: return - start_idx = 0 - chapter_seek = 0 - if seek_position > 0 and chapter_durations_ms: - accumulated_ms = 0 - seek_ms = seek_position * 1000 - for idx, dur_ms in enumerate(chapter_durations_ms): - if accumulated_ms + dur_ms > seek_ms: - start_idx = idx - chapter_seek = (seek_ms - accumulated_ms) // 1000 - break - accumulated_ms += dur_ms - else: - # seek past end of audiobook — start at last chapter - start_idx = len(chapter_ids) - 1 - chapter_seek = 0 + start_idx, chapter_seek = self._resolve_audiobook_seek( + chapter_durations_ms, seek_position, len(chapter_ids) + ) + + max_consecutive_failures = 3 + consecutive_failures = 0 + has_yielded_audio = False + last_error: Exception | None = None for idx in range(start_idx, len(chapter_ids)): chapter_id = chapter_ids[idx] - offset = chapter_seek if idx == start_idx else 0 + requested_offset = chapter_seek if idx == start_idx else 0 + chapter_details: StreamDetails | None = None try: chapter_details = await self.streaming.get_stream_details(chapter_id) + except asyncio.CancelledError: + raise except Exception as err: + last_error = err self.logger.warning( "Audiobook chapter %d (%s) stream-details failed: %s", idx + 1, chapter_id, err, ) + + if chapter_details is None: + consecutive_failures += 1 + if consecutive_failures >= max_consecutive_failures: + raise MediaNotFoundError( + "Unable to stream audiobook: too many consecutive chapter failures" + ) from last_error continue + + # Apply the in-chapter offset only when the chapter codec supports + # byte-offset seeking; otherwise restart the chapter from 0 to avoid + # decoding garbled bytes from mid-file of a container format. + offset = requested_offset if chapter_details.can_seek else 0 + chapter_had_audio = False try: async for chunk in self.streaming.get_audio_stream(chapter_details, offset): + chapter_had_audio = True + has_yielded_audio = True yield chunk except asyncio.CancelledError: raise except Exception as err: + last_error = err self.logger.warning( "Audiobook chapter %d (%s) stream failed mid-play: %s", idx + 1, chapter_id, err, ) - continue + + if chapter_had_audio: + consecutive_failures = 0 + last_error = None + else: + consecutive_failures += 1 + if consecutive_failures >= max_consecutive_failures: + raise MediaNotFoundError( + "Unable to stream audiobook: too many consecutive chapter failures" + ) from last_error + + if not has_yielded_audio: + raise MediaNotFoundError( + "Unable to stream audiobook: no playable chapters found" + ) from last_error async def get_rotor_station_tracks( self, station_id: str, queue: str | int | None = None From be93423343c52cb2c8b567a9e3990ea332d621ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 04:41:17 +0000 Subject: [PATCH 38/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.3.0 --- .../providers/yandex_music/api_client.py | 43 +++ .../providers/yandex_music/constants.py | 4 + .../providers/yandex_music/parsers.py | 4 + .../providers/yandex_music/provider.py | 236 ++++++++++++- .../yandex_music/test_audiobook_progress.py | 311 ++++++++++++++++++ .../yandex_music/test_browse_collection.py | 108 ++++++ tests/providers/yandex_music/test_parsers.py | 27 ++ .../yandex_music/test_search_audiobooks.py | 112 +++++++ 8 files changed, 828 insertions(+), 17 deletions(-) create mode 100644 tests/providers/yandex_music/test_audiobook_progress.py create mode 100644 tests/providers/yandex_music/test_browse_collection.py create mode 100644 tests/providers/yandex_music/test_search_audiobooks.py diff --git a/music_assistant/providers/yandex_music/api_client.py b/music_assistant/providers/yandex_music/api_client.py index 4d83bb5e1b..05e2bcdc00 100644 --- a/music_assistant/providers/yandex_music/api_client.py +++ b/music_assistant/providers/yandex_music/api_client.py @@ -337,6 +337,49 @@ async def _send(c: ClientAsync) -> bool: LOGGER.warning("Rotor feedback %s failed: %s", feedback_type, err) return False + async def play_audio( + self, + *, + track_id: str, + album_id: str, + play_id: str, + track_length_seconds: int, + total_played_seconds: int, + end_position_seconds: int, + from_: str = "music_assistant-audiobook", + ) -> bool: + """Report playback progress for an audiobook chapter or podcast episode. + + Yandex persists this server-side so progress is visible across its + other clients. Failures are swallowed — progress sync is advisory and + must never abort pause/stop handling — so auth failures, rate-limits + and network blips all log at debug and return False. + """ + try: + return bool( + await self._call_no_retry( + lambda c: c.play_audio( + track_id=track_id, + album_id=album_id, + from_=from_, + play_id=play_id, + track_length_seconds=track_length_seconds, + total_played_seconds=total_played_seconds, + end_position_seconds=end_position_seconds, + ) + ) + ) + except ( + BadRequestError, + NetworkError, + ProviderUnavailableError, + UnauthorizedError, + LoginFailed, + ResourceTemporarilyUnavailable, + ) as err: + LOGGER.debug("play_audio failed for %s: %s", track_id, err) + return False + # Library methods async def get_liked_tracks(self) -> list[TrackShort]: diff --git a/music_assistant/providers/yandex_music/constants.py b/music_assistant/providers/yandex_music/constants.py index 87fb938df8..c1e8fd95d4 100644 --- a/music_assistant/providers/yandex_music/constants.py +++ b/music_assistant/providers/yandex_music/constants.py @@ -119,6 +119,8 @@ "albums": "Мои альбомы", "tracks": "Мне нравится", "playlists": "Мои плейлисты", + "audiobooks": "Мои аудиокниги", + "podcasts": "Мои подкасты", "feed": "Для вас", "chart": "Чарт", "new_releases": "Новинки", @@ -193,6 +195,8 @@ "albums": "My Albums", "tracks": "My Favorites", "playlists": "My Playlists", + "audiobooks": "My Audiobooks", + "podcasts": "My Podcasts", "feed": "Made for You", "chart": "Chart", "new_releases": "New Releases", diff --git a/music_assistant/providers/yandex_music/parsers.py b/music_assistant/providers/yandex_music/parsers.py index 006710ab66..d24fd5197d 100644 --- a/music_assistant/providers/yandex_music/parsers.py +++ b/music_assistant/providers/yandex_music/parsers.py @@ -663,4 +663,8 @@ def parse_audiobook(provider: YandexMusicProvider, album_obj: YandexAlbum) -> Au with suppress(ValueError): audiobook.metadata.release_date = datetime.fromisoformat(album_obj.release_date) + listening_finished = getattr(album_obj, "listening_finished", None) + if listening_finished is not None: + audiobook.fully_played = bool(listening_finished) + return audiobook diff --git a/music_assistant/providers/yandex_music/provider.py b/music_assistant/providers/yandex_music/provider.py index 45df2cd526..ee2678d610 100644 --- a/music_assistant/providers/yandex_music/provider.py +++ b/music_assistant/providers/yandex_music/provider.py @@ -5,6 +5,7 @@ import asyncio import logging import random +import uuid from collections.abc import AsyncGenerator, Sequence from datetime import UTC, datetime from io import BytesIO @@ -122,6 +123,21 @@ def _parse_radio_item_id(item_id: str) -> tuple[str, str | None]: return (item_id, None) +def _extract_chapter_map_from_album(album: YandexAlbum) -> tuple[list[str], list[int]]: + """Flatten an audiobook album's volumes into (chapter_track_ids, chapter_durations_ms). + + Shared by ``_get_audiobook_stream_details`` and ``_resolve_audiobook_chapter_map`` + so the two code paths can't drift (e.g. when we later filter bad tracks). + """ + chapter_ids: list[str] = [] + chapter_durations_ms: list[int] = [] + for disc in album.volumes or []: + for track_obj in disc: + chapter_ids.append(str(track_obj.id)) + chapter_durations_ms.append(int(track_obj.duration_ms or 0)) + return chapter_ids, chapter_durations_ms + + class _WaveState: """Per-station mutable state for rotor wave playback.""" @@ -150,6 +166,11 @@ class YandexMusicProvider(MusicProvider): # that all derive from the same liked-albums endpoint. _liked_albums_cache: tuple[float, list[YandexAlbum]] | None = None _liked_albums_lock: asyncio.Lock + # Per-audiobook cache of (chapter_track_ids, chapter_durations_ms) used to + # report playback progress per chapter via play_audio. + _audiobook_chapter_cache: dict[str, tuple[list[str], list[int]]] + # Stable play_id per audiobook session, cleared in on_streamed. + _audiobook_play_ids: dict[str, str] @property def client(self) -> YandexMusicClient: @@ -293,8 +314,8 @@ async def handle_async_init(self) -> None: # Initialize per-station wave state dict self._wave_states = {} self._wave_bg_colors = {} - self._liked_albums_lock = asyncio.Lock() - self._liked_albums_cache = None + self._liked_albums_lock, self._liked_albums_cache = asyncio.Lock(), None + self._audiobook_chapter_cache, self._audiobook_play_ids = {}, {} self.logger.info("Successfully connected to Yandex Music") async def unload(self, is_removed: bool = False) -> None: @@ -306,6 +327,8 @@ async def unload(self, is_removed: bool = False) -> None: await self._client.disconnect() self._client = None self._streaming = None + self._audiobook_chapter_cache.clear() + self._audiobook_play_ids.clear() await super().unload(is_removed) def get_item_mapping(self, media_type: MediaType | str, key: str, name: str) -> ItemMapping: @@ -389,6 +412,8 @@ async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | Brow "albums", "tracks", "playlists", + "audiobooks", + "podcasts", LIKED_TRACKS_PLAYLIST_ID, WAVES_FOLDER_ID, RADIO_FOLDER_ID, @@ -827,6 +852,26 @@ async def _browse_collection( is_playable=True, ) ) + if ProviderFeature.LIBRARY_PODCASTS in self.supported_features: + folders.append( + BrowseFolder( + item_id="podcasts", + provider=self.instance_id, + path=f"{root_base}podcasts", + name=names["podcasts"], + is_playable=False, + ) + ) + if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features: + folders.append( + BrowseFolder( + item_id="audiobooks", + provider=self.instance_id, + path=f"{root_base}audiobooks", + name=names["audiobooks"], + is_playable=False, + ) + ) return folders async def _browse_pins(self) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: @@ -1529,15 +1574,19 @@ async def search( result = SearchResults() # Determine search type based on requested media types - # Map MediaType to Yandex API search type + # Map MediaType to Yandex API search type. AUDIOBOOK has no dedicated + # Yandex type — it maps to "album" and is filtered by classify_album below. type_mapping = { MediaType.TRACK: "track", MediaType.ALBUM: "album", + MediaType.AUDIOBOOK: "album", MediaType.ARTIST: "artist", MediaType.PLAYLIST: "playlist", MediaType.PODCAST: "podcast", } - requested_types = [type_mapping[mt] for mt in media_types if mt in type_mapping] + requested_types = list( + dict.fromkeys(type_mapping[mt] for mt in media_types if mt in type_mapping) + ) # Use specific type if only one requested, otherwise search all search_type = requested_types[0] if len(requested_types) == 1 else "all" @@ -1554,13 +1603,24 @@ async def search( except InvalidDataError as err: self.logger.debug("Error parsing track: %s", err) - # Parse albums - if MediaType.ALBUM in media_types and search_result.albums: + # Parse albums — audiobooks are split into the audiobooks bucket via + # classify_album. Yandex-returned podcast albums are handled separately + # through the dedicated `.podcasts` node below. + want_album = MediaType.ALBUM in media_types + want_audiobook = MediaType.AUDIOBOOK in media_types + if (want_album or want_audiobook) and search_result.albums: for album in search_result.albums.results[:limit]: + kind = classify_album(album) try: - result.albums = [*result.albums, parse_album(self, album)] + if kind == "audiobook" and want_audiobook: + result.audiobooks = [ + *result.audiobooks, + parse_audiobook(self, album), + ] + elif kind == "music" and want_album: + result.albums = [*result.albums, parse_album(self, album)] except InvalidDataError as err: - self.logger.debug("Error parsing album: %s", err) + self.logger.debug("Error parsing %s album: %s", kind, err) # Parse artists if MediaType.ARTIST in media_types and search_result.artists: @@ -2694,15 +2754,12 @@ async def _get_audiobook_stream_details(self, audiobook_id: str) -> StreamDetail if not album or not (album.volumes or []): raise MediaNotFoundError(f"Audiobook {audiobook_id} has no chapters") - chapter_ids: list[str] = [] - chapter_durations_ms: list[int] = [] - for disc in album.volumes or []: - for track_obj in disc: - chapter_ids.append(str(track_obj.id)) - chapter_durations_ms.append(int(track_obj.duration_ms or 0)) + chapter_ids, chapter_durations_ms = _extract_chapter_map_from_album(album) if not chapter_ids: raise MediaNotFoundError(f"Audiobook {audiobook_id} has no chapters") + self._audiobook_chapter_cache[audiobook_id] = (chapter_ids, chapter_durations_ms) + # Resolve first-chapter format so MA/ffmpeg know what it's decoding first = await self.streaming.get_stream_details(chapter_ids[0]) total_duration = sum(chapter_durations_ms) // 1000 @@ -2758,6 +2815,25 @@ def _resolve_audiobook_seek( # Seek past end — start at last chapter from 0 return max(n_chapters - 1, 0), 0 + async def _resolve_audiobook_chapter_map( + self, audiobook_id: str + ) -> tuple[list[str], list[int]]: + """Return (chapter_track_ids, chapter_durations_ms) for an audiobook. + + Served from an in-memory cache populated by ``_get_audiobook_stream_details``. + On a miss (e.g. ``on_played`` fires before streaming has started), falls back + to a fresh ``get_album_with_tracks`` call and refills the cache. + """ + cached = self._audiobook_chapter_cache.get(audiobook_id) + if cached is not None: + return cached + album = await self.client.get_album_with_tracks(audiobook_id) + if not album or not (album.volumes or []): + return [], [] + chapter_ids, chapter_durations_ms = _extract_chapter_map_from_album(album) + self._audiobook_chapter_cache[audiobook_id] = (chapter_ids, chapter_durations_ms) + return chapter_ids, chapter_durations_ms + async def _stream_audiobook_chapters( self, data: dict[str, Any], seek_position: int ) -> AsyncGenerator[bytes, None]: @@ -2922,7 +2998,13 @@ async def on_played( Also auto-enables "Don't stop the music" for any queue playing a radio track so that MA refills the queue via get_similar_tracks when < 5 tracks remain. + + For audiobooks, persists playback position to Yandex via play_audio so the + position is visible across Yandex's other clients. """ + if media_type == MediaType.AUDIOBOOK: + await self._report_audiobook_progress(prov_item_id, position) + return # Radio feedback always enabled if media_type != MediaType.TRACK: return @@ -3032,11 +3114,22 @@ def _ensure_dont_stop_the_music_for_queue(self, queue_id: str | None) -> None: break async def on_streamed(self, streamdetails: StreamDetails) -> None: - """Report stream completion for My Wave rotor feedback. + """Report stream completion for My Wave rotor feedback and audiobooks. - Sends trackFinished or skip with actual seconds_streamed so Yandex - can improve recommendations. + For radio: sends trackFinished or skip with actual seconds_streamed so + Yandex can improve recommendations. + + For audiobooks: sends a final play_audio with the absolute stream position + so the last listening point is preserved in Yandex. """ + data = streamdetails.data if isinstance(streamdetails.data, dict) else None + if streamdetails.media_type == MediaType.AUDIOBOOK: + # Always let the audiobook branch run so session state + # (play_id + chapter cache) is cleaned up even when ``data`` was + # stripped. ``_report_audiobook_final`` no-ops the play_audio call + # when chapter data is absent. + await self._report_audiobook_final(streamdetails, data or {}) + return # Radio feedback always enabled track_id, station_id = _parse_radio_item_id(streamdetails.item_id) if not station_id: @@ -3059,3 +3152,112 @@ async def on_streamed(self, streamdetails: StreamDetails) -> None: total_played_seconds=seconds, batch_id=batch_id, ) + + def _audiobook_progress_point( + self, + chapter_durations_ms: list[int], + n_chapters: int, + absolute_sec: int, + ) -> tuple[int, int, int]: + """Resolve an absolute book position into a play_audio-ready tuple. + + Returns ``(chapter_idx, track_length_seconds, offset_seconds)``, applying + two invariants Yandex cares about and that ``_resolve_audiobook_seek`` + alone doesn't guarantee: + + - At/beyond end-of-book, map to end of the last chapter (not start), + so Yandex's resume point doesn't rewind to the start of the final + chapter on natural completion. + - ``track_length_seconds`` is clamped to at least 1 and ``offset`` to + ``[0, track_length_seconds]`` — a chapter with ``duration_ms=None`` + (coerced to 0 by the chapter-map builder) would otherwise send + ``track_length_seconds=0`` and block progress from syncing. + """ + absolute_sec = max(0, absolute_sec) + total_duration_sec = sum(chapter_durations_ms) // 1000 + last_idx = max(n_chapters - 1, 0) + if absolute_sec >= total_duration_sec > 0: + idx = last_idx + track_length_sec = max(1, chapter_durations_ms[idx] // 1000) + offset = track_length_sec + else: + idx, offset_raw = self._resolve_audiobook_seek( + chapter_durations_ms, absolute_sec, n_chapters + ) + track_length_sec = max(1, chapter_durations_ms[idx] // 1000) + offset = max(0, min(int(offset_raw), track_length_sec)) + return idx, track_length_sec, offset + + async def _report_audiobook_progress(self, audiobook_id: str, position_sec: int) -> None: + """Push current listening position of an audiobook to Yandex. + + Resolves the playing chapter + offset from the cached chapter map, then + calls play_audio so Yandex persists the position for cross-client resume. + + Best-effort: any non-cancellation failure while resolving the chapter + map (rate-limit, network blip, auth edge case bubbling out of + ``_call_with_retry``) must never break pause/stop, so it is swallowed + here in addition to the errors already absorbed inside + ``api_client.play_audio``. + """ + try: + chapter_ids, chapter_durations_ms = await self._resolve_audiobook_chapter_map( + audiobook_id + ) + except asyncio.CancelledError: + raise + except Exception as err: + self.logger.debug( + "Skipping audiobook progress report for %s (chapter map resolution failed): %s", + audiobook_id, + err, + ) + return + if not chapter_ids: + self.logger.debug( + "Audiobook %s has no chapter map; skipping progress report", audiobook_id + ) + return + idx, track_length_sec, offset = self._audiobook_progress_point( + chapter_durations_ms, len(chapter_ids), int(position_sec) + ) + play_id = self._audiobook_play_ids.setdefault(audiobook_id, uuid.uuid4().hex) + await self.client.play_audio( + track_id=chapter_ids[idx], + album_id=audiobook_id, + play_id=play_id, + track_length_seconds=track_length_sec, + total_played_seconds=offset, + end_position_seconds=offset, + ) + + async def _report_audiobook_final( + self, streamdetails: StreamDetails, data: dict[str, Any] + ) -> None: + """Send a closing play_audio for an audiobook stream. + + Uses the streamdetails' own ``chapter_ids`` / ``chapter_durations_ms`` + (populated when the StreamDetails was created) to stay consistent with + what was actually played, then clears the session play_id and drops + the chapter-map cache entry so long-running instances can't grow the + cache without bound as users play more audiobooks. + """ + audiobook_id = streamdetails.item_id + chapter_ids = data.get("chapter_ids") or [] + chapter_durations_ms = data.get("chapter_durations_ms") or [] + play_id = self._audiobook_play_ids.pop(audiobook_id, None) or uuid.uuid4().hex + self._audiobook_chapter_cache.pop(audiobook_id, None) + if not chapter_ids or not chapter_durations_ms: + return + absolute_sec = int(streamdetails.seek_position + (streamdetails.seconds_streamed or 0)) + idx, track_length_sec, offset = self._audiobook_progress_point( + chapter_durations_ms, len(chapter_ids), absolute_sec + ) + await self.client.play_audio( + track_id=chapter_ids[idx], + album_id=audiobook_id, + play_id=play_id, + track_length_seconds=track_length_sec, + total_played_seconds=offset, + end_position_seconds=offset, + ) diff --git a/tests/providers/yandex_music/test_audiobook_progress.py b/tests/providers/yandex_music/test_audiobook_progress.py new file mode 100644 index 0000000000..eb009eede3 --- /dev/null +++ b/tests/providers/yandex_music/test_audiobook_progress.py @@ -0,0 +1,311 @@ +"""Tests for audiobook progress sync via play_audio in on_played/on_streamed.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, Mock + +import pytest +from music_assistant_models.enums import MediaType +from music_assistant_models.errors import ResourceTemporarilyUnavailable +from music_assistant_models.streamdetails import StreamDetails + +from music_assistant.providers.yandex_music.provider import YandexMusicProvider + + +@pytest.fixture +def provider_mock() -> Mock: + """Return a provider mock wired for audiobook progress reporting.""" + provider = Mock(spec=YandexMusicProvider) + provider.domain = "yandex_music" + provider.instance_id = "yandex_music_instance" + provider.logger = Mock() + provider.client = AsyncMock() + provider.client.play_audio = AsyncMock(return_value=True) + provider._audiobook_chapter_cache = {} + provider._audiobook_play_ids = {} + # real method so we don't have to replicate seek math in tests + provider._resolve_audiobook_seek = YandexMusicProvider._resolve_audiobook_seek.__get__( + provider, YandexMusicProvider + ) + provider._audiobook_progress_point = YandexMusicProvider._audiobook_progress_point.__get__( + provider, YandexMusicProvider + ) + provider._resolve_audiobook_chapter_map = AsyncMock() + return provider + + +@pytest.mark.asyncio +async def test_on_played_audiobook_reports_progress(provider_mock: Mock) -> None: + """on_played(AUDIOBOOK) resolves chapter from cache and calls play_audio.""" + chapter_ids = ["c1", "c2", "c3"] + chapter_durations_ms = [60_000, 120_000, 90_000] + provider_mock._resolve_audiobook_chapter_map.return_value = ( + chapter_ids, + chapter_durations_ms, + ) + + # position = 60 (end of c1) + 30 → middle of c2 + await YandexMusicProvider._report_audiobook_progress(provider_mock, "abook-42", 90) + + provider_mock.client.play_audio.assert_awaited_once() + kwargs = provider_mock.client.play_audio.await_args.kwargs + assert kwargs["track_id"] == "c2" + assert kwargs["album_id"] == "abook-42" + assert kwargs["track_length_seconds"] == 120 + assert kwargs["total_played_seconds"] == 30 + assert kwargs["end_position_seconds"] == 30 + # play_id created and persisted for the session + assert provider_mock._audiobook_play_ids["abook-42"] == kwargs["play_id"] + + +@pytest.mark.asyncio +async def test_on_played_audiobook_skips_when_no_chapter_map(provider_mock: Mock) -> None: + """No chapter map → skip play_audio (log at debug).""" + provider_mock._resolve_audiobook_chapter_map.return_value = ([], []) + + await YandexMusicProvider._report_audiobook_progress(provider_mock, "abook-42", 90) + + provider_mock.client.play_audio.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_played_audiobook_reuses_play_id(provider_mock: Mock) -> None: + """Successive on_played calls for the same audiobook share one play_id.""" + provider_mock._resolve_audiobook_chapter_map.return_value = (["c1"], [60_000]) + + await YandexMusicProvider._report_audiobook_progress(provider_mock, "abook-1", 10) + await YandexMusicProvider._report_audiobook_progress(provider_mock, "abook-1", 20) + + calls = provider_mock.client.play_audio.await_args_list + assert calls[0].kwargs["play_id"] == calls[1].kwargs["play_id"] + + +@pytest.mark.asyncio +async def test_on_streamed_audiobook_sends_final_position(provider_mock: Mock) -> None: + """on_streamed uses seek_position + seconds_streamed as absolute position.""" + sd = StreamDetails( + provider="yandex_music", + item_id="abook-7", + audio_format=Mock(), + media_type=MediaType.AUDIOBOOK, + data={ + "chapter_ids": ["c1", "c2", "c3"], + "chapter_durations_ms": [60_000, 120_000, 600_000], + }, + ) + sd.seek_position = 300 # 5 minutes in — inside c3 (starts at 180s, 10min long) + sd.seconds_streamed = 15.0 # stopped at 315s total + # pre-stash a play_id so we can assert it pops + provider_mock._audiobook_play_ids["abook-7"] = "stable-id" + + await YandexMusicProvider._report_audiobook_final(provider_mock, sd, sd.data) + + provider_mock.client.play_audio.assert_awaited_once() + kwargs = provider_mock.client.play_audio.await_args.kwargs + assert kwargs["track_id"] == "c3" + assert kwargs["album_id"] == "abook-7" + # absolute 315s → chapter 3 (starts at 180s) offset 135s + assert kwargs["total_played_seconds"] == 135 + assert kwargs["end_position_seconds"] == 135 + assert kwargs["play_id"] == "stable-id" + # session play_id cleared after final report + assert "abook-7" not in provider_mock._audiobook_play_ids + + +@pytest.mark.asyncio +async def test_on_streamed_audiobook_ignores_empty_data(provider_mock: Mock) -> None: + """Missing chapter_ids in data → no play_audio call.""" + sd = StreamDetails( + provider="yandex_music", + item_id="abook-9", + audio_format=Mock(), + media_type=MediaType.AUDIOBOOK, + data={}, + ) + + await YandexMusicProvider._report_audiobook_final(provider_mock, sd, sd.data) + + provider_mock.client.play_audio.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_played_audiobook_swallows_upstream_unavailable( + provider_mock: Mock, +) -> None: + """ResourceTemporarilyUnavailable from chapter_map resolution must not propagate.""" + provider_mock._resolve_audiobook_chapter_map.side_effect = ResourceTemporarilyUnavailable( + "rate limited" + ) + + # Must not raise; progress report is advisory. + await YandexMusicProvider._report_audiobook_progress(provider_mock, "abook-42", 30) + + provider_mock.client.play_audio.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_played_audiobook_swallows_unexpected_exception( + provider_mock: Mock, +) -> None: + """Any non-cancellation exception while resolving the chapter map is swallowed.""" + + class SomeAuthError(Exception): + pass + + provider_mock._resolve_audiobook_chapter_map.side_effect = SomeAuthError("token expired") + + await YandexMusicProvider._report_audiobook_progress(provider_mock, "abook-42", 30) + + provider_mock.client.play_audio.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_played_audiobook_propagates_cancellation( + provider_mock: Mock, +) -> None: + """asyncio.CancelledError must propagate — never suppressed.""" + provider_mock._resolve_audiobook_chapter_map.side_effect = asyncio.CancelledError() + + with pytest.raises(asyncio.CancelledError): + await YandexMusicProvider._report_audiobook_progress(provider_mock, "abook-42", 30) + + +@pytest.mark.asyncio +async def test_on_streamed_audiobook_evicts_cache_entry(provider_mock: Mock) -> None: + """After on_streamed, the chapter-map cache entry for this book is dropped.""" + sd = StreamDetails( + provider="yandex_music", + item_id="abook-7", + audio_format=Mock(), + media_type=MediaType.AUDIOBOOK, + data={"chapter_ids": ["c1"], "chapter_durations_ms": [60_000]}, + ) + provider_mock._audiobook_chapter_cache["abook-7"] = (["c1"], [60_000]) + provider_mock._audiobook_chapter_cache["abook-OTHER"] = (["x"], [1000]) + + await YandexMusicProvider._report_audiobook_final(provider_mock, sd, sd.data) + + assert "abook-7" not in provider_mock._audiobook_chapter_cache + # Other audiobooks in cache are untouched + assert "abook-OTHER" in provider_mock._audiobook_chapter_cache + + +@pytest.mark.asyncio +async def test_on_streamed_audiobook_reports_end_of_last_chapter_at_eof( + provider_mock: Mock, +) -> None: + """Natural EOF must report end_position_seconds = last chapter's length, not 0.""" + sd = StreamDetails( + provider="yandex_music", + item_id="abook-eof", + audio_format=Mock(), + media_type=MediaType.AUDIOBOOK, + data={ + "chapter_ids": ["c1", "c2"], + "chapter_durations_ms": [60_000, 120_000], + }, + ) + # Total duration = 180s. Reached exactly the end. + sd.seek_position = 0 + sd.seconds_streamed = 180.0 + + await YandexMusicProvider._report_audiobook_final(provider_mock, sd, sd.data) + + provider_mock.client.play_audio.assert_awaited_once() + kwargs = provider_mock.client.play_audio.await_args.kwargs + assert kwargs["track_id"] == "c2" + assert kwargs["track_length_seconds"] == 120 + assert kwargs["end_position_seconds"] == 120 + assert kwargs["total_played_seconds"] == 120 + + +@pytest.mark.asyncio +async def test_on_streamed_audiobook_clamps_zero_duration_chapter( + provider_mock: Mock, +) -> None: + """Missing duration_ms (coerced to 0) must never send track_length_seconds=0.""" + sd = StreamDetails( + provider="yandex_music", + item_id="abook-bad", + audio_format=Mock(), + media_type=MediaType.AUDIOBOOK, + data={"chapter_ids": ["c1"], "chapter_durations_ms": [0]}, + ) + sd.seek_position = 0 + sd.seconds_streamed = 0.0 + + await YandexMusicProvider._report_audiobook_final(provider_mock, sd, sd.data) + + kwargs = provider_mock.client.play_audio.await_args.kwargs + assert kwargs["track_length_seconds"] >= 1 + assert 0 <= kwargs["end_position_seconds"] <= kwargs["track_length_seconds"] + + +@pytest.mark.asyncio +async def test_on_streamed_audiobook_without_data_still_cleans_up( + provider_mock: Mock, +) -> None: + """StreamDetails.data missing or stripped → caches still evicted, no play_audio call.""" + sd = StreamDetails( + provider="yandex_music", + item_id="abook-x", + audio_format=Mock(), + media_type=MediaType.AUDIOBOOK, + ) + provider_mock._audiobook_chapter_cache["abook-x"] = (["c"], [1000]) + provider_mock._audiobook_play_ids["abook-x"] = "sess-id" + + await YandexMusicProvider._report_audiobook_final(provider_mock, sd, {}) + + assert "abook-x" not in provider_mock._audiobook_chapter_cache + assert "abook-x" not in provider_mock._audiobook_play_ids + provider_mock.client.play_audio.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_streamed_audiobook_branch_taken_even_without_chapter_data( + provider_mock: Mock, +) -> None: + """AUDIOBOOK stream without chapter_ids still routes to audiobook cleanup. + + Previously the gate required ``"chapter_ids" in data`` and fell through + to the radio path when data was missing, leaving caches stale. + """ + provider_mock._report_audiobook_final = AsyncMock() + provider_mock.client.send_rotor_station_feedback = AsyncMock() + sd = StreamDetails( + provider="yandex_music", + item_id="abook-y", + audio_format=Mock(), + media_type=MediaType.AUDIOBOOK, + # data=None (not a dict) — previously would fall through to radio + ) + + await YandexMusicProvider.on_streamed(provider_mock, sd) + + provider_mock._report_audiobook_final.assert_awaited_once() + provider_mock.client.send_rotor_station_feedback.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_played_routes_audiobook_and_skips_radio_branch( + provider_mock: Mock, +) -> None: + """on_played with MediaType.AUDIOBOOK early-returns before radio feedback.""" + provider_mock._resolve_audiobook_chapter_map.return_value = (["c1"], [60_000]) + provider_mock._report_audiobook_progress = AsyncMock() + provider_mock.client.send_rotor_station_feedback = AsyncMock() + + await YandexMusicProvider.on_played( + provider_mock, + MediaType.AUDIOBOOK, + "abook-1", + fully_played=False, + position=30, + media_item=Mock(), + is_playing=True, + ) + + provider_mock._report_audiobook_progress.assert_awaited_once_with("abook-1", 30) + provider_mock.client.send_rotor_station_feedback.assert_not_awaited() diff --git a/tests/providers/yandex_music/test_browse_collection.py b/tests/providers/yandex_music/test_browse_collection.py new file mode 100644 index 0000000000..f05f769bb4 --- /dev/null +++ b/tests/providers/yandex_music/test_browse_collection.py @@ -0,0 +1,108 @@ +"""Tests that Collection folder renders audiobooks/podcasts sub-folders.""" + +from __future__ import annotations + +from unittest.mock import Mock + +import pytest +from music_assistant_models.enums import ProviderFeature +from music_assistant_models.media_items import BrowseFolder + +from music_assistant.providers.yandex_music.constants import BROWSE_NAMES_EN, BROWSE_NAMES_RU +from music_assistant.providers.yandex_music.provider import YandexMusicProvider + + +def _make_provider_mock(features: set[ProviderFeature], *, locale: str = "en_US") -> Mock: + provider = Mock(spec=YandexMusicProvider) + provider.instance_id = "yandex_music_instance" + provider.domain = "yandex_music" + provider.supported_features = features + provider.mass = Mock() + provider.mass.metadata = Mock() + provider.mass.metadata.locale = locale + # real method so locale mapping runs + provider._get_browse_names = YandexMusicProvider._get_browse_names.__get__( + provider, YandexMusicProvider + ) + provider.logger = Mock() + return provider + + +@pytest.mark.asyncio +async def test_collection_shows_audiobooks_folder_when_feature_enabled() -> None: + """LIBRARY_AUDIOBOOKS enabled → BrowseFolder for audiobooks is returned.""" + features = { + ProviderFeature.LIBRARY_TRACKS, + ProviderFeature.LIBRARY_ALBUMS, + ProviderFeature.LIBRARY_AUDIOBOOKS, + } + provider = _make_provider_mock(features) + + folders = await YandexMusicProvider._browse_collection( + provider, "yandex_music_instance://collection" + ) + + item_ids = [f.item_id for f in folders if isinstance(f, BrowseFolder)] + assert "audiobooks" in item_ids + audiobook_folder = next( + f for f in folders if isinstance(f, BrowseFolder) and f.item_id == "audiobooks" + ) + assert audiobook_folder.is_playable is False + assert audiobook_folder.path.endswith("audiobooks") + assert audiobook_folder.name == BROWSE_NAMES_EN["audiobooks"] + + +@pytest.mark.asyncio +async def test_collection_shows_podcasts_folder_when_feature_enabled() -> None: + """LIBRARY_PODCASTS enabled → BrowseFolder for podcasts is returned.""" + features = { + ProviderFeature.LIBRARY_TRACKS, + ProviderFeature.LIBRARY_PODCASTS, + } + provider = _make_provider_mock(features) + + folders = await YandexMusicProvider._browse_collection( + provider, "yandex_music_instance://collection" + ) + + item_ids = [f.item_id for f in folders if isinstance(f, BrowseFolder)] + assert "podcasts" in item_ids + + +@pytest.mark.asyncio +async def test_collection_hides_audiobooks_folder_when_feature_disabled() -> None: + """Disabling LIBRARY_AUDIOBOOKS removes the folder from Collection.""" + features = { + ProviderFeature.LIBRARY_TRACKS, + ProviderFeature.LIBRARY_ALBUMS, + } + provider = _make_provider_mock(features) + + folders = await YandexMusicProvider._browse_collection( + provider, "yandex_music_instance://collection" + ) + + item_ids = [f.item_id for f in folders if isinstance(f, BrowseFolder)] + assert "audiobooks" not in item_ids + assert "podcasts" not in item_ids + + +@pytest.mark.asyncio +async def test_collection_audiobooks_folder_russian_locale() -> None: + """Russian locale uses Russian folder names.""" + features = { + ProviderFeature.LIBRARY_AUDIOBOOKS, + ProviderFeature.LIBRARY_PODCASTS, + } + provider = _make_provider_mock(features, locale="ru_RU") + + folders = await YandexMusicProvider._browse_collection( + provider, "yandex_music_instance://collection" + ) + + audiobook = next( + f for f in folders if isinstance(f, BrowseFolder) and f.item_id == "audiobooks" + ) + podcast = next(f for f in folders if isinstance(f, BrowseFolder) and f.item_id == "podcasts") + assert audiobook.name == BROWSE_NAMES_RU["audiobooks"] + assert podcast.name == BROWSE_NAMES_RU["podcasts"] diff --git a/tests/providers/yandex_music/test_parsers.py b/tests/providers/yandex_music/test_parsers.py index 205f8a2507..6d6f162f53 100644 --- a/tests/providers/yandex_music/test_parsers.py +++ b/tests/providers/yandex_music/test_parsers.py @@ -380,6 +380,33 @@ def test_parse_audiobook(example: pathlib.Path, provider_stub: ProviderStub) -> assert list(result.narrators) == [] +def test_parse_audiobook_fully_played_true(provider_stub: ProviderStub) -> None: + """parse_audiobook propagates album.listening_finished=True to fully_played.""" + album_obj = _album_from_fixture(FIXTURES_DIR / "audiobooks" / "basic.json") + assert album_obj is not None + album_obj.listening_finished = True + result = parse_audiobook(cast("YandexMusicProvider", provider_stub), album_obj) + assert result.fully_played is True + + +def test_parse_audiobook_fully_played_false(provider_stub: ProviderStub) -> None: + """parse_audiobook propagates album.listening_finished=False to fully_played.""" + album_obj = _album_from_fixture(FIXTURES_DIR / "audiobooks" / "basic.json") + assert album_obj is not None + album_obj.listening_finished = False + result = parse_audiobook(cast("YandexMusicProvider", provider_stub), album_obj) + assert result.fully_played is False + + +def test_parse_audiobook_fully_played_none(provider_stub: ProviderStub) -> None: + """parse_audiobook leaves fully_played=None when the flag is missing.""" + album_obj = _album_from_fixture(FIXTURES_DIR / "audiobooks" / "basic.json") + assert album_obj is not None + album_obj.listening_finished = None + result = parse_audiobook(cast("YandexMusicProvider", provider_stub), album_obj) + assert result.fully_played is None + + def test_parse_podcast_episode(provider_stub: ProviderStub) -> None: """parse_podcast_episode links episode to its parent podcast.""" podcast_album = _album_from_fixture(FIXTURES_DIR / "podcasts" / "basic.json") diff --git a/tests/providers/yandex_music/test_search_audiobooks.py b/tests/providers/yandex_music/test_search_audiobooks.py new file mode 100644 index 0000000000..521704b5e0 --- /dev/null +++ b/tests/providers/yandex_music/test_search_audiobooks.py @@ -0,0 +1,112 @@ +"""Tests for audiobook search routing.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +import pytest +from music_assistant_models.enums import MediaType + +from music_assistant.providers.yandex_music.provider import YandexMusicProvider + + +def _fake_album(*, album_id: int, title: str, meta_type: str | None, type_: str | None) -> Mock: + """Minimal Yandex Album stand-in sufficient for classify_album + parse_audiobook.""" + album = Mock() + album.id = album_id + album.title = title + album.version = None + album.available = True + album.meta_type = meta_type + album.type = type_ + album.artists = [] + album.labels = [] + album.description = None + album.short_description = None + album.content_warning = None + album.genre = None + album.release_date = None + album.cover_uri = None + album.og_image = None + album.listening_finished = None + album.track_count = None + return album + + +def _fake_search_result(albums: list[Mock]) -> Mock: + """Build a search-result stub with an `albums.results` list and empty siblings.""" + result = Mock() + result.tracks = None + result.artists = None + result.playlists = None + result.podcasts = None + result.albums = Mock() + result.albums.results = albums + return result + + +@pytest.fixture +def provider_mock() -> Mock: + """Return a provider mock with a stubbed api_client.search.""" + provider = Mock(spec=YandexMusicProvider) + provider.domain = "yandex_music" + provider.instance_id = "yandex_music_instance" + provider.logger = Mock() + provider.client = AsyncMock() + # @use_cache decorator reads self.mass.cache — stub returning None (cache miss) + provider.mass = Mock() + provider.mass.cache = AsyncMock() + provider.mass.cache.get = AsyncMock(return_value=None) + provider.mass.cache.set = AsyncMock() + return provider + + +@pytest.mark.asyncio +async def test_search_audiobook_only_filters_albums(provider_mock: Mock) -> None: + """Requesting AUDIOBOOK only routes audiobook albums and drops music ones.""" + music = _fake_album(album_id=1, title="Plain Music", meta_type="music", type_="music") + book = _fake_album(album_id=2, title="Cool Book", meta_type="podcast", type_="audiobook") + provider_mock.client.search = AsyncMock(return_value=_fake_search_result([music, book])) + + result = await YandexMusicProvider.search( + provider_mock, "query", [MediaType.AUDIOBOOK], limit=5 + ) + + # Single Yandex API call with type_="album" + provider_mock.client.search.assert_awaited_once() + assert provider_mock.client.search.await_args.kwargs["search_type"] == "album" + + assert [a.item_id for a in result.audiobooks] == ["2"] + assert list(result.albums) == [] + + +@pytest.mark.asyncio +async def test_search_album_and_audiobook_split(provider_mock: Mock) -> None: + """Requesting both ALBUM and AUDIOBOOK splits the albums bucket cleanly.""" + music = _fake_album(album_id=10, title="Music", meta_type="music", type_="music") + book = _fake_album(album_id=20, title="Book", meta_type="podcast", type_="audiobook") + podcast = _fake_album(album_id=30, title="Podcast", meta_type="podcast", type_="podcast") + provider_mock.client.search = AsyncMock( + return_value=_fake_search_result([music, book, podcast]) + ) + + result = await YandexMusicProvider.search( + provider_mock, "q", [MediaType.ALBUM, MediaType.AUDIOBOOK], limit=5 + ) + + assert [a.item_id for a in result.albums] == ["10"] + assert [a.item_id for a in result.audiobooks] == ["20"] + + +@pytest.mark.asyncio +async def test_search_albums_type_mapping_dedupe(provider_mock: Mock) -> None: + """ALBUM + AUDIOBOOK both map to Yandex 'album' — dedup keeps a single call type.""" + provider_mock.client.search = AsyncMock(return_value=_fake_search_result([])) + + await YandexMusicProvider.search( + provider_mock, "q", [MediaType.ALBUM, MediaType.AUDIOBOOK], limit=3 + ) + + provider_mock.client.search.assert_awaited_once() + # both map to "album"; with dedup there's a single requested_type → search_type='album' + assert provider_mock.client.search.await_args.kwargs["search_type"] == "album" From c172f246320e901a7740cbb0357e6dac1b3f4b6f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 05:49:54 +0000 Subject: [PATCH 39/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.3.1 --- .../providers/yandex_music/provider.py | 18 ++++++-- .../yandex_music/test_search_audiobooks.py | 45 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/music_assistant/providers/yandex_music/provider.py b/music_assistant/providers/yandex_music/provider.py index ee2678d610..311b0bad84 100644 --- a/music_assistant/providers/yandex_music/provider.py +++ b/music_assistant/providers/yandex_music/provider.py @@ -1605,20 +1605,30 @@ async def search( # Parse albums — audiobooks are split into the audiobooks bucket via # classify_album. Yandex-returned podcast albums are handled separately - # through the dedicated `.podcasts` node below. + # through the dedicated `.podcasts` node below. ``limit`` is applied per + # bucket AFTER classification — slicing first would drop audiobooks when + # the first ``limit`` results happen to be music albums (or vice versa). want_album = MediaType.ALBUM in media_types want_audiobook = MediaType.AUDIOBOOK in media_types if (want_album or want_audiobook) and search_result.albums: - for album in search_result.albums.results[:limit]: + album_count = 0 + audiobook_count = 0 + for album in search_result.albums.results: + album_full = not want_album or album_count >= limit + audiobook_full = not want_audiobook or audiobook_count >= limit + if album_full and audiobook_full: + break kind = classify_album(album) try: - if kind == "audiobook" and want_audiobook: + if kind == "audiobook" and want_audiobook and not audiobook_full: result.audiobooks = [ *result.audiobooks, parse_audiobook(self, album), ] - elif kind == "music" and want_album: + audiobook_count += 1 + elif kind == "music" and want_album and not album_full: result.albums = [*result.albums, parse_album(self, album)] + album_count += 1 except InvalidDataError as err: self.logger.debug("Error parsing %s album: %s", kind, err) diff --git a/tests/providers/yandex_music/test_search_audiobooks.py b/tests/providers/yandex_music/test_search_audiobooks.py index 521704b5e0..5d817394b7 100644 --- a/tests/providers/yandex_music/test_search_audiobooks.py +++ b/tests/providers/yandex_music/test_search_audiobooks.py @@ -98,6 +98,51 @@ async def test_search_album_and_audiobook_split(provider_mock: Mock) -> None: assert [a.item_id for a in result.audiobooks] == ["20"] +@pytest.mark.asyncio +async def test_search_audiobook_not_dropped_by_limit_when_music_dominates( + provider_mock: Mock, +) -> None: + """Limit applied per bucket after classification, not before. + + Audiobooks tail-listed by Yandex must still appear when top ``limit`` + results are music albums. + """ + music_albums = [ + _fake_album(album_id=i, title=f"Music {i}", meta_type="music", type_="music") + for i in range(5) + ] + tail_audiobook = _fake_album( + album_id=99, title="Tail Book", meta_type="podcast", type_="audiobook" + ) + provider_mock.client.search = AsyncMock( + return_value=_fake_search_result([*music_albums, tail_audiobook]) + ) + + result = await YandexMusicProvider.search(provider_mock, "q", [MediaType.AUDIOBOOK], limit=3) + + # Even with only 3 results requested and 5 music albums ahead of it, + # the audiobook tail entry still lands in the audiobooks bucket. + assert [a.item_id for a in result.audiobooks] == ["99"] + + +@pytest.mark.asyncio +async def test_search_album_bucket_respects_limit_independently( + provider_mock: Mock, +) -> None: + """Albums bucket is capped at ``limit`` regardless of audiobook count.""" + albums = [ + _fake_album(album_id=i, title=f"M{i}", meta_type="music", type_="music") for i in range(10) + ] + provider_mock.client.search = AsyncMock(return_value=_fake_search_result(albums)) + + result = await YandexMusicProvider.search( + provider_mock, "q", [MediaType.ALBUM, MediaType.AUDIOBOOK], limit=3 + ) + + assert len(result.albums) == 3 + assert list(result.audiobooks) == [] + + @pytest.mark.asyncio async def test_search_albums_type_mapping_dedupe(provider_mock: Mock) -> None: """ALBUM + AUDIOBOOK both map to Yandex 'album' — dedup keeps a single call type.""" From 869ef78c4f6ee4adcebf25e1338b0e16ff9e6c3f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 11:41:03 +0000 Subject: [PATCH 40/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.4.0 --- .../providers/yandex_music/__init__.py | 206 ++++ .../providers/yandex_music/api_client.py | 215 +++- .../providers/yandex_music/constants.py | 107 ++ .../providers/yandex_music/parsers.py | 11 +- .../providers/yandex_music/presets.py | 46 + .../providers/yandex_music/provider.py | 1039 ++++++++++++----- .../providers/yandex_music/streaming.py | 6 +- .../providers/yandex_music/test_api_client.py | 257 +++- .../yandex_music/test_browse_pins_history.py | 47 +- tests/providers/yandex_music/test_my_wave.py | 667 ++++++++++- .../yandex_music/test_recommendations.py | 42 +- .../providers/yandex_music/test_streaming.py | 10 +- 12 files changed, 2249 insertions(+), 404 deletions(-) create mode 100644 music_assistant/providers/yandex_music/presets.py diff --git a/music_assistant/providers/yandex_music/__init__.py b/music_assistant/providers/yandex_music/__init__.py index ae133f0342..105632fac0 100644 --- a/music_assistant/providers/yandex_music/__init__.py +++ b/music_assistant/providers/yandex_music/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING, cast from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType @@ -13,6 +14,8 @@ CONF_ACTION_AUTH_DEVICE, CONF_ACTION_AUTH_QR, CONF_ACTION_CLEAR_AUTH, + CONF_ACTION_DELETE_WAVE_PRESET, + CONF_ACTION_SAVE_WAVE_PRESET, CONF_BASE_URL, CONF_LIKED_TRACKS_MAX_TRACKS, CONF_MY_WAVE_MAX_TRACKS, @@ -20,15 +23,209 @@ CONF_REFRESH_TOKEN, CONF_REMEMBER_SESSION, CONF_TOKEN, + CONF_WAVE_PRESET_DRAFT_DIVERSITY, + CONF_WAVE_PRESET_DRAFT_LANGUAGE, + CONF_WAVE_PRESET_DRAFT_MOOD, + CONF_WAVE_PRESET_DRAFT_NAME, + CONF_WAVE_PRESET_TO_DELETE, + CONF_WAVE_PRESETS_DATA, CONF_X_TOKEN, DEFAULT_BASE_URL, QUALITY_BALANCED, QUALITY_EFFICIENT, QUALITY_HIGH, QUALITY_SUPERB, + WAVE_PRESET_DIVERSITY_VALUES, + WAVE_PRESET_LANGUAGE_VALUES, + WAVE_PRESET_MOOD_VALUES, ) +from .presets import parse_stored_presets as _parse_stored_presets from .provider import YandexMusicProvider + +def _save_wave_preset_action(values: dict[str, ConfigValueType]) -> None: + """Merge the current draft fields into the stored preset list. + + Overwrites an existing preset with the same name instead of creating a + duplicate. Clears draft fields after persisting so the UI returns to a + blank state. Raises ``InvalidDataError`` when the name is blank. + """ + name_raw = values.get(CONF_WAVE_PRESET_DRAFT_NAME) + name = name_raw.strip() if isinstance(name_raw, str) else "" + if not name: + raise InvalidDataError("Please fill the preset name before saving.") + presets = _parse_stored_presets(values.get(CONF_WAVE_PRESETS_DATA)) + presets = [p for p in presets if p["name"] != name] + new_preset: dict[str, str] = {"name": name} + for conf_key, api_key in ( + (CONF_WAVE_PRESET_DRAFT_DIVERSITY, "diversity"), + (CONF_WAVE_PRESET_DRAFT_MOOD, "moodEnergy"), + (CONF_WAVE_PRESET_DRAFT_LANGUAGE, "language"), + ): + val = values.get(conf_key) + if isinstance(val, str) and val: + new_preset[api_key] = val + presets.append(new_preset) + values[CONF_WAVE_PRESETS_DATA] = json.dumps(presets, ensure_ascii=False) + # Clear draft so the UI is ready for the next preset + values[CONF_WAVE_PRESET_DRAFT_NAME] = None + values[CONF_WAVE_PRESET_DRAFT_DIVERSITY] = "" + values[CONF_WAVE_PRESET_DRAFT_MOOD] = "" + values[CONF_WAVE_PRESET_DRAFT_LANGUAGE] = "" + + +def _delete_wave_preset_action(values: dict[str, ConfigValueType]) -> None: + """Remove the preset named by CONF_WAVE_PRESET_TO_DELETE from the store. + + Raises ``InvalidDataError`` when no name is selected. Idempotent — absent + names simply rewrite an unchanged list. + """ + target_raw = values.get(CONF_WAVE_PRESET_TO_DELETE) + target = target_raw.strip() if isinstance(target_raw, str) else "" + if not target: + raise InvalidDataError("Please select a preset to delete.") + presets = _parse_stored_presets(values.get(CONF_WAVE_PRESETS_DATA)) + presets = [p for p in presets if p["name"] != target] + values[CONF_WAVE_PRESETS_DATA] = json.dumps(presets, ensure_ascii=False) + values[CONF_WAVE_PRESET_TO_DELETE] = "" + + +def _wave_preset_config_entries(values: dict[str, ConfigValueType]) -> list[ConfigEntry]: + """Return the wave-preset builder UI (all advanced settings). + + Layout: + - Section label showing how many presets are saved. + - Four "draft" fields (name + three dropdowns) the user fills in. + - "Save preset" action → copies draft into the JSON store. + - "Delete preset" dropdown + action (hidden when no presets exist). + - Hidden STRING carrying the JSON store itself. + + Number of presets is unbounded; the user never edits JSON directly. + """ + empty_title = "— Default —" + diversity_options = [ + ConfigValueOption(title=empty_title if not v else v.title(), value=v) + for v in WAVE_PRESET_DIVERSITY_VALUES + ] + mood_options = [ + ConfigValueOption(title=empty_title if not v else v.title(), value=v) + for v in WAVE_PRESET_MOOD_VALUES + ] + language_options = [ + ConfigValueOption(title=empty_title if not v else v.replace("-", " ").title(), value=v) + for v in WAVE_PRESET_LANGUAGE_VALUES + ] + + presets = _parse_stored_presets(values.get(CONF_WAVE_PRESETS_DATA)) + has_presets = bool(presets) + delete_options = [ConfigValueOption(title=p["name"], value=p["name"]) for p in presets] + if not delete_options: + # Empty options can break some frontends; supply a no-op placeholder. + delete_options = [ConfigValueOption(title="(no presets saved)", value="")] + + def _str_value(key: str) -> str | None: + v = values.get(key) + return v if isinstance(v, str) else None + + return [ + ConfigEntry( + key="wave_preset_section_label", + type=ConfigEntryType.LABEL, + label=(f"My Wave presets ({len(presets)} saved)" if has_presets else "My Wave presets"), + advanced=True, + ), + ConfigEntry( + key=CONF_WAVE_PRESET_DRAFT_NAME, + type=ConfigEntryType.STRING, + label="New preset name", + description=( + "Give the preset a short name, pick up to three dropdowns " + "below and click Save. Saving the same name again overwrites." + ), + default_value=None, + required=False, + advanced=True, + value=_str_value(CONF_WAVE_PRESET_DRAFT_NAME), + ), + ConfigEntry( + key=CONF_WAVE_PRESET_DRAFT_DIVERSITY, + type=ConfigEntryType.STRING, + label="New preset: diversity", + description="How broadly the wave explores.", + options=diversity_options, + default_value="", + required=False, + advanced=True, + value=_str_value(CONF_WAVE_PRESET_DRAFT_DIVERSITY), + ), + ConfigEntry( + key=CONF_WAVE_PRESET_DRAFT_MOOD, + type=ConfigEntryType.STRING, + label="New preset: mood", + description="Energy and mood of the tracks.", + options=mood_options, + default_value="", + required=False, + advanced=True, + value=_str_value(CONF_WAVE_PRESET_DRAFT_MOOD), + ), + ConfigEntry( + key=CONF_WAVE_PRESET_DRAFT_LANGUAGE, + type=ConfigEntryType.STRING, + label="New preset: language", + description="Lyrics language filter.", + options=language_options, + default_value="", + required=False, + advanced=True, + value=_str_value(CONF_WAVE_PRESET_DRAFT_LANGUAGE), + ), + ConfigEntry( + key=CONF_ACTION_SAVE_WAVE_PRESET, + type=ConfigEntryType.ACTION, + label="Save preset", + description=( + "Adds the values above to Saved presets. The list is shown " + "under Radio then My Presets in Browse." + ), + action=CONF_ACTION_SAVE_WAVE_PRESET, + action_label="Save preset", + advanced=True, + ), + ConfigEntry( + key=CONF_WAVE_PRESET_TO_DELETE, + type=ConfigEntryType.STRING, + label="Select preset to delete", + options=delete_options, + default_value="", + required=False, + advanced=True, + hidden=not has_presets, + value=_str_value(CONF_WAVE_PRESET_TO_DELETE), + ), + ConfigEntry( + key=CONF_ACTION_DELETE_WAVE_PRESET, + type=ConfigEntryType.ACTION, + label="Delete selected preset", + description="Removes the selected preset from the Saved presets list.", + action=CONF_ACTION_DELETE_WAVE_PRESET, + action_label="Delete", + advanced=True, + hidden=not has_presets, + ), + ConfigEntry( + key=CONF_WAVE_PRESETS_DATA, + type=ConfigEntryType.STRING, + label="Saved presets (internal)", + default_value="", + required=False, + advanced=True, + hidden=True, + value=_str_value(CONF_WAVE_PRESETS_DATA) or "", + ), + ] + + if TYPE_CHECKING: from music_assistant_models.config_entries import ProviderConfig from music_assistant_models.provider import ProviderManifest @@ -112,6 +309,13 @@ async def get_config_entries( values[CONF_X_TOKEN] = None values[CONF_REFRESH_TOKEN] = None + # Wave-preset save/delete actions mutate the hidden JSON store and clear + # the draft / selection fields so the UI re-renders in a clean state. + if action == CONF_ACTION_SAVE_WAVE_PRESET: + _save_wave_preset_action(values) + if action == CONF_ACTION_DELETE_WAVE_PRESET: + _delete_wave_preset_action(values) + # Check if user is authenticated is_authenticated = bool(values.get(CONF_TOKEN)) @@ -231,6 +435,8 @@ async def get_config_entries( required=False, advanced=True, ), + # User-defined wave presets: builder + save/delete actions (dynamic list) + *_wave_preset_config_entries(values), # Liked Tracks maximum tracks (advanced) ConfigEntry( key=CONF_LIKED_TRACKS_MAX_TRACKS, diff --git a/music_assistant/providers/yandex_music/api_client.py b/music_assistant/providers/yandex_music/api_client.py index 05e2bcdc00..52365ac8f0 100644 --- a/music_assistant/providers/yandex_music/api_client.py +++ b/music_assistant/providers/yandex_music/api_client.py @@ -38,7 +38,7 @@ from yandex_music.rotor.dashboard import Dashboard from yandex_music.rotor.station_result import StationResult -from .constants import DEFAULT_LIMIT, ROTOR_STATION_MY_WAVE +from .constants import DEFAULT_LIMIT # 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) @@ -231,17 +231,6 @@ async def get_rotor_station_tracks( ordered = [order_map[tid] for tid in track_ids if tid in order_map] return (ordered, result.batch_id if result else 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, @@ -337,6 +326,208 @@ async def _send(c: ClientAsync) -> bool: LOGGER.warning("Rotor feedback %s failed: %s", feedback_type, err) return False + # Rotor session API (new session-based endpoints) + # + # Yandex's newer rotor API models a wave as a long-lived session: + # POST /rotor/session/new → {radioSessionId, sequence, batchId} + # POST /rotor/session/{sessionId}/tracks → {sequence, batchId} + # POST /rotor/session/{sessionId}/feedback → {result: "ok"} + # All feedback events carry the same sessionId, so we no longer need to + # thread per-batch batch_ids through call sites the way the stations-based + # API forced us to. + + async def _rotor_session_request( + self, path: str, body: dict[str, Any], *, with_retry: bool = True + ) -> dict[str, Any] | None: + """POST a JSON body to /rotor/session/{path} and return parsed result. + + Reuses the MarshalX ClientAsync internal request object so we inherit + its auth headers and parsing. `json=` is forwarded to `aiohttp.request` + by MarshalX's `**kwargs` passthrough. + + :param path: Path suffix after /rotor/session/ (e.g. "new", + "{session_id}/tracks", "{session_id}/feedback"). + :param body: JSON body to send. + :param with_retry: When True (default), uses the same reconnect-on- + transient-connection-error path as normal data fetches — + appropriate for ``new`` and ``tracks`` which sit on the + user-facing browse/play path. Set to False for ``feedback``, + where a dropped request should be silently lost rather than + hammered against a potentially rate-limiting server. + :return: Parsed result dict, or None on failure. + """ + + async def _do(c: ClientAsync) -> dict[str, Any] | None: + base = getattr(c, "base_url", "https://api.music.yandex.net") + url = f"{base}/rotor/session/{path}" + LOGGER.debug("Rotor session POST %s body_keys=%s", path, list(body.keys())) + try: + result = await c._request.post(url, json=body) + except NetworkError: + # Let the outer retry wrapper see transient drops. On the + # no-retry path the outer `except` below swallows it silently. + if with_retry: + raise + LOGGER.debug("Rotor session POST %s: network error (no retry)", path) + return None + except BadRequestError as err: + # 4xx is terminal — server rejected the body; retry would only + # reproduce the same failure. + LOGGER.warning("Rotor session POST %s failed: %s", path, err) + return None + if isinstance(result, dict): + LOGGER.debug("Rotor session POST %s → result keys=%s", path, list(result.keys())) + return result + LOGGER.debug("Rotor session POST %s → non-dict result: %r", path, result) + return None + + runner = self._call_with_retry if with_retry else self._call_no_retry + try: + return await runner(_do) + except (NetworkError, ProviderUnavailableError) as err: + LOGGER.warning("Rotor session POST %s failed: %s", path, err) + return None + + async def rotor_session_new( + self, + station_id: str, + *, + settings: dict[str, str] | None = None, + queue: list[str] | None = None, + ) -> tuple[str | None, list[YandexTrack], str | None]: + """Create a new rotor session. + + Sends `includeWaveModel: true` so Yandex applies its wave ML model and + `interactive: true` so the session is treated as foreground user play. + + :param station_id: Station ID (e.g. "user:onyourwave" or "track:123"). + :param settings: Optional {diversity, moodEnergy, language} — each + becomes an additional seed like "settingDiversity:discover". + :param queue: Optional initial track IDs in the queue; usually empty. + :return: Tuple of (radio_session_id, list of tracks, batch_id). + Any element may be None/[] on failure. + """ + seeds: list[str] = [station_id] + if settings: + for key, seed_name in ( + ("diversity", "settingDiversity"), + ("moodEnergy", "settingMoodEnergy"), + ("language", "settingLanguage"), + ): + val = settings.get(key) + if val: + seeds.append(f"{seed_name}:{val}") + body: dict[str, Any] = { + "seeds": seeds, + "queue": queue or [], + "includeTracksInResponse": True, + "includeWaveModel": True, + "interactive": True, + } + result = await self._rotor_session_request("new", body) + if not result: + return (None, [], None) + session_id = result.get("radioSessionId") + batch_id = result.get("batchId") + tracks = await self._hydrate_session_tracks(result.get("sequence") or []) + return (session_id, tracks, batch_id) + + async def rotor_session_tracks( + self, session_id: str, *, current_track_id: str + ) -> tuple[list[YandexTrack], str | None]: + """Fetch the next batch of tracks for an active rotor session. + + :param session_id: radioSessionId from rotor_session_new(). + :param current_track_id: Track ID just consumed from the previous batch + (Yandex uses it to decide what to return next). + :return: Tuple of (list of tracks, new batch_id). + """ + body = {"queue": [str(current_track_id)]} + result = await self._rotor_session_request(f"{session_id}/tracks", body) + if not result: + return ([], None) + batch_id = result.get("batchId") + tracks = await self._hydrate_session_tracks(result.get("sequence") or []) + return (tracks, batch_id) + + async def rotor_session_feedback( + self, + session_id: str, + event_type: str, + *, + track_id: str | None = None, + total_played_seconds: int | None = None, + batch_id: str | None = None, + ) -> bool: + """Send a feedback event for an active rotor session. + + Supports the Yandex rotor event types: radioStarted, trackStarted, + trackFinished, skip, like, dislike. For radioStarted the track_id goes + into `event.from`; all other types use `event.trackId`. Only + trackFinished and skip carry `totalPlayedSeconds`. + + :param session_id: radioSessionId. + :param event_type: rotor event type string. + :param track_id: Yandex track ID the event refers to (required for + everything except radioStarted without a seed). + :param total_played_seconds: seconds of the track that were played + (only meaningful for trackFinished / skip). + :param batch_id: batchId from the most recent rotor_session_{new,tracks} + response; anchors the event to a specific batch. + :return: True if the POST succeeded. + """ + timestamp = datetime.now(UTC).isoformat().replace("+00:00", "Z") + event: dict[str, Any] = {"type": event_type, "timestamp": timestamp} + if event_type == "radioStarted": + if track_id is not None: + event["from"] = str(track_id) + elif track_id is not None: + event["trackId"] = str(track_id) + if event_type in ("trackFinished", "skip") and total_played_seconds is not None: + event["totalPlayedSeconds"] = int(total_played_seconds) + body: dict[str, Any] = {"event": event} + if batch_id: + body["batchId"] = batch_id + LOGGER.debug( + "Rotor session feedback: session=%s event=%s track=%s secs=%s batch=%s", + session_id, + event_type, + track_id, + total_played_seconds, + batch_id, + ) + result = await self._rotor_session_request(f"{session_id}/feedback", body, with_retry=False) + return result is not None + + async def _hydrate_session_tracks(self, sequence: list[dict[str, Any]]) -> list[YandexTrack]: + """Extract track IDs from a rotor session sequence and hydrate via get_tracks. + + The session endpoints return tracks inline when includeTracksInResponse + is true, but full track objects (with download info, covers, etc.) are + fetched separately so parsed Track objects have the same shape as in + the rest of the provider. + + :param sequence: List of sequence items from a rotor session response. + :return: List of full track objects in the same order as `sequence`. + """ + track_ids: list[str] = [] + for seq in sequence: + tr = seq.get("track") if isinstance(seq, dict) else None + tid = None + if isinstance(tr, dict): + tid = tr.get("id") or tr.get("track_id") + if tid is not None: + track_ids.append(str(tid)) + if not track_ids: + return [] + try: + full_tracks = await self.get_tracks(track_ids) + except ResourceTemporarilyUnavailable as err: + LOGGER.warning("Rotor session track hydration failed: %s", err) + return [] + order_map = {str(t.id): t for t in full_tracks if hasattr(t, "id") and t.id} + return [order_map[tid] for tid in track_ids if tid in order_map] + async def play_audio( self, *, diff --git a/music_assistant/providers/yandex_music/constants.py b/music_assistant/providers/yandex_music/constants.py index c1e8fd95d4..0d4a1fa7e1 100644 --- a/music_assistant/providers/yandex_music/constants.py +++ b/music_assistant/providers/yandex_music/constants.py @@ -112,6 +112,87 @@ # Composite item_id for My Wave tracks: track_id + separator + station_id (for rotor feedback) RADIO_TRACK_ID_SEP: Final[str] = "@" +# Wave-mode suffix separator: station keys like "user:onyourwave#discover" identify +# a specific preset (diversity/moodEnergy/language) on top of the base My Wave station. +# Chosen because # is not part of any rotor station ID format. +WAVE_MODE_SEP: Final[str] = "#" + +# Known wave-mode presets: preset key (suffix after WAVE_MODE_SEP) → rotor session +# settings dict. Names match the LMS YandexMusic plugin and the Desktop client UI. +MY_WAVE_MODES_FOLDER_ID: Final[str] = "my_wave_modes" +MY_WAVE_PRESETS_FOLDER_ID: Final[str] = "my_wave_presets" + +# User-defined wave presets are now stored in a single hidden JSON config key. +# The UI shows a small "builder" (name + three dropdowns) + Save / Delete +# action buttons, so the user never has to edit JSON by hand but has no fixed +# upper bound on preset count either. + +# Hidden JSON store. Shape: [{"name": str, "diversity"?: str, +# "moodEnergy"?: str, "language"?: str}, ...] +CONF_WAVE_PRESETS_DATA: Final[str] = "wave_presets_data" + +# Visible "working preset" fields — filled in, then copied into the JSON list +# by the save action and cleared afterwards. +CONF_WAVE_PRESET_DRAFT_NAME: Final[str] = "wave_preset_draft_name" +CONF_WAVE_PRESET_DRAFT_DIVERSITY: Final[str] = "wave_preset_draft_diversity" +CONF_WAVE_PRESET_DRAFT_MOOD: Final[str] = "wave_preset_draft_mood" +CONF_WAVE_PRESET_DRAFT_LANGUAGE: Final[str] = "wave_preset_draft_language" + +# Dropdown of saved preset names for the delete flow. +CONF_WAVE_PRESET_TO_DELETE: Final[str] = "wave_preset_to_delete" + +# Action button ids. +CONF_ACTION_SAVE_WAVE_PRESET: Final[str] = "save_wave_preset" +CONF_ACTION_DELETE_WAVE_PRESET: Final[str] = "delete_wave_preset" + +# Allowed per-dimension values (plus "" to mean "use wave default"). +WAVE_PRESET_DIVERSITY_VALUES: Final[tuple[str, ...]] = ( + "", + "discover", + "favorite", + "popular", +) +WAVE_PRESET_MOOD_VALUES: Final[tuple[str, ...]] = ( + "", + "active", + "fun", + "calm", + "sad", +) +WAVE_PRESET_LANGUAGE_VALUES: Final[tuple[str, ...]] = ( + "", + "russian", + "not-russian", + "without-words", +) + +WAVE_MODE_PRESETS: Final[dict[str, dict[str, str]]] = { + "discover": {"diversity": "discover"}, + "favorite": {"diversity": "favorite"}, + "popular": {"diversity": "popular"}, + "calm": {"moodEnergy": "calm"}, + "active": {"moodEnergy": "active"}, + "fun": {"moodEnergy": "fun"}, + "sad": {"moodEnergy": "sad"}, + "russian": {"language": "russian"}, + "not_russian": {"language": "not-russian"}, + "without_words": {"language": "without-words"}, +} + +# Ordered list of preset keys for Browse display. +WAVE_MODE_ORDER: Final[tuple[str, ...]] = ( + "discover", + "favorite", + "popular", + "calm", + "active", + "fun", + "sad", + "russian", + "not_russian", + "without_words", +) + # Browse folder names by locale (item_id -> display name) BROWSE_NAMES_RU: Final[dict[str, str]] = { "my_wave": "Моя волна", @@ -188,6 +269,19 @@ "genre": "Жанры", "epoch": "Эпоха", "local": "Местное", + # Wave-mode folder + presets (P4) + "my_wave_modes": "Режимы волны", + "my_wave_presets": "Мои пресеты", + "wave_mode_discover": "Открытия", + "wave_mode_favorite": "Любимое", + "wave_mode_popular": "Популярное", + "wave_mode_calm": "Спокойнее", + "wave_mode_active": "Активнее", + "wave_mode_fun": "Весёлое", + "wave_mode_sad": "Грустное", + "wave_mode_russian": "Русское", + "wave_mode_not_russian": "Не русское", # noqa: RUF001 + "wave_mode_without_words": "Без слов", } BROWSE_NAMES_EN: Final[dict[str, str]] = { "my_wave": "My Wave", @@ -264,6 +358,19 @@ "genre": "Genres", "epoch": "Era", "local": "Local", + # Wave-mode folder + presets (P4) + "my_wave_modes": "Wave Modes", + "my_wave_presets": "My Presets", + "wave_mode_discover": "Discover", + "wave_mode_favorite": "Favorites", + "wave_mode_popular": "Popular", + "wave_mode_calm": "Calm", + "wave_mode_active": "Active", + "wave_mode_fun": "Fun", + "wave_mode_sad": "Sad", + "wave_mode_russian": "Russian", + "wave_mode_not_russian": "Non-Russian", + "wave_mode_without_words": "Without Words", } # Tag categories for Picks and Recommendations diff --git a/music_assistant/providers/yandex_music/parsers.py b/music_assistant/providers/yandex_music/parsers.py index d24fd5197d..8453f284a2 100644 --- a/music_assistant/providers/yandex_music/parsers.py +++ b/music_assistant/providers/yandex_music/parsers.py @@ -360,13 +360,21 @@ def parse_track( def parse_playlist( - provider: YandexMusicProvider, playlist_obj: YandexPlaylist, owner_name: str | None = None + provider: YandexMusicProvider, + playlist_obj: YandexPlaylist, + owner_name: str | None = None, + *, + is_dynamic: bool = False, ) -> Playlist: """Parse Yandex playlist object to MA Playlist model. :param provider: The Yandex Music provider instance. :param playlist_obj: Yandex playlist object. :param owner_name: Optional owner name override. + :param is_dynamic: Mark the playlist as dynamic so Music Assistant does + not long-cache its content. Yandex regenerates "Playlist of the Day", + "DejaVu", "Premiere" etc. on a schedule, and those need a fresh read + on every browse so users actually see the updated selection. :return: Music Assistant Playlist model. """ # Playlist ID in Yandex is a combination of owner uid and playlist kind @@ -405,6 +413,7 @@ def parse_playlist( ) }, is_editable=is_editable, + is_dynamic=is_dynamic, ) # Metadata diff --git a/music_assistant/providers/yandex_music/presets.py b/music_assistant/providers/yandex_music/presets.py new file mode 100644 index 0000000000..ab6e52b799 --- /dev/null +++ b/music_assistant/providers/yandex_music/presets.py @@ -0,0 +1,46 @@ +"""Shared helpers for user-defined wave presets. + +Both the settings UI (in ``__init__.py``) and the Browse handler (in +``provider.py``) need to read the same JSON-encoded preset store. +Keeping the decoding + validation in one place avoids schema drift. +""" + +from __future__ import annotations + +import json + + +def parse_stored_presets(raw: object) -> list[dict[str, str]]: + """Decode the hidden JSON wave-presets store into a sanitised list. + + Only entries with a non-empty ``name`` string are kept. The optional + ``diversity`` / ``moodEnergy`` / ``language`` fields are carried through + as long as they are non-empty strings. Any other keys are dropped. + Malformed JSON, non-list roots or non-dict items yield an empty list — + the UI treats that as "no presets yet". + + :param raw: Value read from the ``wave_presets_data`` config entry. + :return: List of sanitised preset dicts in source order. + """ + if not isinstance(raw, str) or not raw.strip(): + return [] + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return [] + if not isinstance(parsed, list): + return [] + result: list[dict[str, str]] = [] + for item in parsed: + if not isinstance(item, dict): + continue + name = item.get("name") + if not isinstance(name, str) or not name.strip(): + continue + clean: dict[str, str] = {"name": name.strip()} + for key in ("diversity", "moodEnergy", "language"): + val = item.get(key) + if isinstance(val, str) and val: + clean[key] = val + result.append(clean) + return result diff --git a/music_assistant/providers/yandex_music/provider.py b/music_assistant/providers/yandex_music/provider.py index 311b0bad84..3f65636585 100644 --- a/music_assistant/providers/yandex_music/provider.py +++ b/music_assistant/providers/yandex_music/provider.py @@ -57,6 +57,7 @@ CONF_QUALITY, CONF_REFRESH_TOKEN, CONF_TOKEN, + CONF_WAVE_PRESETS_DATA, CONF_X_TOKEN, DEFAULT_BASE_URL, DISCOVERY_INITIAL_TRACKS, @@ -65,7 +66,9 @@ LIKED_TRACKS_PLAYLIST_ID, LISTENING_HISTORY_FOLDER_ID, MY_WAVE_BATCH_SIZE, + MY_WAVE_MODES_FOLDER_ID, MY_WAVE_PLAYLIST_ID, + MY_WAVE_PRESETS_FOLDER_ID, MY_WAVES_FOLDER_ID, MY_WAVES_SET_FOLDER_ID, PINNED_ITEMS_FOLDER_ID, @@ -85,6 +88,9 @@ TAG_SLUG_CATEGORY, TRACK_BATCH_SIZE, WAVE_CATEGORY_DISPLAY_ORDER, + WAVE_MODE_ORDER, + WAVE_MODE_PRESETS, + WAVE_MODE_SEP, WAVES_FOLDER_ID, WAVES_LANDING_FOLDER_ID, ) @@ -102,10 +108,39 @@ parse_podcast_episode, parse_track, ) +from .presets import parse_stored_presets from .streaming import YandexMusicStreamingManager if TYPE_CHECKING: from yandex_music import Album as YandexAlbum + from yandex_music import Track as YandexTrack + + +# MediaType sub-paths that MA's default MusicProvider.browse() understands. +# Used by the Collection dispatcher to delegate nested paths back to core. +_COLLECTION_SUB_FOLDERS: frozenset[str] = frozenset( + {"tracks", "artists", "albums", "playlists", "audiobooks", "podcasts"} +) + + +def _split_wave_mode(station_id: str) -> tuple[str, dict[str, str]]: + """Split a wave-mode station key into its base station ID and preset settings. + + Keys like ``user:onyourwave#discover`` encode a specific preset on top of + the base rotor station. The part before ``#`` is the station ID that goes + to Yandex; the part after is a key into WAVE_MODE_PRESETS. + + :param station_id: Station key, with or without a ``#preset`` suffix. + :return: Tuple of (base_station_id, settings_dict). The suffix, if + present, is always stripped — only the base station goes to + Yandex. ``settings_dict`` is the preset's settings when the suffix + matches a known WAVE_MODE_PRESETS key, or an empty dict otherwise + (unknown suffix → base station fired with no extra seeds). + """ + if WAVE_MODE_SEP not in station_id: + return (station_id, {}) + base, preset = station_id.split(WAVE_MODE_SEP, 1) + return (base, dict(WAVE_MODE_PRESETS.get(preset, {}))) def _parse_radio_item_id(item_id: str) -> tuple[str, str | None]: @@ -139,13 +174,23 @@ def _extract_chapter_map_from_album(album: YandexAlbum) -> tuple[list[str], list class _WaveState: - """Per-station mutable state for rotor wave playback.""" + """Per-station mutable state for rotor wave playback. + + Holds both the new session-based rotor identifiers (`session_id`) and the + legacy stations-based ones (`batch_id`). Call sites prefer `session_id` + when present; `batch_id` is still carried because feedback events anchor + to a specific batch within the session. + """ def __init__(self) -> None: + self.session_id: str | None = None self.batch_id: str | None = None self.last_track_id: str | None = None + self.playlist_next_cursor: str | None = None self.seen_track_ids: set[str] = set() self.radio_started_sent: bool = False + self.prefetched: list[Any] = [] + self.settings: dict[str, str] = {} self.lock: asyncio.Lock = asyncio.Lock() @@ -154,13 +199,7 @@ class YandexMusicProvider(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 - _my_wave_seen_track_ids: set[str] # Track IDs seen in current My Wave session - _my_wave_lock: asyncio.Lock # Protects My Wave mutable state - _wave_states: dict[str, _WaveState] # Per-station state for tagged wave stations + _wave_states: dict[str, _WaveState] # Per-station state (incl. My Wave) _wave_bg_colors: dict[str, str] # image_url -> hex bg color for transparent covers # Short-lived cache to dedupe the three library syncs (albums/podcasts/audiobooks) # that all derive from the same liked-albums endpoint. @@ -307,11 +346,13 @@ async def handle_async_init(self) -> None: # Suppress yandex_music library DEBUG dumps (full API request/response JSON) logging.getLogger("yandex_music").setLevel(self.logger.level + 10) + # Propagate the MA instance log level to our per-module loggers + # (api_client, streaming, parsers, auth) so DEBUG hooks there actually + # print when MA is set to DEBUG for this provider. + logging.getLogger("music_assistant.providers.yandex_music").setLevel(self.logger.level) self._streaming = YandexMusicStreamingManager(self) - # Initialize My Wave duplicate tracking - self._my_wave_seen_track_ids = set() - self._my_wave_lock = asyncio.Lock() - # Initialize per-station wave state dict + # Per-station wave state (incl. My Wave under ROTOR_STATION_MY_WAVE). + # Entries are created lazily by _get_wave_state() on first access. self._wave_states = {} self._wave_bg_colors = {} self._liked_albums_lock, self._liked_albums_cache = asyncio.Lock(), None @@ -348,7 +389,9 @@ def get_item_mapping(self, media_type: MediaType | str, key: str, name: str) -> name=name, ) - async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + async def browse( # noqa: PLR0911, 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 @@ -365,15 +408,76 @@ async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | Brow sub_subpath = path_parts[1] if len(path_parts) > 1 else None if subpath == MY_WAVE_PLAYLIST_ID: - async with self._my_wave_lock: + async with self._get_wave_state(ROTOR_STATION_MY_WAVE).lock: return await self._browse_my_wave(path, sub_subpath) + # Wave modes — accept two equivalent URL forms so both browse + # navigation (slash form "my_wave_modes/", emitted by our + # listing) and MA's play-time reconstruction (underscore form + # "my_wave_modes_", built as "://") work. + mode_preset: str | None = None + if subpath == MY_WAVE_MODES_FOLDER_ID and sub_subpath is None: + return self._browse_my_wave_modes_list(path) + if subpath == MY_WAVE_MODES_FOLDER_ID and sub_subpath is not None: + mode_preset = sub_subpath if sub_subpath != "next" else None + if mode_preset is None: + return [] + load_more_modes = len(path_parts) > 2 and path_parts[2] == "next" + elif subpath and subpath.startswith(f"{MY_WAVE_MODES_FOLDER_ID}_"): + mode_preset = subpath[len(MY_WAVE_MODES_FOLDER_ID) + 1 :] + load_more_modes = sub_subpath == "next" + if mode_preset is not None: + if mode_preset not in WAVE_MODE_PRESETS: + return [] + station_key = f"{ROTOR_STATION_MY_WAVE}{WAVE_MODE_SEP}{mode_preset}" + async with self._get_wave_state(station_key).lock: + return await self._browse_my_wave_mode(path, station_key, load_more_modes) + + # User-saved wave presets — same dual-form handling. + preset_idx: int | None = None + load_more_presets = False + if subpath == MY_WAVE_PRESETS_FOLDER_ID and sub_subpath is None: + return self._browse_user_presets_list(path, self._get_user_wave_presets()) + if subpath == MY_WAVE_PRESETS_FOLDER_ID and sub_subpath is not None: + try: + preset_idx = int(sub_subpath) + except ValueError: + return [] + load_more_presets = len(path_parts) > 2 and path_parts[2] == "next" + elif subpath and subpath.startswith(f"{MY_WAVE_PRESETS_FOLDER_ID}_"): + try: + preset_idx = int(subpath[len(MY_WAVE_PRESETS_FOLDER_ID) + 1 :]) + except ValueError: + return [] + load_more_presets = sub_subpath == "next" + if preset_idx is not None: + user_presets = self._get_user_wave_presets() + if not 0 <= preset_idx < len(user_presets): + return [] + preset_data = user_presets[preset_idx] + station_key = f"{ROTOR_STATION_MY_WAVE}{WAVE_MODE_SEP}preset_{preset_idx}" + wave = self._get_wave_state(station_key) + # Stash user-chosen settings so _fetch_rotor_session_batch sends them + wave.settings = { + k: v + for k, v in preset_data.items() + if k in ("diversity", "moodEnergy", "language") and v + } + async with wave.lock: + return await self._browse_my_wave_mode(path, station_key, load_more_presets) + # For You folder (picks + mixes) if subpath == FOR_YOU_FOLDER_ID: return await self._browse_for_you(path, path_parts) - # Collection folder (library items) + # Collection folder (library items). Two shapes: + # ://collection → listing of library sub-folders + # ://collection/ → delegate to MA's library handler + # The nested form is what lets MA's "back" button return here (strip + # last /-segment) instead of dumping the user at the provider root. if subpath == COLLECTION_FOLDER_ID: + if sub_subpath in _COLLECTION_SUB_FOLDERS: + return await super().browse(f"{self.instance_id}://{sub_subpath}") return await self._browse_collection(path) # Handle picks/ path (mood, activity, era, genres) @@ -455,6 +559,27 @@ async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | Brow is_playable=True, ) ) + # Wave modes folder (P4): discover / calm / active / language presets + folders.append( + BrowseFolder( + item_id=MY_WAVE_MODES_FOLDER_ID, + provider=self.instance_id, + path=f"{base}{MY_WAVE_MODES_FOLDER_ID}", + name=names.get(MY_WAVE_MODES_FOLDER_ID, "Wave Modes"), + is_playable=False, + ) + ) + # User-defined wave presets (P8) — shown only when any configured. + if self._get_user_wave_presets(): + folders.append( + BrowseFolder( + item_id=MY_WAVE_PRESETS_FOLDER_ID, + provider=self.instance_id, + path=f"{base}{MY_WAVE_PRESETS_FOLDER_ID}", + name=names.get(MY_WAVE_PRESETS_FOLDER_ID, "My Presets"), + is_playable=False, + ) + ) # For You folder — Picks + Mixes (Яндекс «Для вас») folders.append( BrowseFolder( @@ -532,12 +657,13 @@ async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | Brow async def _browse_my_wave( self, path: str, sub_subpath: str | None ) -> list[Track | BrowseFolder]: - """Browse My Wave tracks (must be called under _my_wave_lock). + """Browse My Wave tracks (must be called under the My Wave state lock). :param path: Full browse path. :param sub_subpath: Sub-path part ('next' for load more, or track_id cursor). :return: List of Track and optional BrowseFolder for "Load more". """ + wave = self._get_wave_state(ROTOR_STATION_MY_WAVE) max_tracks_config = int( self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type] ) @@ -557,11 +683,11 @@ async def _browse_my_wave( # Reset seen tracks on fresh browse (not "load more") if sub_subpath != "next": - self._my_wave_seen_track_ids = set() + wave.seen_track_ids = set() queue: str | int | None = None if sub_subpath == "next": - queue = self._my_wave_last_track_id + queue = wave.last_track_id elif sub_subpath: queue = sub_subpath @@ -574,24 +700,25 @@ async def _browse_my_wave( if total_track_count >= effective_limit: break - yandex_tracks, batch_id = await self.client.get_my_wave_tracks(queue=queue) + # On a fresh browse (non-"next"), honour any sub_subpath cursor override + # by seeding wave.last_track_id so the helper picks it up. + if queue is not None: + wave.last_track_id = str(queue) + yandex_tracks, batch_id = await self._fetch_rotor_session_batch( + wave, ROTOR_STATION_MY_WAVE + ) 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: - sent = await self.client.send_rotor_station_feedback( - ROTOR_STATION_MY_WAVE, - "radioStarted", - batch_id=batch_id, - ) + if not wave.radio_started_sent and yandex_tracks: + sent = await self._send_wave_feedback(wave, ROTOR_STATION_MY_WAVE, "radioStarted") if sent: - self._my_wave_radio_started_sent = True + wave.radio_started_sent = True first_track_id_this_batch = None for yt in yandex_tracks: if total_track_count >= effective_limit: break - track = self._parse_my_wave_track(yt, self._my_wave_seen_track_ids) + track = self._parse_my_wave_track(yt, wave.seen_track_ids) if track is None: continue all_tracks.append(track) @@ -602,7 +729,7 @@ async def _browse_my_wave( first_track_id_this_batch = track_id if first_track_id_this_batch is not None: - self._my_wave_last_track_id = first_track_id_this_batch + wave.last_track_id = first_track_id_this_batch if ( first_track_id_this_batch is None or not batch_id @@ -627,15 +754,171 @@ async def _browse_my_wave( ) return all_tracks - def _parse_my_wave_track(self, yt: Any, seen_ids: set[str]) -> Track | None: + def _get_user_wave_presets(self) -> list[dict[str, str]]: + """Decode user-defined wave presets from the hidden JSON config key. + + Thin wrapper around :func:`presets.parse_stored_presets` so browse + code and settings actions use the exact same parsing — avoids schema + drift when preset fields are added or renamed. + """ + return parse_stored_presets(self.config.get_value(CONF_WAVE_PRESETS_DATA)) + + def _browse_user_presets_list( + self, path: str, presets: list[dict[str, str]] + ) -> list[BrowseFolder]: + """Return one playable BrowseFolder per configured user preset. + + ``path`` is nested (``my_wave_presets/``) so MA's back-nav — + which strips the last ``/``-segment — returns the user to the + listing instead of the provider root. ``item_id`` uses the + underscore form (``my_wave_presets_``) because MA rebuilds a + playable folder's path from its item_id at play time. The browse + dispatcher accepts both forms. + + :param path: Current browse path. + :param presets: Sanitized presets from ``_get_user_wave_presets``. + :return: List of playable BrowseFolder entries. + """ + base = path if path.endswith("/") else f"{path}/" + folders: list[BrowseFolder] = [] + for idx, preset in enumerate(presets): + folders.append( + BrowseFolder( + item_id=f"{MY_WAVE_PRESETS_FOLDER_ID}_{idx}", + provider=self.instance_id, + path=f"{base}{idx}", + name=preset.get("name", f"Preset {idx + 1}"), + is_playable=True, + ) + ) + return folders + + def _browse_my_wave_modes_list(self, path: str) -> list[BrowseFolder]: + """Return the 11 wave-mode entries as playable browse folders. + + Same dual-form contract as user presets: nested ``path`` keeps + back-navigation intact, underscore ``item_id`` survives MA's + play-time reconstruction. + + :param path: Browse path the user navigated into. + :return: Ordered list of BrowseFolder entries, one per preset. + """ + names = self._get_browse_names() + base = path if path.endswith("/") else f"{path}/" + folders: list[BrowseFolder] = [] + for preset in WAVE_MODE_ORDER: + name_key = f"wave_mode_{preset}" + folders.append( + BrowseFolder( + item_id=f"{MY_WAVE_MODES_FOLDER_ID}_{preset}", + provider=self.instance_id, + path=f"{base}{preset}", + name=names.get(name_key, preset.replace("_", " ").title()), + is_playable=True, + ) + ) + return folders + + async def _browse_my_wave_mode( + self, path: str, station_key: str, load_more: bool + ) -> list[Track | BrowseFolder]: + """Fetch a batch of tracks for a specific wave-mode preset. + + Reuses the session-API machinery: tracks live in + ``_wave_states[station_key]`` where station_key is + ``user:onyourwave#{preset}``. Tracks carry composite item_ids that + route feedback back to this state. + + :param path: Full browse path to this preset. + :param station_key: Station key with a ``#preset`` suffix. + :param load_more: True when called for ``.../next`` pagination. + :return: Tracks + optional "Load more" folder. + """ + wave = self._get_wave_state(station_key) + max_tracks_config = int( + self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type] + ) + batch_size_config = MY_WAVE_BATCH_SIZE + effective_limit = min( + BROWSE_INITIAL_TRACKS if not load_more else max_tracks_config, + max_tracks_config, + ) + max_batches = batch_size_config if not load_more else 1 + + if not load_more: + wave.seen_track_ids = set() + + all_tracks: list[Track | BrowseFolder] = [] + last_batch_id: str | None = None + total_track_count = 0 + + for _ in range(max_batches): + if total_track_count >= effective_limit: + break + yandex_tracks, batch_id = await self._fetch_rotor_session_batch(wave, station_key) + if batch_id: + last_batch_id = batch_id + if not wave.radio_started_sent and yandex_tracks: + sent = await self._send_wave_feedback(wave, station_key, "radioStarted") + if sent: + wave.radio_started_sent = True + first_track_id_this_batch: str | None = None + for yt in yandex_tracks: + if total_track_count >= effective_limit: + break + track = self._parse_my_wave_track(yt, wave.seen_track_ids, station_key=station_key) + if track is None: + continue + all_tracks.append(track) + total_track_count += 1 + track_id = track.item_id.split(RADIO_TRACK_ID_SEP, 1)[0] + if first_track_id_this_batch is None: + first_track_id_this_batch = track_id + if first_track_id_this_batch is not None: + wave.last_track_id = first_track_id_this_batch + if ( + first_track_id_this_batch is None + or not batch_id + or not yandex_tracks + or total_track_count >= effective_limit + ): + break + + if last_batch_id and total_track_count < max_tracks_config: + names = self._get_browse_names() + next_name = "Ещё" if names == 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 + + def _parse_my_wave_track( + self, + yt: Any, + seen_ids: set[str], + *, + station_key: str = ROTOR_STATION_MY_WAVE, + ) -> Track | None: """Parse a Yandex track into a My Wave Track with composite item_id. Extracts the track_id, checks for duplicates in the seen_ids set, - sets composite item_id (track_id@station_id), and updates provider_mappings. - Callers using shared state must hold _my_wave_lock. + sets composite item_id (track_id@station_key) and updates + provider_mappings. `station_key` is the key in `_wave_states` under + which the matching session lives; for preset modes it carries a + `#preset` suffix so `on_played`/`on_streamed` find the right session. + + Callers using shared state must hold the My Wave state lock. :param yt: Yandex track object from rotor station response. :param seen_ids: Set of already-seen track IDs to check and update. + :param station_key: Station key to embed in the composite item_id. + Defaults to the plain My Wave station. :return: Parsed Track with composite item_id, or None if duplicate/invalid. """ try: @@ -653,7 +936,7 @@ def _parse_my_wave_track(self, yt: Any, seen_ids: set[str]) -> Track | None: return None seen_ids.add(track_id) - t.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_WAVE}" + t.item_id = f"{track_id}{RADIO_TRACK_ID_SEP}{station_key}" for pm in t.provider_mappings: if pm.provider_instance == self.instance_id: pm.item_id = t.item_id @@ -804,72 +1087,36 @@ async def _browse_collection( ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: """Browse «Collection» folder — shows library sub-folders (tracks/artists/albums/playlists). + Child ``path`` is nested (``…/collection/tracks``) so MA's "back" + button lands on this listing instead of the provider root. The + dispatcher then strips the ``collection/`` prefix and hands off to + core's default library handler. + :param path: Full browse path. :return: List of library sub-folders. """ names = self._get_browse_names() - base_parts = path.split("//", 1) - root_base = (base_parts[0] + "//") if len(base_parts) > 1 else path.rstrip("/") + "/" + base = path if path.endswith("/") else f"{path}/" folders: list[BrowseFolder] = [] - if ProviderFeature.LIBRARY_TRACKS in self.supported_features: - folders.append( - BrowseFolder( - item_id="tracks", - provider=self.instance_id, - path=f"{root_base}tracks", - name=names["tracks"], - is_playable=True, - ) - ) - if ProviderFeature.LIBRARY_ARTISTS in self.supported_features: - folders.append( - BrowseFolder( - item_id="artists", - provider=self.instance_id, - path=f"{root_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"{root_base}albums", - name=names["albums"], - is_playable=True, - ) - ) - if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features: - folders.append( - BrowseFolder( - item_id="playlists", - provider=self.instance_id, - path=f"{root_base}playlists", - name=names["playlists"], - is_playable=True, - ) - ) - if ProviderFeature.LIBRARY_PODCASTS in self.supported_features: - folders.append( - BrowseFolder( - item_id="podcasts", - provider=self.instance_id, - path=f"{root_base}podcasts", - name=names["podcasts"], - is_playable=False, - ) - ) - if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features: + feature_map: tuple[tuple[ProviderFeature, str, bool], ...] = ( + (ProviderFeature.LIBRARY_TRACKS, "tracks", True), + (ProviderFeature.LIBRARY_ARTISTS, "artists", True), + (ProviderFeature.LIBRARY_ALBUMS, "albums", True), + (ProviderFeature.LIBRARY_PLAYLISTS, "playlists", True), + (ProviderFeature.LIBRARY_PODCASTS, "podcasts", False), + (ProviderFeature.LIBRARY_AUDIOBOOKS, "audiobooks", False), + ) + for feature, sub_id, is_playable in feature_map: + if feature not in self.supported_features: + continue folders.append( BrowseFolder( - item_id="audiobooks", + item_id=sub_id, provider=self.instance_id, - path=f"{root_base}audiobooks", - name=names["audiobooks"], - is_playable=False, + path=f"{base}{sub_id}", + name=names[sub_id], + is_playable=is_playable, ) ) return folders @@ -910,9 +1157,17 @@ async def _browse_pins(self) -> Sequence[MediaItemType | ItemMapping | BrowseFol async def _browse_history(self) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: """Browse user's recent listening history (flattened across days). - Filters to ``type == "track"`` entries only — album/playlist context - items in the history feed are dropped. Tracks are de-duplicated by - id and returned in most-recent-first order. + Collects ``track_id`` values from each history entry's ``item_id`` + sub-object (``full_model`` is not populated by the current API + response — MarshalX exposes the IDs separately), dedupes, and + batch-resolves them via ``get_tracks`` so the returned Track objects + carry full artist/album/cover metadata. + + Entries without a resolvable ``track_id`` (e.g. album-only context + rows) are skipped silently. Order is preserved — most recent first — + by collecting unique IDs in response order into ``ordered_ids``, + then rebuilding the final list by iterating ``ordered_ids`` and + looking up each batch-fetched track in an id→track map. :return: List of recently played Track items. """ @@ -922,25 +1177,47 @@ async def _browse_history(self) -> Sequence[MediaItemType | ItemMapping | Browse return [] seen_track_ids: set[str] = set() - tracks: list[Track] = [] + ordered_ids: list[str] = [] for tab in tabs: - groups = getattr(tab, "items", None) or [] - for group in groups: - history_items = getattr(group, "tracks", None) or [] - for hist_item in history_items: + for group in getattr(tab, "items", None) or []: + for hist_item in getattr(group, "tracks", None) or []: if getattr(hist_item, "type", None) != "track": continue - full = getattr(getattr(hist_item, "data", None), "full_model", None) - if full is None or getattr(full, "id", None) is None: + item_id_obj = getattr(getattr(hist_item, "data", None), "item_id", None) + track_key: str | None = None + if isinstance(item_id_obj, dict): + track_key = item_id_obj.get("track_id") or item_id_obj.get("id") + else: + track_key = getattr(item_id_obj, "track_id", None) or getattr( + item_id_obj, "id", None + ) + if not track_key: continue - track_key = str(full.id) + track_key = str(track_key) if track_key in seen_track_ids: continue seen_track_ids.add(track_key) - try: - tracks.append(parse_track(self, full)) - except InvalidDataError as err: - self.logger.debug("Skipping history track: %s", err) + ordered_ids.append(track_key) + + if not ordered_ids: + return [] + + try: + fetched = await self.client.get_tracks(ordered_ids) + except ResourceTemporarilyUnavailable as err: + self.logger.warning("Failed to hydrate history tracks: %s", err) + return [] + + by_id = {str(t.id): t for t in fetched if getattr(t, "id", None) is not None} + tracks: list[Track] = [] + for tid in ordered_ids: + yt = by_id.get(tid) + if yt is None: + continue + try: + tracks.append(parse_track(self, yt)) + except InvalidDataError as err: + self.logger.debug("Skipping history track %s: %s", tid, err) return tracks async def _browse_picks( @@ -1098,6 +1375,142 @@ def _get_wave_state(self, station_id: str) -> _WaveState: """ return self._wave_states.setdefault(station_id, _WaveState()) + async def _send_wave_feedback( + self, + wave: _WaveState, + station_id: str, + event_type: str, + *, + track_id: str | None = None, + total_played_seconds: int | None = None, + ) -> bool: + """Route rotor feedback to the session endpoint. + + Requires an active ``wave.session_id`` — rotor feedback is only + meaningful inside the session it originated from. The legacy + stations-based endpoint (``/rotor/station/{id}/feedback``) is no + longer reachable (returns 404 "not-found"), so when there's no + session we skip silently rather than spamming the log. + + This happens when the track's composite item_id was parsed in a + previous provider run (e.g. loaded from MA's library cache) and + the corresponding session_id is not in memory any more. History + reporting via ``play_audio`` still works in that case — only the + rotor recommendation signal is lost. + + :param wave: Station state carrying session_id + batch_id. + :param station_id: Rotor station ID (used only for logging here). + :param event_type: Rotor event type (radioStarted, trackStarted, …). + :param track_id: Yandex track ID the event refers to. + :param total_played_seconds: Seconds played (trackFinished / skip only). + :return: True if the feedback POST succeeded, False when skipped. + """ + if not wave.session_id: + self.logger.debug( + "Skipping rotor feedback %s for %s: no active session", + event_type, + station_id, + ) + return False + return await self.client.rotor_session_feedback( + wave.session_id, + event_type, + track_id=track_id, + total_played_seconds=total_played_seconds, + batch_id=wave.batch_id, + ) + + async def _prefetch_rotor_session(self, station_key: str) -> None: + """Fire-and-forget: fetch the next batch for an active wave session. + + Called from ``on_played`` while a wave track starts playing, so by the + time Music Assistant's DSTM asks for more via ``get_similar_tracks``, + we already have Yandex-curated wave tracks sitting in + ``wave.prefetched`` ready to serve (no extra round-trip). + + No-op when the station has no active session yet (prefetch cannot + safely create one — that requires holding the lock across the + network call and would stall readers), or when the buffer already + has items (avoids burning rate limit). + + Three-phase lock discipline so the network round-trip does not + block browse / drain paths that share the lock: + + 1. Acquire, verify session + empty buffer, snapshot + ``session_id`` and ``last_track_id``, release. + 2. Call ``client.rotor_session_tracks`` **directly** (no + ``_fetch_rotor_session_batch``) — that helper mutates shared + state (session creation, batch_id write) and would race with + other callers now that we hold no lock. The raw client call + only reads the arguments we pass in. + 3. Re-acquire, verify the session hasn't been recycled and the + buffer is still empty, then ``extend``. + + :param station_key: Station key whose state to top up. + """ + wave = self._wave_states.get(station_key) + if wave is None: + return + + async with wave.lock: + if wave.session_id is None or wave.prefetched: + return + session_id = wave.session_id + cursor = wave.last_track_id + + if not cursor: + return # No anchor for the next batch yet; try again later. + + tracks, _ = await self.client.rotor_session_tracks(session_id, current_track_id=str(cursor)) + if not tracks: + return + + async with wave.lock: + # Another task could have restarted the session or filled the + # buffer while we were awaiting the network call; bail in both + # cases to avoid stale extends. + if wave.session_id != session_id or wave.prefetched: + return + wave.prefetched.extend(tracks) + + async def _fetch_rotor_session_batch( + self, wave: _WaveState, station_id: str + ) -> tuple[list[YandexTrack], str | None]: + """Fetch the next rotor-session batch for any station. + + On first call (wave.session_id is None), starts a new rotor session + and records session_id + batch_id on the wave state. On subsequent + calls, paginates via rotor_session_tracks using wave.last_track_id. + + If station_id carries a wave-mode suffix (e.g. "user:onyourwave#discover"), + the suffix maps to a preset in WAVE_MODE_PRESETS and its settings are + merged with wave.settings (wave.settings wins on key conflict). The + base station ID (before "#") is what actually goes to Yandex. + + :param wave: The _WaveState for this station (persists across calls). + :param station_id: Rotor station key (may include a "#preset" suffix). + :return: Tuple of (list of yandex tracks, batch_id or None). + """ + # Session-creation path: no session yet, or we have a session but no + # cursor yet (`tracks` with an empty queue returns a hard-to-debug + # empty batch — starting a fresh session is the same latency but + # actually yields tracks). + if wave.session_id is None or not wave.last_track_id: + base_station, preset_settings = _split_wave_mode(station_id) + merged = {**preset_settings, **wave.settings} + session_id, tracks, batch_id = await self.client.rotor_session_new( + base_station, settings=merged or None + ) + if session_id: + wave.session_id = session_id + else: + tracks, batch_id = await self.client.rotor_session_tracks( + wave.session_id, current_track_id=str(wave.last_track_id) + ) + if batch_id: + wave.batch_id = batch_id + return (tracks, batch_id) + async def _browse_waves( self, path: str, path_parts: list[str] ) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: @@ -1306,23 +1719,20 @@ async def _browse_wave_station( ) self.logger.debug( - "Browse wave station: station_id=%s path=%s last_track_id=%s", + "Browse wave station: station_id=%s path=%s last_track_id=%s session=%s", station_id, path, state.last_track_id, + state.session_id, ) - yandex_tracks, batch_id = await self.client.get_rotor_station_tracks( - station_id, queue=state.last_track_id - ) - if batch_id: - state.batch_id = batch_id + # Tagged stations (genre:*, mood:*, activity:*, epoch:*) accept the + # same /rotor/session/* endpoint as user:onyourwave / track:{id}, + # verified against the live Yandex API. Reuse the session helper so + # batch_id + session_id stay anchored across browse/play/feedback. + yandex_tracks, _ = await self._fetch_rotor_session_batch(state, station_id) if not state.radio_started_sent and yandex_tracks: - sent = await self.client.send_rotor_station_feedback( - station_id, - "radioStarted", - batch_id=batch_id, - ) + sent = await self._send_wave_feedback(state, station_id, "radioStarted") if sent: state.radio_started_sent = True @@ -1384,11 +1794,15 @@ async def _browse_wave_station( def _extract_wave_item_cover(item: dict[str, Any]) -> tuple[str | None, str | None]: """Extract cover URI and background color from a wave/mix item. + Accepts both camelCase (``compactImageUrl`` — what /landing-blocks/ + actually returns) and snake_case (``compact_image_url`` — retained + for safety if MarshalX ever normalises the payload). + :param item: Wave or mix item dict from the API. :return: (cover_uri, bg_color) tuple where bg_color is a hex string or None. """ agent_uri = item.get("agent", {}).get("cover", {}).get("uri", "") - cover_uri = agent_uri or item.get("compact_image_url") + cover_uri = agent_uri or item.get("compactImageUrl") or item.get("compact_image_url") bg_color = item.get("colors", {}).get("average") return cover_uri, bg_color @@ -1483,7 +1897,9 @@ async def _browse_wave_categories( items = wave_category.get("items", []) result: list[BrowseFolder] = [] for item in items: - station_id = item.get("station_id", "") + # API returns camelCase (`stationId`); keep snake_case as a + # safety net if the payload is ever normalised upstream. + station_id = item.get("stationId") or item.get("station_id") or "" title = item.get("title", "") if not station_id or not title: continue @@ -1882,23 +2298,24 @@ async def _get_my_wave_playlist_tracks(self, page: int) -> list[Track]: :param page: Page number (0 = first batch, 1+ = next batches via queue cursor). :return: List of Track objects for this page. """ - async with self._my_wave_lock: + wave = self._get_wave_state(ROTOR_STATION_MY_WAVE) + async with wave.lock: max_tracks_config = int( self.config.get_value(CONF_MY_WAVE_MAX_TRACKS) or 150 # type: ignore[arg-type] ) # Reset seen tracks on first page if page == 0: - self._my_wave_seen_track_ids = set() + wave.seen_track_ids = set() queue: str | int | None = None if page > 0: - queue = self._my_wave_playlist_next_cursor + queue = wave.playlist_next_cursor if not queue: return [] # Check if we've already reached the limit - if len(self._my_wave_seen_track_ids) >= max_tracks_config: + if len(wave.seen_track_ids) >= max_tracks_config: return [] tracks: list[Track] = [] @@ -1906,30 +2323,30 @@ async def _get_my_wave_playlist_tracks(self, page: int) -> list[Track]: # Fetch MY_WAVE_BATCH_SIZE Rotor API batches per page to reduce API round-trips for _ in range(MY_WAVE_BATCH_SIZE): - if len(self._my_wave_seen_track_ids) >= max_tracks_config: + if len(wave.seen_track_ids) >= max_tracks_config: break - 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: - sent = await self.client.send_rotor_station_feedback( - ROTOR_STATION_MY_WAVE, - "radioStarted", - batch_id=batch_id, + if queue is not None: + wave.last_track_id = str(queue) + yandex_tracks, _ = await self._fetch_rotor_session_batch( + wave, ROTOR_STATION_MY_WAVE + ) + if not wave.radio_started_sent and yandex_tracks: + sent = await self._send_wave_feedback( + wave, ROTOR_STATION_MY_WAVE, "radioStarted" ) if sent: - self._my_wave_radio_started_sent = True + wave.radio_started_sent = True if not yandex_tracks: break first_track_id_this_batch = None for yt in yandex_tracks: - if len(self._my_wave_seen_track_ids) >= max_tracks_config: + if len(wave.seen_track_ids) >= max_tracks_config: break - track = self._parse_my_wave_track(yt, self._my_wave_seen_track_ids) + track = self._parse_my_wave_track(yt, wave.seen_track_ids) if track is None: continue @@ -1946,7 +2363,7 @@ async def _get_my_wave_playlist_tracks(self, page: int) -> list[Track]: break # Store cursor for next page call (None clears pagination so next call returns []) - self._my_wave_playlist_next_cursor = next_cursor + wave.playlist_next_cursor = next_cursor return tracks async def _get_liked_tracks_playlist_tracks(self, page: int) -> list[Track]: @@ -2028,27 +2445,81 @@ async def get_album_tracks(self, prov_album_id: str) -> list[Track]: 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. + """Get similar tracks, preferring pre-fetched wave tracks when available. - Uses rotor station track:{id} so MA radio mode gets Yandex recommendations. + Split in two paths with different caching policies: + + - **Wave-drain path** (the seed carries a station suffix and + ``wave.prefetched`` is non-empty). Uncached by design: it mutates + state, a cache hit would replay the same drained tracks forever and + the prefetch buffer would never advance. + - **Fallback path** (plain track_id, no active wave, or empty buffer). + Creates a per-seed rotor session under ``track:{id}`` and is cached + for 3 hours — this is pure and safe to memoise. :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]: + track_id, station_key = _parse_radio_item_id(prov_track_id) + + if station_key: + drained = await self._drain_prefetched_wave_tracks(station_key, limit) + if drained: + return drained + + return await self._fetch_similar_tracks_for_seed(track_id, limit) + + async def _drain_prefetched_wave_tracks(self, station_key: str, limit: int) -> list[Track]: + """Pop up to ``limit`` prefetched tracks off the wave state. + + Runs under ``wave.lock`` so it doesn't race with + ``_prefetch_rotor_session`` which extends the same list under the + same lock. Returns an empty list when there's no active session or + nothing prefetched; callers then fall through to the cached fetch. + + This method is intentionally not cached — it mutates wave state. + """ + wave = self._wave_states.get(station_key) + if not (wave and wave.session_id and wave.prefetched): + return [] + async with wave.lock: + if not wave.prefetched: + return [] + drained_yt = wave.prefetched[:limit] + wave.prefetched = wave.prefetched[limit:] + tracks: list[Track] = [] + for yt in drained_yt: try: tracks.append(parse_track(self, yt)) except InvalidDataError as err: - self.logger.debug("Error parsing similar track: %s", err) + self.logger.debug("Error parsing prefetched wave track: %s", err) return tracks + @use_cache(3600 * 3) + async def _fetch_similar_tracks_for_seed(self, track_id: str, limit: int) -> list[Track]: + """Create a one-off rotor session for ``track:{id}`` and return up to ``limit`` tracks. + + Stateless by design: similar-tracks results don't participate in + playback feedback or prefetch, so there is no need to keep a + ``_WaveState`` entry around. Going through ``_fetch_rotor_session_batch`` + would create one per unique seed and grow ``_wave_states`` without + bound under normal DSTM usage; call ``rotor_session_new`` directly + instead. + + Pure function of ``track_id`` / ``limit``, hence safe to memoise + via ``@use_cache``. + """ + _, yandex_tracks, _ = await self.client.rotor_session_new(f"track:{track_id}") + similar_tracks: list[Track] = [] + for yt in yandex_tracks[:limit]: + try: + similar_tracks.append(parse_track(self, yt)) + except InvalidDataError as err: + self.logger.debug("Error parsing similar track: %s", err) + return similar_tracks + @use_cache(3600 * 3) async def get_similar_artists(self, prov_artist_id: str, limit: int = 25) -> list[Artist]: """Get artists similar to the given one via Yandex artists/similar endpoint. @@ -2125,6 +2596,11 @@ async def recommendations(self) -> list[RecommendationFolder]: async def _get_my_wave_recommendations(self) -> RecommendationFolder | None: """Get My Wave recommendation folder with personalized tracks. + Shares the same `_WaveState(ROTOR_STATION_MY_WAVE)` with browse and + virtual-playlist flows, so session_id + batch_id established here + carry into `on_played`/`on_streamed` feedback even when the user + starts playback from this discovery card. + :return: RecommendationFolder with My Wave tracks, or None if empty. """ max_tracks_config = int( @@ -2132,35 +2608,46 @@ async def _get_my_wave_recommendations(self) -> RecommendationFolder | None: ) batch_size_config = MY_WAVE_BATCH_SIZE + wave = self._get_wave_state(ROTOR_STATION_MY_WAVE) + # Local dedup so the recommendations card stays independent from the + # browse/virtual-playlist dedup set (which may be larger and stale). + # Only session_id + batch_id + last_track_id are shared with `wave`. seen_track_ids: set[str] = set() items: list[Track] = [] - queue: str | int | None = None - - for _ in range(batch_size_config): - if len(seen_track_ids) >= max_tracks_config: - break - - yandex_tracks, _ = await self.client.get_my_wave_tracks(queue=queue) - if not yandex_tracks: - break - first_track_id_this_batch = None - for yt in yandex_tracks: + # Hold the wave lock across the whole fetch chain — we mutate shared + # session_id/batch_id/last_track_id via _fetch_rotor_session_batch, + # and other call sites (browse, virtual-playlist) guard the same + # state with this lock. Concurrent calls without the lock would + # interleave cursor updates and leave the session inconsistent. + async with wave.lock: + for _ in range(batch_size_config): if len(seen_track_ids) >= max_tracks_config: break - track = self._parse_my_wave_track(yt, seen_ids=seen_track_ids) - if track is None: - continue + yandex_tracks, _ = await self._fetch_rotor_session_batch( + wave, ROTOR_STATION_MY_WAVE + ) + if not yandex_tracks: + break - items.append(track) - track_id = track.item_id.split(RADIO_TRACK_ID_SEP, 1)[0] - if first_track_id_this_batch is None: - first_track_id_this_batch = track_id + first_track_id_this_batch: str | None = None + for yt in yandex_tracks: + if len(seen_track_ids) >= max_tracks_config: + break - queue = first_track_id_this_batch - if not queue: - break + track = self._parse_my_wave_track(yt, seen_ids=seen_track_ids) + if track is None: + continue + + items.append(track) + track_id = track.item_id.split(RADIO_TRACK_ID_SEP, 1)[0] + if first_track_id_this_batch is None: + first_track_id_this_batch = track_id + + if first_track_id_this_batch is None: + break + wave.last_track_id = first_track_id_this_batch if not items: return None @@ -2191,7 +2678,10 @@ async def _get_feed_recommendations(self) -> RecommendationFolder | None: for gen_playlist in feed.generated_playlists: if gen_playlist.data and gen_playlist.ready: try: - items.append(parse_playlist(self, gen_playlist.data)) + # Mark feed-generated playlists (Playlist of the Day, DejaVu, + # Premiere, Missed Likes) as dynamic — Yandex regenerates them + # on a schedule so MA must not long-cache the track list. + items.append(parse_playlist(self, gen_playlist.data, is_dynamic=True)) except InvalidDataError as err: self.logger.debug("Error parsing feed playlist: %s", err) if not items: @@ -2689,16 +3179,26 @@ async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: async def library_add(self, item: MediaItemType) -> bool: """Add item to library. + For tracks carrying a wave station context in the item_id (e.g. when + the user adds a My Wave track to favourites during playback), also + fires a rotor ``like`` feedback on the active session so the wave + algorithm biases toward similar tracks immediately. + :param item: The media item to add. :return: True if successful. """ 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) + track_id, station_key = _parse_radio_item_id(prov_item_id) if item.media_type == MediaType.TRACK: - return await self.client.like_track(track_id) + ok = await self.client.like_track(track_id) + if ok and station_key: + wave = self._wave_states.get(station_key) + if wave and wave.session_id: + await self._send_wave_feedback(wave, station_key, "like", track_id=track_id) + return ok if item.media_type in (MediaType.ALBUM, MediaType.PODCAST, MediaType.AUDIOBOOK): return await self.client.like_album(prov_item_id) if item.media_type == MediaType.ARTIST: @@ -2934,11 +3434,34 @@ async def _stream_audiobook_chapters( async def get_rotor_station_tracks( self, station_id: str, queue: str | int | None = None ) -> tuple[list[Any], str | None]: - """Fetch tracks from a rotor station (My Wave, similar, etc.). - - Wrapper around client.get_rotor_station_tracks for use by ynison plugin. - """ - return await self.client.get_rotor_station_tracks(station_id, queue=queue) + """Fetch tracks from a rotor station using the session API. + + Public surface — pinned by the ynison plugin + (`YandexMusicProviderLike.get_rotor_station_tracks`). The + ``(tracks, batch_id)`` return contract is kept for that caller even + though batch_id is now a session-scoped identifier. + + Routes to ``_fetch_rotor_session_batch`` so the wave session state + (`session_id`, seen tracks, prefetch) is shared with our own Browse / + on_played / on_streamed flows. ``queue`` is the most recently played + track ID the external caller observed — we record it as the + pagination cursor before calling through. + + :param station_id: Rotor station ID (e.g. "user:onyourwave", + "genre:rock", "mood:calm", "track:1234"). + :param queue: Last-played track ID for pagination. Ignored on the + very first call (no session yet) but still recorded. + :return: Tuple of (list of yandex tracks, batch_id or None). + """ + wave = self._get_wave_state(station_id) + # Cursor update + batch fetch run under the station's lock, matching + # the discipline in browse / recommendations / prefetch. Without it, + # ynison replenish racing with a concurrent MA browse could interleave + # last_track_id writes and leave session_id / batch_id out of sync. + async with wave.lock: + if queue is not None: + wave.last_track_id = str(queue) + return await self._fetch_rotor_session_batch(wave, station_id) def get_quality(self) -> str: """Return the configured audio quality tier (e.g. 'balanced', 'superb').""" @@ -3001,166 +3524,58 @@ async def on_played( media_item: MediaItemType, is_playing: bool = False, ) -> None: - """Report playback for rotor feedback when the track is from My Wave. + """Report periodic playback updates. - Sends trackStarted when the track is currently playing (is_playing=True). - trackFinished/skip are sent from on_streamed to use accurate seconds_streamed. + - Audiobooks: persist chapter progress via play_audio so Yandex's + own clients resume at the right point. + - Wave tracks: send rotor ``trackStarted`` while actively playing and + kick off a background prefetch so DSTM refill serves wave-curated + tracks with no extra round-trip. DSTM itself is the user's toggle — + the provider does not flip it. - Also auto-enables "Don't stop the music" for any queue playing a radio track - so that MA refills the queue via get_similar_tracks when < 5 tracks remain. - - For audiobooks, persists playback position to Yandex via play_audio so the - position is visible across Yandex's other clients. + Generic track history reporting is not attempted here — the only + known channel Yandex writes into ``/handlers/music-history`` is a + long-lived Ynison WebSocket session, which lives in the sibling + yandex_ynison plugin. Regular tracks played through MA are therefore + invisible to Listening History unless that plugin is also active. """ if media_type == MediaType.AUDIOBOOK: await self._report_audiobook_progress(prov_item_id, position) return - # Radio feedback always enabled if media_type != MediaType.TRACK: return - track_id, station_id = _parse_radio_item_id(prov_item_id) - if not station_id: - return - # Auto-enable "Don't stop the music" on every on_played call for radio tracks. - # Calling on every invocation (not just is_playing=True) ensures it fires even - # for short tracks that finish before the 30-second periodic callback. - self._ensure_dont_stop_the_music(prov_item_id) - if is_playing: - if station_id == ROTOR_STATION_MY_WAVE: - batch_id = self._my_wave_batch_id - else: - state = self._wave_states.get(station_id) - batch_id = state.batch_id if state else None - await self.client.send_rotor_station_feedback( - station_id, - "trackStarted", - track_id=track_id, - batch_id=batch_id, - ) - # Remove duplicate call that was under is_playing guard. - # _ensure_dont_stop_the_music is now called unconditionally above. - - def _ensure_dont_stop_the_music(self, prov_item_id: str) -> None: - """Enable 'Don't stop the music' on queues playing this specific radio item. - - Iterates all queues and enables the setting on queues whose current track - mapping matches this exact composite item_id (track_id@station_id) for this - provider instance. - - Also sets queue.radio_source directly to the current track because - enqueued_media_items is empty for BrowseFolder-initiated playback, which - normally prevents MA's auto-fill from triggering. Setting radio_source - directly bypasses that gap so _fill_radio_tracks runs when < 5 tracks remain. - """ - for queue in self.mass.player_queues: - current = queue.current_item - if current is None or current.media_item is None: - continue - item = current.media_item - # Match by provider instance and exact composite item_id - for mapping in getattr(item, "provider_mappings", []): - if ( - mapping.provider_instance == self.instance_id - and mapping.item_id == prov_item_id - ): - # Set radio_source directly so MA's fill mechanism works even when - # the queue was started from a BrowseFolder (enqueued_media_items empty). - if not queue.radio_source and isinstance(item, Track): - queue.radio_source = [item] - if not queue.dont_stop_the_music_enabled: - try: - self.mass.player_queues.set_dont_stop_the_music( - queue.queue_id, dont_stop_the_music_enabled=True - ) - self.logger.info( - "Auto-enabled 'Don't stop the music' for queue %s (radio station)", - queue.display_name, - ) - except Exception as err: - self.logger.debug( - "Could not enable 'Don't stop the music' for queue %s: %s", - queue.display_name, - err, - ) - break - - def _ensure_dont_stop_the_music_for_queue(self, queue_id: str | None) -> None: - """Enable 'Don't stop the music' for a specific queue by ID. - - Faster variant of _ensure_dont_stop_the_music used from on_streamed where - queue_id is available directly, avoiding iteration over all queues. - """ - if not queue_id: - return - queue = self.mass.player_queues.get(queue_id) - if queue is None: - return - current = queue.current_item - if current is None or current.media_item is None: - return - item = current.media_item - for mapping in getattr(item, "provider_mappings", []): - if ( - mapping.provider_instance == self.instance_id - and RADIO_TRACK_ID_SEP in mapping.item_id - ): - if not queue.radio_source and isinstance(item, Track): - queue.radio_source = [item] - if not queue.dont_stop_the_music_enabled: - try: - self.mass.player_queues.set_dont_stop_the_music( - queue_id, dont_stop_the_music_enabled=True - ) - self.logger.info( - "Auto-enabled 'Don't stop the music' for queue %s (radio)", - queue.display_name, - ) - except Exception as err: - self.logger.debug( - "Could not enable 'Don't stop the music' for queue %s: %s", - queue.display_name, - err, - ) - break + _, station_id = _parse_radio_item_id(prov_item_id) + if station_id and is_playing: + track_id, _ = _parse_radio_item_id(prov_item_id) + wave = self._wave_states.get(station_id) or self._get_wave_state(station_id) + await self._send_wave_feedback(wave, station_id, "trackStarted", track_id=track_id) + self.mass.create_task(self._prefetch_rotor_session(station_id)) async def on_streamed(self, streamdetails: StreamDetails) -> None: - """Report stream completion for My Wave rotor feedback and audiobooks. + """Report stream completion to Yandex. - For radio: sends trackFinished or skip with actual seconds_streamed so - Yandex can improve recommendations. - - For audiobooks: sends a final play_audio with the absolute stream position - so the last listening point is preserved in Yandex. + - Audiobooks: a final ``play_audio`` with the absolute stream + position so the last listening point is preserved across Yandex + clients. Cleans up session state even when ``data`` was stripped. + - Wave tracks (composite item_id carries a station suffix): a rotor + ``trackFinished`` or ``skip`` event with the actual seconds streamed + so Yandex can improve recommendations. """ data = streamdetails.data if isinstance(streamdetails.data, dict) else None if streamdetails.media_type == MediaType.AUDIOBOOK: - # Always let the audiobook branch run so session state - # (play_id + chapter cache) is cleaned up even when ``data`` was - # stripped. ``_report_audiobook_final`` no-ops the play_audio call - # when chapter data is absent. await self._report_audiobook_final(streamdetails, data or {}) return - # Radio feedback always enabled + if streamdetails.media_type != MediaType.TRACK: + return track_id, station_id = _parse_radio_item_id(streamdetails.item_id) if not station_id: return - # Also ensure Don't stop the music is active — on_streamed fires even for - # very short tracks and we have queue_id here directly. - self._ensure_dont_stop_the_music_for_queue(streamdetails.queue_id) seconds = int(streamdetails.seconds_streamed or 0) - duration = streamdetails.duration or 0 + duration = int(streamdetails.duration or 0) feedback_type = "trackFinished" if duration and seconds >= max(0, duration - 10) else "skip" - if station_id == ROTOR_STATION_MY_WAVE: - batch_id = self._my_wave_batch_id - else: - state = self._wave_states.get(station_id) - batch_id = state.batch_id if state else None - await self.client.send_rotor_station_feedback( - station_id, - feedback_type, - track_id=track_id, - total_played_seconds=seconds, - batch_id=batch_id, + wave = self._wave_states.get(station_id) or self._get_wave_state(station_id) + await self._send_wave_feedback( + wave, station_id, feedback_type, track_id=track_id, total_played_seconds=seconds ) def _audiobook_progress_point( diff --git a/music_assistant/providers/yandex_music/streaming.py b/music_assistant/providers/yandex_music/streaming.py index e497f7222c..2effd6ac78 100644 --- a/music_assistant/providers/yandex_music/streaming.py +++ b/music_assistant/providers/yandex_music/streaming.py @@ -244,8 +244,10 @@ def _select_best_quality( reverse=True, ) - # Superb: Prefer FLAC (backward compatibility with "lossless") - if preferred_normalized == QUALITY_SUPERB or "lossless" in preferred_normalized: + # Superb: Prefer FLAC. The legacy "lossless" alias still maps to Superb, + # but we use an exact-match set so a stray value like "lossless_foo" + # doesn't sneak in. + if preferred_normalized in {QUALITY_SUPERB, "lossless"}: for codec in ("flac-mp4", "flac"): for info in sorted_infos: if info.codec and info.codec.lower() == codec: diff --git a/tests/providers/yandex_music/test_api_client.py b/tests/providers/yandex_music/test_api_client.py index 861fb3af56..e260f24b4c 100644 --- a/tests/providers/yandex_music/test_api_client.py +++ b/tests/providers/yandex_music/test_api_client.py @@ -6,6 +6,8 @@ import hashlib import hmac import re +from collections.abc import Mapping +from typing import Any from unittest import mock import pytest @@ -125,47 +127,6 @@ async def test_get_tracks_retry_on_network_error_both_fail() -> None: 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_track_started() -> None: """send_rotor_station_feedback delegates trackStarted to public helper.""" client, underlying = _make_client() @@ -245,6 +206,220 @@ async def test_send_rotor_station_feedback_skip() -> None: assert kwargs["total_played_seconds"] == 10.0 +# -- rotor session API (/rotor/session/*) -------------------------------------- + + +def _patch_rotor_session_request(client: YandexMusicClient, response: object) -> mock.AsyncMock: + """Install a mocked _rotor_session_request on the client and return the mock.""" + req_mock = mock.AsyncMock(return_value=response) + client._rotor_session_request = req_mock # type: ignore[method-assign] + return req_mock + + +def _patch_get_tracks(client: YandexMusicClient, tracks: list[object]) -> mock.AsyncMock: + """Install a mocked get_tracks on the client and return the mock.""" + tracks_mock = mock.AsyncMock(return_value=tracks) + client.get_tracks = tracks_mock # type: ignore[method-assign] + return tracks_mock + + +def _call_args(m: mock.AsyncMock) -> tuple[tuple[Any, ...], Mapping[str, Any]]: + """Return (args, kwargs) from the most recent await on ``m``. + + Raises AssertionError when the mock was never awaited — intentionally + surfacing missed setup rather than letting mypy's `None is not iterable` + propagate into destructuring sites. + """ + call = m.await_args + assert call is not None, "mock was not awaited" + return call.args, call.kwargs + + +async def test_rotor_session_new_posts_expected_body_and_returns_session() -> None: + """rotor_session_new POSTs to /rotor/session/new with wave-model flags and parses result.""" + client, underlying = _make_client() + del underlying # unused; session API bypasses MarshalX client + response = { + "radioSessionId": "sess_abc", + "batchId": "batch_1", + "sequence": [{"track": {"id": 100, "title": "T"}, "liked": False}], + } + req_mock = _patch_rotor_session_request(client, response) + _patch_get_tracks(client, [type("T", (), {"id": 100})()]) + + session_id, tracks, batch_id = await client.rotor_session_new("user:onyourwave") + + req_mock.assert_awaited_once() + args, _ = _call_args(req_mock) + path, body = args[0], args[1] + assert path == "new" + assert body["seeds"] == ["user:onyourwave"] + assert body["queue"] == [] + assert body["includeTracksInResponse"] is True + assert body["includeWaveModel"] is True + assert body["interactive"] is True + assert session_id == "sess_abc" + assert batch_id == "batch_1" + assert len(tracks) == 1 + assert tracks[0].id == 100 + + +async def test_rotor_session_new_appends_settings_as_seeds() -> None: + """rotor_session_new appends settingDiversity / settingMoodEnergy / settingLanguage seeds.""" + client, underlying = _make_client() + del underlying + req_mock = _patch_rotor_session_request( + client, {"radioSessionId": "s1", "batchId": "b1", "sequence": []} + ) + _patch_get_tracks(client, []) + + await client.rotor_session_new( + "user:onyourwave", + settings={"diversity": "discover", "moodEnergy": "calm", "language": "russian"}, + ) + + args, _ = _call_args(req_mock) + body = args[1] + assert body["seeds"] == [ + "user:onyourwave", + "settingDiversity:discover", + "settingMoodEnergy:calm", + "settingLanguage:russian", + ] + + +async def test_rotor_session_new_returns_empty_on_missing_session_id() -> None: + """If the response lacks radioSessionId the call returns (None, [], None) without raising.""" + client, underlying = _make_client() + del underlying + _patch_rotor_session_request(client, None) + + session_id, tracks, batch_id = await client.rotor_session_new("user:onyourwave") + + assert session_id is None + assert tracks == [] + assert batch_id is None + + +async def test_rotor_session_tracks_posts_current_track_queue() -> None: + """rotor_session_tracks POSTs {queue: [current_track_id]} and returns tracks + batch_id.""" + client, underlying = _make_client() + del underlying + response = { + "batchId": "batch_2", + "sequence": [{"track": {"id": 200}}, {"track": {"id": 201}}], + } + req_mock = _patch_rotor_session_request(client, response) + _patch_get_tracks(client, [type("T", (), {"id": 200})(), type("T", (), {"id": 201})()]) + + tracks, batch_id = await client.rotor_session_tracks("sess_abc", current_track_id="100") + + args, _ = _call_args(req_mock) + path, body = args[0], args[1] + assert path == "sess_abc/tracks" + assert body == {"queue": ["100"]} + assert batch_id == "batch_2" + assert [t.id for t in tracks] == [200, 201] + + +async def test_rotor_session_feedback_radio_started_sends_from_field() -> None: + """RadioStarted event uses event.from=track_id (not trackId).""" + client, underlying = _make_client() + del underlying + req_mock = _patch_rotor_session_request(client, {"result": "ok"}) + + result = await client.rotor_session_feedback( + "sess_abc", "radioStarted", track_id="100", batch_id="batch_1" + ) + + assert result is True + args, _ = _call_args(req_mock) + path, body = args[0], args[1] + assert path == "sess_abc/feedback" + assert body["batchId"] == "batch_1" + event = body["event"] + assert event["type"] == "radioStarted" + assert event["from"] == "100" + assert "trackId" not in event + assert "timestamp" in event + assert re.match(r"^\d{4}-\d{2}-\d{2}T", event["timestamp"]) + + +async def test_rotor_session_feedback_track_started_sends_track_id() -> None: + """TrackStarted event uses event.trackId (not from).""" + client, underlying = _make_client() + del underlying + req_mock = _patch_rotor_session_request(client, {"result": "ok"}) + + await client.rotor_session_feedback( + "sess_abc", "trackStarted", track_id="100", batch_id="batch_1" + ) + + args, _ = _call_args(req_mock) + body = args[1] + event = body["event"] + assert event["type"] == "trackStarted" + assert event["trackId"] == "100" + assert "from" not in event + assert "totalPlayedSeconds" not in event + + +async def test_rotor_session_feedback_track_finished_includes_seconds() -> None: + """TrackFinished event includes totalPlayedSeconds.""" + client, underlying = _make_client() + del underlying + req_mock = _patch_rotor_session_request(client, {"result": "ok"}) + + await client.rotor_session_feedback( + "sess_abc", + "trackFinished", + track_id="100", + total_played_seconds=42, + batch_id="batch_1", + ) + + args, _ = _call_args(req_mock) + body = args[1] + event = body["event"] + assert event["type"] == "trackFinished" + assert event["trackId"] == "100" + assert event["totalPlayedSeconds"] == 42 + + +async def test_rotor_session_feedback_skip_includes_seconds() -> None: + """Skip event includes totalPlayedSeconds and trackId.""" + client, underlying = _make_client() + del underlying + req_mock = _patch_rotor_session_request(client, {"result": "ok"}) + + await client.rotor_session_feedback( + "sess_abc", "skip", track_id="100", total_played_seconds=10, batch_id="batch_1" + ) + + args, _ = _call_args(req_mock) + body = args[1] + event = body["event"] + assert event["type"] == "skip" + assert event["trackId"] == "100" + assert event["totalPlayedSeconds"] == 10 + + +async def test_rotor_session_feedback_like_uses_trackid_without_seconds() -> None: + """like/dislike events use trackId but do NOT include totalPlayedSeconds.""" + client, underlying = _make_client() + del underlying + req_mock = _patch_rotor_session_request(client, {"result": "ok"}) + + await client.rotor_session_feedback("sess_abc", "like", track_id="100", batch_id="batch_1") + + args, _ = _call_args(req_mock) + body = args[1] + event = body["event"] + assert event["type"] == "like" + assert event["trackId"] == "100" + assert "totalPlayedSeconds" not in event + + # -- get_similar_artists ------------------------------------------------------ diff --git a/tests/providers/yandex_music/test_browse_pins_history.py b/tests/providers/yandex_music/test_browse_pins_history.py index 4e6d4ffdc9..381e35a092 100644 --- a/tests/providers/yandex_music/test_browse_pins_history.py +++ b/tests/providers/yandex_music/test_browse_pins_history.py @@ -133,30 +133,42 @@ async def test_browse_history_returns_empty_when_no_history( assert result == [] -@pytest.mark.asyncio -async def test_browse_history_flattens_and_deduplicates(provider_mock: Mock) -> None: - """_browse_history flattens days→groups→tracks and de-dupes by track id.""" +def _hist_item(track_id: int) -> object: + """Build a history entry the way MarshalX actually returns it. - def make_track(track_id: int) -> object: - full = type("Track", (), {"id": track_id, "title": f"T{track_id}"})() - data = type("D", (), {"full_model": full})() - return type("HistItem", (), {"type": "track", "data": data})() + `data.item_id` is a dict containing track_id, album_id, etc.; `full_model` + is not populated by the live API. Callers batch-resolve via get_tracks. + """ + data = type("D", (), {"item_id": {"track_id": str(track_id)}, "full_model": None})() + return type("HistItem", (), {"type": "track", "data": data})() - group1 = type("Group", (), {"tracks": [make_track(1), make_track(2)]})() - group2 = type("Group", (), {"tracks": [make_track(2), make_track(3)]})() # dup id=2 + +@pytest.mark.asyncio +async def test_browse_history_flattens_and_deduplicates(provider_mock: Mock) -> None: + """_browse_history flattens days→groups→tracks, de-dupes by track id, preserves order.""" + group1 = type("Group", (), {"tracks": [_hist_item(1), _hist_item(2)]})() + group2 = type("Group", (), {"tracks": [_hist_item(2), _hist_item(3)]})() # dup id=2 tab1 = type("Tab", (), {"items": [group1]})() tab2 = type("Tab", (), {"items": [group2]})() history = type("MusicHistory", (), {"history_tabs": [tab1, tab2]})() provider_mock.client.get_music_history = AsyncMock(return_value=history) - parsed = [Mock(spec=Track), Mock(spec=Track), Mock(spec=Track)] + # Batch-hydrate returns the yandex tracks in their own order; the provider + # re-orders them to match the de-duplicated id list. + yt1 = type("Yt", (), {"id": 1})() + yt2 = type("Yt", (), {"id": 2})() + yt3 = type("Yt", (), {"id": 3})() + provider_mock.client.get_tracks = AsyncMock(return_value=[yt3, yt1, yt2]) + + parsed = [Mock(spec=Track, name="p1"), Mock(spec=Track, name="p2"), Mock(spec=Track, name="p3")] with patch( "music_assistant.providers.yandex_music.provider.parse_track", side_effect=parsed, ): result = await YandexMusicProvider._browse_history(provider_mock) - assert result == parsed # 3 unique tracks across two days + provider_mock.client.get_tracks.assert_awaited_once_with(["1", "2", "3"]) + assert result == parsed @pytest.mark.asyncio @@ -167,32 +179,35 @@ async def test_browse_history_skips_non_track_items(provider_mock: Mock) -> None (), { "type": "album", - "data": type("D", (), {"full_model": type("X", (), {"id": 99})()})(), + "data": type("D", (), {"item_id": {"track_id": "99"}})(), }, )() group = type("Group", (), {"tracks": [album_item]})() tab = type("Tab", (), {"items": [group]})() history = type("MusicHistory", (), {"history_tabs": [tab]})() provider_mock.client.get_music_history = AsyncMock(return_value=history) + provider_mock.client.get_tracks = AsyncMock() with patch("music_assistant.providers.yandex_music.provider.parse_track") as parse_track: result = await YandexMusicProvider._browse_history(provider_mock) parse_track.assert_not_called() assert result == [] + # No IDs collected → no hydration round-trip at all + provider_mock.client.get_tracks.assert_not_awaited() @pytest.mark.asyncio async def test_browse_history_skips_invalid_track(provider_mock: Mock) -> None: """_browse_history drops tracks where parse_track raises InvalidDataError.""" - full = type("Track", (), {"id": 1, "title": "T1"})() - data = type("D", (), {"full_model": full})() - item = type("HistItem", (), {"type": "track", "data": data})() - group = type("Group", (), {"tracks": [item]})() + group = type("Group", (), {"tracks": [_hist_item(1)]})() tab = type("Tab", (), {"items": [group]})() history = type("MusicHistory", (), {"history_tabs": [tab]})() provider_mock.client.get_music_history = AsyncMock(return_value=history) + yt1 = type("Yt", (), {"id": 1})() + provider_mock.client.get_tracks = AsyncMock(return_value=[yt1]) + with patch( "music_assistant.providers.yandex_music.provider.parse_track", side_effect=InvalidDataError("nope"), diff --git a/tests/providers/yandex_music/test_my_wave.py b/tests/providers/yandex_music/test_my_wave.py index 57af434de0..7101d42be3 100644 --- a/tests/providers/yandex_music/test_my_wave.py +++ b/tests/providers/yandex_music/test_my_wave.py @@ -2,11 +2,33 @@ from __future__ import annotations +import json +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from music_assistant_models.enums import MediaType +from music_assistant_models.errors import InvalidDataError +from music_assistant_models.media_items import ProviderMapping +from music_assistant_models.media_items import Track as MATrack + +from music_assistant.providers.yandex_music import ( + _delete_wave_preset_action, + _save_wave_preset_action, +) 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 +from music_assistant.providers.yandex_music.parsers import parse_playlist +from music_assistant.providers.yandex_music.provider import ( + YandexMusicProvider, + _parse_radio_item_id, + _WaveState, +) + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ConfigValueType def test_parse_radio_item_id_plain_track_id() -> None: @@ -22,3 +44,646 @@ def test_parse_radio_item_id_composite() -> None: ROTOR_STATION_MY_WAVE, ) assert _parse_radio_item_id("99@user:custom") == ("99", "user:custom") + + +def test_wave_state_has_session_fields() -> None: + """_WaveState exposes session_id, playlist_next_cursor, prefetched, settings.""" + state = _WaveState() + # Session-based rotor API identifiers + assert state.session_id is None + # Legacy stations-based identifier retained during migration + assert state.batch_id is None + # Pagination cursor for virtual playlist pages + assert state.playlist_next_cursor is None + # Prefetch buffer for future-batch tracks + assert state.prefetched == [] + # Persistent station settings (diversity/moodEnergy/language) + assert state.settings == {} + # Once-per-session flag + assert state.radio_started_sent is False + + +def test_wave_state_is_per_instance_isolated() -> None: + """Each _WaveState has its own mutable containers (no shared class state).""" + a, b = _WaveState(), _WaveState() + a.seen_track_ids.add("1") + a.prefetched.append("x") + a.settings["diversity"] = "discover" + assert b.seen_track_ids == set() + assert b.prefetched == [] + assert b.settings == {} + + +# -- _fetch_rotor_session_batch (session-API helper) -------------------------- + + +@pytest.mark.asyncio +async def test_fetch_rotor_session_batch_starts_session_on_first_call() -> None: + """First call creates a rotor session and records session_id + batch_id on wave state.""" + provider = Mock(spec=YandexMusicProvider) + provider.client = AsyncMock() + provider.client.rotor_session_new = AsyncMock( + return_value=("sess_1", ["track1", "track2"], "batch_a") + ) + provider.client.rotor_session_tracks = AsyncMock() + wave = _WaveState() + + tracks, batch_id = await YandexMusicProvider._fetch_rotor_session_batch( + provider, wave, ROTOR_STATION_MY_WAVE + ) + + provider.client.rotor_session_new.assert_awaited_once_with(ROTOR_STATION_MY_WAVE, settings=None) + provider.client.rotor_session_tracks.assert_not_awaited() + assert wave.session_id == "sess_1" + assert wave.batch_id == "batch_a" + assert tracks == ["track1", "track2"] + assert batch_id == "batch_a" + + +@pytest.mark.asyncio +async def test_fetch_rotor_session_batch_passes_wave_settings_to_session_new() -> None: + """Session creation forwards wave.settings (diversity/moodEnergy/language) as seeds.""" + provider = Mock(spec=YandexMusicProvider) + provider.client = AsyncMock() + provider.client.rotor_session_new = AsyncMock(return_value=("s", [], "b")) + wave = _WaveState() + wave.settings = {"diversity": "discover", "moodEnergy": "calm"} + + await YandexMusicProvider._fetch_rotor_session_batch(provider, wave, ROTOR_STATION_MY_WAVE) + + _, kwargs = provider.client.rotor_session_new.await_args + assert kwargs["settings"] == {"diversity": "discover", "moodEnergy": "calm"} + + +@pytest.mark.asyncio +async def test_fetch_rotor_session_batch_paginates_via_session_tracks_after_first_call() -> None: + """Once session_id is set, subsequent calls use rotor_session_tracks with last_track_id.""" + provider = Mock(spec=YandexMusicProvider) + provider.client = AsyncMock() + provider.client.rotor_session_new = AsyncMock() + provider.client.rotor_session_tracks = AsyncMock(return_value=(["t3"], "batch_b")) + wave = _WaveState() + wave.session_id = "sess_1" + wave.last_track_id = "42" + + tracks, _batch_id = await YandexMusicProvider._fetch_rotor_session_batch( + provider, wave, ROTOR_STATION_MY_WAVE + ) + + provider.client.rotor_session_new.assert_not_awaited() + provider.client.rotor_session_tracks.assert_awaited_once_with("sess_1", current_track_id="42") + assert wave.batch_id == "batch_b" + assert tracks == ["t3"] + + +@pytest.mark.asyncio +async def test_fetch_rotor_session_batch_returns_empty_when_session_new_fails() -> None: + """When session creation returns None session_id, wave is not mutated and result is empty.""" + provider = Mock(spec=YandexMusicProvider) + provider.client = AsyncMock() + provider.client.rotor_session_new = AsyncMock(return_value=(None, [], None)) + wave = _WaveState() + + tracks, batch_id = await YandexMusicProvider._fetch_rotor_session_batch( + provider, wave, ROTOR_STATION_MY_WAVE + ) + + assert wave.session_id is None + assert tracks == [] + assert batch_id is None + + +@pytest.mark.asyncio +async def test_fetch_rotor_session_batch_works_with_track_seed_station() -> None: + """get_similar_tracks uses station 'track:{id}' — same session machinery.""" + provider = Mock(spec=YandexMusicProvider) + provider.client = AsyncMock() + provider.client.rotor_session_new = AsyncMock(return_value=("s", ["t"], "b")) + wave = _WaveState() + + await YandexMusicProvider._fetch_rotor_session_batch(provider, wave, "track:9999") + + provider.client.rotor_session_new.assert_awaited_once_with("track:9999", settings=None) + assert wave.session_id == "s" + + +# -- ynison compatibility wrapper --------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_rotor_station_tracks_wrapper_delegates_to_session_batch() -> None: + """Ynison-facing wrapper routes through _fetch_rotor_session_batch. + + This keeps ynison on the session API (long-lived radioSessionId, shared + wave state, prefetch) without any code change on its side — the + ``(tracks, batch_id)`` shape stays the same. + """ + wave = _WaveState() + provider = Mock(spec=YandexMusicProvider) + provider._get_wave_state = Mock(return_value=wave) + provider._fetch_rotor_session_batch = AsyncMock(return_value=(["t1", "t2"], "batch_1")) + + tracks, batch_id = await YandexMusicProvider.get_rotor_station_tracks( + provider, "genre:rock", queue=None + ) + + provider._get_wave_state.assert_called_once_with("genre:rock") + provider._fetch_rotor_session_batch.assert_awaited_once_with(wave, "genre:rock") + assert tracks == ["t1", "t2"] + assert batch_id == "batch_1" + + +@pytest.mark.asyncio +async def test_get_rotor_station_tracks_wrapper_records_queue_as_cursor() -> None: + """Ynison's queue= arg becomes wave.last_track_id so the next call paginates.""" + wave = _WaveState() + provider = Mock(spec=YandexMusicProvider) + provider._get_wave_state = Mock(return_value=wave) + provider._fetch_rotor_session_batch = AsyncMock(return_value=([], None)) + + await YandexMusicProvider.get_rotor_station_tracks(provider, "mood:calm", queue="42") + + assert wave.last_track_id == "42" + provider._fetch_rotor_session_batch.assert_awaited_once_with(wave, "mood:calm") + + +# -- wave-mode preset routing ------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fetch_rotor_session_batch_resolves_wave_mode_preset_settings() -> None: + """A station key like 'user:onyourwave#discover' translates to settingDiversity=discover.""" + provider = Mock(spec=YandexMusicProvider) + provider.client = AsyncMock() + provider.client.rotor_session_new = AsyncMock(return_value=("sess_1", [], "batch_a")) + wave = _WaveState() + + await YandexMusicProvider._fetch_rotor_session_batch( + provider, wave, f"{ROTOR_STATION_MY_WAVE}#discover" + ) + + provider.client.rotor_session_new.assert_awaited_once_with( + ROTOR_STATION_MY_WAVE, settings={"diversity": "discover"} + ) + assert wave.session_id == "sess_1" + + +@pytest.mark.asyncio +async def test_fetch_rotor_session_batch_preset_merges_with_explicit_wave_settings() -> None: + """Explicit wave.settings overrides preset settings on the same key.""" + provider = Mock(spec=YandexMusicProvider) + provider.client = AsyncMock() + provider.client.rotor_session_new = AsyncMock(return_value=("s", [], "b")) + wave = _WaveState() + wave.settings = {"diversity": "popular"} # overrides preset + + await YandexMusicProvider._fetch_rotor_session_batch( + provider, wave, f"{ROTOR_STATION_MY_WAVE}#discover" + ) + + _, kwargs = provider.client.rotor_session_new.await_args + # wave.settings wins over preset + assert kwargs["settings"] == {"diversity": "popular"} + + +@pytest.mark.asyncio +async def test_fetch_rotor_session_batch_unknown_preset_strips_suffix_no_settings() -> None: + """Unknown '#' suffix is stripped from the station key and no extra settings are sent.""" + provider = Mock(spec=YandexMusicProvider) + provider.client = AsyncMock() + provider.client.rotor_session_new = AsyncMock(return_value=(None, [], None)) + wave = _WaveState() + + await YandexMusicProvider._fetch_rotor_session_batch( + provider, wave, f"{ROTOR_STATION_MY_WAVE}#does_not_exist" + ) + + # Base station is used; unknown preset yields empty settings → settings=None. + provider.client.rotor_session_new.assert_awaited_once_with(ROTOR_STATION_MY_WAVE, settings=None) + + +# -- _parse_my_wave_track with explicit station_key -------------------------- + + +# -- prefetch next batch (P6) ------------------------------------------------- + + +@pytest.mark.asyncio +async def test_prefetch_rotor_session_fills_prefetched_when_idle() -> None: + """With an active session + cursor and no prefetched tracks, fills wave.prefetched.""" + provider = Mock(spec=YandexMusicProvider) + provider.client = AsyncMock() + provider.client.rotor_session_tracks = AsyncMock(return_value=(["t1", "t2"], "batch_b")) + wave = _WaveState() + wave.session_id = "sess_1" + wave.last_track_id = "42" + provider._wave_states = {ROTOR_STATION_MY_WAVE: wave} + + await YandexMusicProvider._prefetch_rotor_session(provider, ROTOR_STATION_MY_WAVE) + + provider.client.rotor_session_tracks.assert_awaited_once_with("sess_1", current_track_id="42") + assert wave.prefetched == ["t1", "t2"] + + +@pytest.mark.asyncio +async def test_prefetch_rotor_session_noop_without_session() -> None: + """Prefetch does nothing when the station has no active session_id.""" + provider = Mock(spec=YandexMusicProvider) + provider.client = AsyncMock() + provider.client.rotor_session_tracks = AsyncMock() + wave = _WaveState() + provider._wave_states = {ROTOR_STATION_MY_WAVE: wave} + + await YandexMusicProvider._prefetch_rotor_session(provider, ROTOR_STATION_MY_WAVE) + + provider.client.rotor_session_tracks.assert_not_awaited() + assert wave.prefetched == [] + + +@pytest.mark.asyncio +async def test_prefetch_rotor_session_noop_without_cursor() -> None: + """Prefetch bails when session exists but no last_track_id cursor yet.""" + provider = Mock(spec=YandexMusicProvider) + provider.client = AsyncMock() + provider.client.rotor_session_tracks = AsyncMock() + wave = _WaveState() + wave.session_id = "sess_1" # but last_track_id still None + provider._wave_states = {ROTOR_STATION_MY_WAVE: wave} + + await YandexMusicProvider._prefetch_rotor_session(provider, ROTOR_STATION_MY_WAVE) + + provider.client.rotor_session_tracks.assert_not_awaited() + assert wave.prefetched == [] + + +@pytest.mark.asyncio +async def test_prefetch_rotor_session_noop_when_already_prefilled() -> None: + """Prefetch skips work when wave.prefetched already has items (avoid rate burn).""" + provider = Mock(spec=YandexMusicProvider) + provider.client = AsyncMock() + provider.client.rotor_session_tracks = AsyncMock() + wave = _WaveState() + wave.session_id = "sess_1" + wave.last_track_id = "42" + wave.prefetched = ["existing_track"] + provider._wave_states = {ROTOR_STATION_MY_WAVE: wave} + + await YandexMusicProvider._prefetch_rotor_session(provider, ROTOR_STATION_MY_WAVE) + + provider.client.rotor_session_tracks.assert_not_awaited() + + +# -- rotor feedback on library_add (P5) --------------------------------------- + + +@pytest.mark.asyncio +async def test_library_add_track_from_wave_also_sends_rotor_like() -> None: + """library_add for a track from a wave session sends both users.like and rotor.like.""" + provider = Mock(spec=YandexMusicProvider) + provider.instance_id = "yandex_music_instance" + provider.logger = Mock() + provider.client = AsyncMock() + provider.client.like_track = AsyncMock(return_value=True) + composite = f"12345{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_WAVE}" + provider._get_provider_item_id = Mock(return_value=composite) + # Share a session so like is routed to rotor_session_feedback + wave = _WaveState() + wave.session_id = "sess_1" + wave.batch_id = "batch_a" + provider._wave_states = {ROTOR_STATION_MY_WAVE: wave} + provider._get_wave_state = Mock(return_value=wave) + provider._send_wave_feedback = AsyncMock(return_value=True) + + item = MATrack( + item_id=composite, + provider="yandex_music_instance", + name="Test", + provider_mappings={ + ProviderMapping( + item_id=composite, + provider_domain="yandex_music", + provider_instance="yandex_music_instance", + ) + }, + ) + item.media_type = MediaType.TRACK + + result = await YandexMusicProvider.library_add(provider, item) + + assert result is True + provider.client.like_track.assert_awaited_once_with("12345") + provider._send_wave_feedback.assert_awaited_once() + args, kwargs = provider._send_wave_feedback.await_args + assert args[0] is wave + assert args[1] == ROTOR_STATION_MY_WAVE + assert args[2] == "like" + assert kwargs == {"track_id": "12345"} + + +@pytest.mark.asyncio +async def test_library_add_track_without_station_skips_rotor_feedback() -> None: + """Plain track_id (no station suffix) does NOT trigger rotor feedback.""" + provider = Mock(spec=YandexMusicProvider) + provider.instance_id = "yandex_music_instance" + provider.logger = Mock() + provider.client = AsyncMock() + provider.client.like_track = AsyncMock(return_value=True) + provider._get_provider_item_id = Mock(return_value="12345") + provider._send_wave_feedback = AsyncMock() + + item = MATrack( + item_id="12345", + provider="yandex_music_instance", + name="Test", + provider_mappings={ + ProviderMapping( + item_id="12345", + provider_domain="yandex_music", + provider_instance="yandex_music_instance", + ) + }, + ) + item.media_type = MediaType.TRACK + + await YandexMusicProvider.library_add(provider, item) + + provider.client.like_track.assert_awaited_once_with("12345") + provider._send_wave_feedback.assert_not_awaited() + + +# -- user wave presets (P8) --------------------------------------------------- + + +def _preset_config(values: dict[str, str]) -> Mock: + """Build a config stub whose get_value looks up keys in the given dict. + + Non-listed keys return None, matching MA's ``ConfigValueType | None`` contract. + """ + config = Mock() + config.get_value = Mock(side_effect=values.get) + return config + + +def test_get_user_wave_presets_decodes_stored_json() -> None: + """A valid JSON list in CONF_WAVE_PRESETS_DATA yields the same presets out.""" + provider = Mock(spec=YandexMusicProvider) + provider.config = _preset_config( + { + "wave_presets_data": ( + '[{"name": "Morning", "diversity": "discover", "moodEnergy": "calm"},' + ' {"name": "Evening", "language": "russian"}]' + ), + } + ) + provider.logger = Mock() + + result = YandexMusicProvider._get_user_wave_presets(provider) + + assert result == [ + {"name": "Morning", "diversity": "discover", "moodEnergy": "calm"}, + {"name": "Evening", "language": "russian"}, + ] + + +def test_get_user_wave_presets_empty_store_returns_empty() -> None: + """No stored data / empty string / None → empty list.""" + provider = Mock(spec=YandexMusicProvider) + provider.config = _preset_config({"wave_presets_data": ""}) + provider.logger = Mock() + + assert YandexMusicProvider._get_user_wave_presets(provider) == [] + + +def test_get_user_wave_presets_invalid_json_returns_empty() -> None: + """Malformed JSON → empty list (silent; matches the settings-UI parser).""" + provider = Mock(spec=YandexMusicProvider) + provider.config = _preset_config({"wave_presets_data": "not-json {{{"}) + provider.logger = Mock() + + assert YandexMusicProvider._get_user_wave_presets(provider) == [] + + +def test_get_user_wave_presets_skips_items_without_name() -> None: + """Entries missing a name or with non-string values are silently skipped.""" + provider = Mock(spec=YandexMusicProvider) + provider.config = _preset_config( + { + "wave_presets_data": ( + '[{"diversity": "discover"}, {"name": ""}, ' + '{"name": "Good", "moodEnergy": "active"}]' + ), + } + ) + provider.logger = Mock() + + assert YandexMusicProvider._get_user_wave_presets(provider) == [ + {"name": "Good", "moodEnergy": "active"}, + ] + + +# -- save / delete preset actions -------------------------------------------- + + +def test_save_wave_preset_action_appends_and_clears_draft() -> None: + """Save action writes the draft into JSON storage and clears draft fields.""" + values: dict[str, ConfigValueType] = { + "wave_preset_draft_name": "Morning", + "wave_preset_draft_diversity": "discover", + "wave_preset_draft_mood": "calm", + "wave_preset_draft_language": "", # "default" dropdown → skipped + "wave_presets_data": "", + } + + _save_wave_preset_action(values) + + stored_raw = values["wave_presets_data"] + assert isinstance(stored_raw, str) + assert json.loads(stored_raw) == [ + {"name": "Morning", "diversity": "discover", "moodEnergy": "calm"}, + ] + assert values["wave_preset_draft_name"] is None + assert values["wave_preset_draft_diversity"] == "" + assert values["wave_preset_draft_mood"] == "" + assert values["wave_preset_draft_language"] == "" + + +def test_save_wave_preset_action_overwrites_same_name() -> None: + """Saving with an existing name replaces the prior entry — no duplicates.""" + values: dict[str, ConfigValueType] = { + "wave_preset_draft_name": "Morning", + "wave_preset_draft_diversity": "favorite", + "wave_preset_draft_mood": "", + "wave_preset_draft_language": "", + "wave_presets_data": ( + '[{"name": "Morning", "diversity": "discover"},' + ' {"name": "Evening", "language": "russian"}]' + ), + } + + _save_wave_preset_action(values) + + stored_raw = values["wave_presets_data"] + assert isinstance(stored_raw, str) + stored = json.loads(stored_raw) + assert {p["name"] for p in stored} == {"Morning", "Evening"} + morning = next(p for p in stored if p["name"] == "Morning") + assert morning == {"name": "Morning", "diversity": "favorite"} + + +def test_save_wave_preset_action_rejects_blank_name() -> None: + """Save without a preset name raises InvalidDataError and changes nothing.""" + values: dict[str, ConfigValueType] = { + "wave_preset_draft_name": " ", + "wave_presets_data": "", + } + + with pytest.raises(InvalidDataError): + _save_wave_preset_action(values) + assert values["wave_presets_data"] == "" + + +def test_delete_wave_preset_action_removes_by_name() -> None: + """Delete action drops the selected preset and clears the selector.""" + values: dict[str, ConfigValueType] = { + "wave_preset_to_delete": "Morning", + "wave_presets_data": ( + '[{"name": "Morning", "diversity": "discover"},' + ' {"name": "Evening", "language": "russian"}]' + ), + } + + _delete_wave_preset_action(values) + + stored_raw = values["wave_presets_data"] + assert isinstance(stored_raw, str) + assert json.loads(stored_raw) == [{"name": "Evening", "language": "russian"}] + assert values["wave_preset_to_delete"] == "" + + +def test_delete_wave_preset_action_requires_selection() -> None: + """No selection → InvalidDataError; storage untouched.""" + values: dict[str, ConfigValueType] = { + "wave_preset_to_delete": "", + "wave_presets_data": '[{"name": "Keep"}]', + } + + with pytest.raises(InvalidDataError): + _delete_wave_preset_action(values) + assert values["wave_presets_data"] == '[{"name": "Keep"}]' + + +def test_parse_playlist_is_dynamic_flag_propagates() -> None: + """parse_playlist honours is_dynamic=True so feed autoplaylists skip MA cache.""" + provider = Mock(spec=YandexMusicProvider) + provider.instance_id = "yandex_music_instance" + provider.domain = "yandex_music" + provider.client = Mock() + provider.client.user_id = 12345 + + playlist_obj = Mock() + playlist_obj.owner = Mock(uid=67890, name="Яндекс") + playlist_obj.kind = 42 + playlist_obj.title = "Плейлист дня" + playlist_obj.description = None + playlist_obj.cover = None + playlist_obj.track_count = 50 + playlist_obj.modified = None + playlist_obj.created = None + playlist_obj.tags = [] + + result_dynamic = parse_playlist(provider, playlist_obj, is_dynamic=True) + result_static = parse_playlist(provider, playlist_obj) + + assert result_dynamic.is_dynamic is True + assert result_static.is_dynamic is False + + +def test_parse_my_wave_track_uses_provided_station_key_for_item_id() -> None: + """_parse_my_wave_track stamps the supplied station_key on composite item_id.""" + # Build a minimal provider instance with the attributes _parse_my_wave_track + # reads; don't use Mock(spec=...) because we call the real method. + provider = Mock(spec=YandexMusicProvider) + provider.instance_id = "yandex_music_instance" + provider.logger = Mock() + + # Fake yandex track object + yt = type("YTrack", (), {"id": "12345", "track_id": "12345"})() + + # Return a minimal MA Track from parse_track; _parse_my_wave_track rewrites + # its item_id in-place to the composite form. + base_track = MATrack( + item_id="12345", + provider="yandex_music_instance", + name="Test", + provider_mappings={ + ProviderMapping( + item_id="12345", + provider_domain="yandex_music", + provider_instance="yandex_music_instance", + ) + }, + ) + with patch( + "music_assistant.providers.yandex_music.provider.parse_track", + return_value=base_track, + ): + station_key = f"{ROTOR_STATION_MY_WAVE}#discover" + seen: set[str] = set() + result = YandexMusicProvider._parse_my_wave_track( + provider, yt, seen, station_key=station_key + ) + + assert result is not None + assert result.item_id == f"12345{RADIO_TRACK_ID_SEP}{station_key}" + # And round-trip via _parse_radio_item_id + assert _parse_radio_item_id(result.item_id) == ("12345", station_key) + assert "12345" in seen + + +# -- _send_wave_feedback (session vs. stations API router) --------------------- + + +@pytest.mark.asyncio +async def test_send_wave_feedback_uses_session_api_when_session_id_present() -> None: + """When wave.session_id is set, feedback is routed to rotor_session_feedback.""" + provider = Mock(spec=YandexMusicProvider) + provider.client = AsyncMock() + provider.client.rotor_session_feedback = AsyncMock(return_value=True) + provider.client.send_rotor_station_feedback = AsyncMock() + wave = _WaveState() + wave.session_id = "sess_1" + wave.batch_id = "batch_a" + + result = await YandexMusicProvider._send_wave_feedback( + provider, wave, "user:onyourwave", "trackStarted", track_id="100" + ) + + assert result is True + provider.client.rotor_session_feedback.assert_awaited_once_with( + "sess_1", "trackStarted", track_id="100", total_played_seconds=None, batch_id="batch_a" + ) + provider.client.send_rotor_station_feedback.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_send_wave_feedback_skips_silently_without_session() -> None: + """Without ``wave.session_id`` the call is a silent no-op returning False. + + The legacy stations-based feedback endpoint is gone (returns 404), so we + can't usefully fall back there. Callers treat the False result as + "signal was dropped" — history reporting via play_audio still fires. + """ + provider = Mock(spec=YandexMusicProvider) + provider.logger = Mock() + provider.client = AsyncMock() + provider.client.rotor_session_feedback = AsyncMock() + wave = _WaveState() + wave.batch_id = "batch_a" # session_id still None + + result = await YandexMusicProvider._send_wave_feedback( + provider, wave, "genre:rock", "skip", track_id="9", total_played_seconds=7 + ) + + assert result is False + provider.client.rotor_session_feedback.assert_not_awaited() + provider.logger.debug.assert_called_once() diff --git a/tests/providers/yandex_music/test_recommendations.py b/tests/providers/yandex_music/test_recommendations.py index 83e45a062a..e375f5864d 100644 --- a/tests/providers/yandex_music/test_recommendations.py +++ b/tests/providers/yandex_music/test_recommendations.py @@ -21,7 +21,7 @@ RADIO_TRACK_ID_SEP, ROTOR_STATION_MY_WAVE, ) -from music_assistant.providers.yandex_music.provider import YandexMusicProvider +from music_assistant.providers.yandex_music.provider import YandexMusicProvider, _WaveState @pytest.fixture @@ -54,18 +54,26 @@ def provider_mock() -> Mock: return provider +def _install_wave_state(provider_mock: Mock) -> _WaveState: + """Stub _get_wave_state to return a fresh in-memory _WaveState per provider_mock.""" + wave = _WaveState() + provider_mock._get_wave_state = Mock(return_value=wave) + return wave + + @pytest.mark.asyncio async def test_get_my_wave_recommendations_success(provider_mock: Mock) -> None: - """Test _get_my_wave_recommendations returns data when API provides tracks.""" - # Create mock track with required attributes + """Test _get_my_wave_recommendations returns data when session API provides tracks.""" + _install_wave_state(provider_mock) mock_track = Mock() mock_track.id = "12345" mock_track.track_id = "12345" - # Mock get_my_wave_tracks to return tracks - provider_mock.client.get_my_wave_tracks = AsyncMock(return_value=([mock_track], None)) + # Mock the session-API helper; return the same track every time — matches + # the old single-track-per-batch test intent where the fake rotor returns + # the same shape across repeated batch calls. + provider_mock._fetch_rotor_session_batch = AsyncMock(return_value=([mock_track], "batch_a")) - # Mock _parse_my_wave_track to return a Track object with composite item_id mock_parsed_track = Mock(spec=Track) mock_parsed_track.item_id = f"12345{RADIO_TRACK_ID_SEP}{ROTOR_STATION_MY_WAVE}" mock_parsed_track.name = "Test Track" @@ -85,8 +93,9 @@ async def test_get_my_wave_recommendations_success(provider_mock: Mock) -> None: @pytest.mark.asyncio async def test_get_my_wave_recommendations_empty(provider_mock: Mock) -> None: - """Test _get_my_wave_recommendations returns None when API returns no tracks.""" - provider_mock.client.get_my_wave_tracks = AsyncMock(return_value=([], None)) + """Test _get_my_wave_recommendations returns None when session API yields no tracks.""" + _install_wave_state(provider_mock) + provider_mock._fetch_rotor_session_batch = AsyncMock(return_value=([], None)) result = await YandexMusicProvider._get_my_wave_recommendations(provider_mock) @@ -95,8 +104,8 @@ async def test_get_my_wave_recommendations_empty(provider_mock: Mock) -> None: @pytest.mark.asyncio async def test_get_my_wave_recommendations_duplicate_filtering(provider_mock: Mock) -> None: - """Test _get_my_wave_recommendations filters duplicate tracks.""" - # Create mock tracks with same ID + """Test _get_my_wave_recommendations filters duplicate tracks across batches.""" + _install_wave_state(provider_mock) mock_track1 = Mock() mock_track1.id = "12345" mock_track1.track_id = "12345" @@ -105,11 +114,11 @@ async def test_get_my_wave_recommendations_duplicate_filtering(provider_mock: Mo mock_track2.id = "12345" # Same ID mock_track2.track_id = "12345" - # First call returns track1, second call returns track2 (duplicate) - provider_mock.client.get_my_wave_tracks = AsyncMock( + # First batch returns track1, second batch returns track2 (duplicate) + provider_mock._fetch_rotor_session_batch = AsyncMock( side_effect=[ - ([mock_track1], None), - ([mock_track2], None), + ([mock_track1], "batch_a"), + ([mock_track2], "batch_b"), ] ) @@ -130,12 +139,13 @@ async def test_get_my_wave_recommendations_duplicate_filtering(provider_mock: Mo @pytest.mark.asyncio async def test_get_my_wave_recommendations_invalid_data_error(provider_mock: Mock) -> None: - """Test _get_my_wave_recommendations handles InvalidDataError gracefully.""" + """Test _get_my_wave_recommendations handles parse failures gracefully.""" + _install_wave_state(provider_mock) mock_track = Mock() mock_track.id = "12345" mock_track.track_id = "12345" - provider_mock.client.get_my_wave_tracks = AsyncMock(return_value=([mock_track], None)) + provider_mock._fetch_rotor_session_batch = AsyncMock(return_value=([mock_track], "batch_a")) # _parse_my_wave_track returns None (simulates parse error handled internally) provider_mock._parse_my_wave_track = Mock(return_value=None) diff --git a/tests/providers/yandex_music/test_streaming.py b/tests/providers/yandex_music/test_streaming.py index cf47cf0571..814209bdf9 100644 --- a/tests/providers/yandex_music/test_streaming.py +++ b/tests/providers/yandex_music/test_streaming.py @@ -92,15 +92,19 @@ def test_select_best_quality_balanced_falls_back_to_highest( assert result.bitrate_in_kbps == 320 -def test_select_best_quality_label_lossless_flac_returns_flac( +def test_select_best_quality_legacy_lossless_alias_returns_flac( streaming_manager: YandexMusicStreamingManager, ) -> None: - """When preferred_quality is UI label 'Lossless (FLAC)', FLAC is selected.""" + """Legacy stored value 'lossless' (pre-Superb rename) still maps to FLAC. + + Current UI writes ``superb``; older configs may still hold the literal + ``lossless`` string. The selector must treat the two as synonyms. + """ mp3 = _make_download_info("mp3", 320, "https://example.com/track.mp3") flac = _make_download_info("flac", 0, "https://example.com/track.flac") download_infos = [mp3, flac] - result = streaming_manager._select_best_quality(download_infos, "Lossless (FLAC)") + result = streaming_manager._select_best_quality(download_infos, "lossless") assert result is not None assert result.codec == "flac" From 3b439fd9cdce70392fff8c529f0c3ef36b74de71 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 14:16:40 +0000 Subject: [PATCH 41/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.4.0 --- .../providers/yandex_music/presets.py | 15 +++++++---- requirements_all.txt | 2 +- tests/providers/yandex_music/test_my_wave.py | 26 +++++++++++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/music_assistant/providers/yandex_music/presets.py b/music_assistant/providers/yandex_music/presets.py index ab6e52b799..49e085208d 100644 --- a/music_assistant/providers/yandex_music/presets.py +++ b/music_assistant/providers/yandex_music/presets.py @@ -15,9 +15,12 @@ def parse_stored_presets(raw: object) -> list[dict[str, str]]: Only entries with a non-empty ``name`` string are kept. The optional ``diversity`` / ``moodEnergy`` / ``language`` fields are carried through - as long as they are non-empty strings. Any other keys are dropped. - Malformed JSON, non-list roots or non-dict items yield an empty list — - the UI treats that as "no presets yet". + as long as they are non-empty *after stripping whitespace* — Yandex + would reject rotor seeds like ``settingDiversity: `` (space) with a 4xx, + so we never pass such values through. Stripped form is stored so the + downstream seed builder gets the canonical value. Any other keys are + dropped. Malformed JSON, non-list roots or non-dict items yield an + empty list — the UI treats that as "no presets yet". :param raw: Value read from the ``wave_presets_data`` config entry. :return: List of sanitised preset dicts in source order. @@ -40,7 +43,9 @@ def parse_stored_presets(raw: object) -> list[dict[str, str]]: clean: dict[str, str] = {"name": name.strip()} for key in ("diversity", "moodEnergy", "language"): val = item.get(key) - if isinstance(val, str) and val: - clean[key] = val + if isinstance(val, str): + val = val.strip() + if val: + clean[key] = val result.append(clean) return result diff --git a/requirements_all.txt b/requirements_all.txt index 951753c3f3..879a3846da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -89,7 +89,7 @@ websocket-client==1.9.0 wiim==0.1.1 xmltodict==1.0.4 ya-passport-auth==1.3.0 -yandex-music==2.2.0 +yandex-music==3.0.0 ytmusicapi==1.11.5 zeroconf==0.148.0 zvuk-music[async]==0.6.1 diff --git a/tests/providers/yandex_music/test_my_wave.py b/tests/providers/yandex_music/test_my_wave.py index 7101d42be3..3fd0b4fbd7 100644 --- a/tests/providers/yandex_music/test_my_wave.py +++ b/tests/providers/yandex_music/test_my_wave.py @@ -481,6 +481,32 @@ def test_get_user_wave_presets_skips_items_without_name() -> None: ] +def test_get_user_wave_presets_drops_whitespace_only_values() -> None: + """Whitespace-only dropdown values (e.g. hand-edited JSON) are treated as empty. + + Yandex rejects ``settingDiversity:`` with a 4xx, so the parser must not + propagate such values. Valid values are also stripped to their canonical + form so the downstream rotor seed builder always gets the stored string + without surrounding whitespace. + """ + provider = Mock(spec=YandexMusicProvider) + provider.config = _preset_config( + { + "wave_presets_data": ( + '[{"name": "WS-only", "diversity": " ",' + ' "moodEnergy": "\\t", "language": ""},' + ' {"name": "Trim", "diversity": " discover "}]' + ), + } + ) + provider.logger = Mock() + + assert YandexMusicProvider._get_user_wave_presets(provider) == [ + {"name": "WS-only"}, + {"name": "Trim", "diversity": "discover"}, + ] + + # -- save / delete preset actions -------------------------------------------- From 9614f95c6b92f4dd99abfc7bae158a27507745d9 Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy <139659391+trudenboy@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:25:50 +0300 Subject: [PATCH 42/54] Remove gql dependency from requirements --- requirements_all.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index 879a3846da..574feaf95c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -36,7 +36,6 @@ defusedxml==0.7.1 deno==2.7.4 duration-parser==1.0.1 getmac==0.9.5 -gql[all]==4.0.0 hass-client==1.2.3 ibroadcastaio==0.6.0 ifaddr==0.2.0 From be4d0f97f6fd0c0953b579efd6e75a6d37648e3f Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy <139659391+trudenboy@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:48:44 +0300 Subject: [PATCH 43/54] Update requirements_all.txt to remove warning Removed autogenerated warning and updated package list. --- requirements_all.txt | 93 +------------------------------------------- 1 file changed, 1 insertion(+), 92 deletions(-) diff --git a/requirements_all.txt b/requirements_all.txt index 574feaf95c..9d2b43bf18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,94 +1,3 @@ -# WARNING: this file is autogenerated! - ---extra-index-url https://download.pytorch.org/whl/cpu - -Brotli>=1.0.9 -aioaudiobookshelf==0.1.20 -aiodns>=3.2.0 -aiofiles==24.1.0 -aiohttp==3.13.5 -aiohttp_asyncmdnsresolver==0.1.1 -aiohttp-fast-zlib==0.3.0 -aiohttp-socks==0.11.0 -aiojellyfin==0.14.1 -aiomusiccast==0.15.0 -aiortc>=1.6.0 -aiosendspin[server]==5.1.1 -aioslimproto==3.1.8 -aiosonos==0.1.12 -aiosqlite==0.22.1 -aiovban==0.6.3 -alexapy==1.29.17 -async-upnp-client==0.46.2 -audible==0.10.0 -auntie-sounds==1.1.8 -av==16.1.0 -awesomeversion>=24.6.0 -bandcamp-async-api==0.1.1 -beat-this==1.1.0 -bidict==0.23.1 -certifi==2025.11.12 -chardet>=5.2.0 -colorlog==6.10.1 -cryptography==46.0.7 -deezer-python-async==0.3.0 -defusedxml==0.7.1 -deno==2.7.4 -duration-parser==1.0.1 -getmac==0.9.5 -hass-client==1.2.3 -ibroadcastaio==0.6.0 -ifaddr==0.2.0 -liblistenbrainz==0.7.0 -librosa==0.11.0 -lyricsgenius==3.11.0 -mashumaro==3.20 -music-assistant-frontend==2.17.152 -music-assistant-models==1.1.115 -mutagen==1.47.0 -niconico.py-ma==2.1.0.post1 -nnAudio==0.3.3 -numpy==2.3.5 -orjson==3.11.6 -pillow==12.2.0 -pkce==1.0.3 -plexapi==4.17.2 -podcastparser==0.6.11 -propcache>=0.2.1 -py-opensonic==9.0.1 -pyblu==2.0.6 -pycares==4.11.0 -PyChromecast==14.0.9 -pycryptodome==3.23.0 -pyheos==1.0.6 -pyjwt[crypto]>=2.10.1 -pylast==7.0.2 -python-fullykiosk==0.0.14 -python-mpd2>=3.1.1 -python-slugify==8.0.4 -pytz==2025.2 -pywidevine==1.9.0 -qqmusic-api-python==0.4.1 -radios==0.3.2 -rokuecp==0.19.5 -shortuuid==1.0.13 -snapcast==2.3.7 -soco==0.30.14 -soundcloudpy==0.1.4 -sounddevice==0.5.5 -srptools>=1.0.0 -sxm==0.2.8 -torch==2.11.0+cpu; sys_platform == 'linux' and platform_machine == 'x86_64' -torch==2.11.0; sys_platform != 'linux' or platform_machine != 'x86_64' -torchaudio==2.11.0+cpu; sys_platform == 'linux' and platform_machine == 'x86_64' -torchaudio==2.11.0; sys_platform != 'linux' or platform_machine != 'x86_64' -unidecode==1.4.0 -uv>=0.8.0 -websocket-client==1.9.0 -wiim==0.1.1 -xmltodict==1.0.4 ya-passport-auth==1.3.0 yandex-music==3.0.0 -ytmusicapi==1.11.5 -zeroconf==0.148.0 -zvuk-music[async]==0.6.1 + From be769235ee5b809183f77a1e408c24204bb1d67a Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy <139659391+trudenboy@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:01:35 +0300 Subject: [PATCH 44/54] Remove ya-passport-auth from requirements --- requirements_all.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/requirements_all.txt b/requirements_all.txt index 9d2b43bf18..631f100dcb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,3 +1 @@ -ya-passport-auth==1.3.0 yandex-music==3.0.0 - From 068417ef341777f4332d902052b091f02523329a Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy <139659391+trudenboy@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:05:13 +0300 Subject: [PATCH 45/54] Remove yandex-music dependency from requirements --- requirements_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index 631f100dcb..8b13789179 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1 +1 @@ -yandex-music==3.0.0 + From 43538d51a318f947d71f654e7d099012b40aea5c Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Thu, 23 Apr 2026 18:16:06 +0300 Subject: [PATCH 46/54] fix(requirements): restore requirements_all.txt via gen_requirements_all.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commits on this branch stripped the file to 1 line while addressing the dependency-approval bot, which broke CI — tests fail with ModuleNotFoundError: No module named 'hass_client' at conftest import, because hass_client is a core MA dependency shipped via this file (it is not manually removable without breaking the whole suite). Regenerated from pyproject.toml + provider manifests using the canonical scripts/gen_requirements_all.py. yandex-music==3.0.0 and ya-passport-auth==1.3.0 are now correctly included alongside the rest of the core dependencies — they are consumed by music_assistant.providers.yandex_music and must be installable. --- requirements_all.txt | 94 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/requirements_all.txt b/requirements_all.txt index 8b13789179..879a3846da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1 +1,95 @@ +# WARNING: this file is autogenerated! +--extra-index-url https://download.pytorch.org/whl/cpu + +Brotli>=1.0.9 +aioaudiobookshelf==0.1.20 +aiodns>=3.2.0 +aiofiles==24.1.0 +aiohttp==3.13.5 +aiohttp_asyncmdnsresolver==0.1.1 +aiohttp-fast-zlib==0.3.0 +aiohttp-socks==0.11.0 +aiojellyfin==0.14.1 +aiomusiccast==0.15.0 +aiortc>=1.6.0 +aiosendspin[server]==5.1.1 +aioslimproto==3.1.8 +aiosonos==0.1.12 +aiosqlite==0.22.1 +aiovban==0.6.3 +alexapy==1.29.17 +async-upnp-client==0.46.2 +audible==0.10.0 +auntie-sounds==1.1.8 +av==16.1.0 +awesomeversion>=24.6.0 +bandcamp-async-api==0.1.1 +beat-this==1.1.0 +bidict==0.23.1 +certifi==2025.11.12 +chardet>=5.2.0 +colorlog==6.10.1 +cryptography==46.0.7 +deezer-python-async==0.3.0 +defusedxml==0.7.1 +deno==2.7.4 +duration-parser==1.0.1 +getmac==0.9.5 +gql[all]==4.0.0 +hass-client==1.2.3 +ibroadcastaio==0.6.0 +ifaddr==0.2.0 +liblistenbrainz==0.7.0 +librosa==0.11.0 +lyricsgenius==3.11.0 +mashumaro==3.20 +music-assistant-frontend==2.17.152 +music-assistant-models==1.1.115 +mutagen==1.47.0 +niconico.py-ma==2.1.0.post1 +nnAudio==0.3.3 +numpy==2.3.5 +orjson==3.11.6 +pillow==12.2.0 +pkce==1.0.3 +plexapi==4.17.2 +podcastparser==0.6.11 +propcache>=0.2.1 +py-opensonic==9.0.1 +pyblu==2.0.6 +pycares==4.11.0 +PyChromecast==14.0.9 +pycryptodome==3.23.0 +pyheos==1.0.6 +pyjwt[crypto]>=2.10.1 +pylast==7.0.2 +python-fullykiosk==0.0.14 +python-mpd2>=3.1.1 +python-slugify==8.0.4 +pytz==2025.2 +pywidevine==1.9.0 +qqmusic-api-python==0.4.1 +radios==0.3.2 +rokuecp==0.19.5 +shortuuid==1.0.13 +snapcast==2.3.7 +soco==0.30.14 +soundcloudpy==0.1.4 +sounddevice==0.5.5 +srptools>=1.0.0 +sxm==0.2.8 +torch==2.11.0+cpu; sys_platform == 'linux' and platform_machine == 'x86_64' +torch==2.11.0; sys_platform != 'linux' or platform_machine != 'x86_64' +torchaudio==2.11.0+cpu; sys_platform == 'linux' and platform_machine == 'x86_64' +torchaudio==2.11.0; sys_platform != 'linux' or platform_machine != 'x86_64' +unidecode==1.4.0 +uv>=0.8.0 +websocket-client==1.9.0 +wiim==0.1.1 +xmltodict==1.0.4 +ya-passport-auth==1.3.0 +yandex-music==3.0.0 +ytmusicapi==1.11.5 +zeroconf==0.148.0 +zvuk-music[async]==0.6.1 From 6048467f978f66736d72cdfca8390af7975ce821 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 15:36:01 +0000 Subject: [PATCH 47/54] feat(yandex_music): sync provider from ma-provider-yandex-music v3.4.1 --- .../providers/yandex_music/api_client.py | 6 +++++ requirements_all.txt | 2 +- .../providers/yandex_music/test_api_client.py | 22 +++++++++++++++++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/music_assistant/providers/yandex_music/api_client.py b/music_assistant/providers/yandex_music/api_client.py index 52365ac8f0..6e6d94b126 100644 --- a/music_assistant/providers/yandex_music/api_client.py +++ b/music_assistant/providers/yandex_music/api_client.py @@ -384,6 +384,12 @@ async def _do(c: ClientAsync) -> dict[str, Any] | None: runner = self._call_with_retry if with_retry else self._call_no_retry try: return await runner(_do) + except UnauthorizedError as err: + # Expired/invalidated token. Surface as LoginFailed so MA prompts + # for re-auth instead of the raw yandex_music exception bubbling + # through browse / play and crashing the caller. + LOGGER.warning("Rotor session POST %s: token no longer valid", path) + raise LoginFailed("Invalid Yandex Music token") from err except (NetworkError, ProviderUnavailableError) as err: LOGGER.warning("Rotor session POST %s failed: %s", path, err) return None diff --git a/requirements_all.txt b/requirements_all.txt index 879a3846da..951753c3f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -89,7 +89,7 @@ websocket-client==1.9.0 wiim==0.1.1 xmltodict==1.0.4 ya-passport-auth==1.3.0 -yandex-music==3.0.0 +yandex-music==2.2.0 ytmusicapi==1.11.5 zeroconf==0.148.0 zvuk-music[async]==0.6.1 diff --git a/tests/providers/yandex_music/test_api_client.py b/tests/providers/yandex_music/test_api_client.py index e260f24b4c..92379cbbd5 100644 --- a/tests/providers/yandex_music/test_api_client.py +++ b/tests/providers/yandex_music/test_api_client.py @@ -11,9 +11,9 @@ from unittest import mock import pytest -from music_assistant_models.errors import ResourceTemporarilyUnavailable +from music_assistant_models.errors import LoginFailed, ResourceTemporarilyUnavailable from ya_passport_auth import SecretStr -from yandex_music.exceptions import NetworkError +from yandex_music.exceptions import NetworkError, UnauthorizedError from yandex_music.rotor.dashboard import Dashboard from yandex_music.rotor.station_result import StationResult from yandex_music.utils.sign_request import DEFAULT_SIGN_KEY @@ -420,6 +420,24 @@ async def test_rotor_session_feedback_like_uses_trackid_without_seconds() -> Non assert "totalPlayedSeconds" not in event +async def test_rotor_session_request_maps_unauthorized_to_login_failed() -> None: + """Expired/invalid token during /rotor/session/* surfaces as LoginFailed. + + Without this mapping the raw ``UnauthorizedError`` from the MarshalX + client would bubble up through browse / play paths and crash the + provider instead of triggering MA's re-auth prompt. + """ + client, underlying = _make_client() + # _do is awaited via _call_with_retry → _ensure_connected → returns our + # AsyncMock underlying client. The underlying client's ._request.post is + # what actually raises. + underlying._request = mock.MagicMock() + underlying._request.post = mock.AsyncMock(side_effect=UnauthorizedError("stale token")) + + with pytest.raises(LoginFailed): + await client._rotor_session_request("new", {"seeds": ["user:onyourwave"]}) + + # -- get_similar_artists ------------------------------------------------------ From 785fc5e7738c31c68d558233ab82878a351296ea Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Thu, 23 Apr 2026 18:41:30 +0300 Subject: [PATCH 48/54] chore: remove tests/providers/kion_music/test_integration.py Co-Authored-By: Claude Opus 4.7 --- .../providers/kion_music/test_integration.py | 354 ------------------ 1 file changed, 354 deletions(-) delete mode 100644 tests/providers/kion_music/test_integration.py diff --git a/tests/providers/kion_music/test_integration.py b/tests/providers/kion_music/test_integration.py deleted file mode 100644 index e3e969b726..0000000000 --- a/tests/providers/kion_music/test_integration.py +++ /dev/null @@ -1,354 +0,0 @@ -"""Integration tests for the KION Music provider with in-process Music Assistant.""" - -from __future__ import annotations - -import json -import pathlib -from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING, Any, cast -from unittest import mock - -import pytest -from music_assistant_models.enums import ContentType, MediaType, StreamType -from yandex_music import Album as YandexAlbum -from yandex_music import Artist as YandexArtist -from yandex_music import Playlist as YandexPlaylist -from yandex_music import Track as YandexTrack - -from music_assistant.mass import MusicAssistant -from music_assistant.models.music_provider import MusicProvider -from tests.common import wait_for_sync_completion - -if TYPE_CHECKING: - from music_assistant_models.config_entries import ProviderConfig - -FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" -_DE_JSON_CLIENT = type("ClientStub", (), {"report_unknown_fields": False})() - - -def _load_json(path: pathlib.Path) -> dict[str, Any]: - """Load JSON fixture.""" - with open(path) as f: - return cast("dict[str, Any]", json.load(f)) - - -def _load_kion_objects() -> tuple[Any, Any, Any, Any]: - """Load Artist, Album, Track, Playlist from fixtures for mock client.""" - artist = YandexArtist.de_json( - _load_json(FIXTURES_DIR / "artists" / "minimal.json"), _DE_JSON_CLIENT - ) - album = YandexAlbum.de_json( - _load_json(FIXTURES_DIR / "albums" / "minimal.json"), _DE_JSON_CLIENT - ) - track = YandexTrack.de_json( - _load_json(FIXTURES_DIR / "tracks" / "minimal.json"), _DE_JSON_CLIENT - ) - playlist = YandexPlaylist.de_json( - _load_json(FIXTURES_DIR / "playlists" / "minimal.json"), _DE_JSON_CLIENT - ) - return artist, album, track, playlist - - -def _make_search_result(track: Any, album: Any, artist: Any, playlist: Any) -> Any: - """Build a Search-like object with .tracks.results, .albums.results, etc.""" - return type( - "Search", - (), - { - "tracks": type("TracksResult", (), {"results": [track]})(), - "albums": type("AlbumsResult", (), {"results": [album]})(), - "artists": type("ArtistsResult", (), {"results": [artist]})(), - "playlists": type("PlaylistsResult", (), {"results": [playlist]})(), - }, - )() - - -def _make_download_info( - codec: str = "mp3", - direct_link: str = "https://example.com/kion_track.mp3", - bitrate_in_kbps: int = 320, -) -> Any: - """Build DownloadInfo-like object for streaming.""" - return type( - "DownloadInfo", - (), - { - "direct_link": direct_link, - "codec": codec, - "bitrate_in_kbps": bitrate_in_kbps, - }, - )() - - -@pytest.fixture -async def kion_music_provider( - mass: MusicAssistant, -) -> AsyncGenerator[ProviderConfig, None]: - """Configure KION Music provider with mocked API client and add to mass.""" - artist, album, track, playlist = _load_kion_objects() - search_result = _make_search_result(track, album, artist, playlist) - download_info = _make_download_info() - - # Album with volumes for get_album_tracks - album_with_volumes = type( - "AlbumWithVolumes", - (), - { - "id": album.id, - "title": album.title, - "volumes": [[track]], - "artists": album.artists if hasattr(album, "artists") else [], - "year": getattr(album, "year", None), - "release_date": getattr(album, "release_date", None), - "genre": getattr(album, "genre", None), - "cover_uri": getattr(album, "cover_uri", None), - "og_image": getattr(album, "og_image", None), - "type": getattr(album, "type", "album"), - "available": getattr(album, "available", True), - }, - )() - - with mock.patch( - "music_assistant.providers.kion_music.provider.KionMusicClient" - ) as mock_client_class: - mock_client = mock.AsyncMock() - mock_client_class.return_value = mock_client - - mock_client.connect = mock.AsyncMock(return_value=True) - mock_client.user_id = 12345 - - mock_client.get_liked_tracks = mock.AsyncMock(return_value=[]) - mock_client.get_liked_albums = mock.AsyncMock(return_value=[]) - mock_client.get_liked_artists = mock.AsyncMock(return_value=[]) - mock_client.get_user_playlists = mock.AsyncMock(return_value=[playlist]) - - mock_client.search = mock.AsyncMock(return_value=search_result) - mock_client.get_track = mock.AsyncMock(return_value=track) - mock_client.get_tracks = mock.AsyncMock(return_value=[track]) - mock_client.get_album = mock.AsyncMock(return_value=album) - mock_client.get_album_with_tracks = mock.AsyncMock(return_value=album_with_volumes) - mock_client.get_artist = mock.AsyncMock(return_value=artist) - mock_client.get_artist_albums = mock.AsyncMock(return_value=[album]) - mock_client.get_artist_tracks = mock.AsyncMock(return_value=[track]) - mock_client.get_playlist = mock.AsyncMock(return_value=playlist) - mock_client.get_track_download_info = mock.AsyncMock(return_value=[download_info]) - - async with wait_for_sync_completion(mass): - config = await mass.config.save_provider_config( - "kion_music", - {"token": "mock_kion_token", "quality": "high"}, - ) - await mass.music.start_sync() - - yield config - - -@pytest.fixture -async def kion_music_provider_lossless( - mass: MusicAssistant, -) -> AsyncGenerator[ProviderConfig, None]: - """Configure KION Music with quality=lossless and mock returning MP3 + FLAC.""" - artist, album, track, playlist = _load_kion_objects() - search_result = _make_search_result(track, album, artist, playlist) - mp3_info = _make_download_info( - codec="mp3", - direct_link="https://example.com/kion_track.mp3", - bitrate_in_kbps=320, - ) - flac_info = _make_download_info( - codec="flac", - direct_link="https://example.com/kion_track.flac", - bitrate_in_kbps=0, - ) - download_infos = [mp3_info, flac_info] - - album_with_volumes = type( - "AlbumWithVolumes", - (), - { - "id": album.id, - "title": album.title, - "volumes": [[track]], - "artists": album.artists if hasattr(album, "artists") else [], - "year": getattr(album, "year", None), - "release_date": getattr(album, "release_date", None), - "genre": getattr(album, "genre", None), - "cover_uri": getattr(album, "cover_uri", None), - "og_image": getattr(album, "og_image", None), - "type": getattr(album, "type", "album"), - "available": getattr(album, "available", True), - }, - )() - - with mock.patch( - "music_assistant.providers.kion_music.provider.KionMusicClient" - ) as mock_client_class: - mock_client = mock.AsyncMock() - mock_client_class.return_value = mock_client - - mock_client.connect = mock.AsyncMock(return_value=True) - mock_client.user_id = 12345 - - mock_client.get_liked_tracks = mock.AsyncMock(return_value=[]) - mock_client.get_liked_albums = mock.AsyncMock(return_value=[]) - mock_client.get_liked_artists = mock.AsyncMock(return_value=[]) - mock_client.get_user_playlists = mock.AsyncMock(return_value=[playlist]) - - mock_client.search = mock.AsyncMock(return_value=search_result) - mock_client.get_track = mock.AsyncMock(return_value=track) - mock_client.get_tracks = mock.AsyncMock(return_value=[track]) - mock_client.get_album = mock.AsyncMock(return_value=album) - mock_client.get_album_with_tracks = mock.AsyncMock(return_value=album_with_volumes) - mock_client.get_artist = mock.AsyncMock(return_value=artist) - mock_client.get_artist_albums = mock.AsyncMock(return_value=[album]) - mock_client.get_artist_tracks = mock.AsyncMock(return_value=[track]) - mock_client.get_playlist = mock.AsyncMock(return_value=playlist) - mock_client.get_track_file_info_lossless = mock.AsyncMock(return_value=None) - mock_client.get_track_download_info = mock.AsyncMock(return_value=download_infos) - - async with wait_for_sync_completion(mass): - config = await mass.config.save_provider_config( - "kion_music", - {"token": "mock_kion_token", "quality": "lossless"}, - ) - await mass.music.start_sync() - - yield config - - -def _get_kion_provider(mass: MusicAssistant) -> MusicProvider | None: - """Get KION Music provider instance from mass.""" - for provider in mass.music.providers: - if provider.domain == "kion_music": - return provider - return None - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_registration_and_sync(mass: MusicAssistant) -> None: - """Test that provider is registered and sync completes.""" - prov = _get_kion_provider(mass) - assert prov is not None - assert prov.domain == "kion_music" - assert prov.instance_id - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_search(mass: MusicAssistant) -> None: - """Test search returns results from kion_music.""" - results = await mass.music.search("test query", [MediaType.TRACK], limit=5) - kion_tracks = [t for t in results.tracks if t.provider and "kion_music" in t.provider] - assert len(kion_tracks) >= 0 - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_get_artist(mass: MusicAssistant) -> None: - """Test getting artist by id.""" - prov = _get_kion_provider(mass) - assert prov is not None - artist = await prov.get_artist("100") - assert artist is not None - assert artist.name - assert artist.provider == prov.instance_id - assert artist.item_id == "100" - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_get_album(mass: MusicAssistant) -> None: - """Test getting album by id.""" - prov = _get_kion_provider(mass) - assert prov is not None - album = await prov.get_album("300") - assert album is not None - assert album.name - assert album.provider == prov.instance_id - assert album.item_id == "300" - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_get_track(mass: MusicAssistant) -> None: - """Test getting track by id.""" - prov = _get_kion_provider(mass) - assert prov is not None - track = await prov.get_track("400") - assert track is not None - assert track.name - assert track.provider == prov.instance_id - assert track.item_id == "400" - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_get_album_tracks(mass: MusicAssistant) -> None: - """Test getting album tracks.""" - prov = _get_kion_provider(mass) - assert prov is not None - tracks = await prov.get_album_tracks("300") - assert isinstance(tracks, list) - assert len(tracks) >= 0 - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_get_playlist_tracks(mass: MusicAssistant) -> None: - """Test getting playlist tracks.""" - prov = _get_kion_provider(mass) - assert prov is not None - tracks = await prov.get_playlist_tracks("12345:3", page=0) - assert isinstance(tracks, list) - assert len(tracks) >= 0 - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_get_playlist_tracks_page_gt_zero_returns_empty(mass: MusicAssistant) -> None: - """Test that page > 0 returns empty list (no server-side pagination).""" - prov = _get_kion_provider(mass) - assert prov is not None - tracks = await prov.get_playlist_tracks("12345:3", page=1) - assert tracks == [] - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_get_stream_details(mass: MusicAssistant) -> None: - """Test stream details retrieval.""" - prov = _get_kion_provider(mass) - assert prov is not None - stream_details = await prov.get_stream_details("400", MediaType.TRACK) - assert stream_details is not None - assert stream_details.stream_type == StreamType.HTTP - assert stream_details.path == "https://example.com/kion_track.mp3" - - -@pytest.mark.usefixtures("kion_music_provider_lossless") -async def test_get_stream_details_returns_flac_when_lossless_selected( - mass: MusicAssistant, -) -> None: - """When quality=lossless and API returns MP3+FLAC, stream details use FLAC.""" - prov = _get_kion_provider(mass) - assert prov is not None - stream_details = await prov.get_stream_details("400", MediaType.TRACK) - assert stream_details is not None - assert stream_details.audio_format.content_type == ContentType.FLAC - assert stream_details.path == "https://example.com/kion_track.flac" - - -@pytest.mark.usefixtures("kion_music_provider") -async def test_library_items(mass: MusicAssistant) -> None: - """Test library artists, albums, tracks, playlists.""" - prov = _get_kion_provider(mass) - assert prov is not None - instance_id = prov.instance_id - - artists = await mass.music.artists.library_items() - kion_artists = [a for a in artists if a.provider == instance_id] - assert len(kion_artists) >= 0 - - albums = await mass.music.albums.library_items() - kion_albums = [a for a in albums if a.provider == instance_id] - assert len(kion_albums) >= 0 - - tracks = await mass.music.tracks.library_items() - kion_tracks = [t for t in tracks if t.provider == instance_id] - assert len(kion_tracks) >= 0 - - playlists = await mass.music.playlists.library_items() - kion_playlists = [p for p in playlists if p.provider == instance_id] - assert len(kion_playlists) >= 0 From 093128adcdd4ba69301754622a98fdf521640bc2 Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Thu, 23 Apr 2026 18:45:16 +0300 Subject: [PATCH 49/54] chore: regenerate requirements_all.txt (yandex-music 3.0.0) Co-Authored-By: Claude Opus 4.7 --- requirements_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index 951753c3f3..879a3846da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -89,7 +89,7 @@ websocket-client==1.9.0 wiim==0.1.1 xmltodict==1.0.4 ya-passport-auth==1.3.0 -yandex-music==2.2.0 +yandex-music==3.0.0 ytmusicapi==1.11.5 zeroconf==0.148.0 zvuk-music[async]==0.6.1 From 6d934be96f83e92df4b19cfce3a8c9ec691f131b Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Thu, 23 Apr 2026 18:55:48 +0300 Subject: [PATCH 50/54] Revert "chore: regenerate requirements_all.txt (yandex-music 3.0.0)" This reverts commit 093128adcdd4ba69301754622a98fdf521640bc2. --- requirements_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index 879a3846da..951753c3f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -89,7 +89,7 @@ websocket-client==1.9.0 wiim==0.1.1 xmltodict==1.0.4 ya-passport-auth==1.3.0 -yandex-music==3.0.0 +yandex-music==2.2.0 ytmusicapi==1.11.5 zeroconf==0.148.0 zvuk-music[async]==0.6.1 From e9ce07a4114ddc30f09226badc894ed34a7e17ef Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Tue, 28 Apr 2026 10:20:10 +0300 Subject: [PATCH 51/54] fix(scripts): sort provider dirs in gen_requirements_all for deterministic output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit os.listdir order depends on filesystem (inode order on Linux ext4 vs alphabetic on macOS APFS), making requirements_all.txt regeneration non-deterministic across platforms. When two providers declare different versions of the same package, the order decided which one wins — leading to CI lint failures that flipped between runs. Wrapping the outer listdir in sorted() makes the result stable. Co-Authored-By: Claude Opus 4.7 --- scripts/gen_requirements_all.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/gen_requirements_all.py b/scripts/gen_requirements_all.py index 9b727decf5..148d0f7b6b 100644 --- a/scripts/gen_requirements_all.py +++ b/scripts/gen_requirements_all.py @@ -67,7 +67,7 @@ def gather_requirements_from_manifests() -> list[str]: """Gather all of the requirements from provider manifests.""" dependencies: list[str] = [] providers_path = "music_assistant/providers" - for dir_str in os.listdir(providers_path): # noqa: PTH208, RUF100 + for dir_str in sorted(os.listdir(providers_path)): # noqa: PTH208, RUF100 dir_path = os.path.join(providers_path, dir_str) if not os.path.isdir(dir_path): continue From 0a2c6a26b31ffd04ac21fa211c7adf36a402f66a Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Tue, 28 Apr 2026 10:23:50 +0300 Subject: [PATCH 52/54] Revert "fix(scripts): sort provider dirs in gen_requirements_all for deterministic output" This reverts commit e9ce07a4114ddc30f09226badc894ed34a7e17ef. --- scripts/gen_requirements_all.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/gen_requirements_all.py b/scripts/gen_requirements_all.py index 148d0f7b6b..9b727decf5 100644 --- a/scripts/gen_requirements_all.py +++ b/scripts/gen_requirements_all.py @@ -67,7 +67,7 @@ def gather_requirements_from_manifests() -> list[str]: """Gather all of the requirements from provider manifests.""" dependencies: list[str] = [] providers_path = "music_assistant/providers" - for dir_str in sorted(os.listdir(providers_path)): # noqa: PTH208, RUF100 + for dir_str in os.listdir(providers_path): # noqa: PTH208, RUF100 dir_path = os.path.join(providers_path, dir_str) if not os.path.isdir(dir_path): continue From 8200f5cd3a7b5c96a5a54b5c562b178db382129f Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Tue, 28 Apr 2026 10:33:52 +0300 Subject: [PATCH 53/54] chore: pin yandex-music==3.0.0 in requirements_all.txt to match CI regeneration Temporary alignment until #3804 (sorted gen_requirements_all) lands and makes the choice deterministic. Co-Authored-By: Claude Opus 4.7 --- requirements_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index 5516e52483..43e5a19b56 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -89,7 +89,7 @@ websocket-client==1.9.0 wiim==0.1.4 xmltodict==1.0.4 ya-passport-auth==1.3.0 -yandex-music==2.2.0 +yandex-music==3.0.0 ytmusicapi==1.11.5 zeroconf==0.148.0 zvuk-music[async]==0.6.1 From be10725e3a098239e59a4e8f55d3b6daeeeedacb Mon Sep 17 00:00:00 2001 From: Mikhail Nevskiy Date: Tue, 28 Apr 2026 10:37:25 +0300 Subject: [PATCH 54/54] chore: flip yandex-music pin back to 2.2.0 (CI inode order shifted again) Co-Authored-By: Claude Opus 4.7 --- requirements_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index 43e5a19b56..5516e52483 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -89,7 +89,7 @@ websocket-client==1.9.0 wiim==0.1.4 xmltodict==1.0.4 ya-passport-auth==1.3.0 -yandex-music==3.0.0 +yandex-music==2.2.0 ytmusicapi==1.11.5 zeroconf==0.148.0 zvuk-music[async]==0.6.1