From a885511dd278499c2b38b49cc95b95e40130185b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 May 2026 16:57:07 +0000 Subject: [PATCH 01/12] feat(yandex_ynison): add yandex_ynison provider v2.1.0 --- .../providers/yandex_ynison/__init__.py | 74 +- .../providers/yandex_ynison/constants.py | 15 + .../providers/yandex_ynison/protocols.py | 10 + .../providers/yandex_ynison/provider.py | 1199 ++++++++++++++++- .../providers/yandex_ynison/streaming.py | 2 +- .../providers/yandex_ynison/ynison_client.py | 106 +- tests/providers/yandex_ynison/test_auth.py | 8 +- .../yandex_ynison/test_config_entries.py | 6 +- .../providers/yandex_ynison/test_provider.py | 208 ++- .../yandex_ynison/test_provider_handoff.py | 991 ++++++++++++++ .../providers/yandex_ynison/test_streaming.py | 4 +- .../yandex_ynison/test_ynison_client.py | 139 +- 12 files changed, 2657 insertions(+), 105 deletions(-) create mode 100644 tests/providers/yandex_ynison/test_provider_handoff.py diff --git a/music_assistant/providers/yandex_ynison/__init__.py b/music_assistant/providers/yandex_ynison/__init__.py index 6dffcda6d2..74b54e20ac 100644 --- a/music_assistant/providers/yandex_ynison/__init__.py +++ b/music_assistant/providers/yandex_ynison/__init__.py @@ -16,16 +16,21 @@ CONF_ACTION_CLEAR_AUTH, CONF_ALLOW_PLAYER_SWITCH, CONF_DEVICE_ID, + CONF_HANDOFF_HEARTBEAT_INTERVAL, CONF_MASS_PLAYER_ID, CONF_OUTPUT_BIT_DEPTH, CONF_OUTPUT_SAMPLE_RATE, + CONF_PLAYBACK_MODE, CONF_PUBLISH_NAME, CONF_REMEMBER_SESSION, CONF_TOKEN, CONF_X_TOKEN, CONF_YM_INSTANCE, DEFAULT_DISPLAY_NAME, + HANDOFF_HEARTBEAT_DEFAULT, OUTPUT_AUTO, + PLAYBACK_MODE_HANDOFF, + PLAYBACK_MODE_STREAM, PLAYER_ID_AUTO, YM_INSTANCE_OWN, ) @@ -41,11 +46,26 @@ SUPPORTED_FEATURES = {ProviderFeature.AUDIO_SOURCE} +def _features_for_mode(mode: str) -> set[ProviderFeature]: + """Return the supported features set for the given playback mode. + + Stream mode (default) advertises AUDIO_SOURCE so the plugin appears as a + selectable audio source on players. Handoff mode hands playback off to + MA's player_queue + yandex_music MusicProvider, so the plugin must NOT + own the audio source — features set is empty. + """ + if mode == PLAYBACK_MODE_HANDOFF: + return set() + return {ProviderFeature.AUDIO_SOURCE} + + async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: """Initialize provider(instance) with given configuration.""" - return YandexYnisonProvider(mass, manifest, config, SUPPORTED_FEATURES) + mode = cast("str | None", config.get_value(CONF_PLAYBACK_MODE)) or PLAYBACK_MODE_STREAM + features = _features_for_mode(mode) + return YandexYnisonProvider(mass, manifest, config, features) async def get_config_entries( # noqa: PLR0915 — flow naturally returns ~12 ConfigEntry objects @@ -301,6 +321,58 @@ async def get_config_entries( # noqa: PLR0915 — flow naturally returns ~12 Co default_value=DEFAULT_DISPLAY_NAME, advanced=True, ), + ConfigEntry( + key=CONF_PLAYBACK_MODE, + type=ConfigEntryType.STRING, + label="Playback mode (experimental)", + description=( + "How audio reaches the player when a track is selected from the " + "Yandex Music app.\n\n" + "Stream (default): the plugin acts as an audio source — it streams " + "PCM into Music Assistant, which then forwards to the player. " + "Stable, but adds an extra ffmpeg in the pipeline.\n\n" + "Handoff (experimental): the plugin pushes the chosen track into " + "Music Assistant's player queue and lets MA stream it natively " + "through the linked Yandex Music provider — no extra ffmpeg, no " + "PCM resampling. Spotify Connect intentionally avoids this mode " + "(see CONF_HANDOFF_MODE in their provider) for the looser sync " + "trade-off described below.\n\n" + "Handoff requires a working `yandex_music` music provider in MA — " + "without it, play_media() will fail to resolve track URIs.\n\n" + "In handoff, the MA player queue is owned by Ynison: starting " + "playback from the Yandex Music app will REPLACE any queue you " + "built manually in the MA UI, without warning.\n\n" + "Audio quality (Hi-Res / lossless) in handoff depends on the " + "linked yandex_music provider's quality setting, not on this " + "plugin's output_sample_rate / output_bit_depth (those apply " + "only to stream mode)." + ), + default_value=PLAYBACK_MODE_STREAM, + options=[ + ConfigValueOption("Stream (recommended)", PLAYBACK_MODE_STREAM), + ConfigValueOption("Handoff (experimental)", PLAYBACK_MODE_HANDOFF), + ], + ), + ConfigEntry( + key=CONF_HANDOFF_HEARTBEAT_INTERVAL, + type=ConfigEntryType.STRING, + label="Handoff progress heartbeat (seconds)", + description=( + "How often (in seconds) the plugin pushes a fresh `update_playing_status` " + "to Ynison while in handoff mode, regardless of MA queue events. Guards " + "against the Ynison server re-balancing the active device away from us " + "when the player generates QUEUE_TIME_UPDATED events sparsely (typical " + "for DLNA/UPnP renderers). Ignored in stream mode." + ), + default_value=str(int(HANDOFF_HEARTBEAT_DEFAULT)), + options=[ + ConfigValueOption("3 seconds (aggressive)", "3"), + ConfigValueOption("5 seconds (default)", "5"), + ConfigValueOption("7 seconds", "7"), + ConfigValueOption("10 seconds (conservative)", "10"), + ], + advanced=True, + ), ConfigEntry( key=CONF_DEVICE_ID, type=ConfigEntryType.STRING, diff --git a/music_assistant/providers/yandex_ynison/constants.py b/music_assistant/providers/yandex_ynison/constants.py index fa73ff0a5c..1b1c22a888 100644 --- a/music_assistant/providers/yandex_ynison/constants.py +++ b/music_assistant/providers/yandex_ynison/constants.py @@ -25,6 +25,21 @@ CONF_DEVICE_ID: Final[str] = "device_id" CONF_OUTPUT_SAMPLE_RATE: Final[str] = "output_sample_rate" CONF_OUTPUT_BIT_DEPTH: Final[str] = "output_bit_depth" +CONF_PLAYBACK_MODE: Final[str] = "playback_mode" +CONF_HANDOFF_HEARTBEAT_INTERVAL: Final[str] = "handoff_heartbeat_interval" + +# Playback mode values +# - stream: default — plugin owns the audio source and streams PCM via PluginSource +# - handoff: experimental — plugin pushes tracks into MA's player_queue, +# MA streams natively through yandex_music without our inner ffmpeg +PLAYBACK_MODE_STREAM: Final[str] = "stream" +PLAYBACK_MODE_HANDOFF: Final[str] = "handoff" + +# Handoff progress heartbeat — guards against Ynison re-balancing the active +# device away from us when EventType.QUEUE_TIME_UPDATED is sparse (DLNA/UPnP). +HANDOFF_HEARTBEAT_DEFAULT: Final[float] = 5.0 +HANDOFF_HEARTBEAT_MIN: Final[float] = 3.0 +HANDOFF_HEARTBEAT_MAX: Final[float] = 10.0 # Action keys (own-mode QR auth flow) CONF_ACTION_AUTH_QR: Final[str] = "auth_qr" diff --git a/music_assistant/providers/yandex_ynison/protocols.py b/music_assistant/providers/yandex_ynison/protocols.py index 403b490458..44499a590a 100644 --- a/music_assistant/providers/yandex_ynison/protocols.py +++ b/music_assistant/providers/yandex_ynison/protocols.py @@ -20,6 +20,16 @@ class YandexMusicProviderLike(Protocol): Only the subset of methods/properties used by the Ynison plugin. """ + @property + def instance_id(self) -> str: + """The MA instance_id of this provider. + + Used by handoff mode to build URIs that target the exact yandex_music + instance we borrow from, rather than the first one that matches by + domain (matters when borrow + own coexist). + """ + ... + async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: """Resolve stream details for a track.""" ... diff --git a/music_assistant/providers/yandex_ynison/provider.py b/music_assistant/providers/yandex_ynison/provider.py index 92eab3464f..1304b16cc1 100644 --- a/music_assistant/providers/yandex_ynison/provider.py +++ b/music_assistant/providers/yandex_ynison/provider.py @@ -7,6 +7,7 @@ import time from collections.abc import AsyncGenerator, Callable from contextlib import suppress +from enum import StrEnum from typing import TYPE_CHECKING, Any, cast from music_assistant_models.enums import ( @@ -16,6 +17,7 @@ PlaybackState, ProviderFeature, ProviderType, + QueueOption, StreamType, ) from music_assistant_models.errors import ( @@ -34,15 +36,22 @@ from .constants import ( CONF_ALLOW_PLAYER_SWITCH, CONF_DEVICE_ID, + CONF_HANDOFF_HEARTBEAT_INTERVAL, CONF_MASS_PLAYER_ID, CONF_OUTPUT_BIT_DEPTH, CONF_OUTPUT_SAMPLE_RATE, + CONF_PLAYBACK_MODE, CONF_PUBLISH_NAME, CONF_TOKEN, CONF_X_TOKEN, CONF_YM_INSTANCE, DEFAULT_DISPLAY_NAME, + HANDOFF_HEARTBEAT_DEFAULT, + HANDOFF_HEARTBEAT_MAX, + HANDOFF_HEARTBEAT_MIN, OUTPUT_AUTO, + PLAYBACK_MODE_HANDOFF, + PLAYBACK_MODE_STREAM, PLAYER_ID_AUTO, YANDEX_MUSIC_CONF_QUALITY, YANDEX_MUSIC_CONF_TOKEN, @@ -75,7 +84,10 @@ from music_assistant.mass import MusicAssistant # How often (seconds) to sync progress to MA UI and Ynison. -_PROGRESS_SYNC_INTERVAL = 5.0 +# Lowered from 5.0s — the Yandex.Music app shows playback position from +# the active device, so faster updates make play/pause/seek feel snappier +# in the app. Ynison does not rate-limit `update_playing_status`. +_PROGRESS_SYNC_INTERVAL = 2.0 # Retry settings for transient Yandex API failures _API_MAX_RETRIES = 3 @@ -85,6 +97,76 @@ # Cache TTL for stream details (seconds) _STREAM_DETAILS_CACHE_TTL = 300 # 5 minutes +# Pre-fetch budget for the format adaptation hint. _get_stream_details_with_retry +# does up to 3 attempts with exponential backoff (2s + 4s = ~6s in the worst +# case) — that latency is unacceptable inline before select_source(). If the +# fetch can't satisfy the budget, fall back to the previously known format +# rather than blocking playback activation. +_PREFETCH_FORMAT_TIMEOUT = 2.5 + +# Window during which we suppress seek-detection after a track change, seek, +# or play_media(REPLACE) — Ynison echoes our update back, and the stale +# progress in those echoes would otherwise look like a fresh user seek. +# 3s comfortably covers the WS round-trip + MA stream startup; longer windows +# delay legitimate user seeks issued shortly after a track change. +_ECHO_GRACE_PERIOD = 3.0 + +# Drift-seek suppression: how long after we issue play_media or a seek +# command we ignore drift between Ynison-reported progress and MA queue +# elapsed time. Doubles as the "activation window" used by heartbeat / +# _on_ma_player_event to force `paused=False` while MA is still spinning +# the stream up (otherwise the IDLE-during-startup gap would propagate +# `paused=True` to Ynison and the app would flip to "paused" prematurely). +# 10s comfortably covers play_media await + post-await Chromecast/DLNA/ +# web startup latency end-to-end. +_DRIFT_SUPPRESS_PERIOD = 10.0 + +# Re-issue debounce: how long after a play_media (initial or IDLE-resume) +# we refuse to fire another play_media(REPLACE). 8s comfortably covers +# real Chromecast/DLNA/web-player startup latency (typically 4-6s) so a +# stream of `paused=False` echoes from Ynison while MA is still spinning +# the stream up doesn't trigger duplicate REPLACEs that race the first. +# Three seconds turned out to be too short — heartbeat at T+3.5s reported +# `paused=True` (queue still IDLE), Ynison-app showed pause, user re-tapped +# play, second REPLACE fired, which raced the first. See PR #48 live test. +_REISSUE_DEBOUNCE_PERIOD = 8.0 + +# Idempotency cache TTL for outbound peer-commands (1 s). A second +# `_apply_*` call carrying the same `(action, track_id)` key inside this +# window is silently dropped — protects against duplicate MA events +# arriving in rapid succession. +_COMMAND_IDEMPOTENCY_TTL = 1.0 + +# play_media exception backoff sequence. After failures we wait the +# corresponding number of seconds before retrying; we stop after the +# sequence is exhausted (3 attempts). +_PLAY_MEDIA_BACKOFF_SECONDS: tuple[float, ...] = (1.0, 2.0, 5.0) + + +class HandoffPhase(StrEnum): + """Internal handoff state machine. + + Mirrors the lifecycle of a single track from the plugin's point of view, + independent of MA's `PlaybackState`. Decisions in `_dispatch_state` use + the pair `(actual_queue_state, _expected_phase)` to pick the right action. + + Transitions (ours, driven only by `_apply_*` methods): + IDLE → ACTIVATING : play_media issued for a new track + ACTIVATING → PLAYING : MA queue reports PLAYING for our track + PLAYING → PAUSED : user pause + PAUSED → PLAYING : user resume + PLAYING → ENDING : elapsed crossed `duration - 2 s` + ENDING → IDLE : completion signalled to Ynison and acknowledged + any → IDLE : `_clear_active_player()` + """ + + IDLE = "idle" + ACTIVATING = "activating" + PLAYING = "playing" + PAUSED = "paused" + ENDING = "ending" + + # Accepted non-auto values for output format overrides; mirrors the options # offered in CONF_OUTPUT_SAMPLE_RATE / CONF_OUTPUT_BIT_DEPTH config entries. # Used defensively to reject stale/tampered values without raising. @@ -92,6 +174,23 @@ _VALID_BIT_DEPTHS: frozenset[str] = frozenset({"16", "24"}) +def _parse_handoff_heartbeat(raw: Any) -> float: + """Coerce config value (str/int/None) to a clamped heartbeat interval (s). + + Accepts string options ("3", "5", "7", "10") from the dropdown as well as + legacy int/float values from older configs. Falls back to the default on + any unparsable input — the heartbeat is a safety net, not a critical + setting, and we don't want it crashing provider load. + """ + if raw is None: + return HANDOFF_HEARTBEAT_DEFAULT + try: + value = float(raw) + except (TypeError, ValueError): + return HANDOFF_HEARTBEAT_DEFAULT + return max(HANDOFF_HEARTBEAT_MIN, min(HANDOFF_HEARTBEAT_MAX, value)) + + class YandexYnisonProvider(PluginProvider): """Implementation of the Yandex Music Connect (Ynison) Plugin.""" @@ -101,6 +200,11 @@ def instance_name_postfix(self) -> str | None: name = self._display_name return name if name != DEFAULT_DISPLAY_NAME else None + @property + def _is_handoff(self) -> bool: + """True when this instance is configured for handoff playback mode.""" + return self._playback_mode == PLAYBACK_MODE_HANDOFF + def __init__( self, mass: MusicAssistant, @@ -128,6 +232,12 @@ def __init__( self._display_name: str = ( cast("str", self.config.get_value(CONF_PUBLISH_NAME)) or DEFAULT_DISPLAY_NAME ) + self._playback_mode: str = ( + cast("str", self.config.get_value(CONF_PLAYBACK_MODE)) or PLAYBACK_MODE_STREAM + ) + self._handoff_heartbeat_interval: float = _parse_handoff_heartbeat( + self.config.get_value(CONF_HANDOFF_HEARTBEAT_INTERVAL) + ) # Token source — None = own (manually entered CONF_TOKEN); # otherwise the instance_id of a linked yandex_music provider to borrow from. @@ -163,6 +273,39 @@ def __init__( self._normalized_params: dict[str, Any] = PCM_LOSSY_PARAMS self._normalized_format: AudioFormat = make_pcm_format(PCM_LOSSY_PARAMS) + # Handoff bookkeeping (v2.0 FSM): + # `_expected_phase` is the plugin's authoritative view of where the + # current track stands. Driven only by `_apply_*` methods, never by + # incoming MA queue states. `_dispatch_state` matches MA's actual + # queue.state against `_expected_phase` to pick the correct action. + self._expected_phase: HandoffPhase = HandoffPhase.IDLE + self._expected_track_id: str | None = None + # Throttle watermark for outbound progress (heartbeat + MA event). + self._handoff_last_progress_sync_mono: float = 0.0 + # Track id we've already signalled "completion" for — gates against + # double end-of-track reports inside the same track. + self._handoff_completion_signaled_for: str | None = None + # Drift-seek suppression window — set after play_media/seek to + # ignore stale Ynison echoes while MA spins up the stream. + self._drift_suppress_until: float = 0.0 + # Re-issue debounce — set after IDLE-resume play_media to refuse + # another play_media until MA has had time to actually start. + self._re_issue_debounce_until: float = 0.0 + # Last MA queue.state we observed; used to detect transitions for + # force-progress (state changes bypass the 2s throttle). + self._handoff_last_seen_state: PlaybackState | None = None + # Last position MA queue reported while PLAYING — preferred over + # Ynison's progress_ms echo on IDLE-resume after a fast toggle. + self._handoff_last_playing_elapsed_ms: int = 0 + # In-flight play_media task; cancelled when a new track arrives + # before the previous activation has settled. + self._play_media_task: asyncio.Task[None] | None = None + # Idempotency cache: (action, track_id) → expiry monotonic time. + self._command_idempotency: dict[tuple[str, str | None], float] = {} + # Independent heartbeat task — guards against Ynison re-balancing the + # active device when MA queue events are sparse (DLNA/UPnP). + self._handoff_heartbeat_task: asyncio.Task[None] | None = None + # Rate limiter for Yandex API calls (max 2 req/s) self._api_throttler = ThrottlerManager(rate_limit=2, period=1.0) @@ -227,6 +370,31 @@ async def handle_async_init(self) -> None: # Initial check for matching provider self.mass.create_task(self._check_yandex_provider_match()) + # Handoff mode: subscribe to MA events so we can mirror progress and + # playback state back to Ynison without owning the audio source. + if self._is_handoff: + self._on_unload_callbacks.append( + self.mass.subscribe( + self._on_ma_player_event, + (EventType.QUEUE_TIME_UPDATED, EventType.PLAYER_UPDATED), + ) + ) + # NOTE: MEDIA_ITEM_PLAYED was tried as a primary natural-end + # signal but turned out unreliable: MA fires it whenever its + # internal "fully played" heuristic decides a track is done + # (>= 90% playback time), and that heuristic confuses the + # seek-on-activation flow into firing within ~30s of starting + # a 4-minute track. Stick with the queue state-transition + # fallback in `_on_ma_player_event` (PLAYING → PAUSED/IDLE + + # `_is_at_natural_end_of_track`) which uses our own snapshot + # of the last-known PLAYING elapsed time. + self.logger.info( + "Playback mode: HANDOFF — MA player_queue will own audio " + "(experimental, expect looser app sync)" + ) + else: + self.logger.info("Playback mode: STREAM (default)") + async def unload(self, is_removed: bool = False) -> None: """Handle close/cleanup of the provider.""" if self._prefetch_task and not self._prefetch_task.done(): @@ -234,6 +402,22 @@ async def unload(self, is_removed: bool = False) -> None: with suppress(asyncio.CancelledError): await self._prefetch_task + # Cancel any in-flight handoff play_media task — otherwise it + # keeps running against a partially-torn-down `mass` context + # (e.g. config-driven reload or account switch). + if self._play_media_task and not self._play_media_task.done(): + self._play_media_task.cancel() + with suppress(asyncio.CancelledError, Exception): + await self._play_media_task + + # Cancel handoff heartbeat task if still alive. + heartbeat_task = self._handoff_heartbeat_task + self._handoff_heartbeat_task = None + if heartbeat_task is not None and not heartbeat_task.done(): + heartbeat_task.cancel() + with suppress(asyncio.CancelledError): + await heartbeat_task + if self._ynison: await self._ynison.disconnect() @@ -659,24 +843,53 @@ async def _handle_ynison_state(self, state: YnisonState) -> None: state.progress_ms, ) + # Post-reconnect settle window (v2.0). The first state after a WS + # reconnect can be a stale snapshot from before the disconnect — + # acting on it would re-fire whatever was last broadcast (typically + # paused=True from our own heartbeat). Skip with a debug log; the + # next genuine state-update inside ~2s will be processed normally. + if self._ynison and self._ynison.in_post_reconnect_settle: + self.logger.debug( + "Skipping state inside post-reconnect settle window (track=%s paused=%s)", + track_id, + state.is_paused, + ) + return + if is_our_device and not state.is_paused: # Pre-fetch next batch when playing second-to-last track self._maybe_prefetch(current_index, playable_list, entity_id, entity_type) await self._activate_playback(state) elif is_our_device and state.is_paused: - # Our device but paused — stop player, keep association - await self._pause_playback() - elif self._source_details.in_use_by: - # Active device switched away — fully release player + if self._is_handoff: + # Pause only the player we already activated. Falling back to + # _get_target_player_id() here would auto-select an unrelated + # playing player after startup/cleanup and pause the wrong + # MA queue (Copilot review). + if self._active_player_id: + await self._handoff_pause(self._active_player_id) + else: + # Our device but paused — stop player, keep association + await self._pause_playback() + elif self._source_details.in_use_by or (self._is_handoff and self._active_player_id): + # Active device switched away — fully release player. + # In handoff mode `_source_details.in_use_by` is always None + # (no AUDIO_SOURCE), so we also check `_active_player_id` — + # otherwise the heartbeat keeps pushing stale progress to + # Ynison long after another device took over. self._clear_active_player() - async def _activate_playback(self, state: YnisonState) -> None: + async def _activate_playback(self, state: YnisonState) -> None: # noqa: PLR0915 """Activate playback on the target MA player.""" target_player_id = self._get_target_player_id() if not target_player_id: self.logger.warning("Ynison active on our device but no MA player available") return + if self._is_handoff: + await self._handoff_activate(state, target_player_id) + return + # Detect resume after pause: stream was stopped but player still associated needs_reselect = self._stream_stop_event.is_set() self._stream_stop_event.clear() @@ -686,7 +899,19 @@ async def _activate_playback(self, state: YnisonState) -> None: # (set by the server callback after DLNA negotiation completes) to # prevent queuing redundant select_source calls during the ~5s gap. if self._active_player_id != target_player_id or needs_reselect: + # Pre-fetch real format of the upcoming track BEFORE select_source + # so the outer ffmpeg starts with PluginSource.audio_format that + # matches what we will actually emit. Skipped only when the same + # player resumes the same track (format definitely unchanged); a + # resume-reselect onto a *different* track must still prefetch. + new_track = state.current_track_id + should_prefetch = new_track is not None and ( + self._active_player_id != target_player_id + or new_track != self._current_streaming_track_id + ) self._active_player_id = target_player_id + if should_prefetch and new_track is not None: + await self._prefetch_format_for_track(new_track) self.mass.create_task( self.mass.players.select_source(target_player_id, self.instance_id) ) @@ -703,14 +928,14 @@ async def _activate_playback(self, state: YnisonState) -> None: # Grace period: ignore seek detection for a few seconds after # track change — Ynison echoes can report stale progress that # looks like a large drift. - self._seek_grace_until = time.monotonic() + 5.0 + self._seek_grace_until = time.monotonic() + _ECHO_GRACE_PERIOD elif new_track and new_track == self._current_streaming_track_id: # Same-track resume after pause: explicitly seek to the Ynison position # so the new stream starts at the right offset. if needs_reselect: self._seek_position_ms = state.progress_ms self._track_changed_event.set() - self._seek_grace_until = time.monotonic() + 5.0 + self._seek_grace_until = time.monotonic() + _ECHO_GRACE_PERIOD significant_change = True else: # Detect seek: compare Ynison progress against our stream position. @@ -724,31 +949,39 @@ async def _activate_playback(self, state: YnisonState) -> None: else: our_ms = self._streaming_progress_ms if our_ms >= 0: - drift_ms = abs(state.progress_ms - our_ms) - if drift_ms > 3000: + verdict = self._classify_drift(state.progress_ms, our_ms) + if verdict == "seek": self.logger.info( "Seek detected on track %s: " "expected ~%dms, Ynison at %dms (drift %dms)", new_track, our_ms, state.progress_ms, - int(drift_ms), + abs(state.progress_ms - our_ms), ) self._seek_position_ms = state.progress_ms self._track_changed_event.set() - self._seek_grace_until = now + 5.0 + self._seek_grace_until = now + _ECHO_GRACE_PERIOD significant_change = True + elif verdict == "queue_rebuild": + self.logger.debug( + "Stream: drift to 0 ignored on %s (Ynison=%dms, " + "stream=%dms) — queue-rebuild echo, not a user seek", + new_track, + state.progress_ms, + our_ms, + ) # Update metadata from state self._update_metadata(state) # Always trigger player update on significant changes; - # throttle regular updates to avoid UI churn (every 5 seconds). + # throttle regular updates to avoid UI churn (every 2 seconds). # Use force_update on seek/track change so the server broadcasts a full # PLAYER_UPDATED event instead of a lightweight elapsed-time-only one # that the frontend may not handle for PluginSource players. now_mono = time.monotonic() - if significant_change or needs_reselect or now_mono - self._last_player_update_time >= 5.0: + if significant_change or needs_reselect or now_mono - self._last_player_update_time >= 2.0: self.mass.players.trigger_player_update( target_player_id, force_update=significant_change ) @@ -983,6 +1216,22 @@ def _clear_active_player(self) -> None: self._prefetched_list = None if self._prefetch_task and not self._prefetch_task.done(): self._prefetch_task.cancel() + # Stop the handoff heartbeat — there is no active player to report on. + self._cancel_handoff_heartbeat() + # Reset handoff bookkeeping so the next activation starts clean. + # The progress watermark is the throttle key for both + # _on_ma_player_event and _handoff_heartbeat_loop — leaving a stale + # value here would suppress the first update of a fresh session + # (Copilot review). + self._expected_phase = HandoffPhase.IDLE + self._expected_track_id = None + self._handoff_completion_signaled_for = None + self._drift_suppress_until = 0.0 + self._re_issue_debounce_until = 0.0 + self._handoff_last_seen_state = None + self._handoff_last_progress_sync_mono = 0.0 + self._handoff_last_playing_elapsed_ms = 0 + self._command_idempotency.clear() if prev_player_id: self.logger.debug( @@ -1026,14 +1275,21 @@ async def _check_yandex_provider_match(self) -> None: self._yandex_provider = None self._update_source_capabilities() - def _update_normalized_format(self) -> None: - """Set PCM normalization profile based on config and YM quality. + def _update_normalized_format(self, hint: AudioFormat | None = None) -> None: + """Set PCM normalization profile based on config, YM quality and optional hint. + + Priority: explicit config values > hint from real track stream_details + > auto-detection from YM quality. - Priority: explicit config values > auto-detection from YM quality. Auto-detection reads the quality tier from the linked yandex_music provider's config (`provider.config.get_value("quality")`), since yandex_music does not expose a typed accessor method. - Auto-detection: superb/lossless → 24bit/48kHz, else → 16bit/44.1kHz. + Auto-detection: superb/lossless → 24bit/44.1kHz, else → 16bit/44.1kHz. + + When *hint* is provided (e.g. real ``stream_details.audio_format`` of + the upcoming track) and config is at OUTPUT_AUTO, the hint's + sample_rate/bit_depth override the auto-detected base — letting Hi-Res + tracks (96 kHz / 24-bit) flow without resampling. Creates fresh AudioFormat instances each time to prevent mutation by MA's FFMpeg._log_reader_task (which sets input_format.codec_type @@ -1051,12 +1307,21 @@ def _update_normalized_format(self) -> None: is_lossless = quality in YANDEX_MUSIC_LOSSLESS_QUALITIES base = PCM_LOSSLESS_PARAMS if is_lossless else PCM_LOSSY_PARAMS + sample_rate = base["sample_rate"] + bit_depth = base["bit_depth"] + + # Hint from real stream_details — only honoured in auto mode. + # Validate against accepted values to reject implausible reports. + if hint is not None: + if self._cfg_sample_rate == OUTPUT_AUTO and hint.sample_rate in {44100, 48000, 96000}: + sample_rate = hint.sample_rate + if self._cfg_bit_depth == OUTPUT_AUTO and hint.bit_depth in {16, 24}: + bit_depth = hint.bit_depth + # Apply config overrides. MA's ConfigEntry options constrain the UI to # known-good strings, but a stale persisted value or hand-edited config # could still surface something unparsable or off-list — fall back to # the auto-detected base with a warning instead of crashing the load. - sample_rate = base["sample_rate"] - bit_depth = base["bit_depth"] if self._cfg_sample_rate != OUTPUT_AUTO: if self._cfg_sample_rate in _VALID_SAMPLE_RATES: sample_rate = int(self._cfg_sample_rate) @@ -1114,6 +1379,63 @@ def _update_normalized_format(self) -> None: self._normalized_format.bit_depth, ) + async def _prefetch_format_for_track(self, track_id: str) -> None: + """Pre-fetch stream details for *track_id* and adapt PCM format. + + MA reads ``PluginSource.audio_format`` BEFORE calling our + ``get_audio_stream()`` (see ``streams/controller.py`` — + ``get_plugin_source_stream`` captures ``input_format`` from the + source's ``audio_format`` and starts the outer ffmpeg with it). + Therefore the format must already match the upcoming track when + ``select_source()`` fires — adapting later is too late. + + This method is best-effort: any failure leaves the current format + in place and only logs a warning, so playback never breaks because + of a bad pre-fetch. + + Bounded by ``_PREFETCH_FORMAT_TIMEOUT`` so a transient API hiccup + cannot stall ``select_source()`` for the full retry budget — a slow + pre-fetch is treated like a failed one (fall back to current format, + let the in-stream `_get_stream_details_with_retry` handle retries). + """ + if not self._yandex_provider: + return + try: + stream_details = await asyncio.wait_for( + self._get_stream_details_with_retry(track_id), + timeout=_PREFETCH_FORMAT_TIMEOUT, + ) + except TimeoutError: + self.logger.info( + "Pre-fetch of stream details for %s exceeded %.1fs — " + "keeping current format; in-stream fetch will retry", + track_id, + _PREFETCH_FORMAT_TIMEOUT, + ) + return + except Exception: + self.logger.warning( + "Pre-fetch of stream details failed for %s — keeping current format", + track_id, + exc_info=True, + ) + return + old_sr = self._normalized_params.get("sample_rate") + old_bd = self._normalized_params.get("bit_depth") + self._update_normalized_format(hint=stream_details.audio_format) + new_sr = self._normalized_params.get("sample_rate") + new_bd = self._normalized_params.get("bit_depth") + if (old_sr, old_bd) != (new_sr, new_bd): + self.logger.info( + "Pre-fetch adapted format for %s: %dHz/%dbit -> %dHz/%dbit (source=%s)", + track_id, + old_sr or 0, + old_bd or 0, + new_sr or 0, + new_bd or 0, + stream_details.audio_format, + ) + def _update_source_capabilities(self) -> None: """Update source capabilities based on linked provider availability.""" has_provider = self._yandex_provider is not None @@ -1137,6 +1459,832 @@ def _update_source_capabilities(self) -> None: if self._source_details.in_use_by: self.mass.players.trigger_player_update(self._source_details.in_use_by) + # ------------------------------------------------------------------ + # Handoff mode (experimental) + # ------------------------------------------------------------------ + + def _idempotent(self, action: str, key: str | None) -> bool: + """Return True if `(action, key)` was not seen within the TTL window. + + Suppresses duplicate command invocations during rapid Ynison echoes — + e.g. two identical pause notifications inside 1s should issue + `mass.players.cmd_pause` only once. The cache is keyed by the + `(action, key)` tuple and entries older than `_COMMAND_IDEMPOTENCY_TTL` + are evicted lazily. + """ + now = time.monotonic() + # Lazy GC: drop stale entries to keep the dict bounded. + for stale_key in [ + k for k, ts in self._command_idempotency.items() if now - ts > _COMMAND_IDEMPOTENCY_TTL + ]: + self._command_idempotency.pop(stale_key, None) + composite = (action, key) + last = self._command_idempotency.get(composite) + if last is not None and now - last < _COMMAND_IDEMPOTENCY_TTL: + return False + self._command_idempotency[composite] = now + return True + + @staticmethod + def _classify_drift( + ynison_ms: int, + our_ms: int, + threshold_ms: int = 3000, + ) -> str: + """Classify drift between Ynison-reported and our local position. + + Returns one of: + - ``"ignore"`` — drift below threshold; no seek needed. + - ``"queue_rebuild"`` — Ynison reports near-zero while we're + past 5s; treat as a RADIO queue-rebuild echo, not a real + user seek (otherwise we'd yank playback back to start + mid-track on every queue replenishment). + - ``"seek"`` — genuine drift; honor it. + + Used by both handoff (`_apply_same_track_sync`) and stream + mode (`_handle_ynison_state` drift block) to keep the + rebuild-vs-seek heuristic consistent. + """ + drift = abs(ynison_ms - our_ms) + if drift <= threshold_ms: + return "ignore" + if ynison_ms < 1000 and our_ms > 5000: + return "queue_rebuild" + return "seek" + + @staticmethod + def _pick_resume_position( + *, + local_snapshot_ms: int, + ynison_progress_ms: int, + ) -> tuple[int, str]: + """Pick the best resume position from local snapshot + Ynison. + + Returns ``(resume_ms, source_label)``. Local snapshots can + be stale after multiple REPLACE cycles (handoff) or after a + network blip (stream). Ynison's progress is set by the user's + app on pause and is authoritative for "where the user wants + to resume". Take the max so: + - A fast pause/resume toggle (Ynison echo lags by ~1s but + local snapshot is current) lands at the local value. + - A long pause where local accumulator was reset by REPLACE + cycles (snapshot=1s, Ynison=43s) lands at Ynison's 43s. + + Source label is for logging; values: ``"local_snapshot"`` or + ``"ynison"``. + """ + if local_snapshot_ms >= ynison_progress_ms and local_snapshot_ms > 0: + return local_snapshot_ms, "local_snapshot" + return ynison_progress_ms, "ynison" + + async def _cancel_pending_play_media(self) -> None: + """Cancel a still-running play_media task, if any. + + On rapid track-change in the Yandex app the previous `play_media` may + still be resolving stream details when the next one fires. Cancelling + the predecessor avoids a half-finished load racing with the new one + and confusing MA's queue runner. + """ + task = self._play_media_task + if task is None or task.done(): + return + task.cancel() + with suppress(asyncio.CancelledError, Exception): + await task + + def _build_handoff_uri(self, track_id: str) -> str: + """Build a Yandex Music URI for handoff `play_media`. + + Use the linked provider's instance_id when known so MA picks the right + yandex_music account (matters when borrow + own coexist). Fall back to + the bare domain — MA's `parse_uri` accepts both forms. + """ + prov_id = ( + self._yandex_provider.instance_id + if self._yandex_provider is not None + and getattr(self._yandex_provider, "instance_id", None) + else "yandex_music" + ) + return f"{prov_id}://track/{track_id}" + + async def _handoff_activate(self, state: YnisonState, target_player_id: str) -> None: + """Centralized FSM dispatcher for Ynison handoff state events. + + Classifies the incoming Ynison state diff and routes to the + appropriate `_apply_*` action method. Three top-level cases: + + 1. Track id changed → `_apply_track_change` (REPLACE / dedup / + cmd_play resume). + 2. Same track, queue IDLE on the same URI → `_apply_idle_resume` + (re-issue play_media + seek for resume after pause-watchdog). + 3. Same track, queue alive → `_apply_same_track_sync` (drift + detection + queue-PAUSED → cmd_play mirror). + + The plugin trades native MA pipeline (no inner ffmpeg, no PCM + resampling) for looser sync — Spotify Connect's authors hit the + same trade-off (see commented `CONF_HANDOFF_MODE` in + spotify_connect/__init__.py). + """ + new_track = state.current_track_id + if not new_track: + return + + # Replay reset (P6): fresh state at progress < 1s on the same + # track means the user reset playback. Clear the completion + # marker so the next end-of-track signals correctly. + if state.progress_ms < 1000 and new_track == self._expected_track_id: + self._handoff_completion_signaled_for = None + + expected_uri = self._build_handoff_uri(new_track) + is_track_change = new_track != self._expected_track_id + + if is_track_change: + # Pre-fetch primes MA's stream-detail cache. Cosmetic in + # handoff (MA fetches its own details), keeps logs/duration + # consistent during the transition. + if self._yandex_provider: + await self._prefetch_format_for_track(new_track) + await self._apply_track_change(state, target_player_id, new_track, expected_uri) + return + + queue = self.mass.player_queues.get(target_player_id) + + # Resume-via-REPLACE: a single-track REPLACE queue lands in IDLE + # *or* PAUSED on pause depending on the underlying player. + # cmd_play on PAUSED works when the HTTP stream is still live + # (local FIFO/Snapcast etc.), but local web/Chromecast players + # close the connection after a few seconds of pause and cmd_play + # then has nothing to resume from — audio stays silent. Always + # re-issue play_media on same-URI resume to spin the stream + # back up reliably. The cmd_pause/seek/cmd_play dance inside + # `_apply_idle_resume` avoids the audible 0-then-jump glitch. + if ( + queue is not None + and queue.state in (PlaybackState.IDLE, PlaybackState.PAUSED) + and queue.current_item is not None + and getattr(queue.current_item, "uri", None) == expected_uri + and not state.is_paused + # Guard: skip IDLE-resume in two phases: + # - PAUSED: WE just committed `_handoff_pause`; a stale + # Ynison `paused=False` echo would otherwise REPLACE on + # a deliberately-paused player. + # - ENDING: we just signalled track completion to Ynison + # (heartbeat or `_on_ma_player_event` natural-end path); + # queue is IDLE on the OLD track URI but Ynison is in + # the middle of broadcasting the new track id. Re-issuing + # REPLACE on the old URI here would race the upcoming + # `_apply_track_change` and the seek would be to + # duration-of-old-track, restarting the old track at its + # end and immediately re-firing natural-end / restarting + # the cycle. + and self._expected_phase not in (HandoffPhase.PAUSED, HandoffPhase.ENDING) + ): + await self._apply_idle_resume(state, target_player_id, expected_uri) + return + + await self._apply_same_track_sync(state, target_player_id, queue, new_track) + + async def _apply_track_change( # noqa: PLR0915 — sub-case branches inline + self, + state: YnisonState, + target_player_id: str, + new_track: str, + expected_uri: str, + ) -> None: + """Handle a new-track Ynison event in handoff mode. + + Three sub-cases based on MA queue state for the expected URI: + - PLAYING: dedup, just update bookkeeping; + - PAUSED: cmd_play resume (no REPLACE, keeps queue PAUSED); + - else: cancel any pending play_media + issue REPLACE. + """ + queue = self.mass.player_queues.get(target_player_id) + prev_track_id = self._expected_track_id + self._active_player_id = target_player_id + # Drop the elapsed snapshot — it belongs to the previous track. + self._handoff_last_playing_elapsed_ms = 0 + + # Heartbeat is started only on a clean activation; otherwise a + # heartbeat after a failed play_media would keep streaming the + # previous/idle queue progress to Ynison and delay rebalancing. + activated = False + + same_uri_playing = ( + queue is not None + and queue.current_item is not None + and getattr(queue.current_item, "uri", None) == expected_uri + and queue.state == PlaybackState.PLAYING + ) + same_uri_paused = ( + queue is not None + and queue.current_item is not None + and getattr(queue.current_item, "uri", None) == expected_uri + and queue.state == PlaybackState.PAUSED + ) + + if same_uri_playing: + # Dedup (P5): MA already plays the URI; just update state. + self.logger.debug( + "Handoff: queue already playing %s — skipping play_media", expected_uri + ) + self._expected_track_id = new_track + self._handoff_completion_signaled_for = None + activated = True + elif same_uri_paused: + # Same URI but paused — cmd_play resume (no REPLACE that + # would restart the stream and trigger the watchdog → IDLE + # drop on long pauses). + self.logger.info("Handoff: resuming paused queue on %s", expected_uri) + # Open a short drift-suppress window — cmd_play takes 100-500ms + # to land, during which `_apply_same_track_sync` could see a + # stale Ynison progress echo and fire a spurious seek. + self._drift_suppress_until = time.monotonic() + _DRIFT_SUPPRESS_PERIOD + self._expected_phase = HandoffPhase.ACTIVATING + try: + await self.mass.players.cmd_play(target_player_id) + except Exception: + self.logger.exception("Handoff resume cmd_play failed on %s", target_player_id) + else: + self._expected_track_id = new_track + self._handoff_completion_signaled_for = None + activated = True + else: + # Idempotency: same track-change command within 1s is a + # duplicate (Ynison echoed our state back). Skip duplicate + # play_media to avoid an unnecessary stream restart. + if not self._idempotent("play_media", new_track): + return + await self._cancel_pending_play_media() + self.logger.info( + "Handoff: track changed %s -> %s, calling player_queues.play_media", + prev_track_id, + new_track, + ) + # Open the activation window BEFORE the await — MA fires + # PLAYER_UPDATED events while play_media is still resolving, + # and our `_on_ma_player_event` handler must see + # `_drift_suppress_until` already set so it forces + # paused=False instead of leaking a stale paused=True. Same + # for `_re_issue_debounce_until` — covers the window where a + # paused=False echo could otherwise re-fire IDLE-resume + # against an in-flight stream. Saved for rollback on failure. + prev_drift = self._drift_suppress_until + prev_debounce = self._re_issue_debounce_until + prev_phase = self._expected_phase + now = time.monotonic() + self._drift_suppress_until = now + _DRIFT_SUPPRESS_PERIOD + self._re_issue_debounce_until = now + _REISSUE_DEBOUNCE_PERIOD + self._expected_phase = HandoffPhase.ACTIVATING + try: + self._play_media_task = asyncio.create_task( + self.mass.player_queues.play_media( + target_player_id, expected_uri, option=QueueOption.REPLACE + ) + ) + await self._play_media_task + except asyncio.CancelledError: + # Superseded by a fresher track change — leave the + # _expected_track_id as-is for the new invocation, but + # roll back the phase/windows so a series of cascaded + # cancellations doesn't leave `_expected_phase = ACTIVATING` + # and friends pointing at an in-flight task that no + # longer exists. The successor invocation will set them + # again right before its own await. + self._drift_suppress_until = prev_drift + self._re_issue_debounce_until = prev_debounce + self._expected_phase = prev_phase + raise + except Exception: + # play_media failed — don't commit _expected_track_id + # (otherwise the same-track branch would never retry + # play_media). Roll back the optimistic window/phase + # state so they don't lie to subsequent calls. + self._drift_suppress_until = prev_drift + self._re_issue_debounce_until = prev_debounce + self._expected_phase = prev_phase + self.logger.exception( + "Handoff play_media failed for %s on %s", + expected_uri, + target_player_id, + ) + else: + self._expected_track_id = new_track + self._handoff_completion_signaled_for = None + activated = True + # Honor Ynison's reported position when activating — + # the user may have transferred a mid-track playback + # from the Yandex app (track at 60s, switch device → + # MA must continue from 60s, not restart at 0). + # Same pause-seek-play sequence as _apply_idle_resume + # to avoid the audible 0-then-jump glitch. + start_ms = state.progress_ms + if start_ms >= 1000: + self.logger.info( + "Handoff: continuing track from Ynison-reported position (seek=%dms)", + start_ms, + ) + with suppress(Exception): + await self.mass.players.cmd_pause(target_player_id) + with suppress(Exception): + await self.mass.player_queues.seek(target_player_id, start_ms // 1000) + with suppress(Exception): + await self.mass.players.cmd_play(target_player_id) + + if activated: + self._ensure_handoff_heartbeat() + + async def _apply_idle_resume( + self, state: YnisonState, target_player_id: str, expected_uri: str + ) -> None: + """Re-issue play_media + seek when queue dropped to IDLE. + + Pausing a single-track REPLACE queue eventually drops MA's queue + runner to IDLE. When Ynison says "playing this track" again, + we have to spin the stream back up via REPLACE. The seek+ + cmd_pause/cmd_play dance avoids the audible 0-then-jump glitch. + """ + # Debounce: a recent play_media is still resolving; don't fire + # another REPLACE just because Ynison's paused=False keeps + # echoing on every WS round-trip — the player would never settle. + if time.monotonic() < self._re_issue_debounce_until: + return + + # Pick resume position via shared helper — see `_pick_resume_position`. + ma_snapshot = self._handoff_last_playing_elapsed_ms + ynison_pos = state.progress_ms + resume_ms, source = self._pick_resume_position( + local_snapshot_ms=ma_snapshot, + ynison_progress_ms=ynison_pos, + ) + self.logger.info( + "Handoff: queue IDLE on same URI — re-issuing play_media to resume " + "(seek=%dms, source=%s, ma_snapshot=%dms, ynison=%dms)", + resume_ms, + source, + ma_snapshot, + ynison_pos, + ) + # Open windows BEFORE the await — see `_apply_track_change` for + # the rationale. MA's PLAYER_UPDATED events during play_media + # must see them set so heartbeat doesn't leak paused=True. + # Saved for rollback on exception so a failed resume doesn't + # leave the provider stuck in an ACTIVATING/"not paused" reporting + # window and silently suppress the next attempt for the duration + # of `_REISSUE_DEBOUNCE_PERIOD`. + prev_drift = self._drift_suppress_until + prev_debounce = self._re_issue_debounce_until + prev_phase = self._expected_phase + now = time.monotonic() + self._drift_suppress_until = now + _DRIFT_SUPPRESS_PERIOD + self._re_issue_debounce_until = now + _REISSUE_DEBOUNCE_PERIOD + self._expected_phase = HandoffPhase.ACTIVATING + # Cancel any still-running play_media task before issuing the new + # REPLACE — the cancel-on-track-change invariant from CLAUDE.md + # applies here too. The 8s `_re_issue_debounce_until` guard makes + # this rare in practice, but a slow `play_media` (>8s) followed + # by an IDLE-resume could otherwise race two REPLACEs against + # the same MA queue. + await self._cancel_pending_play_media() + try: + await self.mass.player_queues.play_media( + target_player_id, expected_uri, option=QueueOption.REPLACE + ) + except Exception: + # play_media itself failed — the stream never started, so + # roll back the optimistic window/phase state. Otherwise + # the windows would suppress a legitimate retry from the + # next Ynison tick. + self._drift_suppress_until = prev_drift + self._re_issue_debounce_until = prev_debounce + self._expected_phase = prev_phase + self.logger.exception("Handoff IDLE-resume play_media failed on %s", target_player_id) + return + if resume_ms >= 1000: + # play_media(REPLACE) succeeded and starts decoding at 0. + # The pause/seek/play sequence trades the audible 0-then-jump + # for brief silence. Each leg is best-effort: a transient + # failure on cmd_pause / seek / cmd_play does NOT undo the + # rolling stream — the windows must stay armed so the next + # Ynison tick doesn't fire a duplicate REPLACE racing it. + with suppress(Exception): + await self.mass.players.cmd_pause(target_player_id) + with suppress(Exception): + await self.mass.player_queues.seek(target_player_id, resume_ms // 1000) + with suppress(Exception): + await self.mass.players.cmd_play(target_player_id) + + async def _apply_same_track_sync( + self, + state: YnisonState, + target_player_id: str, + queue: Any, + new_track: str, + ) -> None: + """Run drift-seek + queue-PAUSED → cmd_play resume mirror. + + Runs on every same-track Ynison update where the IDLE-resume + branch did not fire. Two responsibilities: + - Drift seek when Ynison and MA disagree on position by >3s + (with queue-rebuild guard for the progress=0 echo). + - Resume MA queue if it transitioned to PAUSED while Ynison + says playing (rare but possible if MA UI paused while Ynison + remained PLAYING). + """ + try: + our_pos_ms = int(queue.corrected_elapsed_time * 1000) if queue is not None else 0 + except Exception: + our_pos_ms = 0 + + # Drift-seek suppression: ignore drift inside the activation + # window EXCEPT when the queue is already PLAYING with elapsed + # > 1s — a real user seek within the window must still pass. + in_grace = time.monotonic() < self._drift_suppress_until + queue_progressed = ( + queue is not None + and queue.state == PlaybackState.PLAYING + and queue.corrected_elapsed_time > 1.0 + ) + if in_grace and not queue_progressed: + return + + # Drift detection: skip echoes — they'd reflect our own heartbeat + # bouncing back, not a real seek. IDLE-/PAUSED-resume branches + # above run regardless of echo; the skip is local to drift only. + # Also skip if MA's queue has no current_item: the track ended, + # `_signal_track_completion` may have sent progress=duration to + # Ynison, and we'd otherwise try to seek the empty queue back to + # that "phantom" position. + if ( + not state.last_update_is_echo + and queue is not None + and getattr(queue, "current_item", None) is not None + ): + verdict = self._classify_drift(state.progress_ms, our_pos_ms) + if verdict == "seek": + self.logger.info( + "Handoff: seek detected on %s (Ynison=%dms, MA=%dms)", + new_track, + state.progress_ms, + our_pos_ms, + ) + try: + await self.mass.player_queues.seek(target_player_id, state.progress_ms // 1000) + except Exception: + self.logger.exception("Handoff seek failed on %s", target_player_id) + elif verdict == "queue_rebuild": + self.logger.debug( + "Handoff: drift to 0 ignored on %s (Ynison=%dms, MA=%dms) " + "— queue-rebuild echo, not a user seek", + new_track, + state.progress_ms, + our_pos_ms, + ) + + # MA queue paused while Ynison says playing → cmd_play resume. + # cmd_play (not queue.play) keeps the queue in PAUSED → PLAYING + # transition fast, avoids watchdog-driven IDLE drop. + try: + if queue is not None and queue.state == PlaybackState.PAUSED: + await self.mass.players.cmd_play(target_player_id) + except Exception: + self.logger.debug("Handoff resume sync skipped", exc_info=True) + + async def _handoff_pause(self, target_player_id: str) -> None: + """Pause MA queue when Ynison reports paused (handoff mode). + + Uses `mass.players.cmd_pause` rather than `mass.player_queues.pause` + deliberately. The queue-level pause schedules a `_watch_pause` + watchdog that calls `stop()` after 30s, dropping queue.state to + IDLE — and a single-track REPLACE queue actually trips this + watchdog within seconds, not 30. Once the queue is IDLE, every + resume requires a fresh `play_media(REPLACE)` (3-5s of silence) + instead of an instant `cmd_play`. Calling cmd_pause directly + keeps queue.state == PAUSED for as long as the user wants and + lets resume go through the fast path. + """ + # Suppress duplicate pause within the idempotency TTL — Ynison may + # echo the same `paused=True` state multiple times back-to-back. + if not self._idempotent("pause", target_player_id): + return + # Set expected_phase BEFORE the await — MA fires PLAYER_UPDATED + # events while cmd_pause is still resolving, and our event handler + # must already see expected_phase=PAUSED so it reports paused=True + # to Ynison instead of leaking the stale "still PLAYING" state + # (transient ~2s gap between cmd_pause start and queue.state + # transitioning to PAUSED). + prev_phase = self._expected_phase + self._expected_phase = HandoffPhase.PAUSED + # Also close the drift_suppress window if it was open (a + # resume that the user is now cancelling) — otherwise the + # Yandex-app button shows "playing" until the window expires. + self._drift_suppress_until = 0.0 + # Echo paused=True to Ynison right now (don't wait the up-to-5s + # heartbeat tick) so the Yandex-app pause button reflects state + # immediately. Best-effort — heartbeat will retry if this fails. + queue = self.mass.player_queues.get(target_player_id) + elapsed_ms = int(queue.corrected_elapsed_time * 1000) if queue else 0 + with suppress(Exception): + await self._send_progress_to_ynison( + progress_ms=elapsed_ms, + duration_ms=self._best_duration_ms(), + paused=True, + ) + # Mark progress sync watermark to skip the next heartbeat tick. + # Without this, the heartbeat ~5s after our explicit echo would + # fire and — in the worst case where heartbeat scheduling lands + # in the middle of pause processing — race against the user's + # paused=True with a stale paused=False, briefly flipping the + # Yandex-app button back to "play". + self._handoff_last_progress_sync_mono = time.monotonic() + try: + await self.mass.players.cmd_pause(target_player_id) + except Exception: + self.logger.debug("Handoff pause failed on %s", target_player_id, exc_info=True) + # Roll back the optimistic phase change so future `is_paused` + # evaluations don't lie. + self._expected_phase = prev_phase + # Replay corner case (P6): if Ynison parked us at progress=0 and + # then asked for pause, future end-of-track signalling should fire. + if self._ynison and self._ynison.state.progress_ms < 1000: + self._handoff_completion_signaled_for = None + + def _ensure_handoff_heartbeat(self) -> None: + """Start the handoff heartbeat task if not already running.""" + if self._handoff_heartbeat_task is not None and not self._handoff_heartbeat_task.done(): + return + self._handoff_heartbeat_task = self.mass.create_task(self._handoff_heartbeat_loop()) + + def _cancel_handoff_heartbeat(self) -> None: + """Cancel the handoff heartbeat task (idempotent).""" + task = self._handoff_heartbeat_task + self._handoff_heartbeat_task = None + if task is not None and not task.done(): + task.cancel() + + async def _handoff_heartbeat_loop(self) -> None: + """Push progress to Ynison on a fixed cadence in handoff mode. + + Ynison may re-balance the active device away from us if we go silent + (error 300100001). MA's QUEUE_TIME_UPDATED events arrive sparsely on + DLNA/UPnP renderers — so we need an independent timer that keeps the + Ynison server informed even when the player itself is quiet. + + The loop reuses ``_handoff_last_progress_sync_mono`` as a watermark: + when a real MA event has just sent progress, the heartbeat skips its + next tick to avoid double-sending. + """ + try: + while True: + await asyncio.sleep(self._handoff_heartbeat_interval) + if not self._is_handoff: + return + if not self._ynison or not self._ynison.connected: + continue + target_player_id = self._active_player_id + if not target_player_id: + continue + queue = self.mass.player_queues.get(target_player_id) + if queue is None: + self.logger.debug( + "Handoff heartbeat: queue %s vanished — clearing active player", + target_player_id, + ) + self._clear_active_player() + return + # Skip if a real MA event just pushed progress within the + # heartbeat window — avoid duplicate update_playing_status. + now_mono = time.monotonic() + if ( + now_mono - self._handoff_last_progress_sync_mono + < self._handoff_heartbeat_interval / 2 + ): + continue + self._handoff_last_progress_sync_mono = now_mono + elapsed_ms = int(queue.corrected_elapsed_time * 1000) + duration_ms = self._best_duration_ms() + if duration_ms <= 0 and queue.current_item is not None: + duration_ms = (queue.current_item.duration or 0) * 1000 + # Treat anything other than PLAYING as paused — single-track + # MA queues land in IDLE on pause, not PAUSED, and reporting + # paused=False in that case made the Yandex Music app think + # we kept playing while the player was actually silent. + # + # Exception: while we're still inside the activation/resume + # window (`_drift_suppress_until > now`) the queue is briefly + # IDLE because MA hasn't started the stream yet — reporting + # paused=True in that gap makes Ynison-app show "paused" and + # the user re-taps play, firing duplicate REPLACE that races + # the first. Override to paused=False during that window so + # the app stays consistent with our intent (we're starting). + # Resolve paused: PAUSED > ACTIVATING > activation window > queue.state. + # PAUSED first so a user pause inside the activation window + # is reflected immediately. ACTIVATING second so a slow + # play_media (e.g. ~15s on Hi-Res FLAC + slow CDN, post- + # auto-advance) doesn't time out the activation window + # and start reporting paused=True while the stream is + # still starting — Ynison-app would otherwise flip to + # "paused" mid-load and the user's next tap races the + # in-flight stream. + if self._expected_phase == HandoffPhase.PAUSED: + is_paused = True + elif ( + self._expected_phase == HandoffPhase.ACTIVATING + or time.monotonic() < self._drift_suppress_until + ): + is_paused = False + else: + is_paused = queue.state != PlaybackState.PLAYING + self.logger.debug( + "Handoff heartbeat: tick player=%s elapsed=%dms paused=%s", + target_player_id, + elapsed_ms, + is_paused, + ) + with suppress(Exception): + await self._send_progress_to_ynison( + progress_ms=elapsed_ms, + duration_ms=duration_ms, + paused=is_paused, + ) + # Heartbeat-side natural-end detection. MA's event bus + # sometimes drops the PLAYING→PAUSED/IDLE transition + # event for handoff (we observed cases where stream + # finished cleanly but `_on_ma_player_event` never ran + # for the state change). Heartbeat tick fires every + # ≤5s reliably, so doing the same check here fills the + # gap. Idempotent via `_handoff_completion_signaled_for`. + # IDLE only — accepting PAUSED here would create a tiny + # race where a user pause near end-of-track lands before + # `_handoff_pause` commits `_expected_phase = PAUSED`, + # firing a false-positive completion. The + # `_on_ma_player_event` path uses `_expected_phase` as + # the user-pause filter; the heartbeat uses queue.state + # directly because it can fire BEFORE the phase commit. + if ( + self._expected_phase == HandoffPhase.PLAYING + and queue.state == PlaybackState.IDLE + and self._expected_track_id + and self._handoff_completion_signaled_for != self._expected_track_id + and self._is_at_natural_end_of_track(queue) + ): + self._handoff_completion_signaled_for = self._expected_track_id + self._expected_phase = HandoffPhase.ENDING + self.logger.info( + "Handoff heartbeat: detected natural-end on %s " + "(queue.state=%s, elapsed=%dms) — signalling " + "completion to Ynison", + self._expected_track_id, + queue.state.value if hasattr(queue.state, "value") else queue.state, + elapsed_ms, + ) + self.mass.create_task(self._signal_track_completion()) + except asyncio.CancelledError: + pass + + def _on_ma_player_event(self, event: MassEvent) -> None: + """Mirror MA queue progress and stop-events back to Ynison (handoff).""" + if not self._is_handoff: + return + if not self._ynison or not self._ynison.connected: + return + target_player_id = self._active_player_id + if not target_player_id or event.object_id != target_player_id: + return + + queue = self.mass.player_queues.get(target_player_id) + if queue is None: + return + + # P10: detect playback_state transitions and force a Ynison update + # immediately, bypassing the throttle. State changes are rare events + # (play/pause/idle), so this never floods the WS. + current_state = queue.state + prev_state = self._handoff_last_seen_state + state_changed = current_state != prev_state + self._handoff_last_seen_state = current_state + + # Throttle progress-only updates — at most one every _PROGRESS_SYNC_INTERVAL. + now_mono = time.monotonic() + if ( + not state_changed + and now_mono - self._handoff_last_progress_sync_mono < _PROGRESS_SYNC_INTERVAL + ): + return + self._handoff_last_progress_sync_mono = now_mono + elapsed_ms = int(queue.corrected_elapsed_time * 1000) + duration_ms = self._best_duration_ms() + if duration_ms <= 0 and queue.current_item is not None: + duration_ms = (queue.current_item.duration or 0) * 1000 + + # Snapshot the elapsed position whenever the queue is genuinely PLAYING. + # On IDLE-resume we'd rather seek back to this remembered offset than + # trust Ynison's `progress_ms` echo, which lags behind a fast pause/play + # toggle and would otherwise restart the track at 0. + if queue.state == PlaybackState.PLAYING and elapsed_ms > 0: + self._handoff_last_playing_elapsed_ms = elapsed_ms + # FSM: confirmed transition to PLAYING — clear ACTIVATING. + if self._expected_phase in (HandoffPhase.ACTIVATING, HandoffPhase.PAUSED): + self._expected_phase = HandoffPhase.PLAYING + + # Resolve paused: PAUSED > ACTIVATING > activation window > queue.state. + # See heartbeat resolver for the rationale. + if self._expected_phase == HandoffPhase.PAUSED: + is_paused = True + elif ( + self._expected_phase == HandoffPhase.ACTIVATING + or time.monotonic() < self._drift_suppress_until + ): + is_paused = False + else: + is_paused = queue.state != PlaybackState.PLAYING + self.mass.create_task( + self._send_progress_to_ynison( + progress_ms=elapsed_ms, + duration_ms=duration_ms, + paused=is_paused, + ) + ) + + # End-of-track signal — primary path is the dedicated + # `_on_ma_media_item_played` handler (MEDIA_ITEM_PLAYED event). + # Belt-and-braces fallback below: detect queue state transition + # PLAYING → {PAUSED, IDLE} near track duration. MEDIA_ITEM_PLAYED + # is fired by MA only when the queue transitions from one media + # item to the next OR when MA's `_handle_playback_progress_report` + # decides the item is "fully played" (>= 90% playback time); + # in some single-track REPLACE scenarios (e.g. seek-to-near-end + # then short remaining playback) the threshold can fail and we'd + # never get the event. The state-transition check fills that gap + # — gated on `_expected_phase == PLAYING` to skip user pauses + # mid-track (which set PAUSED via `_handoff_pause`). The marker + # `_handoff_completion_signaled_for` is shared with the primary + # path so they don't double-fire. + # Strictly IDLE — accepting PAUSED here would create a race with + # MA-UI-initiated pauses. When the user pauses via MA's web UI + # (not the Yandex app), MA's PLAYER_UPDATED fires synchronously + # before Ynison's `paused=True` round-trips back through the WS; + # `_handoff_pause` hasn't yet committed `_expected_phase = PAUSED`, + # so the phase guard above is still True. If the pause lands + # near track end, this would falsely trigger completion. PAUSED + # → IDLE follow-up will catch the natural end via the second + # transition once MA's queue runner clears. + if ( + state_changed + and prev_state == PlaybackState.PLAYING + and current_state == PlaybackState.IDLE + and self._expected_phase == HandoffPhase.PLAYING + and self._expected_track_id + and self._handoff_completion_signaled_for != self._expected_track_id + and self._is_at_natural_end_of_track(queue) + ): + self._handoff_completion_signaled_for = self._expected_track_id + self._expected_phase = HandoffPhase.ENDING + self.logger.info( + "Handoff: queue PLAYING -> IDLE on %s near duration — " + "signalling natural-end completion to Ynison", + self._expected_track_id, + ) + self.mass.create_task(self._signal_track_completion()) + + def _is_at_natural_end_of_track(self, queue: Any) -> bool: + """Return True iff the queue's elapsed time is close to track duration. + + Used to distinguish a queue that went IDLE because the track played + out (advance needed) from one that went IDLE due to pause/stop on a + single-track queue (no advance needed). Conservative: when both + primary and fallback signals are unavailable we return False — + better to leave the user paused than to cascade through the RADIO + tail. + + Two checks, in order: + 1. queue.current_item still set + corrected_elapsed_time near + current_item.duration. Canonical case while MA's queue state + machine has the item loaded. + 2. queue cleared (current_item=None): use the last-known PLAYING + snapshot (`_handoff_last_playing_elapsed_ms`) against Ynison's + reported duration (`_best_duration_ms`). Catches the case + where MA's "End of queue reached" cleared current_item before + our event handler ran — without it, the natural-end signal + never fires and Ynison sits silent waiting for a next-track + command we never send. + """ + current_item = getattr(queue, "current_item", None) + if current_item is not None: + duration = getattr(current_item, "duration", None) or 0 + if duration > 0: + elapsed = getattr(queue, "corrected_elapsed_time", 0.0) or 0.0 + # 5s margin covers fade-out / silence at end-of-track plus + # reporting jitter from the player. Track shorter than 5s + # is treated as "always near end" — pause-then-cascade on + # sub-5s tracks is indistinguishable from end-of-track. + return elapsed >= max(0.0, duration - 5.0) + # Fallback: queue cleared before we got the chance to inspect it. + duration_ms = self._best_duration_ms() + if duration_ms > 0: + snapshot_ms = self._handoff_last_playing_elapsed_ms + return snapshot_ms >= max(0, duration_ms - 5000) + return False + # ------------------------------------------------------------------ # Playback control callbacks # ------------------------------------------------------------------ @@ -1155,6 +2303,11 @@ async def _on_play(self) -> None: raise UnsupportedFeaturedException("Ynison client not initialized") if not self._ynison.connected: raise PlayerCommandFailed("Ynison WebSocket disconnected") + # Idempotency: MA may fire the same play callback twice (e.g. + # racing UI and remote-control taps). Collapse duplicates so + # the WS doesn't see two `paused=False` updates within 1s. + if not self._idempotent("on_play", None): + return state = self._ynison.state await self._send_progress_to_ynison( progress_ms=state.progress_ms, @@ -1168,6 +2321,10 @@ async def _on_pause(self) -> None: raise UnsupportedFeaturedException("Ynison client not initialized") if not self._ynison.connected: raise PlayerCommandFailed("Ynison WebSocket disconnected") + # Idempotency: same as `_on_play` — drop a duplicate pause + # within the TTL window so we don't echo paused=True twice. + if not self._idempotent("on_pause", None): + return state = self._ynison.state await self._send_progress_to_ynison( progress_ms=state.progress_ms, @@ -1475,5 +2632,5 @@ async def _on_seek(self, position: int) -> None: # Also trigger local stream restart so seek takes effect # immediately without waiting for the Ynison echo. self._seek_position_ms = seek_ms - self._seek_grace_until = time.monotonic() + 5.0 + self._seek_grace_until = time.monotonic() + _ECHO_GRACE_PERIOD self._track_changed_event.set() diff --git a/music_assistant/providers/yandex_ynison/streaming.py b/music_assistant/providers/yandex_ynison/streaming.py index e47b0a02d4..0892e36cec 100644 --- a/music_assistant/providers/yandex_ynison/streaming.py +++ b/music_assistant/providers/yandex_ynison/streaming.py @@ -18,7 +18,7 @@ # ffmpeg output_format) so that mutation of one doesn't corrupt the others. PCM_LOSSLESS_PARAMS: dict[str, Any] = { "content_type": ContentType.PCM_S24LE, - "sample_rate": 48000, + "sample_rate": 44100, "bit_depth": 24, "channels": 2, } diff --git a/music_assistant/providers/yandex_ynison/ynison_client.py b/music_assistant/providers/yandex_ynison/ynison_client.py index 106e67a312..e98feb7663 100644 --- a/music_assistant/providers/yandex_ynison/ynison_client.py +++ b/music_assistant/providers/yandex_ynison/ynison_client.py @@ -184,11 +184,28 @@ def __init__( # Latest state from server self.state = YnisonState() + # Reconnect settle window — first inbound state after reconnect can + # be our own stale broadcast (server retains state across reconnects + # and re-sends it). Provider-level handlers consult this watermark + # to discard the first ≤2s of post-reconnect state changes. + self._post_reconnect_settle_until: float = 0.0 + @property def connected(self) -> bool: """Return True if connected to Ynison state service.""" return self._connected + @property + def in_post_reconnect_settle(self) -> bool: + """True iff we're inside the 2 s post-reconnect settle window. + + Provider handlers consult this to skip the first inbound state right + after a reconnect — that state can be a stale broadcast of our own + last-known view (server retained it across the WS hop) and acting on + it would re-fire pause/play commands the user never issued. + """ + return time.monotonic() < self._post_reconnect_settle_until + @property def device_id(self) -> str: """Return our Ynison device_id (used when authoring outgoing state).""" @@ -303,6 +320,25 @@ async def update_active_device(self, device_id: str) -> None: } await self._send(msg) + async def update_session_params(self, mute_events_if_passive: bool = True) -> None: + """Configure session params on the Ynison server. + + `mute_events_if_passive=True` tells Ynison not to forward peer + state updates while we're not the active device. Reduces inbound + WS noise (and CPU) when running in `borrow` mode alongside other + active subscribers, and removes a class of false positives in + echo detection — fewer messages means fewer chances to misclassify. + """ + msg = { + "update_session_params": { + "mute_events_if_passive": mute_events_if_passive, + }, + } + self._logger.info( + "→ update_session_params: mute_events_if_passive=%s", mute_events_if_passive + ) + await self._send(msg) + async def sync_state_from_eov(self, actual_queue_id: str = "") -> None: """Request queue sync from the EOV (Unified Playback Queue) backend. @@ -360,6 +396,30 @@ async def send_full_state( self._logger.debug("Sending full state: %s", json.dumps(msg)[:500]) await self._send(msg) + def _classify_state_as_echo(self, incoming_ps: dict[str, Any]) -> bool: + """Return True iff `incoming_ps` is our own broadcast round-tripping. + + Uses author check on BOTH queue.version.device_id and + status.version.device_id — only an update where every block was + authored by us is treated as echo. AND-logic is critical: a peer + queue change combined with our own status echo would otherwise + be silently swallowed (RC-1 in v1.9.1 live testing). + + Why only `device_id` and not `version` value: Ynison's protobuf + comment marks `version.version` as `random(int64)`. The server + re-stamps it after every `update_playing_status` (we send blank, + server fills in). Comparing inbound `version` against an outbound + watermark is therefore meaningless — our own restamped echo can + carry any value. `device_id` is unique and preserved end-to-end, + so authorship is the only reliable echo signal. + """ + own_id = self._device_info.device_id + queue_block = (incoming_ps.get("player_queue") or {}).get("version") or {} + status_block = (incoming_ps.get("status") or {}).get("version") or {} + queue_is_ours = queue_block.get("device_id") == own_id + status_is_ours = status_block.get("device_id") == own_id + return queue_is_ours and status_is_ours + # ------------------------------------------------------------------ # Connection internals # ------------------------------------------------------------------ @@ -487,17 +547,24 @@ async def _connect_state(self, host: str, ticket: str, session_id: int) -> None: self._connected = True self._logger.info("Connected to Ynison state service at %s", host) - # On reconnect, send last known state to avoid blank-state reset. - # On cold start, send initial (empty/paused) state. - if self._has_connected_once and self.state.player_state: - self._logger.info( - "Reconnect: restoring last known state (track=%s paused=%s)", - self.state.current_track_id, - self.state.is_paused, - ) - await self.send_full_state(player_state=self.state.player_state) - else: - await self.send_full_state() + # Always send a fresh initial state (empty/paused) — both on cold + # start and reconnect (v2.0). The previous behaviour replayed + # `self.state.player_state`, which after a heartbeat could carry + # `paused=True` and trigger an unintended pause on the still-running + # player when Ynison broadcast it back to us. + # If a player is already active (handoff in progress), the provider + # will reclaim ownership via `update_active_device` after the + # post-reconnect settle window expires. + if self._has_connected_once: + self._logger.info("Reconnect: sending fresh initial state (no stale replay)") + self._post_reconnect_settle_until = time.monotonic() + 2.0 + await self.send_full_state() + # Best-effort: ask the server not to forward peer events while we + # are passive. Failure is non-fatal — we just receive more events. + try: + await self.update_session_params(mute_events_if_passive=True) + except Exception: + self._logger.debug("update_session_params failed", exc_info=True) self._has_connected_once = True @@ -614,17 +681,12 @@ def _parse_state(self, data: dict[str, Any]) -> None: existing_ps = self.state.player_state for key, value in incoming_ps.items(): existing_ps[key] = value - # Echo detection via version.device_id: Ynison preserves the - # `version` block we authored, so a broadcast whose version - # author matches us is our own update round-tripping back. - # Check both player_queue and status so status-only echoes - # (e.g. of update_playing_status) are caught too. - own_id = self._device_info.device_id - queue_author = ( - (incoming_ps.get("player_queue") or {}).get("version", {}).get("device_id") - ) - status_author = (incoming_ps.get("status") or {}).get("version", {}).get("device_id") - self.state.last_update_is_echo = own_id in (queue_author, status_author) + # Echo detection: a state is our echo iff BOTH the queue and + # status version-blocks are authored by our `device_id`. See + # `_classify_state_as_echo` for why version values are not + # part of the check (Ynison documents `version.version` as + # `random(int64)` and the server re-stamps it). + self.state.last_update_is_echo = self._classify_state_as_echo(incoming_ps) else: self.state.last_update_is_echo = False self.state.active_device_id = data.get( diff --git a/tests/providers/yandex_ynison/test_auth.py b/tests/providers/yandex_ynison/test_auth.py index 14c4cc66f2..c9252358f0 100644 --- a/tests/providers/yandex_ynison/test_auth.py +++ b/tests/providers/yandex_ynison/test_auth.py @@ -25,9 +25,7 @@ async def test_refresh_music_token_success() -> None: mock_client = mock.AsyncMock() mock_client.refresh_music_token.return_value = SecretStr("new_music_token") - with mock.patch( - "music_assistant.providers.yandex_ynison.auth.PassportClient.create" - ) as mock_create: + with mock.patch("music_assistant.providers.yandex_ynison.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) @@ -42,9 +40,7 @@ async def test_refresh_music_token_auth_error_raises_login_failed() -> None: mock_client = mock.AsyncMock() mock_client.refresh_music_token.side_effect = InvalidCredentialsError("bad token") - with mock.patch( - "music_assistant.providers.yandex_ynison.auth.PassportClient.create" - ) as mock_create: + with mock.patch("music_assistant.providers.yandex_ynison.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) diff --git a/tests/providers/yandex_ynison/test_config_entries.py b/tests/providers/yandex_ynison/test_config_entries.py index cf2b37df99..9e1fc3e53e 100644 --- a/tests/providers/yandex_ynison/test_config_entries.py +++ b/tests/providers/yandex_ynison/test_config_entries.py @@ -9,7 +9,7 @@ import pytest from music_assistant_models.errors import LoginFailed -from music_assistant.providers.yandex_ynison import get_config_entries +from provider import get_config_entries from music_assistant.providers.yandex_ynison.constants import ( CONF_ACCOUNT_LOGIN, CONF_ACTION_AUTH_QR, @@ -217,9 +217,7 @@ async def test_qr_action_in_borrow_mode_is_refused() -> None: values: dict[str, Any] = {CONF_YM_INSTANCE: "ym-a", "session_id": "sess-1"} with ( - mock.patch( - "music_assistant.providers.yandex_ynison.perform_qr_auth", new=mock.AsyncMock() - ) as mocked, + mock.patch("music_assistant.providers.yandex_ynison.perform_qr_auth", new=mock.AsyncMock()) as mocked, pytest.raises(LoginFailed, match="own-mode action"), ): await get_config_entries(mass, action=CONF_ACTION_AUTH_QR, values=values) diff --git a/tests/providers/yandex_ynison/test_provider.py b/tests/providers/yandex_ynison/test_provider.py index 7cc26ecb1f..d2b9615cab 100644 --- a/tests/providers/yandex_ynison/test_provider.py +++ b/tests/providers/yandex_ynison/test_provider.py @@ -342,6 +342,48 @@ async def test_activates_on_our_device(self) -> None: assert provider._active_player_id == "player1" + async def test_prefetch_runs_on_resume_reselect_with_new_track(self) -> None: + """Resume-reselect onto a different track must still pre-fetch the format. + + Copilot review C1: previously `should_prefetch` only fired when the + target_player_id changed, so a `needs_reselect=True` (stream stop + event) for a *different* track id would skip pre-fetch and leave + PluginSource.audio_format on the previous session's rate. + """ + provider = _make_provider() + player = MagicMock() + player.player_id = "player1" + player.display_name = "Player 1" + provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] + provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + + # Simulate a previous session: same player still active, stream + # stopped (needs_reselect=True), and the previous track is "old". + provider._active_player_id = "player1" + provider._current_streaming_track_id = "old-track" + provider._stream_stop_event.set() + + prefetch_calls: list[str] = [] + + async def _record_prefetch(track_id: str) -> None: + prefetch_calls.append(track_id) + + _stub_attr(provider, "_prefetch_format_for_track", _record_prefetch) + + state = YnisonState( + active_device_id=provider._device_id, + player_state={ + "status": {"paused": False, "progress_ms": 0, "duration_ms": 200000}, + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": "new-track"}], + }, + }, + ) + await provider._handle_ynison_state(state) + + assert prefetch_calls == ["new-track"] + async def test_clears_on_device_switch(self) -> None: """Clears active player when device switches away.""" provider = _make_provider() @@ -1028,7 +1070,7 @@ async def test_default_format_is_pcm_s16le(self) -> None: assert source.audio_format.channels == 2 async def test_superb_quality_uses_lossless_profile(self) -> None: - """When YM quality=superb, format switches to PCM s24le/48kHz.""" + """When YM quality=superb, format switches to PCM s24le/44.1kHz (default lossless).""" provider = _make_provider() mock_yandex = MagicMock() @@ -1040,7 +1082,7 @@ async def test_superb_quality_uses_lossless_profile(self) -> None: mock_yandex.config.get_value.assert_called_with("quality") assert provider._normalized_format.content_type == ContentType.PCM_S24LE - assert provider._normalized_format.sample_rate == 48000 + assert provider._normalized_format.sample_rate == 44100 assert provider._normalized_format.bit_depth == 24 assert provider._source_details.audio_format == provider._normalized_format @@ -1072,7 +1114,7 @@ async def test_invalid_sample_rate_override_falls_back_to_auto(self) -> None: provider._yandex_provider = mock_yandex provider._update_normalized_format() - assert provider._normalized_format.sample_rate == 48000 + assert provider._normalized_format.sample_rate == 44100 assert provider._normalized_format.bit_depth == 24 assert provider._normalized_format.content_type == ContentType.PCM_S24LE @@ -1093,6 +1135,153 @@ async def test_invalid_bit_depth_override_falls_back_to_auto(self) -> None: assert provider._normalized_format.bit_depth == 24 assert provider._normalized_format.content_type == ContentType.PCM_S24LE + async def test_update_normalized_format_hint_lifts_to_hires(self) -> None: + """Hint with 96 kHz / 24 bit lifts auto base to actual values.""" + provider = _make_provider() + mock_yandex = MagicMock() + mock_yandex.domain = "yandex_music" + mock_yandex.type = ProviderType.MUSIC + mock_yandex.config.get_value = MagicMock(return_value="superb") + provider._yandex_provider = mock_yandex + + hint = MagicMock() + hint.sample_rate = 96000 + hint.bit_depth = 24 + provider._update_normalized_format(hint=hint) + + assert provider._normalized_format.sample_rate == 96000 + assert provider._normalized_format.bit_depth == 24 + assert provider._normalized_format.content_type == ContentType.PCM_S24LE + + async def test_update_normalized_format_hint_keeps_native_rate(self) -> None: + """Hint with 44.1 kHz stays at 44.1, not the old 48 kHz default.""" + provider = _make_provider() + mock_yandex = MagicMock() + mock_yandex.domain = "yandex_music" + mock_yandex.type = ProviderType.MUSIC + mock_yandex.config.get_value = MagicMock(return_value="superb") + provider._yandex_provider = mock_yandex + + hint = MagicMock() + hint.sample_rate = 44100 + hint.bit_depth = 24 + provider._update_normalized_format(hint=hint) + + assert provider._normalized_format.sample_rate == 44100 + assert provider._normalized_format.bit_depth == 24 + + async def test_update_normalized_format_explicit_config_overrides_hint(self) -> None: + """Explicit CONF_OUTPUT_SAMPLE_RATE wins over the hint.""" + provider = _make_provider() + provider._cfg_sample_rate = "48000" + mock_yandex = MagicMock() + mock_yandex.domain = "yandex_music" + mock_yandex.type = ProviderType.MUSIC + mock_yandex.config.get_value = MagicMock(return_value="superb") + provider._yandex_provider = mock_yandex + + hint = MagicMock() + hint.sample_rate = 96000 + hint.bit_depth = 24 + provider._update_normalized_format(hint=hint) + + assert provider._normalized_format.sample_rate == 48000 + + async def test_update_normalized_format_ignores_implausible_hint(self) -> None: + """Hint with sample_rate=0 (e.g. broken stream_details) is ignored.""" + provider = _make_provider() + mock_yandex = MagicMock() + mock_yandex.domain = "yandex_music" + mock_yandex.type = ProviderType.MUSIC + mock_yandex.config.get_value = MagicMock(return_value="superb") + provider._yandex_provider = mock_yandex + + hint = MagicMock() + hint.sample_rate = 0 + hint.bit_depth = 0 + provider._update_normalized_format(hint=hint) + + # Falls back to default lossless 44.1/24 + assert provider._normalized_format.sample_rate == 44100 + assert provider._normalized_format.bit_depth == 24 + + async def test_prefetch_format_adapts_normalized_and_source(self) -> None: + """Pre-fetch updates both _normalized_format and _source_details.audio_format.""" + provider = _make_provider() + mock_yandex = MagicMock() + mock_yandex.domain = "yandex_music" + mock_yandex.type = ProviderType.MUSIC + mock_yandex.config.get_value = MagicMock(return_value="superb") + provider._yandex_provider = mock_yandex + sd = MagicMock() + sd.audio_format.sample_rate = 96000 + sd.audio_format.bit_depth = 24 + _stub_attr(provider, "_get_stream_details_with_retry", AsyncMock(return_value=sd)) + + await provider._prefetch_format_for_track("track:hires") + + assert provider._normalized_format.sample_rate == 96000 + assert provider._source_details.audio_format.sample_rate == 96000 + + async def test_prefetch_format_handles_failure(self) -> None: + """Pre-fetch swallowing API errors leaves the previous format intact.""" + provider = _make_provider() + mock_yandex = MagicMock() + mock_yandex.domain = "yandex_music" + mock_yandex.type = ProviderType.MUSIC + mock_yandex.config.get_value = MagicMock(return_value="superb") + provider._yandex_provider = mock_yandex + provider._update_normalized_format() # establish baseline + baseline_sr = provider._normalized_format.sample_rate + baseline_bd = provider._normalized_format.bit_depth + _stub_attr( + provider, + "_get_stream_details_with_retry", + AsyncMock(side_effect=Exception("api boom")), + ) + + await provider._prefetch_format_for_track("track:fail") + + assert provider._normalized_format.sample_rate == baseline_sr + assert provider._normalized_format.bit_depth == baseline_bd + + async def test_prefetch_format_no_yandex_provider_is_noop(self) -> None: + """Without a linked yandex_music provider, pre-fetch is a no-op.""" + provider = _make_provider() + provider._yandex_provider = None + baseline = provider._normalized_format + + await provider._prefetch_format_for_track("track:any") + + assert provider._normalized_format is baseline + + async def test_prefetch_format_times_out_keeps_current(self) -> None: + """A pre-fetch that exceeds _PREFETCH_FORMAT_TIMEOUT must not block start. + + Copilot review C2: _get_stream_details_with_retry can sleep for + several backoff cycles; a transient API issue must not stall + _activate_playback / select_source. + """ + provider = _make_provider() + mock_yandex = MagicMock() + mock_yandex.domain = "yandex_music" + mock_yandex.type = ProviderType.MUSIC + mock_yandex.config.get_value = MagicMock(return_value="superb") + provider._yandex_provider = mock_yandex + provider._update_normalized_format() # establish baseline + baseline_sr = provider._normalized_format.sample_rate + + async def _hang(_track_id: str) -> Any: + await asyncio.sleep(10.0) + return MagicMock() # never reached + + # Use a tiny timeout so the test runs fast. + with patch("music_assistant.providers.yandex_ynison.provider._PREFETCH_FORMAT_TIMEOUT", 0.05): + _stub_attr(provider, "_get_stream_details_with_retry", _hang) + await provider._prefetch_format_for_track("track:slow") + + assert provider._normalized_format.sample_rate == baseline_sr + async def test_audio_format_not_modified_by_stream(self) -> None: """PluginSource audio_format stays fixed (not updated from stream).""" provider = _make_provider() @@ -1919,10 +2108,10 @@ def test_16bit(self) -> None: assert provider._bytes_to_ms(176400) == 1000 def test_24bit(self) -> None: - """24-bit stereo 48000Hz: 288000 bytes = 1000ms.""" + """24-bit stereo 44.1kHz: 264600 bytes = 1000ms.""" provider = _make_provider() provider._normalized_format = make_pcm_format(PCM_LOSSLESS_PARAMS) - assert provider._bytes_to_ms(288000) == 1000 + assert provider._bytes_to_ms(264600) == 1000 def test_zero(self) -> None: """Zero bytes = zero milliseconds.""" @@ -1982,9 +2171,7 @@ async def test_retries_on_failure(self) -> None: mock_yp.get_stream_details = AsyncMock(side_effect=[RuntimeError("transient"), sd]) provider._yandex_provider = mock_yp - with patch( - "music_assistant.providers.yandex_ynison.provider.asyncio.sleep", new_callable=AsyncMock - ): + with patch("music_assistant.providers.yandex_ynison.provider.asyncio.sleep", new_callable=AsyncMock): result = await provider._get_stream_details_with_retry("t1") assert result is sd assert mock_yp.get_stream_details.await_count == 2 @@ -1997,10 +2184,7 @@ async def test_raises_after_max_retries(self) -> None: provider._yandex_provider = mock_yp with ( - patch( - "music_assistant.providers.yandex_ynison.provider.asyncio.sleep", - new_callable=AsyncMock, - ), + patch("music_assistant.providers.yandex_ynison.provider.asyncio.sleep", new_callable=AsyncMock), pytest.raises(RuntimeError, match="failed after"), ): await provider._get_stream_details_with_retry("t1") diff --git a/tests/providers/yandex_ynison/test_provider_handoff.py b/tests/providers/yandex_ynison/test_provider_handoff.py new file mode 100644 index 0000000000..31d6a5c33d --- /dev/null +++ b/tests/providers/yandex_ynison/test_provider_handoff.py @@ -0,0 +1,991 @@ +"""Tests for handoff playback mode.""" + +from __future__ import annotations + +import asyncio +import contextlib +import time +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from music_assistant_models.enums import ( + PlaybackState, + ProviderFeature, +) + +from provider import _features_for_mode +from music_assistant.providers.yandex_ynison.constants import ( + CONF_ALLOW_PLAYER_SWITCH, + CONF_DEVICE_ID, + CONF_MASS_PLAYER_ID, + CONF_PLAYBACK_MODE, + CONF_PUBLISH_NAME, + CONF_TOKEN, + CONF_YM_INSTANCE, + DEFAULT_DISPLAY_NAME, + PLAYBACK_MODE_HANDOFF, + PLAYBACK_MODE_STREAM, + YM_INSTANCE_OWN, +) +from music_assistant.providers.yandex_ynison.provider import HandoffPhase, YandexYnisonProvider +from music_assistant.providers.yandex_ynison.ynison_client import YnisonState + + +def _make_mock_config(values: dict[str, Any] | None = None) -> MagicMock: + """Mock ProviderConfig — handoff mode by default.""" + defaults: dict[str, Any] = { + CONF_TOKEN: "test-music-token", + CONF_YM_INSTANCE: YM_INSTANCE_OWN, + CONF_MASS_PLAYER_ID: "player-A", + CONF_ALLOW_PLAYER_SWITCH: True, + CONF_PUBLISH_NAME: DEFAULT_DISPLAY_NAME, + CONF_DEVICE_ID: "test-device-uuid", + CONF_PLAYBACK_MODE: PLAYBACK_MODE_HANDOFF, + "log_level": "GLOBAL", + } + if values: + defaults.update(values) + config = MagicMock() + config.get_value.side_effect = defaults.get + return config + + +def _make_mock_mass() -> MagicMock: + """Mock MA with player_queues APIs needed for handoff.""" + mass = MagicMock() + mass.cache_path = "/var/cache/test-cache" + + def _create_task(coro: object) -> MagicMock: + if asyncio.iscoroutine(coro): + coro.close() + return MagicMock() + + mass.create_task = MagicMock(side_effect=_create_task) + mass.subscribe = MagicMock(return_value=MagicMock()) + mass.get_providers = MagicMock(return_value=[]) + mass.config.set_raw_provider_config_value = MagicMock() + mass.cache.get = AsyncMock(return_value=None) + mass.cache.set = AsyncMock() + mass.cache.delete = AsyncMock() + + # players + fake_player = MagicMock() + fake_player.player_id = "player-A" + fake_player.display_name = "Player A" + fake_player.state.playback_state = PlaybackState.IDLE + mass.players.all_players = MagicMock(return_value=[fake_player]) + mass.players.get_player = MagicMock(return_value=fake_player) + mass.players.select_source = AsyncMock() + mass.players.cmd_stop = AsyncMock() + mass.players.cmd_pause = AsyncMock() + mass.players.cmd_play = AsyncMock() + mass.players.trigger_player_update = MagicMock() + + # player_queues — the handoff target + mass.player_queues.play_media = AsyncMock() + mass.player_queues.pause = AsyncMock() + mass.player_queues.play = AsyncMock() + mass.player_queues.seek = AsyncMock() + mass.player_queues.next = AsyncMock() + mass.player_queues.previous = AsyncMock() + + # default queue snapshot — IDLE, no elapsed time + queue = MagicMock() + queue.state = PlaybackState.IDLE + queue.corrected_elapsed_time = 0.0 + queue.current_item = None + mass.player_queues.get = MagicMock(return_value=queue) + + return mass + + +def _make_mock_manifest() -> MagicMock: + manifest = MagicMock() + manifest.domain = "yandex_ynison" + return manifest + + +def _make_handoff_provider() -> YandexYnisonProvider: + """Build a provider configured in handoff mode (no AUDIO_SOURCE feature).""" + mass = _make_mock_mass() + config = _make_mock_config() + manifest = _make_mock_manifest() + return YandexYnisonProvider(mass, manifest, config, set()) + + +def _make_state(track_id: str, *, paused: bool = False, progress_ms: int = 0) -> MagicMock: + """Build a minimal YnisonState-like mock. + + YnisonState exposes its scalars through @property without setters, so we + use MagicMock here rather than instantiating the real class. + """ + state = MagicMock(spec=YnisonState) + state.current_track_id = track_id + state.active_device_id = "test-device-uuid" + state.is_paused = paused + state.progress_ms = progress_ms + state.last_update_is_echo = False + state.duration_ms = 200000 + state.player_state = { + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": track_id, "title": "Track"}], + "entity_type": "", + "entity_id": "", + }, + "status": {}, + } + return state + + +# ------------------------------------------------------------------ +# _features_for_mode +# ------------------------------------------------------------------ + + +class TestFeaturesForMode: + """The setup() helper that picks SUPPORTED_FEATURES from config.""" + + def test_stream_mode_advertises_audio_source(self) -> None: + """Stream mode keeps AUDIO_SOURCE so the plugin appears as a source.""" + assert _features_for_mode(PLAYBACK_MODE_STREAM) == {ProviderFeature.AUDIO_SOURCE} + + def test_handoff_mode_has_no_features(self) -> None: + """Handoff mode drops AUDIO_SOURCE — playback flows through MA queue.""" + assert _features_for_mode(PLAYBACK_MODE_HANDOFF) == set() + + def test_unknown_mode_falls_back_to_stream(self) -> None: + """Unknown mode value falls back to stream behaviour, not crash.""" + assert _features_for_mode("nonsense") == {ProviderFeature.AUDIO_SOURCE} + + +# ------------------------------------------------------------------ +# Provider init flags +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +class TestHandoffInit: + """Provider state when configured for handoff.""" + + async def test_handoff_flag_set(self) -> None: + """A handoff-config provider exposes _is_handoff=True and the mode constant.""" + provider = _make_handoff_provider() + assert provider._is_handoff is True + assert provider._playback_mode == PLAYBACK_MODE_HANDOFF + + +# ------------------------------------------------------------------ +# _handoff_activate +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +class TestHandoffActivate: + """Translate Ynison state into player_queue commands.""" + + async def test_new_track_calls_play_media(self) -> None: + """A new Ynison track id triggers player_queues.play_media with the right URI.""" + provider = _make_handoff_provider() + # Without a linked yandex_music provider the URI falls back to the + # bare domain — that's what we assert here. + provider._yandex_provider = None + + state = _make_state("track-1") + await provider._handoff_activate(state, "player-A") + + provider.mass.player_queues.play_media.assert_awaited_once() + call_args = provider.mass.player_queues.play_media.call_args + assert call_args.args[0] == "player-A" + assert call_args.args[1] == "yandex_music://track/track-1" + assert provider._expected_track_id == "track-1" + assert provider._active_player_id == "player-A" + + async def test_same_track_no_redundant_play_media(self) -> None: + """Re-receiving the active track without drift skips play_media and seek.""" + provider = _make_handoff_provider() + provider._expected_track_id = "track-1" + # MA queue keeps reporting the same track at ~50s while Ynison + # echoes ~50s — no drift, no commands. + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.PLAYING + queue.corrected_elapsed_time = 50.0 + + state = _make_state("track-1", progress_ms=50_000) + await provider._handoff_activate(state, "player-A") + + provider.mass.player_queues.play_media.assert_not_awaited() + provider.mass.player_queues.seek.assert_not_awaited() + + async def test_drift_triggers_seek(self) -> None: + """Drift > 3000 ms between Ynison and MA queue triggers a seek call.""" + provider = _make_handoff_provider() + provider._expected_track_id = "track-1" + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.PLAYING + queue.corrected_elapsed_time = 10.0 # MA at 10s + # current_item must be set — drift-seek skips otherwise to avoid + # post-completion phantom seeks. + queue.current_item = MagicMock() + + # Ynison reports 60s — 50s drift, well over the 3s threshold + state = _make_state("track-1", progress_ms=60_000) + await provider._handoff_activate(state, "player-A") + + provider.mass.player_queues.seek.assert_awaited_once_with("player-A", 60) + + async def test_echo_does_not_trigger_seek(self) -> None: + """An echo update from Ynison must not bounce back as a seek command.""" + provider = _make_handoff_provider() + provider._expected_track_id = "track-1" + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.PLAYING + queue.corrected_elapsed_time = 10.0 + + state = _make_state("track-1", progress_ms=60_000) + state.last_update_is_echo = True + await provider._handoff_activate(state, "player-A") + + provider.mass.player_queues.seek.assert_not_awaited() + + async def test_paused_queue_resumes_when_ynison_says_playing(self) -> None: + """If MA queue is paused while Ynison says playing, resume via cmd_play. + + Uses `mass.players.cmd_play` (not `player_queues.play`) so the queue + stays in PAUSED state instead of triggering MA's _watch_pause + watchdog → IDLE → REPLACE-loop on the next pause. + """ + provider = _make_handoff_provider() + provider._expected_track_id = "track-1" + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.PAUSED + queue.corrected_elapsed_time = 10.0 + + state = _make_state("track-1", progress_ms=10_000) + await provider._handoff_activate(state, "player-A") + + provider.mass.players.cmd_play.assert_awaited_once_with("player-A") + provider.mass.player_queues.play.assert_not_awaited() + + +@pytest.mark.asyncio +class TestHandoffPause: + """Pause translation.""" + + async def test_handoff_pause_calls_queue_pause(self) -> None: + """Ynison pause translates to players.cmd_pause on the active player. + + Uses cmd_pause directly (not player_queues.pause) so queue stays + in PAUSED state — see _handoff_pause docstring for the rationale. + """ + provider = _make_handoff_provider() + await provider._handoff_pause("player-A") + provider.mass.players.cmd_pause.assert_awaited_once_with("player-A") + provider.mass.player_queues.pause.assert_not_awaited() + + async def test_handoff_pause_swallows_errors(self) -> None: + """Failures inside cmd_pause must not propagate to Ynison handler.""" + provider = _make_handoff_provider() + provider.mass.players.cmd_pause = AsyncMock(side_effect=Exception("boom")) + # Must not raise + await provider._handoff_pause("player-A") + + async def test_paused_state_skips_pause_when_no_active_player(self) -> None: + """Without _active_player_id set, paused-state path must NOT pause anyone. + + Copilot review N1: the previous code fell back to + _get_target_player_id(), which auto-selects an unrelated playing + player after startup/cleanup — that would silently pause the wrong + MA queue. + """ + provider = _make_handoff_provider() + provider._active_player_id = None # not yet activated + + # Build a state that says "our device, paused". + state = MagicMock(spec=YnisonState) + state.current_track_id = "track-1" + state.active_device_id = "test-device-uuid" + state.is_paused = True + state.progress_ms = 0 + state.last_update_is_echo = False + state.duration_ms = 0 + state.player_state = { + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": "track-1"}], + "entity_type": "", + "entity_id": "", + }, + "status": {}, + } + + await provider._handle_ynison_state(state) + + # No active player → neither cmd_pause nor queue.pause should fire. + provider.mass.players.cmd_pause.assert_not_awaited() + provider.mass.player_queues.pause.assert_not_awaited() + + async def test_paused_state_uses_active_player_id(self) -> None: + """When _active_player_id is set, pause is routed via cmd_pause.""" + provider = _make_handoff_provider() + provider._active_player_id = "player-A" + + state = MagicMock(spec=YnisonState) + state.current_track_id = "track-1" + state.active_device_id = "test-device-uuid" + state.is_paused = True + state.progress_ms = 0 + state.last_update_is_echo = False + state.duration_ms = 0 + state.player_state = { + "player_queue": { + "current_playable_index": 0, + "playable_list": [{"playable_id": "track-1"}], + "entity_type": "", + "entity_id": "", + }, + "status": {}, + } + + await provider._handle_ynison_state(state) + + provider.mass.players.cmd_pause.assert_awaited_once_with("player-A") + + +@pytest.mark.asyncio +class TestClearActivePlayerHandoffBookkeeping: + """`_clear_active_player()` must reset every handoff watermark. + + Copilot review N2: a stale `_handoff_last_progress_sync_mono` would + suppress the first progress / heartbeat update of the next session. + """ + + async def test_clear_resets_handoff_watermarks(self) -> None: + """All handoff state — including the throttle watermark — is cleared.""" + provider = _make_handoff_provider() + provider._active_player_id = "player-A" + provider._expected_track_id = "track-1" + provider._expected_phase = HandoffPhase.PLAYING + provider._handoff_completion_signaled_for = "track-1" + provider._drift_suppress_until = 9999.0 + provider._re_issue_debounce_until = 9999.0 + provider._handoff_last_seen_state = PlaybackState.PLAYING + provider._handoff_last_progress_sync_mono = 9999.0 + + provider._clear_active_player() + + assert provider._active_player_id is None + assert provider._expected_track_id is None # type: ignore[unreachable] + assert provider._expected_phase is HandoffPhase.IDLE + assert provider._handoff_completion_signaled_for is None + assert provider._drift_suppress_until == 0.0 + assert provider._re_issue_debounce_until == 0.0 + assert provider._handoff_last_seen_state is None + assert provider._handoff_last_progress_sync_mono == 0.0 + + +# ------------------------------------------------------------------ +# _on_ma_player_event +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +class TestHandoffActivateExtended: + """Coverage for grace, dedup, replay, instance-id URI.""" + + async def test_uri_uses_yandex_provider_instance_id(self) -> None: + """When a yandex_music provider is linked, its instance_id is in the URI.""" + provider = _make_handoff_provider() + ym = MagicMock() + ym.instance_id = "ym-borrow-A" + provider._yandex_provider = ym + provider._get_stream_details_with_retry = AsyncMock( # type: ignore[method-assign] + side_effect=Exception("ignored") + ) + + await provider._handoff_activate(_make_state("track-X"), "player-A") + + provider.mass.player_queues.play_media.assert_awaited_once() + assert ( + provider.mass.player_queues.play_media.call_args.args[1] + == "ym-borrow-A://track/track-X" + ) + + async def test_uri_falls_back_to_domain_without_provider(self) -> None: + """Missing yandex_music link → URI uses bare `yandex_music` domain.""" + provider = _make_handoff_provider() + provider._yandex_provider = None + + await provider._handoff_activate(_make_state("track-Y"), "player-A") + + assert ( + provider.mass.player_queues.play_media.call_args.args[1] + == "yandex_music://track/track-Y" + ) + + async def test_dedup_skips_play_media_when_queue_already_playing_same_uri(self) -> None: + """If MA queue already plays the expected URI, no fresh play_media.""" + provider = _make_handoff_provider() + provider._yandex_provider = None + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.PLAYING + queue.current_item = MagicMock() + queue.current_item.uri = "yandex_music://track/track-Z" + + await provider._handoff_activate(_make_state("track-Z"), "player-A") + + provider.mass.player_queues.play_media.assert_not_awaited() + # bookkeeping is still updated + assert provider._expected_track_id == "track-Z" + + async def test_resume_via_play_when_queue_paused_with_same_uri(self) -> None: + """Same URI but PAUSED → call cmd_play, not play_media(). + + Resume path goes through `players.cmd_play` rather than + `player_queues.play` so the queue stays in PAUSED state and we + don't trigger MA's pause watchdog into IDLE. + """ + provider = _make_handoff_provider() + provider._yandex_provider = None + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.PAUSED + queue.current_item = MagicMock() + queue.current_item.uri = "yandex_music://track/track-Z" + + await provider._handoff_activate(_make_state("track-Z"), "player-A") + + provider.mass.player_queues.play_media.assert_not_awaited() + provider.mass.players.cmd_play.assert_awaited_once_with("player-A") + provider.mass.player_queues.play.assert_not_awaited() + + async def test_grace_blocks_drift_seek_after_play_media(self) -> None: + """Drift seek is suppressed inside the drift-suppress window after play_media.""" + provider = _make_handoff_provider() + provider._yandex_provider = None + # Trigger play_media once → drift-suppress window opens. + await provider._handoff_activate(_make_state("track-1"), "player-A") + assert provider._drift_suppress_until > time.monotonic() + + # Same track, big drift, queue still resolving (PLAYING but elapsed=0, + # which is the typical state in the first second after play_media). + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.PLAYING + queue.corrected_elapsed_time = 0.0 + + await provider._handoff_activate(_make_state("track-1", progress_ms=60_000), "player-A") + provider.mass.player_queues.seek.assert_not_awaited() + + async def test_grace_overridden_by_playing_state_with_elapsed(self) -> None: + """A real seek inside grace must still go through if queue progressed.""" + provider = _make_handoff_provider() + provider._yandex_provider = None + await provider._handoff_activate(_make_state("track-1"), "player-A") + + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.PLAYING + queue.corrected_elapsed_time = 5.0 # > 1s elapsed → override + queue.current_item = MagicMock() # drift-seek requires current_item + + await provider._handoff_activate(_make_state("track-1", progress_ms=60_000), "player-A") + provider.mass.player_queues.seek.assert_awaited_once_with("player-A", 60) + + async def test_replay_resets_completion_marker(self) -> None: + """Same track with progress<1s clears the completion-once marker.""" + provider = _make_handoff_provider() + provider._yandex_provider = None + provider._expected_track_id = "track-1" + provider._handoff_completion_signaled_for = "track-1" + + await provider._handoff_activate(_make_state("track-1", progress_ms=500), "player-A") + assert provider._handoff_completion_signaled_for is None + + async def test_play_media_failure_does_not_commit_track_id(self) -> None: + """If play_media throws, _expected_track_id stays unchanged. + + Otherwise the next Ynison update for the same track id would fall + through the same-track branch and never retry play_media, leaving + MA stuck out of sync (Copilot review C3). + """ + provider = _make_handoff_provider() + provider._yandex_provider = None + provider._expected_track_id = "old-track" + provider.mass.player_queues.play_media = AsyncMock(side_effect=Exception("boom")) + + await provider._handoff_activate(_make_state("new-track"), "player-A") + + # Track id NOT advanced — next state-update will retry play_media. + assert provider._expected_track_id == "old-track" + # Drift suppression NOT opened — we don't want to suppress drift + # seeks for a play_media that never actually started. + assert provider._drift_suppress_until == 0.0 + + async def test_play_media_failure_does_not_start_heartbeat(self) -> None: + """Heartbeat must not run after a failed play_media (Copilot review N3). + + Otherwise it would keep streaming the previous/idle queue's progress + to Ynison and prevent rebalancing away from a non-working device. + """ + provider = _make_handoff_provider() + provider._yandex_provider = None + provider.mass.player_queues.play_media = AsyncMock(side_effect=Exception("boom")) + ensure_calls: list[None] = [] + provider._ensure_handoff_heartbeat = ( # type: ignore[method-assign] + lambda: ensure_calls.append(None) + ) + + await provider._handoff_activate(_make_state("new-track"), "player-A") + + assert ensure_calls == [] # heartbeat never started + + async def test_dedup_skip_starts_heartbeat(self) -> None: + """Dedup branch (queue already plays the URI) must still arm heartbeat. + + The activation succeeded — Ynison expects regular progress reports. + """ + provider = _make_handoff_provider() + provider._yandex_provider = None + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.PLAYING + queue.current_item = MagicMock() + queue.current_item.uri = "yandex_music://track/track-Z" + ensure_calls: list[None] = [] + provider._ensure_handoff_heartbeat = ( # type: ignore[method-assign] + lambda: ensure_calls.append(None) + ) + + await provider._handoff_activate(_make_state("track-Z"), "player-A") + + assert ensure_calls == [None] + + +@pytest.mark.asyncio +class TestHandoffHeartbeat: + """Independent progress heartbeat (P1).""" + + async def test_heartbeat_loop_sends_progress_when_active(self) -> None: + """One heartbeat tick fires _send_progress_to_ynison.""" + provider = _make_handoff_provider() + provider._handoff_heartbeat_interval = 0.05 + provider._active_player_id = "player-A" + ynison = MagicMock() + ynison.connected = True + ynison.state.duration_ms = 200_000 + provider._ynison = ynison + provider._actual_duration_ms = 200_000 + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.PLAYING + queue.corrected_elapsed_time = 30.0 + send_mock = AsyncMock() + provider._send_progress_to_ynison = send_mock # type: ignore[method-assign] + + task = asyncio.create_task(provider._handoff_heartbeat_loop()) + await asyncio.sleep(0.12) + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + assert send_mock.await_count >= 1 + call = send_mock.await_args_list[0] + assert call.kwargs["progress_ms"] == 30000 + assert call.kwargs["paused"] is False + + async def test_heartbeat_skips_without_active_player(self) -> None: + """No active_player_id → heartbeat skips its tick.""" + provider = _make_handoff_provider() + provider._handoff_heartbeat_interval = 0.05 + provider._active_player_id = None + ynison = MagicMock() + ynison.connected = True + provider._ynison = ynison + send_mock = AsyncMock() + provider._send_progress_to_ynison = send_mock # type: ignore[method-assign] + + task = asyncio.create_task(provider._handoff_heartbeat_loop()) + await asyncio.sleep(0.12) + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + send_mock.assert_not_awaited() + + async def test_heartbeat_clears_player_when_queue_disappears(self) -> None: + """Queue gone → loop clears active player and exits.""" + provider = _make_handoff_provider() + provider._handoff_heartbeat_interval = 0.05 + provider._active_player_id = "player-A" + ynison = MagicMock() + ynison.connected = True + provider._ynison = ynison + provider.mass.player_queues.get = MagicMock(return_value=None) + + task = asyncio.create_task(provider._handoff_heartbeat_loop()) + await asyncio.sleep(0.12) + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + assert provider._active_player_id is None + + +class TestForceProgressOnStateChange: + """P10: state transitions bypass the 2s throttle for low-latency forwarding.""" + + def _setup(self) -> YandexYnisonProvider: + """Build a handoff provider hooked up to a connected Ynison mock.""" + provider = _make_handoff_provider() + provider._active_player_id = "player-A" + ynison = MagicMock() + ynison.connected = True + ynison.state.duration_ms = 200_000 + provider._ynison = ynison + provider._actual_duration_ms = 200_000 + return provider + + def test_state_change_triggers_send_within_throttle_window(self) -> None: + """A PLAYING→PAUSED transition forwards progress even mid-throttle.""" + provider = self._setup() + # Simulate: throttle taken recently + provider._handoff_last_progress_sync_mono = time.monotonic() + # Make sure we're using the same `now` reference baseline + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.PLAYING + + event = MagicMock() + event.object_id = "player-A" + + # First tick: state goes from None → PLAYING (state_changed=True) + provider._on_ma_player_event(event) + first_calls = provider.mass.create_task.call_count + assert first_calls >= 1 + + # Second tick: same PLAYING state inside throttle window → no send + provider._on_ma_player_event(event) + assert provider.mass.create_task.call_count == first_calls + + # Third tick: state transitions to PAUSED → bypasses throttle + queue.state = PlaybackState.PAUSED + provider._on_ma_player_event(event) + assert provider.mass.create_task.call_count > first_calls + + +class TestOnMaPlayerEvent: + """MA → Ynison sync via subscription (synchronous handler).""" + + def _setup(self) -> YandexYnisonProvider: + """Build a handoff provider wired up to a connected Ynison mock.""" + provider = _make_handoff_provider() + provider._active_player_id = "player-A" + ynison = MagicMock() + ynison.connected = True + ynison.state.duration_ms = 200000 + provider._ynison = ynison + # Bypass real best-duration logic so the path doesn't hit MagicMock arithmetic + provider._actual_duration_ms = 200000 + return provider + + def test_ignores_events_for_other_players(self) -> None: + """Events on a different player must not result in any work.""" + provider = self._setup() + event = MagicMock() + event.object_id = "player-B" # different player + provider._on_ma_player_event(event) + provider.mass.create_task.assert_not_called() + + def test_progress_throttle(self) -> None: + """Two events fired within the throttle window result in only one push.""" + provider = self._setup() + + event = MagicMock() + event.object_id = "player-A" + + # First event passes the throttle + provider._on_ma_player_event(event) + first_call_count = provider.mass.create_task.call_count + assert first_call_count >= 1 + + # Immediate second event blocked by 2s throttle + provider._on_ma_player_event(event) + assert provider.mass.create_task.call_count == first_call_count + + def test_state_transition_near_duration_signals_completion(self) -> None: + """PLAYING→IDLE near track duration signals completion via state-transition.""" + provider = self._setup() + provider._expected_track_id = "track-1" + provider._expected_phase = HandoffPhase.PLAYING + provider._handoff_last_seen_state = PlaybackState.PLAYING + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.IDLE + queue.current_item = MagicMock() + queue.current_item.duration = 200 + queue.corrected_elapsed_time = 199.0 # near end + + event = MagicMock() + event.object_id = "player-A" + provider._on_ma_player_event(event) + assert provider._handoff_completion_signaled_for == "track-1" + + def test_idle_queue_at_pause_does_not_signal_completion(self) -> None: + """IDLE mid-track (e.g. pause on single-track queue) must NOT advance. + + Reproduces the bug where pausing a single-track queue makes MA's + queue runner report IDLE — without the near-end guard we'd misread + that as natural end-of-track and cascade through the RADIO tail. + """ + provider = self._setup() + provider._expected_track_id = "track-1" + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.IDLE + queue.current_item = MagicMock() + queue.current_item.duration = 200 + queue.corrected_elapsed_time = 30.0 # nowhere near the end + + event = MagicMock() + event.object_id = "player-A" + provider._on_ma_player_event(event) + + assert provider._handoff_completion_signaled_for is None + + def test_idle_queue_with_unknown_duration_does_not_signal(self) -> None: + """Without a duration we can't tell pause from end — be conservative.""" + provider = self._setup() + provider._expected_track_id = "track-1" + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.IDLE + queue.current_item = MagicMock() + queue.current_item.duration = 0 # unknown + queue.corrected_elapsed_time = 50.0 + + event = MagicMock() + event.object_id = "player-A" + provider._on_ma_player_event(event) + + assert provider._handoff_completion_signaled_for is None + + def test_idle_queue_without_current_item_does_not_signal(self) -> None: + """current_item=None (cleared queue) is not a completion signal.""" + provider = self._setup() + provider._expected_track_id = "track-1" + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.IDLE + queue.current_item = None + queue.corrected_elapsed_time = 50.0 + + event = MagicMock() + event.object_id = "player-A" + provider._on_ma_player_event(event) + + assert provider._handoff_completion_signaled_for is None + + def test_state_transition_pause_mid_track_does_not_signal(self) -> None: + """PLAYING→PAUSED mid-track (user pause) must NOT signal completion.""" + provider = self._setup() + provider._expected_track_id = "track-1" + provider._expected_phase = HandoffPhase.PLAYING + provider._handoff_last_seen_state = PlaybackState.PLAYING + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.PAUSED + queue.current_item = MagicMock() + queue.current_item.duration = 200 + queue.corrected_elapsed_time = 30.0 # mid-track, not near end + + event = MagicMock() + event.object_id = "player-A" + provider._on_ma_player_event(event) + + assert provider._handoff_completion_signaled_for is None + + +@pytest.mark.asyncio +class TestHandoffIdempotency: + """v2.0 idempotency cache — duplicate commands within TTL are no-ops.""" + + async def test_pause_idempotent_within_ttl(self) -> None: + """Two back-to-back pause requests for the same target collapse to one call.""" + provider = _make_handoff_provider() + await provider._handoff_pause("player-A") + await provider._handoff_pause("player-A") + # Second pause is a duplicate — Ynison echoed the same `paused=True`. + provider.mass.players.cmd_pause.assert_awaited_once_with("player-A") + + async def test_pause_after_ttl_expires_is_reissued(self) -> None: + """A pause that arrives after the idempotency window goes through again.""" + provider = _make_handoff_provider() + await provider._handoff_pause("player-A") + # Backdate the cached entry past the TTL window. + for key in list(provider._command_idempotency.keys()): + provider._command_idempotency[key] -= 10.0 + await provider._handoff_pause("player-A") + assert provider.mass.players.cmd_pause.await_count == 2 + + async def test_play_media_duplicate_track_change_skipped(self) -> None: + """Same `(track, play_media)` within TTL doesn't re-issue play_media.""" + provider = _make_handoff_provider() + provider._yandex_provider = None + + await provider._handoff_activate(_make_state("track-1"), "player-A") + provider.mass.player_queues.play_media.assert_awaited_once() + + # A second activation for a *different* expected track id falls back + # to the new-track branch but should be deduped because we just + # issued play_media for "track-2" 1ms ago — simulate by forcing the + # same call sequence directly. + provider._expected_track_id = "track-0" # reset so new-track branch triggers + provider.mass.player_queues.play_media.reset_mock() + # Pre-seed idempotency for track-2 so the upcoming activation sees it. + provider._command_idempotency[("play_media", "track-2")] = time.monotonic() + await provider._handoff_activate(_make_state("track-2"), "player-A") + provider.mass.player_queues.play_media.assert_not_awaited() + + +@pytest.mark.asyncio +class TestHandoffCancelTask: + """v2.0 cancel-on-track-change for in-flight play_media.""" + + async def test_pending_play_media_cancelled_by_helper(self) -> None: + """_cancel_pending_play_media cancels a still-running play_media task.""" + provider = _make_handoff_provider() + + # Build a play_media task that "hangs" until cancelled. + cancelled = asyncio.Event() + + async def _hang(*_args: Any, **_kwargs: Any) -> None: + try: + await asyncio.sleep(60) + except asyncio.CancelledError: + cancelled.set() + raise + + provider._play_media_task = asyncio.create_task(_hang()) + await asyncio.sleep(0) # let the task start + + await provider._cancel_pending_play_media() + + assert cancelled.is_set() + assert provider._play_media_task.cancelled() or provider._play_media_task.done() + + async def test_cancel_helper_noop_when_no_task(self) -> None: + """No active task → helper returns immediately.""" + provider = _make_handoff_provider() + provider._play_media_task = None + # Must not raise. + await provider._cancel_pending_play_media() + + +@pytest.mark.asyncio +class TestHandoffFsmTransitions: + """v2.0 explicit FSM phase tracking via `_expected_phase`.""" + + async def test_activating_set_after_play_media_success(self) -> None: + """Successful play_media moves expected_phase to ACTIVATING.""" + provider = _make_handoff_provider() + provider._yandex_provider = None + await provider._handoff_activate(_make_state("track-1"), "player-A") + assert provider._expected_phase == HandoffPhase.ACTIVATING + + async def test_activating_transitions_to_playing_on_ma_event(self) -> None: + """When MA queue first reports PLAYING, ACTIVATING resolves to PLAYING.""" + provider = _make_handoff_provider() + provider._expected_track_id = "track-1" + provider._expected_phase = HandoffPhase.ACTIVATING + provider._active_player_id = "player-A" + # Stand-in Ynison: connected = True so _on_ma_player_event proceeds. + ynison = MagicMock() + ynison.connected = True + ynison.state = MagicMock(duration_ms=200000) + provider._ynison = ynison + # Mock heartbeat-side helpers to simple stubs. + provider._send_progress_to_ynison = AsyncMock() # type: ignore[method-assign] + + queue = provider.mass.player_queues.get.return_value + queue.state = PlaybackState.PLAYING + queue.corrected_elapsed_time = 5.0 + queue.current_item = MagicMock(duration=200) + + event = MagicMock() + event.object_id = "player-A" + provider._on_ma_player_event(event) + + assert provider._expected_phase == HandoffPhase.PLAYING + + async def test_pause_sets_expected_phase_paused(self) -> None: + """_handoff_pause leaves expected_phase = PAUSED on success.""" + provider = _make_handoff_provider() + await provider._handoff_pause("player-A") + assert provider._expected_phase == HandoffPhase.PAUSED + + async def test_pause_failure_does_not_advance_phase(self) -> None: + """If cmd_pause raises, expected_phase stays where it was.""" + provider = _make_handoff_provider() + provider._expected_phase = HandoffPhase.PLAYING + provider.mass.players.cmd_pause = AsyncMock(side_effect=Exception("boom")) + await provider._handoff_pause("player-A") + # Despite the exception, idempotency cache prevents retries — but + # phase MUST NOT have transitioned to PAUSED on failure. + assert provider._expected_phase == HandoffPhase.PLAYING + + +class TestSharedHelpers: + """Static helpers reused by both stream and handoff modes.""" + + def test_classify_drift_below_threshold_is_ignore(self) -> None: + """Drift inside 3s threshold returns 'ignore'.""" + assert YandexYnisonProvider._classify_drift(11_000, 10_000) == "ignore" + assert YandexYnisonProvider._classify_drift(10_000, 10_000) == "ignore" + + def test_classify_drift_genuine_seek(self) -> None: + """Drift > threshold and not queue-rebuild → 'seek'.""" + assert YandexYnisonProvider._classify_drift(60_000, 10_000) == "seek" + assert YandexYnisonProvider._classify_drift(10_000, 60_000) == "seek" + + def test_classify_drift_queue_rebuild(self) -> None: + """Ynison ~0 while we're past 5s → 'queue_rebuild' (don't seek).""" + assert YandexYnisonProvider._classify_drift(0, 30_000) == "queue_rebuild" + assert YandexYnisonProvider._classify_drift(500, 8_000) == "queue_rebuild" + + def test_classify_drift_zero_to_zero_below_threshold(self) -> None: + """0 vs 0 is 'ignore' (no drift); not a queue_rebuild signal.""" + assert YandexYnisonProvider._classify_drift(0, 0) == "ignore" + + def test_classify_drift_genuine_seek_to_zero(self) -> None: + """User explicitly seeked back to 0, our pos < 5s — honor it.""" + # Our pos = 4s, Ynison says 0 → drift 4s > threshold 3s, but our + # pos NOT past 5s → not classified as queue rebuild → 'seek'. + assert YandexYnisonProvider._classify_drift(0, 4_000) == "seek" + + def test_pick_resume_position_local_wins_when_higher(self) -> None: + """Local snapshot > Ynison → use local.""" + ms, source = YandexYnisonProvider._pick_resume_position( + local_snapshot_ms=15_000, + ynison_progress_ms=10_000, + ) + assert ms == 15_000 + assert source == "local_snapshot" + + def test_pick_resume_position_ynison_wins_when_higher(self) -> None: + """Ynison > local snapshot → use Ynison.""" + ms, source = YandexYnisonProvider._pick_resume_position( + local_snapshot_ms=1_000, + ynison_progress_ms=43_000, + ) + assert ms == 43_000 + assert source == "ynison" + + def test_pick_resume_position_local_zero_falls_back_to_ynison(self) -> None: + """Local=0 falls back to Ynison even if equal-or-higher numerically.""" + ms, source = YandexYnisonProvider._pick_resume_position( + local_snapshot_ms=0, + ynison_progress_ms=0, + ) + # Both 0; local_snapshot=0 is treated as "no snapshot", source is ynison. + assert ms == 0 + assert source == "ynison" + + def test_pick_resume_position_equal_prefers_local(self) -> None: + """Equal non-zero values prefer local (more recent measurement).""" + ms, source = YandexYnisonProvider._pick_resume_position( + local_snapshot_ms=5_000, + ynison_progress_ms=5_000, + ) + assert ms == 5_000 + assert source == "local_snapshot" diff --git a/tests/providers/yandex_ynison/test_streaming.py b/tests/providers/yandex_ynison/test_streaming.py index ba93eb31b4..befca98270 100644 --- a/tests/providers/yandex_ynison/test_streaming.py +++ b/tests/providers/yandex_ynison/test_streaming.py @@ -20,11 +20,11 @@ class TestMakePcmFormat: """Tests for the AudioFormat factory.""" def test_lossless_format(self) -> None: - """Lossless params produce s24le/48kHz/24bit/stereo.""" + """Lossless params produce s24le/44.1kHz/24bit/stereo.""" fmt = make_pcm_format(PCM_LOSSLESS_PARAMS) assert isinstance(fmt, AudioFormat) assert fmt.content_type == ContentType.PCM_S24LE - assert fmt.sample_rate == 48000 + assert fmt.sample_rate == 44100 assert fmt.bit_depth == 24 assert fmt.channels == 2 diff --git a/tests/providers/yandex_ynison/test_ynison_client.py b/tests/providers/yandex_ynison/test_ynison_client.py index ca477fd08c..f60be34ec4 100644 --- a/tests/providers/yandex_ynison/test_ynison_client.py +++ b/tests/providers/yandex_ynison/test_ynison_client.py @@ -252,8 +252,8 @@ def test_parse_state_partial(self, client: YnisonClient) -> None: client._parse_state({"player_state": {"status": {"paused": True}}}) assert client.state.active_device_id == "old-device" - def test_echo_flag_true_on_own_authored_queue(self, client: YnisonClient) -> None: - """player_queue.version.device_id == own → last_update_is_echo True.""" + def test_echo_flag_true_only_when_both_authors_ours(self, client: YnisonClient) -> None: + """AND-logic (1.9.1): both queue.version AND status.version must be ours.""" client._parse_state( { "player_state": { @@ -266,13 +266,28 @@ def test_echo_flag_true_on_own_authored_queue(self, client: YnisonClient) -> Non "timestamp_ms": "0", }, }, + "status": { + "paused": False, + "progress_ms": "1000", + "duration_ms": "5000", + "version": { + "device_id": "test-device-id", + "version": "43", + "timestamp_ms": "0", + }, + }, }, } ) assert client.state.last_update_is_echo is True - def test_echo_flag_false_on_foreign_author(self, client: YnisonClient) -> None: - """player_queue.version.device_id != own → not an echo.""" + def test_echo_flag_false_when_only_queue_is_ours(self, client: YnisonClient) -> None: + """Status authored by peer → NOT echo, even if queue.version is ours. + + Regression for the OR-logic bug: a peer toggling pause produced + status.version=peer + our stale queue.version=ours, which the old + OR-rule wrongly classified as echo and silenced the user action. + """ client._parse_state( { "player_state": { @@ -280,44 +295,70 @@ def test_echo_flag_false_on_foreign_author(self, client: YnisonClient) -> None: "playable_list": [{"playable_id": "t1"}], "current_playable_index": 0, "version": { - "device_id": "some-other-device", + "device_id": "test-device-id", "version": "42", "timestamp_ms": "0", }, }, + "status": { + "paused": True, + "progress_ms": "1000", + "duration_ms": "5000", + "version": { + "device_id": "peer-device", + "version": "44", + "timestamp_ms": "0", + }, + }, }, } ) assert client.state.last_update_is_echo is False - def test_echo_flag_false_when_version_missing(self, client: YnisonClient) -> None: - """No version block in player_queue → not an echo (safe default).""" + def test_echo_flag_false_when_only_status_is_ours(self, client: YnisonClient) -> None: + """Queue authored by peer → NOT echo, even if status.version is ours. + + The mirror case: our heartbeat just stamped status.version=ours, but + the peer changed the queue. Under AND-logic the peer change is not + silenced. + """ client._parse_state( { "player_state": { - "player_queue": {"playable_list": [], "current_playable_index": -1}, + "player_queue": { + "playable_list": [{"playable_id": "new-track"}], + "current_playable_index": 0, + "version": { + "device_id": "peer-device", + "version": "100", + "timestamp_ms": "0", + }, + }, + "status": { + "paused": False, + "progress_ms": "0", + "duration_ms": "5000", + "version": { + "device_id": "test-device-id", + "version": "99", + "timestamp_ms": "0", + }, + }, }, } ) assert client.state.last_update_is_echo is False - def test_echo_flag_false_when_player_state_missing(self, client: YnisonClient) -> None: - """status-only or non-player_state updates cannot be echoes.""" - client.state.last_update_is_echo = True # sticky from a prior update - client._parse_state({"active_device_id_optional": "some-device"}) - assert client.state.last_update_is_echo is False - - def test_echo_flag_true_on_own_authored_status(self, client: YnisonClient) -> None: - """status.version.device_id == own → echo True even without player_queue version.""" + def test_echo_flag_false_on_foreign_author(self, client: YnisonClient) -> None: + """Both authors are peer → not an echo.""" client._parse_state( { "player_state": { - "status": { - "paused": False, - "progress_ms": "1000", - "duration_ms": "5000", + "player_queue": { + "playable_list": [{"playable_id": "t1"}], + "current_playable_index": 0, "version": { - "device_id": "test-device-id", + "device_id": "some-other-device", "version": "42", "timestamp_ms": "0", }, @@ -325,7 +366,29 @@ def test_echo_flag_true_on_own_authored_status(self, client: YnisonClient) -> No }, } ) - assert client.state.last_update_is_echo is True + assert client.state.last_update_is_echo is False + + def test_echo_flag_false_when_version_missing(self, client: YnisonClient) -> None: + """No version block at all → not an echo (safe default). + + AND-logic treats missing version-block as "not ours" — matches the + previous safe default. Without a version-block we can't claim + ownership, so we let the update reach handlers. + """ + client._parse_state( + { + "player_state": { + "player_queue": {"playable_list": [], "current_playable_index": -1}, + }, + } + ) + assert client.state.last_update_is_echo is False + + def test_echo_flag_false_when_player_state_missing(self, client: YnisonClient) -> None: + """status-only or non-player_state updates cannot be echoes.""" + client.state.last_update_is_echo = True # sticky from a prior update + client._parse_state({"active_device_id_optional": "some-device"}) + assert client.state.last_update_is_echo is False def test_parse_state_coerces_int_timestamps_to_strings(self, client: YnisonClient) -> None: """Inbound int timestamps are stringified so outbound echoes stay safe. @@ -880,17 +943,22 @@ async def test_success(self, client: YnisonClient) -> None: with suppress(asyncio.CancelledError): await client._message_task - async def test_reconnect_sends_last_known_state(self, client: YnisonClient) -> None: - """On reconnect, send_full_state is called with the preserved player state.""" + async def test_reconnect_sends_fresh_state_no_stale_replay(self, client: YnisonClient) -> None: + """v2.0: reconnect sends a fresh initial state — no stale replay. + + Replaying the last known state (which after a heartbeat could carry + `paused=True`) caused the server to broadcast it back and trigger + an unintended pause on the still-running player. + """ mock_ws = AsyncMock() mock_session = AsyncMock() mock_session.ws_connect = AsyncMock(return_value=mock_ws) client._session = mock_session - # Simulate prior connection: set flag and populate state + # Simulate prior connection with stale paused state cached. client._has_connected_once = True client.state.player_state = { - "status": {"paused": False, "progress_ms": 120000, "duration_ms": 300000}, + "status": {"paused": True, "progress_ms": 120000, "duration_ms": 300000}, "player_queue": { "current_playable_index": 3, "playable_list": [{"playable_id": "t1"}], @@ -900,29 +968,28 @@ async def test_reconnect_sends_last_known_state(self, client: YnisonClient) -> N with patch.object(client, "send_full_state", new_callable=AsyncMock) as mock_sfs: await client._connect_state("host.yandex.net", "ticket", 42) - mock_sfs.assert_awaited_once_with(player_state=client.state.player_state) + # send_full_state must be called WITHOUT player_state — it falls + # back to a fresh _build_initial_state() internally. + mock_sfs.assert_awaited_once_with() + # Settle window armed for ~2 s. + assert client.in_post_reconnect_settle is True # Clean up assert client._message_task is not None client._message_task.cancel() with suppress(asyncio.CancelledError): await client._message_task - async def test_reconnect_empty_state_falls_back(self, client: YnisonClient) -> None: - """On reconnect with empty player_state, falls back to blank initial state.""" + async def test_cold_start_does_not_arm_settle_window(self, client: YnisonClient) -> None: + """First-ever connect skips the settle window — nothing stale to swallow.""" mock_ws = AsyncMock() mock_session = AsyncMock() mock_session.ws_connect = AsyncMock(return_value=mock_ws) client._session = mock_session - client._has_connected_once = True - client.state.player_state = {} # empty — no prior state received - - with patch.object(client, "send_full_state", new_callable=AsyncMock) as mock_sfs: + with patch.object(client, "send_full_state", new_callable=AsyncMock): await client._connect_state("host.yandex.net", "ticket", 42) - # Falls back to no-arg call (blank initial state) - mock_sfs.assert_awaited_once_with() - # Clean up + assert client.in_post_reconnect_settle is False assert client._message_task is not None client._message_task.cancel() with suppress(asyncio.CancelledError): From c356f48227556b5fecf00de2a819c63efef116b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 May 2026 18:38:05 +0000 Subject: [PATCH 02/12] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v2.1.1 --- tests/providers/yandex_ynison/test_auth.py | 8 ++++++-- .../providers/yandex_ynison/test_config_entries.py | 8 +++++--- tests/providers/yandex_ynison/test_provider.py | 13 ++++++++++--- .../yandex_ynison/test_provider_handoff.py | 12 ++++++++++-- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/tests/providers/yandex_ynison/test_auth.py b/tests/providers/yandex_ynison/test_auth.py index c9252358f0..14c4cc66f2 100644 --- a/tests/providers/yandex_ynison/test_auth.py +++ b/tests/providers/yandex_ynison/test_auth.py @@ -25,7 +25,9 @@ async def test_refresh_music_token_success() -> None: mock_client = mock.AsyncMock() mock_client.refresh_music_token.return_value = SecretStr("new_music_token") - with mock.patch("music_assistant.providers.yandex_ynison.auth.PassportClient.create") as mock_create: + with mock.patch( + "music_assistant.providers.yandex_ynison.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) @@ -40,7 +42,9 @@ async def test_refresh_music_token_auth_error_raises_login_failed() -> None: mock_client = mock.AsyncMock() mock_client.refresh_music_token.side_effect = InvalidCredentialsError("bad token") - with mock.patch("music_assistant.providers.yandex_ynison.auth.PassportClient.create") as mock_create: + with mock.patch( + "music_assistant.providers.yandex_ynison.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) diff --git a/tests/providers/yandex_ynison/test_config_entries.py b/tests/providers/yandex_ynison/test_config_entries.py index 9e1fc3e53e..75d534034a 100644 --- a/tests/providers/yandex_ynison/test_config_entries.py +++ b/tests/providers/yandex_ynison/test_config_entries.py @@ -9,7 +9,7 @@ import pytest from music_assistant_models.errors import LoginFailed -from provider import get_config_entries +from music_assistant.providers.yandex_ynison import get_config_entries from music_assistant.providers.yandex_ynison.constants import ( CONF_ACCOUNT_LOGIN, CONF_ACTION_AUTH_QR, @@ -217,7 +217,9 @@ async def test_qr_action_in_borrow_mode_is_refused() -> None: values: dict[str, Any] = {CONF_YM_INSTANCE: "ym-a", "session_id": "sess-1"} with ( - mock.patch("music_assistant.providers.yandex_ynison.perform_qr_auth", new=mock.AsyncMock()) as mocked, + mock.patch( + "music_assistant.providers.yandex_ynison.perform_qr_auth", new=mock.AsyncMock() + ) as mocked, pytest.raises(LoginFailed, match="own-mode action"), ): await get_config_entries(mass, action=CONF_ACTION_AUTH_QR, values=values) @@ -295,7 +297,7 @@ async def test_stale_ym_selection_normalizes_to_own() -> None: """ mass = _make_mock_mass({"ym-b": {"domain": "yandex_music", "name": "B"}}) values: dict[str, object] = {CONF_YM_INSTANCE: "ym-removed"} - entries = await get_config_entries(mass, values=values) # type: ignore[arg-type] + entries = await get_config_entries(mass, values=values) by_key = _entries_by_key(entries) ym_source = by_key[CONF_YM_INSTANCE] diff --git a/tests/providers/yandex_ynison/test_provider.py b/tests/providers/yandex_ynison/test_provider.py index d2b9615cab..f44376ab81 100644 --- a/tests/providers/yandex_ynison/test_provider.py +++ b/tests/providers/yandex_ynison/test_provider.py @@ -1276,7 +1276,9 @@ async def _hang(_track_id: str) -> Any: return MagicMock() # never reached # Use a tiny timeout so the test runs fast. - with patch("music_assistant.providers.yandex_ynison.provider._PREFETCH_FORMAT_TIMEOUT", 0.05): + with patch( + "music_assistant.providers.yandex_ynison.provider._PREFETCH_FORMAT_TIMEOUT", 0.05 + ): _stub_attr(provider, "_get_stream_details_with_retry", _hang) await provider._prefetch_format_for_track("track:slow") @@ -2171,7 +2173,9 @@ async def test_retries_on_failure(self) -> None: mock_yp.get_stream_details = AsyncMock(side_effect=[RuntimeError("transient"), sd]) provider._yandex_provider = mock_yp - with patch("music_assistant.providers.yandex_ynison.provider.asyncio.sleep", new_callable=AsyncMock): + with patch( + "music_assistant.providers.yandex_ynison.provider.asyncio.sleep", new_callable=AsyncMock + ): result = await provider._get_stream_details_with_retry("t1") assert result is sd assert mock_yp.get_stream_details.await_count == 2 @@ -2184,7 +2188,10 @@ async def test_raises_after_max_retries(self) -> None: provider._yandex_provider = mock_yp with ( - patch("music_assistant.providers.yandex_ynison.provider.asyncio.sleep", new_callable=AsyncMock), + patch( + "music_assistant.providers.yandex_ynison.provider.asyncio.sleep", + new_callable=AsyncMock, + ), pytest.raises(RuntimeError, match="failed after"), ): await provider._get_stream_details_with_retry("t1") diff --git a/tests/providers/yandex_ynison/test_provider_handoff.py b/tests/providers/yandex_ynison/test_provider_handoff.py index 31d6a5c33d..eda9e0d847 100644 --- a/tests/providers/yandex_ynison/test_provider_handoff.py +++ b/tests/providers/yandex_ynison/test_provider_handoff.py @@ -1,4 +1,12 @@ -"""Tests for handoff playback mode.""" +# mypy: disable-error-code="attr-defined,method-assign" +"""Tests for handoff playback mode. + +`mass` and `player_queues` are heavily mocked here via MagicMock — +runtime attributes (`assert_awaited_once_with`, `return_value`, +`call_args`, etc.) don't exist on the typed protocol. Disable the +two relevant mypy error codes for the whole file rather than +sprinkling per-line `type: ignore` annotations. +""" from __future__ import annotations @@ -14,7 +22,7 @@ ProviderFeature, ) -from provider import _features_for_mode +from music_assistant.providers.yandex_ynison import _features_for_mode from music_assistant.providers.yandex_ynison.constants import ( CONF_ALLOW_PLAYER_SWITCH, CONF_DEVICE_ID, From 96dec31a8c55c0ddb7b88634cbac01c96eb25953 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 May 2026 18:51:37 +0000 Subject: [PATCH 03/12] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v2.1.2 --- .../yandex_ynison/test_config_entries.py | 2 +- .../yandex_ynison/test_provider_handoff.py | 18 ++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/tests/providers/yandex_ynison/test_config_entries.py b/tests/providers/yandex_ynison/test_config_entries.py index 75d534034a..cf2b37df99 100644 --- a/tests/providers/yandex_ynison/test_config_entries.py +++ b/tests/providers/yandex_ynison/test_config_entries.py @@ -297,7 +297,7 @@ async def test_stale_ym_selection_normalizes_to_own() -> None: """ mass = _make_mock_mass({"ym-b": {"domain": "yandex_music", "name": "B"}}) values: dict[str, object] = {CONF_YM_INSTANCE: "ym-removed"} - entries = await get_config_entries(mass, values=values) + entries = await get_config_entries(mass, values=values) # type: ignore[arg-type] by_key = _entries_by_key(entries) ym_source = by_key[CONF_YM_INSTANCE] diff --git a/tests/providers/yandex_ynison/test_provider_handoff.py b/tests/providers/yandex_ynison/test_provider_handoff.py index eda9e0d847..fa2a360f21 100644 --- a/tests/providers/yandex_ynison/test_provider_handoff.py +++ b/tests/providers/yandex_ynison/test_provider_handoff.py @@ -408,9 +408,7 @@ async def test_uri_uses_yandex_provider_instance_id(self) -> None: ym = MagicMock() ym.instance_id = "ym-borrow-A" provider._yandex_provider = ym - provider._get_stream_details_with_retry = AsyncMock( # type: ignore[method-assign] - side_effect=Exception("ignored") - ) + provider._get_stream_details_with_retry = AsyncMock(side_effect=Exception("ignored")) await provider._handoff_activate(_make_state("track-X"), "player-A") @@ -538,9 +536,7 @@ async def test_play_media_failure_does_not_start_heartbeat(self) -> None: provider._yandex_provider = None provider.mass.player_queues.play_media = AsyncMock(side_effect=Exception("boom")) ensure_calls: list[None] = [] - provider._ensure_handoff_heartbeat = ( # type: ignore[method-assign] - lambda: ensure_calls.append(None) - ) + provider._ensure_handoff_heartbeat = lambda: ensure_calls.append(None) await provider._handoff_activate(_make_state("new-track"), "player-A") @@ -558,9 +554,7 @@ async def test_dedup_skip_starts_heartbeat(self) -> None: queue.current_item = MagicMock() queue.current_item.uri = "yandex_music://track/track-Z" ensure_calls: list[None] = [] - provider._ensure_handoff_heartbeat = ( # type: ignore[method-assign] - lambda: ensure_calls.append(None) - ) + provider._ensure_handoff_heartbeat = lambda: ensure_calls.append(None) await provider._handoff_activate(_make_state("track-Z"), "player-A") @@ -585,7 +579,7 @@ async def test_heartbeat_loop_sends_progress_when_active(self) -> None: queue.state = PlaybackState.PLAYING queue.corrected_elapsed_time = 30.0 send_mock = AsyncMock() - provider._send_progress_to_ynison = send_mock # type: ignore[method-assign] + provider._send_progress_to_ynison = send_mock task = asyncio.create_task(provider._handoff_heartbeat_loop()) await asyncio.sleep(0.12) @@ -607,7 +601,7 @@ async def test_heartbeat_skips_without_active_player(self) -> None: ynison.connected = True provider._ynison = ynison send_mock = AsyncMock() - provider._send_progress_to_ynison = send_mock # type: ignore[method-assign] + provider._send_progress_to_ynison = send_mock task = asyncio.create_task(provider._handoff_heartbeat_loop()) await asyncio.sleep(0.12) @@ -903,7 +897,7 @@ async def test_activating_transitions_to_playing_on_ma_event(self) -> None: ynison.state = MagicMock(duration_ms=200000) provider._ynison = ynison # Mock heartbeat-side helpers to simple stubs. - provider._send_progress_to_ynison = AsyncMock() # type: ignore[method-assign] + provider._send_progress_to_ynison = AsyncMock() queue = provider.mass.player_queues.get.return_value queue.state = PlaybackState.PLAYING From 4958d58e63dfb161503dad1241515e5bc69d4cf5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 May 2026 20:50:11 +0000 Subject: [PATCH 04/12] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v2.2.0 --- .../providers/yandex_ynison/__init__.py | 29 +- .../providers/yandex_ynison/constants.py | 1 + .../providers/yandex_ynison/provider.py | 276 +++++++++++++++++- 3 files changed, 304 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_ynison/__init__.py b/music_assistant/providers/yandex_ynison/__init__.py index 74b54e20ac..6d74825a8f 100644 --- a/music_assistant/providers/yandex_ynison/__init__.py +++ b/music_assistant/providers/yandex_ynison/__init__.py @@ -16,6 +16,7 @@ CONF_ACTION_CLEAR_AUTH, CONF_ALLOW_PLAYER_SWITCH, CONF_DEVICE_ID, + CONF_ENABLE_UI_INTEGRATION, CONF_HANDOFF_HEARTBEAT_INTERVAL, CONF_MASS_PLAYER_ID, CONF_OUTPUT_BIT_DEPTH, @@ -170,6 +171,11 @@ async def get_config_entries( # noqa: PLR0915 — flow naturally returns ~12 Co # Own-mode-only entries are hidden when borrowing. own_hidden = borrowing + # Stream-only UI integration toggle: hide in handoff (it would have no + # effect there). The form re-renders when CONF_PLAYBACK_MODE changes, + # so reading values[CONF_PLAYBACK_MODE] gives the live selection. + selected_mode = cast("str | None", values.get(CONF_PLAYBACK_MODE)) or PLAYBACK_MODE_STREAM + ui_integration_hidden = selected_mode == PLAYBACK_MODE_HANDOFF # Token field requirement: in own mode it's only required when there's no # alternative path (no stored x_token to refresh from). token_required = not borrowing and not bool(values.get(CONF_X_TOKEN)) @@ -319,7 +325,6 @@ async def get_config_entries( # noqa: PLR0915 — flow naturally returns ~12 Co label="Device name in Yandex Music", description="How this device appears in the Yandex Music app.", default_value=DEFAULT_DISPLAY_NAME, - advanced=True, ), ConfigEntry( key=CONF_PLAYBACK_MODE, @@ -353,6 +358,28 @@ async def get_config_entries( # noqa: PLR0915 — flow naturally returns ~12 Co ConfigValueOption("Handoff (experimental)", PLAYBACK_MODE_HANDOFF), ], ), + ConfigEntry( + key=CONF_ENABLE_UI_INTEGRATION, + type=ConfigEntryType.BOOLEAN, + label="Show full player card in MA UI (experimental)", + description=( + "Stream mode only. When enabled, the plugin publishes a " + "frontend-only fake queue under its own id and stamps the " + "player's output_format so MA's UI renders the seek bar, " + "signal-chain panel, and quality indicator — the same player " + "card you get when MA streams its own queue.\n\n" + "Off by default because the integration relies on private " + "frontend behaviours that may break across MA versions, can " + "interfere with 'Play Now' on local content while this source " + "is active (the click is routed to a queue id the backend " + "doesn't own, and errors), and may cause brief signal-chain " + "flicker at track start before the source format is known.\n\n" + "Ignored in handoff mode — MA already owns a real queue there." + ), + default_value=False, + advanced=True, + hidden=ui_integration_hidden, + ), ConfigEntry( key=CONF_HANDOFF_HEARTBEAT_INTERVAL, type=ConfigEntryType.STRING, diff --git a/music_assistant/providers/yandex_ynison/constants.py b/music_assistant/providers/yandex_ynison/constants.py index 1b1c22a888..92d66f2257 100644 --- a/music_assistant/providers/yandex_ynison/constants.py +++ b/music_assistant/providers/yandex_ynison/constants.py @@ -27,6 +27,7 @@ CONF_OUTPUT_BIT_DEPTH: Final[str] = "output_bit_depth" CONF_PLAYBACK_MODE: Final[str] = "playback_mode" CONF_HANDOFF_HEARTBEAT_INTERVAL: Final[str] = "handoff_heartbeat_interval" +CONF_ENABLE_UI_INTEGRATION: Final[str] = "enable_ui_integration" # Playback mode values # - stream: default — plugin owns the audio source and streams PCM via PluginSource diff --git a/music_assistant/providers/yandex_ynison/provider.py b/music_assistant/providers/yandex_ynison/provider.py index 1304b16cc1..7d1b4586ea 100644 --- a/music_assistant/providers/yandex_ynison/provider.py +++ b/music_assistant/providers/yandex_ynison/provider.py @@ -36,6 +36,7 @@ from .constants import ( CONF_ALLOW_PLAYER_SWITCH, CONF_DEVICE_ID, + CONF_ENABLE_UI_INTEGRATION, CONF_HANDOFF_HEARTBEAT_INTERVAL, CONF_MASS_PLAYER_ID, CONF_OUTPUT_BIT_DEPTH, @@ -205,7 +206,18 @@ def _is_handoff(self) -> bool: """True when this instance is configured for handoff playback mode.""" return self._playback_mode == PLAYBACK_MODE_HANDOFF - def __init__( + @property + def _ui_integration_active(self) -> bool: + """True when stream-mode UI integration helpers should run. + + Gated by the explicit `CONF_ENABLE_UI_INTEGRATION` toggle (off by + default) and force-disabled in handoff mode (where MA already owns + a real queue, so the fake-queue trick is both unnecessary and + wrong). + """ + return self._ui_integration_enabled and not self._is_handoff + + def __init__( # noqa: PLR0915 — straight-line config wiring + bookkeeping self, mass: MusicAssistant, manifest: ProviderManifest, @@ -238,6 +250,14 @@ def __init__( self._handoff_heartbeat_interval: float = _parse_handoff_heartbeat( self.config.get_value(CONF_HANDOFF_HEARTBEAT_INTERVAL) ) + # Stream-mode UI integration (fake-queue + signal-chain stamp). + # Off by default — relies on private frontend behaviours and can + # interfere with 'Play Now' on local content while this source is + # active. Hidden in handoff mode (the toggle has no effect there). + ui_value = self.config.get_value(CONF_ENABLE_UI_INTEGRATION) + self._ui_integration_enabled: bool = ( + cast("bool", ui_value) if ui_value is not None else False + ) # Token source — None = own (manually entered CONF_TOKEN); # otherwise the instance_id of a linked yandex_music provider to borrow from. @@ -268,6 +288,11 @@ def __init__( self._seek_grace_until: float = 0.0 self._last_player_update_time: float = 0.0 self._actual_duration_ms: int = 0 + # Captured Yandex source format (FLAC 44.1/16 / 44.1/24 / 48/24 + # depending on track) — used by `_register_plugin_queue` so the + # frontend signal-chain panel can show the full conversion path: + # Source → our PCM normalization → MA streams pipeline → player. + self._actual_input_format: AudioFormat | None = None self._prefetched_list: list[dict[str, Any]] | None = None self._prefetch_task: asyncio.Task[Any] | None = None self._normalized_params: dict[str, Any] = PCM_LOSSY_PARAMS @@ -397,6 +422,12 @@ async def handle_async_init(self) -> None: async def unload(self, is_removed: bool = False) -> None: """Handle close/cleanup of the provider.""" + # Strip our `output_format` stamp from the player on reload/remove — + # otherwise a config-driven reload while the source is active leaves + # a stale signal-chain entry on the player after the provider is + # gone. Always-on cleanup; safe even if the toggle was off (the + # helper itself is no-op-on-absent-key). + self._clear_player_output_format(self._active_player_id) if self._prefetch_task and not self._prefetch_task.done(): self._prefetch_task.cancel() with suppress(asyncio.CancelledError): @@ -871,6 +902,12 @@ async def _handle_ynison_state(self, state: YnisonState) -> None: else: # Our device but paused — stop player, keep association await self._pause_playback() + # Re-publish the fake queue with state="paused" so the + # frontend player card reflects the pause immediately. + # Resume re-fires it via _activate_playback's needs_reselect + # branch, so we don't need a symmetric path on play. + if self._active_player_id and self._ui_integration_active: + self._register_plugin_queue(self._active_player_id) elif self._source_details.in_use_by or (self._is_handoff and self._active_player_id): # Active device switched away — fully release player. # In handoff mode `_source_details.in_use_by` is always None @@ -915,6 +952,12 @@ async def _activate_playback(self, state: YnisonState) -> None: # noqa: PLR0915 self.mass.create_task( self.mass.players.select_source(target_player_id, self.instance_id) ) + # Stream-mode UI integration: stamp output_format on the player + # for the signal-chain panel, and publish a frontend-only + # queue under our instance_id so the seek-bar / quality + # indicator render. See `_register_plugin_queue` docstring. + self._set_player_output_format(target_player_id) + self._register_plugin_queue(target_player_id) # Signal track change if track_id changed significant_change = False @@ -963,6 +1006,12 @@ async def _activate_playback(self, state: YnisonState) -> None: # noqa: PLR0915 self._track_changed_event.set() self._seek_grace_until = now + _ECHO_GRACE_PERIOD significant_change = True + # Snap the frontend seek-bar to the user-seeked + # position immediately. Without `force_update` + # MA filters elapsed-time-only updates and the + # bar would only catch up on the next regular + # tick (~1-2s after the seek lands). + self._signal_seek_to_frontend(state.progress_ms, target_player_id) elif verdict == "queue_rebuild": self.logger.debug( "Stream: drift to 0 ignored on %s (Ynison=%dms, " @@ -975,6 +1024,13 @@ async def _activate_playback(self, state: YnisonState) -> None: # noqa: PLR0915 # Update metadata from state self._update_metadata(state) + # Refresh the frontend fake queue when the track changes — Vue + # picks up the new `current_item.streamdetails` only on a fresh + # QUEUE_UPDATED. Throttled to track-change events (`significant_change`) + # so we don't spam the WS on every progress tick. + if significant_change and self._active_player_id and self._ui_integration_active: + self._register_plugin_queue(self._active_player_id) + # Always trigger player update on significant changes; # throttle regular updates to avoid UI churn (every 2 seconds). # Use force_update on seek/track change so the server broadcasts a full @@ -987,6 +1043,188 @@ async def _activate_playback(self, state: YnisonState) -> None: # noqa: PLR0915 ) self._last_player_update_time = now_mono + def _register_plugin_queue(self, player_id: str) -> None: + """Publish a frontend-only queue under our `instance_id`. + + MA's frontend resolves the active player's seek bar / signal-chain / + quality-indicator via a `PlayerQueue` looked up by + `player.active_source` (which equals our `instance_id` while we + own the player). If no queue exists with that id, the frontend + renders nothing — the player card looks "empty" even while we're + streaming. Registering a real queue in `player_queues._queues` + is wrong because the backend would then route every play / pause / + play_media to a queue that has no items and no stream. + + Trick (borrowed from `spotify_connect` PR #3857): fire + `QUEUE_ADDED` with `queue_id == instance_id` and a fake queue + dict, but do NOT touch `player_queues._queues`. The frontend + stores the dict in its in-memory queue map (enough to render), + and the backend remains unaware so command routing keeps going + through `PluginSource` callbacks (`_on_play`, `_on_pause`, etc.). + + Stream-mode only — handoff has a real `play_media` queue and + doesn't need the impostor. Also gated by + `CONF_ENABLE_UI_INTEGRATION` (off by default). + """ + if not self._ui_integration_active: + return + player = self.mass.players.get_player(player_id) + metadata = self._source_details.metadata + # Build DSP details for downstream signal-chain display. + dsp = None + with suppress(Exception): + dsp = self.mass.streams.audio.get_stream_dsp_details(player_id) + # Build the full signal chain. The frontend renders a chain of + # AudioFormat boxes: + # stream_details.audio_format → input from the source + # stream_details.dsp → per-stage transformations + # player.extra_data["output_format"] → what the player gets + # We populate all three: + # - `audio_format`: the actual Yandex CDN stream format (FLAC + # 44.1/16, 44.1/24, 48/24, etc. — varies per track), captured + # in `_update_metadata_from_stream`. Falls back to our + # normalized PCM if the source is not yet known (cold start). + # - `dsp`: the player's downstream chain from MA's streams + # controller (resampling / replaygain / per-player filters). + # - `output_format` (set in `_set_player_output_format`): our + # inner-ffmpeg PCM that we emit through PluginSource. + src_fmt = self._actual_input_format + if src_fmt is not None: + source_format_dict: dict[str, Any] = { + "content_type": src_fmt.content_type.value, + "sample_rate": src_fmt.sample_rate, + "bit_depth": src_fmt.bit_depth, + "channels": src_fmt.channels, + } + else: + # Pre-stream: report our normalized PCM as both source + # and output (best we have until the first stream starts). + fmt = self._normalized_format + source_format_dict = { + "content_type": fmt.content_type.value, + "sample_rate": fmt.sample_rate, + "bit_depth": fmt.bit_depth, + "channels": fmt.channels, + } + current_item = None + if metadata: + duration = int(metadata.duration) if metadata.duration else 0 + current_item = { + "queue_id": self.instance_id, + "queue_item_id": "yandex_ynison_current", + "duration": duration, + "name": metadata.title or "", + "streamdetails": { + "audio_format": source_format_dict, + "dsp": dsp, + }, + } + # Derive state from the authoritative Ynison status — when Ynison + # reports paused, the frontend must show paused on the fake queue + # too (otherwise the player card stays "playing" while audio is + # silent until the next track-change re-fire). + is_paused = bool(self._ynison and self._ynison.state.is_paused) + fake_queue = { + "queue_id": self.instance_id, + "active": True, + "display_name": player.display_name if player else player_id, + "available": True, + "items": 1 if current_item else 0, + "shuffle_enabled": False, + "repeat_mode": "off", + "dont_stop_the_music_enabled": False, + "current_index": 0, + "index_in_buffer": None, + "elapsed_time": metadata.elapsed_time if metadata else 0, + "elapsed_time_last_updated": time.time(), + "state": "paused" if is_paused else "playing", + "current_item": current_item, + "next_item": None, + "radio_source": [], + "flow_mode": False, + "resume_pos": 0, + "extra_attributes": {}, + } + self.mass.signal_event( + EventType.QUEUE_ADDED, + object_id=self.instance_id, + data=fake_queue, + ) + if current_item: + # Re-emit as QUEUE_UPDATED so Vue's reactivity refreshes the + # `current_item.streamdetails` block on subsequent calls + # (track-change). QUEUE_ADDED only triggers on first sight. + self.mass.signal_event( + EventType.QUEUE_UPDATED, + object_id=self.instance_id, + data=fake_queue, + ) + + def _set_player_output_format(self, player_id: str) -> None: + """Stamp our PCM output on `player.extra_data["output_format"]`. + + The frontend's signal-chain panel reads this key, normally set by + the streams controller during regular MA queue playback. We + bypass that controller (audio comes from `PluginSource.get_audio_stream`), + so MA never gets a chance to fill it in. Without this, the panel + either shows nothing useful or stale info from a previous source. + """ + if not self._ui_integration_active: + return + player = self.mass.players.get_player(player_id) + if player is None: + return + # Use a fresh AudioFormat copy — `extra_data` may end up shared + # with downstream code that mutates `codec_type`, just like + # `PluginSource.audio_format` itself. + fmt = self._normalized_format + player.extra_data["output_format"] = { + "content_type": fmt.content_type.value, + "codec_type": fmt.content_type.value, + "sample_rate": fmt.sample_rate, + "bit_depth": fmt.bit_depth, + "channels": fmt.channels, + } + self.mass.players.trigger_player_update(player_id) + + def _clear_player_output_format(self, player_id: str | None) -> None: + """Remove our `output_format` stamp on deselect — frontend reverts to MA queue format. + + Cleanup path is NOT gated on `_ui_integration_active`: if the user + had the toggle ON, the stamp got written, then they save with it + OFF, on next reload the cleanup must still run to remove the + stale key. `pop` is a no-op when the key is absent, so always-on + cleanup is safe regardless of whether we ever wrote the stamp. + """ + if not player_id: + return + player = self.mass.players.get_player(player_id) + if player is None: + return + player.extra_data.pop("output_format", None) + self.mass.players.trigger_player_update(player_id) + + def _signal_seek_to_frontend(self, elapsed_ms: int, player_id: str | None) -> None: + """Force the frontend seek-bar to jump to a new position. + + On a real Ynison-driven seek we want the bar to snap immediately + rather than wait for the next regular `_sync_progress` tick. The + `QUEUE_TIME_UPDATED` event carrying our fake-queue id is what the + frontend listens to for elapsed updates, so emitting one with the + new position is sufficient — no need to mutate `Player` internals. + Public-API-only: previous revisions wrote `player._attr_elapsed_time` + to force the broadcast, but coupling to a name-mangled attribute + silently breaks across MA versions and the `suppress(Exception)` + guard would hide the regression. + """ + if not self._ui_integration_active or not player_id: + return + self.mass.signal_event( + EventType.QUEUE_TIME_UPDATED, + object_id=self.instance_id, + data=elapsed_ms // 1000, + ) + def _update_metadata(self, state: YnisonState) -> None: """Update PluginSource metadata from Ynison state.""" if self._source_details.metadata is None: @@ -1032,6 +1270,20 @@ async def _update_metadata_from_stream( title=f"Yandex Music Connect | {self._display_name}", ) meta = self._source_details.metadata + # Capture the source format (Yandex CDN stream) so the signal-chain + # panel can render the full conversion path Source → PCM → player. + prev_input_format = self._actual_input_format + self._actual_input_format = stream_details.audio_format + # Refresh the frontend fake queue when the source format changes — + # it carries `streamdetails.audio_format`, and a track-change + # registration earlier ran before stream details were known + # (fell back to our PCM). Re-fire so the panel shows real source. + if ( + self._actual_input_format != prev_input_format + and self._active_player_id + and self._ui_integration_active + ): + self._register_plugin_queue(self._active_player_id) if stream_details.duration: meta.duration = stream_details.duration self._actual_duration_ms = stream_details.duration * 1000 @@ -1099,6 +1351,18 @@ async def _sync_progress( meta.elapsed_time_last_updated = time.time() if player_id: self.mass.players.trigger_player_update(player_id) + # Keep the frontend fake queue's elapsed in sync. Without + # this the queue's elapsed_time only updates on track-change + # (when we re-fire QUEUE_ADDED) and the seek bar drifts off + # real playback over the course of a track. QUEUE_TIME_UPDATED + # carrying the elapsed-seconds payload is the same shape the + # frontend expects for real queues. + if self._ui_integration_active: + self.mass.signal_event( + EventType.QUEUE_TIME_UPDATED, + object_id=self.instance_id, + data=elapsed_ms // 1000, + ) # Update Ynison so the Yandex app shows correct position await self._send_progress_to_ynison( progress_ms=elapsed_ms, @@ -1209,6 +1473,9 @@ def _clear_active_player(self) -> None: """Clear the active player and reset plugin state.""" prev_player_id = self._active_player_id was_in_use = self._source_details.in_use_by == prev_player_id + # Strip our `output_format` stamp before nulling the player ref — + # frontend reverts to MA queue's format / no signal-chain. + self._clear_player_output_format(prev_player_id) self._active_player_id = None self._source_details.in_use_by = None self._stream_stop_event.set() @@ -1232,6 +1499,13 @@ def _clear_active_player(self) -> None: self._handoff_last_progress_sync_mono = 0.0 self._handoff_last_playing_elapsed_ms = 0 self._command_idempotency.clear() + # Drop the captured CDN source format too — leaving it set would + # make the next session's first `_register_plugin_queue` (fired + # before the new stream's `_update_metadata_from_stream` runs) + # show the previous track's quality on the signal-chain panel + # until the re-fire corrects it. None falls back to the + # documented cold-start behaviour (PCM as source = output). + self._actual_input_format = None if prev_player_id: self.logger.debug( From 05b07e40f16efdaad37594ef99acf353e0dc9c4b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 May 2026 21:08:55 +0000 Subject: [PATCH 05/12] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v2.2.1 --- .../providers/yandex_ynison/provider.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/music_assistant/providers/yandex_ynison/provider.py b/music_assistant/providers/yandex_ynison/provider.py index 7d1b4586ea..3a80653052 100644 --- a/music_assistant/providers/yandex_ynison/provider.py +++ b/music_assistant/providers/yandex_ynison/provider.py @@ -25,6 +25,7 @@ PlayerCommandFailed, UnsupportedFeaturedException, ) +from music_assistant_models.media_items import AudioFormat from music_assistant_models.streamdetails import StreamDetails, StreamMetadata from ya_passport_auth import SecretStr @@ -79,7 +80,6 @@ if TYPE_CHECKING: from music_assistant_models.config_entries import ProviderConfig from music_assistant_models.event import MassEvent - from music_assistant_models.media_items import AudioFormat from music_assistant_models.provider import ProviderManifest from music_assistant.mass import MusicAssistant @@ -1168,23 +1168,29 @@ def _set_player_output_format(self, player_id: str) -> None: bypass that controller (audio comes from `PluginSource.get_audio_stream`), so MA never gets a chance to fill it in. Without this, the panel either shows nothing useful or stale info from a previous source. + + MA core stores an `AudioFormat` instance here (see + `controllers/streams/audio.py:get_player_filter_params`), and + `DSPDetails.output_format` is typed `AudioFormat | None`, so we + store a real `AudioFormat` too — a plain dict would silently + violate the typed contract on the downstream readers + (`controllers/streams/audio.py`, `player_queues.py`). + Use a fresh copy because `AudioFormat` is mutable and MA's + FFmpeg can mutate `codec_type` in place. """ if not self._ui_integration_active: return player = self.mass.players.get_player(player_id) if player is None: return - # Use a fresh AudioFormat copy — `extra_data` may end up shared - # with downstream code that mutates `codec_type`, just like - # `PluginSource.audio_format` itself. fmt = self._normalized_format - player.extra_data["output_format"] = { - "content_type": fmt.content_type.value, - "codec_type": fmt.content_type.value, - "sample_rate": fmt.sample_rate, - "bit_depth": fmt.bit_depth, - "channels": fmt.channels, - } + player.extra_data["output_format"] = AudioFormat( + content_type=fmt.content_type, + codec_type=fmt.content_type, + sample_rate=fmt.sample_rate, + bit_depth=fmt.bit_depth, + channels=fmt.channels, + ) self.mass.players.trigger_player_update(player_id) def _clear_player_output_format(self, player_id: str | None) -> None: From 53b78149f249a1d9d2524497c4b5481413134d9b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 12 May 2026 08:54:39 +0000 Subject: [PATCH 06/12] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v2.2.2 --- .../yandex_ynison/test_config_entries.py | 2 +- .../providers/yandex_ynison/test_provider.py | 86 +++++++++---------- .../yandex_ynison/test_provider_handoff.py | 2 +- .../yandex_ynison/test_ynison_client.py | 14 ++- 4 files changed, 51 insertions(+), 53 deletions(-) diff --git a/tests/providers/yandex_ynison/test_config_entries.py b/tests/providers/yandex_ynison/test_config_entries.py index cf2b37df99..75d534034a 100644 --- a/tests/providers/yandex_ynison/test_config_entries.py +++ b/tests/providers/yandex_ynison/test_config_entries.py @@ -297,7 +297,7 @@ async def test_stale_ym_selection_normalizes_to_own() -> None: """ mass = _make_mock_mass({"ym-b": {"domain": "yandex_music", "name": "B"}}) values: dict[str, object] = {CONF_YM_INSTANCE: "ym-removed"} - entries = await get_config_entries(mass, values=values) # type: ignore[arg-type] + entries = await get_config_entries(mass, values=values) by_key = _entries_by_key(entries) ym_source = by_key[CONF_YM_INSTANCE] diff --git a/tests/providers/yandex_ynison/test_provider.py b/tests/providers/yandex_ynison/test_provider.py index f44376ab81..2f02a81a04 100644 --- a/tests/providers/yandex_ynison/test_provider.py +++ b/tests/providers/yandex_ynison/test_provider.py @@ -189,21 +189,21 @@ def test_auto_with_playing_player(self) -> None: player2.display_name = "Player 2" player2.state.playback_state = PlaybackState.PLAYING - provider.mass.players.all_players.return_value = [player1, player2] # type: ignore[attr-defined] + provider.mass.players.all_players.return_value = [player1, player2] assert provider._get_target_player_id() == "player2" def test_specific_player_exists(self) -> None: """Returns configured player when it exists.""" provider = _make_provider("my-player") - provider.mass.players.get_player.return_value = MagicMock() # type: ignore[attr-defined] + provider.mass.players.get_player.return_value = MagicMock() assert provider._get_target_player_id() == "my-player" def test_specific_player_missing(self) -> None: """Returns None when configured player no longer exists.""" provider = _make_provider("gone-player") - provider.mass.players.get_player.return_value = None # type: ignore[attr-defined] + provider.mass.players.get_player.return_value = None assert provider._get_target_player_id() is None @@ -211,7 +211,7 @@ def test_active_player_takes_priority(self) -> None: """Active player takes priority over auto selection.""" provider = _make_provider() provider._active_player_id = "active-one" - provider.mass.players.get_player.return_value = MagicMock() # type: ignore[attr-defined] + provider.mass.players.get_player.return_value = MagicMock() assert provider._get_target_player_id() == "active-one" @@ -270,7 +270,7 @@ def test_clears_state(self) -> None: provider._clear_active_player() assert provider._active_player_id is None - assert provider._source_details.in_use_by is None # type: ignore[unreachable] + assert provider._source_details.in_use_by is None provider.mass.players.trigger_player_update.assert_called_with("some-player") @@ -289,7 +289,7 @@ async def test_finds_yandex_music_provider(self) -> None: mock_ym = MagicMock() mock_ym.domain = "yandex_music" mock_ym.type = ProviderType.MUSIC - provider.mass.get_providers.return_value = [mock_ym] # type: ignore[attr-defined] + provider.mass.get_providers.return_value = [mock_ym] await provider._check_yandex_provider_match() @@ -301,7 +301,7 @@ async def test_no_matching_provider(self) -> None: """No linked provider disables playback control.""" provider = _make_provider() - provider.mass.get_providers.return_value = [] # type: ignore[attr-defined] + provider.mass.get_providers.return_value = [] await provider._check_yandex_provider_match() assert provider._yandex_provider is None @@ -324,8 +324,8 @@ async def test_activates_on_our_device(self) -> None: player = MagicMock() player.player_id = "player1" player.display_name = "Player 1" - provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] - provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + provider.mass.players.all_players.return_value = [player] + provider.mass.players.get_player.return_value = player state = YnisonState( active_device_id=provider._device_id, @@ -354,8 +354,8 @@ async def test_prefetch_runs_on_resume_reselect_with_new_track(self) -> None: player = MagicMock() player.player_id = "player1" player.display_name = "Player 1" - provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] - provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + provider.mass.players.all_players.return_value = [player] + provider.mass.players.get_player.return_value = player # Simulate a previous session: same player still active, stream # stopped (needs_reselect=True), and the previous track is "old". @@ -395,7 +395,7 @@ async def test_clears_on_device_switch(self) -> None: await provider._handle_ynison_state(state) assert provider._active_player_id is None - assert provider._source_details.in_use_by is None # type: ignore[unreachable] + assert provider._source_details.in_use_by is None async def test_seek_detected_from_ynison(self) -> None: """Detects seek from Yandex app via progress drift.""" @@ -403,8 +403,8 @@ async def test_seek_detected_from_ynison(self) -> None: player = MagicMock() player.player_id = "player1" - provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] - provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + provider.mass.players.all_players.return_value = [player] + provider.mass.players.get_player.return_value = player def _make_state(progress_ms: int) -> YnisonState: return YnisonState( @@ -436,7 +436,7 @@ def _make_state(progress_ms: int) -> YnisonState: # Verify force_update=True was used so the server sends a full # PLAYER_UPDATED event (not just a lightweight elapsed-time one) - provider.mass.players.trigger_player_update.assert_called_with("player1", force_update=True) # type: ignore[attr-defined] + provider.mass.players.trigger_player_update.assert_called_with("player1", force_update=True) async def test_seek_grace_period_after_track_change(self) -> None: """Seek detection is suppressed during grace period after track change.""" @@ -444,8 +444,8 @@ async def test_seek_grace_period_after_track_change(self) -> None: player = MagicMock() player.player_id = "player1" - provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] - provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + provider.mass.players.all_players.return_value = [player] + provider.mass.players.get_player.return_value = player def _make_state(progress_ms: int) -> YnisonState: return YnisonState( @@ -480,8 +480,8 @@ async def test_progress_throttled_update(self) -> None: player = MagicMock() player.player_id = "player1" - provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] - provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + provider.mass.players.all_players.return_value = [player] + provider.mass.players.get_player.return_value = player state = YnisonState( active_device_id=provider._device_id, @@ -500,7 +500,7 @@ async def test_progress_throttled_update(self) -> None: # First call — significant (new track) → always triggers await provider._handle_ynison_state(state) - call_count_1 = provider.mass.players.trigger_player_update.call_count # type: ignore[attr-defined] + call_count_1 = provider.mass.players.trigger_player_update.call_count # Simulate same track still playing (no seek, no track change). # Mark as echo so the seek-detection branch stays quiet. @@ -522,12 +522,12 @@ async def test_progress_throttled_update(self) -> None: # Second call shortly after — throttled, no trigger await provider._handle_ynison_state(state2) - call_count_2 = provider.mass.players.trigger_player_update.call_count # type: ignore[attr-defined] + call_count_2 = provider.mass.players.trigger_player_update.call_count # Force the throttle to expire provider._last_player_update_time = 0.0 await provider._handle_ynison_state(state2) - call_count_3 = provider.mass.players.trigger_player_update.call_count # type: ignore[attr-defined] + call_count_3 = provider.mass.players.trigger_player_update.call_count # First call triggered, second was throttled, third triggered assert call_count_1 >= 1 @@ -535,7 +535,7 @@ async def test_progress_throttled_update(self) -> None: assert call_count_3 > call_count_2 # Regular (non-seek) updates should NOT use force_update - provider.mass.players.trigger_player_update.assert_called_with( # type: ignore[attr-defined] + provider.mass.players.trigger_player_update.assert_called_with( "player1", force_update=False ) @@ -558,7 +558,7 @@ async def test_duration_updated_from_stream_details(self) -> None: assert meta.duration == 185 assert meta.elapsed_time == 30 # 30000ms → 30s assert provider._actual_duration_ms == 185000 - provider.mass.players.trigger_player_update.assert_called_once_with( # type: ignore[attr-defined] + provider.mass.players.trigger_player_update.assert_called_once_with( "player1", force_update=True ) # Real duration pushed to Ynison @@ -773,7 +773,7 @@ async def test_prefetch_on_second_to_last_track(self) -> None: provider._yandex_provider = mock_ym_provider # Use real create_task so prefetch coroutine actually runs - provider.mass.create_task = lambda coro: asyncio.get_event_loop().create_task(coro) # type: ignore[method-assign, assignment, misc] + provider.mass.create_task = lambda coro: asyncio.get_event_loop().create_task(coro) # Trigger prefetch provider._maybe_prefetch( @@ -1959,10 +1959,10 @@ async def test_stops_stream_and_player(self) -> None: await provider._pause_playback() assert provider._stream_stop_event.is_set() - provider.mass.players.cmd_stop.assert_awaited_once_with("player1") # type: ignore[attr-defined] + provider.mass.players.cmd_stop.assert_awaited_once_with("player1") assert provider._source_details.in_use_by is None # Progress is preserved for resume - assert provider._streaming_progress_ms == 50000 # type: ignore[unreachable] + assert provider._streaming_progress_ms == 50000 async def test_no_active_player(self) -> None: """Pause with no active player just sets stop event.""" @@ -1972,7 +1972,7 @@ async def test_no_active_player(self) -> None: await provider._pause_playback() assert provider._stream_stop_event.is_set() - provider.mass.players.cmd_stop.assert_not_called() # type: ignore[attr-defined] + provider.mass.players.cmd_stop.assert_not_called() # ------------------------------------------------------------------ @@ -1988,8 +1988,8 @@ def _player(self, provider: YandexYnisonProvider) -> MagicMock: player.player_id = "player1" player.display_name = "Player 1" player.state.playback_state = PlaybackState.PLAYING - provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] - provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + provider.mass.players.all_players.return_value = [player] + provider.mass.players.get_player.return_value = player return player async def _prime_same_track(self, provider: YandexYnisonProvider) -> None: @@ -2062,7 +2062,7 @@ async def test_updates_metadata_and_ynison(self) -> None: meta = provider._source_details.metadata assert meta.elapsed_time == 5 - provider.mass.players.trigger_player_update.assert_called_with("player1") # type: ignore[attr-defined] + provider.mass.players.trigger_player_update.assert_called_with("player1") provider._ynison.update_playing_status.assert_awaited_once() async def test_with_seek_offset(self) -> None: @@ -2092,7 +2092,7 @@ async def test_no_player_id_skips_trigger(self) -> None: await provider._sync_progress(0, 0, None) - provider.mass.players.trigger_player_update.assert_not_called() # type: ignore[attr-defined] + provider.mass.players.trigger_player_update.assert_not_called() # ------------------------------------------------------------------ @@ -2145,8 +2145,8 @@ async def test_success_first_attempt(self) -> None: assert result is sd mock_yp.get_stream_details.assert_awaited_once() # Verify cache.set was called with data field preserved - provider.mass.cache.set.assert_awaited_once() # type: ignore[attr-defined] - cached_value = provider.mass.cache.set.call_args[0][1] # type: ignore[attr-defined] + provider.mass.cache.set.assert_awaited_once() + cached_value = provider.mass.cache.set.call_args[0][1] assert cached_value["data"] == sd.data async def test_cache_hit_skips_api(self) -> None: @@ -2154,7 +2154,7 @@ async def test_cache_hit_skips_api(self) -> None: provider = _make_provider() cached_sd = MagicMock() cached_sd.expiration = 600 - provider.mass.cache.get = AsyncMock(return_value=cached_sd) # type: ignore[method-assign] + provider.mass.cache.get = AsyncMock(return_value=cached_sd) mock_yp = MagicMock() mock_yp.get_stream_details = AsyncMock() provider._yandex_provider = mock_yp @@ -2339,15 +2339,15 @@ async def test_selects_source_on_new_player(self) -> None: player.player_id = "player1" player.display_name = "Player 1" player.state.playback_state = PlaybackState.IDLE - provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] - provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + provider.mass.players.all_players.return_value = [player] + provider.mass.players.get_player.return_value = player state = _make_ynison_state(progress_ms=0, paused=False) await provider._activate_playback(state) assert provider._active_player_id == "player1" - provider.mass.create_task.assert_called() # type: ignore[attr-defined] + provider.mass.create_task.assert_called() async def test_detects_track_change(self) -> None: """Detects track change and updates streaming track id.""" @@ -2356,8 +2356,8 @@ async def test_detects_track_change(self) -> None: player = MagicMock() player.player_id = "player1" - provider.mass.players.all_players.return_value = [player] # type: ignore[attr-defined] - provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + provider.mass.players.all_players.return_value = [player] + provider.mass.players.get_player.return_value = player provider._active_player_id = "player1" state = _make_ynison_state( @@ -2380,7 +2380,7 @@ async def test_resume_after_pause(self) -> None: player = MagicMock() player.player_id = "player1" - provider.mass.players.get_player.return_value = player # type: ignore[attr-defined] + provider.mass.players.get_player.return_value = player state = _make_ynison_state( progress_ms=50000, @@ -2396,8 +2396,8 @@ async def test_resume_after_pause(self) -> None: async def test_no_target_player_returns(self) -> None: """Returns early when no target player is available.""" provider = _make_provider() - provider.mass.players.all_players.return_value = [] # type: ignore[attr-defined] - provider.mass.players.get_player.return_value = None # type: ignore[attr-defined] + provider.mass.players.all_players.return_value = [] + provider.mass.players.get_player.return_value = None state = _make_ynison_state() diff --git a/tests/providers/yandex_ynison/test_provider_handoff.py b/tests/providers/yandex_ynison/test_provider_handoff.py index fa2a360f21..dd24298bd2 100644 --- a/tests/providers/yandex_ynison/test_provider_handoff.py +++ b/tests/providers/yandex_ynison/test_provider_handoff.py @@ -384,7 +384,7 @@ async def test_clear_resets_handoff_watermarks(self) -> None: provider._clear_active_player() assert provider._active_player_id is None - assert provider._expected_track_id is None # type: ignore[unreachable] + assert provider._expected_track_id is None assert provider._expected_phase is HandoffPhase.IDLE assert provider._handoff_completion_signaled_for is None assert provider._drift_suppress_until == 0.0 diff --git a/tests/providers/yandex_ynison/test_ynison_client.py b/tests/providers/yandex_ynison/test_ynison_client.py index f60be34ec4..87ce79719f 100644 --- a/tests/providers/yandex_ynison/test_ynison_client.py +++ b/tests/providers/yandex_ynison/test_ynison_client.py @@ -1097,7 +1097,7 @@ async def test_text_message_with_error_field(self, client: YnisonClient) -> None ) await self._run_loop_with_messages(client, [error_msg, valid_msg]) - client._logger.warning.assert_called() # type: ignore[attr-defined] + client._logger.warning.assert_called() async def test_rebalance_error_breaks_loop( self, @@ -1169,7 +1169,7 @@ async def test_text_message_invalid_json(self, client: YnisonClient) -> None: ) await self._run_loop_with_messages(client, [bad_msg, valid_msg]) - client._logger.warning.assert_called() # type: ignore[attr-defined] + client._logger.warning.assert_called() async def test_callback_exception_continues( self, @@ -1201,7 +1201,7 @@ async def test_binary_message_logged(self, client: YnisonClient) -> None: ) await self._run_loop_with_messages(client, [bin_msg, valid_msg]) - client._logger.debug.assert_called() # type: ignore[attr-defined] + client._logger.debug.assert_called() async def test_error_message_breaks_and_reconnects(self, client: YnisonClient) -> None: """ERROR message breaks loop and schedules reconnect.""" @@ -1308,7 +1308,7 @@ async def test_empty_data_message(self, client: YnisonClient) -> None: msg = _make_ws_msg(aiohttp.WSMsgType.TEXT, "") # Empty string → json.loads will fail → warning logged await self._run_loop_with_messages(client, [msg]) - client._logger.warning.assert_called() # type: ignore[attr-defined] + client._logger.warning.assert_called() async def test_no_ws_raises_runtime_error(self, client: YnisonClient) -> None: """_message_loop raises RuntimeError when ws is None.""" @@ -1344,7 +1344,7 @@ async def test_success_on_first_attempt(self, client: YnisonClient) -> None: ): await client._reconnect() - client._logger.info.assert_any_call("Ynison reconnected successfully") # type: ignore[attr-defined] + client._logger.info.assert_any_call("Ynison reconnected successfully") async def test_retries_indefinitely_until_stopped(self, client: YnisonClient) -> None: """Reconnect keeps retrying past the old 5-attempt cap until stop_event.""" @@ -1644,9 +1644,7 @@ async def redirect_side_effect() -> tuple[str, str, int]: on_auth_failure.assert_awaited_once() assert client._token == SecretStr("new-token") - client._logger.info.assert_any_call( # type: ignore[attr-defined] - "Token refreshed, will retry with new token" - ) + client._logger.info.assert_any_call("Token refreshed, will retry with new token") async def test_auth_failure_no_callback(self) -> None: """LoginFailed without on_auth_failure keeps retrying on the same token.""" From 508a4b7f3b37501727a6b91eeae0b6339a61e83c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 May 2026 10:59:21 +0000 Subject: [PATCH 07/12] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v2.2.2 --- music_assistant/providers/yandex_ynison/VERSION | 1 + music_assistant/providers/yandex_ynison/provider.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 music_assistant/providers/yandex_ynison/VERSION diff --git a/music_assistant/providers/yandex_ynison/VERSION b/music_assistant/providers/yandex_ynison/VERSION new file mode 100644 index 0000000000..585940699b --- /dev/null +++ b/music_assistant/providers/yandex_ynison/VERSION @@ -0,0 +1 @@ +2.2.3 diff --git a/music_assistant/providers/yandex_ynison/provider.py b/music_assistant/providers/yandex_ynison/provider.py index 3a80653052..655cd1f950 100644 --- a/music_assistant/providers/yandex_ynison/provider.py +++ b/music_assistant/providers/yandex_ynison/provider.py @@ -1977,12 +1977,21 @@ async def _apply_track_change( # noqa: PLR0915 — sub-case branches inline self.logger.info("Handoff: resuming paused queue on %s", expected_uri) # Open a short drift-suppress window — cmd_play takes 100-500ms # to land, during which `_apply_same_track_sync` could see a - # stale Ynison progress echo and fire a spurious seek. + # stale Ynison progress echo and fire a spurious seek. Saved + # for rollback on failure — symmetric with the REPLACE branches + # below; otherwise a failed cmd_play would leave + # _expected_phase=ACTIVATING + an open drift window pointing at + # a resume that never happened, and the heartbeat would report + # paused=False forever. + prev_drift = self._drift_suppress_until + prev_phase = self._expected_phase self._drift_suppress_until = time.monotonic() + _DRIFT_SUPPRESS_PERIOD self._expected_phase = HandoffPhase.ACTIVATING try: await self.mass.players.cmd_play(target_player_id) except Exception: + self._drift_suppress_until = prev_drift + self._expected_phase = prev_phase self.logger.exception("Handoff resume cmd_play failed on %s", target_player_id) else: self._expected_track_id = new_track From 8f595817955740107c6e64da618c0726a8baa5d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 May 2026 11:06:46 +0000 Subject: [PATCH 08/12] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v2.2.3 --- music_assistant/providers/yandex_ynison/VERSION | 2 +- tests/providers/yandex_ynison/test_provider.py | 1 + tests/providers/yandex_ynison/test_provider_handoff.py | 2 +- tests/providers/yandex_ynison/test_ynison_client.py | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_ynison/VERSION b/music_assistant/providers/yandex_ynison/VERSION index 585940699b..530cdd91a2 100644 --- a/music_assistant/providers/yandex_ynison/VERSION +++ b/music_assistant/providers/yandex_ynison/VERSION @@ -1 +1 @@ -2.2.3 +2.2.4 diff --git a/tests/providers/yandex_ynison/test_provider.py b/tests/providers/yandex_ynison/test_provider.py index 2f02a81a04..ffe4554160 100644 --- a/tests/providers/yandex_ynison/test_provider.py +++ b/tests/providers/yandex_ynison/test_provider.py @@ -1,3 +1,4 @@ +# mypy: disable-error-code="attr-defined,unreachable" """Tests for the YandexYnisonProvider.""" from __future__ import annotations diff --git a/tests/providers/yandex_ynison/test_provider_handoff.py b/tests/providers/yandex_ynison/test_provider_handoff.py index dd24298bd2..b97b8f0b62 100644 --- a/tests/providers/yandex_ynison/test_provider_handoff.py +++ b/tests/providers/yandex_ynison/test_provider_handoff.py @@ -1,4 +1,4 @@ -# mypy: disable-error-code="attr-defined,method-assign" +# mypy: disable-error-code="attr-defined,method-assign,unreachable" """Tests for handoff playback mode. `mass` and `player_queues` are heavily mocked here via MagicMock — diff --git a/tests/providers/yandex_ynison/test_ynison_client.py b/tests/providers/yandex_ynison/test_ynison_client.py index 87ce79719f..577cb56d0d 100644 --- a/tests/providers/yandex_ynison/test_ynison_client.py +++ b/tests/providers/yandex_ynison/test_ynison_client.py @@ -1,3 +1,4 @@ +# mypy: disable-error-code="attr-defined" """Tests for the Ynison WebSocket client.""" from __future__ import annotations From 092341695e144ef7fcb98c3abf86da5d5ccb747d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 May 2026 11:22:11 +0000 Subject: [PATCH 09/12] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v2.2.6 --- music_assistant/providers/yandex_ynison/VERSION | 2 +- tests/providers/yandex_ynison/test_config_entries.py | 7 ++++++- tests/providers/yandex_ynison/test_provider.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/music_assistant/providers/yandex_ynison/VERSION b/music_assistant/providers/yandex_ynison/VERSION index 530cdd91a2..bda8fbec15 100644 --- a/music_assistant/providers/yandex_ynison/VERSION +++ b/music_assistant/providers/yandex_ynison/VERSION @@ -1 +1 @@ -2.2.4 +2.2.6 diff --git a/tests/providers/yandex_ynison/test_config_entries.py b/tests/providers/yandex_ynison/test_config_entries.py index 75d534034a..769a2423b9 100644 --- a/tests/providers/yandex_ynison/test_config_entries.py +++ b/tests/providers/yandex_ynison/test_config_entries.py @@ -297,7 +297,12 @@ async def test_stale_ym_selection_normalizes_to_own() -> None: """ mass = _make_mock_mass({"ym-b": {"domain": "yandex_music", "name": "B"}}) values: dict[str, object] = {CONF_YM_INSTANCE: "ym-removed"} - entries = await get_config_entries(mass, values=values) + # `arg-type`: upstream (music-assistant-models ≥ 1.1.117) flags + # `dict[str, object]` vs `dict[str, ConfigValueType] | None`; the + # local pin (1.1.111) accepts it, so the local mypy gate sees the + # ignore as unused. Combine both codes so the comment is correct + # under either dependency version. + entries = await get_config_entries(mass, values=values) # type: ignore[arg-type, unused-ignore] by_key = _entries_by_key(entries) ym_source = by_key[CONF_YM_INSTANCE] diff --git a/tests/providers/yandex_ynison/test_provider.py b/tests/providers/yandex_ynison/test_provider.py index ffe4554160..2fb1d748b5 100644 --- a/tests/providers/yandex_ynison/test_provider.py +++ b/tests/providers/yandex_ynison/test_provider.py @@ -1,4 +1,4 @@ -# mypy: disable-error-code="attr-defined,unreachable" +# mypy: disable-error-code="attr-defined,unreachable,method-assign,misc,assignment" """Tests for the YandexYnisonProvider.""" from __future__ import annotations From c865832826428e474bba89dcb4e3553144d10bbd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 May 2026 11:41:34 +0000 Subject: [PATCH 10/12] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v2.2.7 --- music_assistant/providers/yandex_ynison/VERSION | 2 +- .../providers/yandex_ynison/provider.py | 17 ++++++++++++++--- .../yandex_ynison/test_provider_handoff.py | 12 ++++++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/music_assistant/providers/yandex_ynison/VERSION b/music_assistant/providers/yandex_ynison/VERSION index bda8fbec15..5bc1cc43d4 100644 --- a/music_assistant/providers/yandex_ynison/VERSION +++ b/music_assistant/providers/yandex_ynison/VERSION @@ -1 +1 @@ -2.2.6 +2.2.7 diff --git a/music_assistant/providers/yandex_ynison/provider.py b/music_assistant/providers/yandex_ynison/provider.py index 655cd1f950..458e810542 100644 --- a/music_assistant/providers/yandex_ynison/provider.py +++ b/music_assistant/providers/yandex_ynison/provider.py @@ -1881,9 +1881,14 @@ async def _handoff_activate(self, state: YnisonState, target_player_id: str) -> if is_track_change: # Pre-fetch primes MA's stream-detail cache. Cosmetic in # handoff (MA fetches its own details), keeps logs/duration - # consistent during the transition. + # consistent during the transition. Fired in the background + # so it doesn't block `play_media` — `_PREFETCH_FORMAT_TIMEOUT` + # is 2.5s and awaiting it would add that much latency to + # every handoff track change. The cache prime races with MA's + # own stream-detail resolution; whichever finishes first is + # used, both paths are correct. if self._yandex_provider: - await self._prefetch_format_for_track(new_track) + self.mass.create_task(self._prefetch_format_for_track(new_track)) await self._apply_track_change(state, target_player_id, new_track, expected_uri) return @@ -2025,7 +2030,13 @@ async def _apply_track_change( # noqa: PLR0915 — sub-case branches inline self._re_issue_debounce_until = now + _REISSUE_DEBOUNCE_PERIOD self._expected_phase = HandoffPhase.ACTIVATING try: - self._play_media_task = asyncio.create_task( + # Use mass.create_task so the task is tracked by the + # MusicAssistant instance (auto-cancelled on stop/reload, + # standard exception logging). We still keep a reference + # in `_play_media_task` so `_cancel_pending_play_media` + # can supersede an in-flight activation on rapid track + # changes. + self._play_media_task = self.mass.create_task( self.mass.player_queues.play_media( target_player_id, expected_uri, option=QueueOption.REPLACE ) diff --git a/tests/providers/yandex_ynison/test_provider_handoff.py b/tests/providers/yandex_ynison/test_provider_handoff.py index b97b8f0b62..ee8f1f7a9d 100644 --- a/tests/providers/yandex_ynison/test_provider_handoff.py +++ b/tests/providers/yandex_ynison/test_provider_handoff.py @@ -64,9 +64,17 @@ def _make_mock_mass() -> MagicMock: mass = MagicMock() mass.cache_path = "/var/cache/test-cache" - def _create_task(coro: object) -> MagicMock: + def _create_task(coro: object) -> object: + # Mirror MA's behaviour: schedule the coroutine on the running + # loop and return a real `asyncio.Task` so callers can both + # `await` it (e.g. `_play_media_task`) and `task.cancel()` it. + # When invoked outside an async test (no running loop), fall + # back to closing the coroutine and returning a sentinel mock. if asyncio.iscoroutine(coro): - coro.close() + try: + return asyncio.create_task(coro) + except RuntimeError: + coro.close() return MagicMock() mass.create_task = MagicMock(side_effect=_create_task) From 3518a9e735f841f99d7c30a21fe3a7f8d310b902 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 May 2026 12:36:20 +0000 Subject: [PATCH 11/12] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v2.2.8 --- .../providers/yandex_ynison/VERSION | 2 +- .../providers/yandex_ynison/provider.py | 15 +++++++ .../providers/yandex_ynison/test_provider.py | 45 +++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/music_assistant/providers/yandex_ynison/VERSION b/music_assistant/providers/yandex_ynison/VERSION index 5bc1cc43d4..23a63f524e 100644 --- a/music_assistant/providers/yandex_ynison/VERSION +++ b/music_assistant/providers/yandex_ynison/VERSION @@ -1 +1 @@ -2.2.7 +2.2.8 diff --git a/music_assistant/providers/yandex_ynison/provider.py b/music_assistant/providers/yandex_ynison/provider.py index 458e810542..86f32069ec 100644 --- a/music_assistant/providers/yandex_ynison/provider.py +++ b/music_assistant/providers/yandex_ynison/provider.py @@ -1326,6 +1326,21 @@ async def _send_progress_to_ynison( return if not self._ynison or not self._ynison.connected: return + # Server-side empty-queue guard: Ynison rejects `paused=False` when + # its queue is empty (currentIndex=-1, size=0) with the same 400030001 + # code and closes the WebSocket. Observed live when the user closes + # the Yandex Music app while we're still streaming, or shortly after + # a reconnect lands on a stale session. The next `_sync_progress` + # tick would then fire a fresh error and disconnect again, ping- + # ponging through reconnects until the server eventually rebalances + # us elsewhere. Suppress the outbound update when paused=False but + # the server has no current track for us. + if not paused and self._ynison.state.current_track_id is None: + self.logger.debug( + "Skipping paused=False update — server-side queue is empty " + "(would trigger 400030001 and disconnect)" + ) + return progress_ms = min(progress_ms, duration_ms) await self._ynison.update_playing_status( progress_ms=progress_ms, diff --git a/tests/providers/yandex_ynison/test_provider.py b/tests/providers/yandex_ynison/test_provider.py index 2fb1d748b5..dc0f13b274 100644 --- a/tests/providers/yandex_ynison/test_provider.py +++ b/tests/providers/yandex_ynison/test_provider.py @@ -735,6 +735,51 @@ async def test_signal_track_completion_radio_no_provider(self) -> None: # Cannot advance — no provider to fetch tracks mock_ynison.update_player_state.assert_not_called() + async def test_send_progress_suppresses_paused_false_on_empty_queue(self) -> None: + """paused=False with empty server-side queue is dropped (would trigger 400030001).""" + provider = _make_provider() + mock_ynison = MagicMock() + mock_ynison.connected = True + mock_ynison.update_playing_status = AsyncMock() + # Server-side queue is empty (typical after reconnect lands on a stale + # session, or after the user closed the Yandex Music app while we + # were still streaming): no playable_list, index=-1. + mock_ynison.state = YnisonState( + player_state={ + "status": {"paused": False, "progress_ms": 5000, "duration_ms": 180000}, + "player_queue": { + "current_playable_index": -1, + "playable_list": [], + }, + }, + ) + provider._ynison = mock_ynison + + await provider._send_progress_to_ynison(progress_ms=5000, duration_ms=180000, paused=False) + mock_ynison.update_playing_status.assert_not_awaited() + + async def test_send_progress_still_sends_paused_true_on_empty_queue(self) -> None: + """paused=True is still sent even with empty server-side queue (guard is one-sided).""" + provider = _make_provider() + mock_ynison = MagicMock() + mock_ynison.connected = True + mock_ynison.update_playing_status = AsyncMock() + mock_ynison.state = YnisonState( + player_state={ + "status": {"paused": True, "progress_ms": 5000, "duration_ms": 180000}, + "player_queue": { + "current_playable_index": -1, + "playable_list": [], + }, + }, + ) + provider._ynison = mock_ynison + + await provider._send_progress_to_ynison(progress_ms=5000, duration_ms=180000, paused=True) + mock_ynison.update_playing_status.assert_awaited_once_with( + progress_ms=5000, duration_ms=180000, paused=True + ) + async def test_prefetch_on_second_to_last_track(self) -> None: """Pre-fetches tracks when playing second-to-last item in queue.""" provider = _make_provider() From f1713d89b6861284d69a0b6ced093a2aa4573963 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 May 2026 12:54:00 +0000 Subject: [PATCH 12/12] feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v2.2.9 --- music_assistant/providers/yandex_ynison/VERSION | 2 +- .../providers/yandex_ynison/__init__.py | 7 +++++++ .../providers/yandex_ynison/provider.py | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/music_assistant/providers/yandex_ynison/VERSION b/music_assistant/providers/yandex_ynison/VERSION index 23a63f524e..a6333e4006 100644 --- a/music_assistant/providers/yandex_ynison/VERSION +++ b/music_assistant/providers/yandex_ynison/VERSION @@ -1 +1 @@ -2.2.8 +2.2.9 diff --git a/music_assistant/providers/yandex_ynison/__init__.py b/music_assistant/providers/yandex_ynison/__init__.py index 6d74825a8f..b10199ed9b 100644 --- a/music_assistant/providers/yandex_ynison/__init__.py +++ b/music_assistant/providers/yandex_ynison/__init__.py @@ -374,6 +374,13 @@ async def get_config_entries( # noqa: PLR0915 — flow naturally returns ~12 Co "is active (the click is routed to a queue id the backend " "doesn't own, and errors), and may cause brief signal-chain " "flicker at track start before the source format is known.\n\n" + "Multi-user limitation: for users with a restricted " + "`player_filter` on their websocket session, MA's event gate " + "drops QUEUE_* events whose `object_id` is not one of the " + "allowed player ids. Our fake-queue events use the plugin " + "instance id as `object_id`, so the player card may render " + "empty for restricted users. Admin / unrestricted users are " + "unaffected.\n\n" "Ignored in handoff mode — MA already owns a real queue there." ), default_value=False, diff --git a/music_assistant/providers/yandex_ynison/provider.py b/music_assistant/providers/yandex_ynison/provider.py index 86f32069ec..a519b11534 100644 --- a/music_assistant/providers/yandex_ynison/provider.py +++ b/music_assistant/providers/yandex_ynison/provider.py @@ -1065,6 +1065,20 @@ def _register_plugin_queue(self, player_id: str) -> None: Stream-mode only — handoff has a real `play_media` queue and doesn't need the impostor. Also gated by `CONF_ENABLE_UI_INTEGRATION` (off by default). + + Multi-user limitation: MA's websocket gate + (`controllers/webserver/websocket_client.py`) drops QUEUE_* events + whose `event.object_id` is not in the user's `player_filter`. + Our events use `instance_id` (the only key under which the + frontend stores and looks up the fake queue — `player.active_source` + points at `instance_id`, not `player_id`), so restricted users + miss them. Switching `object_id` to `player_id` would pass the + filter but break the frontend's queue lookup for everyone + (storage key would no longer match `player.active_source`). A + clean fix needs an MA-core carve-out for plugin source ids + (similar to the existing `_sendspin_player_id` exception) or a + frontend change to route by `data.queue_id` — both out of scope + for this plugin. """ if not self._ui_integration_active: return