Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 100 additions & 1 deletion music_assistant/providers/yandex_ynison/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,22 @@
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,
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,
)
Expand All @@ -41,11 +47,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
Expand Down Expand Up @@ -150,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))
Expand Down Expand Up @@ -299,6 +325,79 @@ 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,
),
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_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,
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(
Expand Down
16 changes: 16 additions & 0 deletions music_assistant/providers/yandex_ynison/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@
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"
CONF_ENABLE_UI_INTEGRATION: Final[str] = "enable_ui_integration"

# 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"
Expand Down
10 changes: 10 additions & 0 deletions music_assistant/providers/yandex_ynison/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
...
Expand Down
Loading
Loading