Skip to content
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
231bf56
feat: add Yandex Music Connect (Ynison) plugin provider
trudenboy Apr 15, 2026
81f0872
Merge branch 'dev' into upstream/yandex_ynison
trudenboy Apr 15, 2026
df0d3c5
feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.5.3
github-actions[bot] Apr 15, 2026
a4074da
Merge branch 'dev' into upstream/yandex_ynison
trudenboy Apr 16, 2026
b09bb8f
feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.5.4
github-actions[bot] Apr 16, 2026
8465e7b
feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.5.4
github-actions[bot] Apr 16, 2026
0e9c739
feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.5.4
github-actions[bot] Apr 16, 2026
f92f465
Merge branch 'dev' into upstream/yandex_ynison
trudenboy Apr 17, 2026
a2c5b40
feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.5.4
github-actions[bot] Apr 17, 2026
483fda9
Merge branch 'dev' into upstream/yandex_ynison
trudenboy Apr 20, 2026
6a5741f
feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.6.0
github-actions[bot] Apr 20, 2026
8afd2f9
Merge branch 'dev' into upstream/yandex_ynison
trudenboy Apr 20, 2026
b995b9f
feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.6.1
github-actions[bot] Apr 20, 2026
27ed252
Merge branch 'dev' into upstream/yandex_ynison
trudenboy Apr 21, 2026
7094934
feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.7.0
github-actions[bot] Apr 21, 2026
730ddef
Merge branch 'dev' into upstream/yandex_ynison
trudenboy Apr 21, 2026
be6fdbc
feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.7.1
github-actions[bot] Apr 21, 2026
c6f41c1
Merge branch 'dev' into upstream/yandex_ynison
trudenboy Apr 21, 2026
969ed6d
Merge branch 'dev' into upstream/yandex_ynison
trudenboy Apr 21, 2026
90e3595
feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.7.2
github-actions[bot] Apr 22, 2026
384affc
Merge branch 'dev' into upstream/yandex_ynison
trudenboy Apr 22, 2026
1341f30
feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.7.4
github-actions[bot] Apr 22, 2026
ce13bba
feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.8.0
github-actions[bot] Apr 23, 2026
5bfd05a
Merge branch 'dev' into upstream/yandex_ynison
trudenboy Apr 23, 2026
20caae3
Merge branch 'dev' into upstream/yandex_ynison
trudenboy Apr 23, 2026
f1acc4b
feat(yandex_ynison): sync provider from ma-provider-yandex-ynison v1.8.1
github-actions[bot] Apr 23, 2026
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
311 changes: 311 additions & 0 deletions music_assistant/providers/yandex_ynison/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
"""Yandex Music Connect (Ynison) plugin for Music Assistant."""

from __future__ import annotations

from typing import TYPE_CHECKING, cast

from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
from music_assistant_models.enums import ConfigEntryType, ProviderFeature
from music_assistant_models.errors import LoginFailed

from .auth import perform_qr_auth
from .config_helpers import list_yandex_music_instances
from .constants import (
CONF_ACCOUNT_LOGIN,
CONF_ACTION_AUTH_QR,
CONF_ACTION_CLEAR_AUTH,
CONF_ALLOW_PLAYER_SWITCH,
CONF_DEVICE_ID,
CONF_MASS_PLAYER_ID,
CONF_OUTPUT_BIT_DEPTH,
CONF_OUTPUT_SAMPLE_RATE,
CONF_PUBLISH_NAME,
CONF_REMEMBER_SESSION,
CONF_TOKEN,
CONF_X_TOKEN,
CONF_YM_INSTANCE,
DEFAULT_DISPLAY_NAME,
OUTPUT_AUTO,
PLAYER_ID_AUTO,
YM_INSTANCE_OWN,
)
from .provider import YandexYnisonProvider

if TYPE_CHECKING:
from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
from music_assistant_models.provider import ProviderManifest

from music_assistant.mass import MusicAssistant
from music_assistant.models import ProviderInstanceType

SUPPORTED_FEATURES = {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)


async def get_config_entries( # noqa: PLR0915 — flow naturally returns ~12 ConfigEntry objects
mass: MusicAssistant,
instance_id: str | None = None, # noqa: ARG001 — required by MA callback signature
action: str | None = None,
values: dict[str, ConfigValueType] | None = None,
) -> tuple[ConfigEntry, ...]:
"""Return Config entries to setup this provider."""
if values is None:
values = {}

# Migrate legacy config keys (renamed in v1.5.0)
if "player" in values and CONF_MASS_PLAYER_ID not in values:
values[CONF_MASS_PLAYER_ID] = values.pop("player")
if "display_name" in values and CONF_PUBLISH_NAME not in values:
values[CONF_PUBLISH_NAME] = values.pop("display_name")

# Discover available yandex_music instances for borrow-mode dropdown
ym_instances = list_yandex_music_instances(mass)
ym_instance_ids = {inst_id for inst_id, _ in ym_instances}

# Determine the currently selected source (borrow vs own)
selected = cast("str | None", values.get(CONF_YM_INSTANCE))
if selected is None:
# Preserve existing own-token configs on upgrade (CONF_TOKEN already set
# but CONF_YM_INSTANCE absent). Only auto-select borrowing for truly
# fresh installs with no stored token and exactly one YM instance.
has_manual_token = bool(values.get(CONF_TOKEN))
if has_manual_token:
selected = YM_INSTANCE_OWN
else:
selected = ym_instances[0][0] if len(ym_instances) == 1 else YM_INSTANCE_OWN
borrowing = selected != YM_INSTANCE_OWN and selected in ym_instance_ids

# ------------------------------------------------------------------
# Own-mode action handling: QR login / reset auth
# ------------------------------------------------------------------
# The buttons are only surfaced in own mode, but the action callback is
# invoked with whatever `values` the frontend has cached — guard against
# a stale-state save that fires the action while the dropdown points at
# a (possibly missing) yandex_music instance. Otherwise we'd overwrite
# token/x_token in a config that won't even use them.
remember_session = bool(values.get(CONF_REMEMBER_SESSION, True))
if action in (CONF_ACTION_AUTH_QR, CONF_ACTION_CLEAR_AUTH) and selected != YM_INSTANCE_OWN:
raise LoginFailed(
f"Cannot run own-mode action '{action}' while the source is set to "
f"'{selected}'. Switch the dropdown to 'Use own credentials' first."
)
if action == CONF_ACTION_AUTH_QR:
session_id = values.get("session_id")
if not session_id:
raise LoginFailed("Missing session_id for QR authentication")
x_token, music_token, display_login = await perform_qr_auth(mass, str(session_id))
values[CONF_TOKEN] = music_token
values[CONF_X_TOKEN] = x_token if remember_session else None
values[CONF_ACCOUNT_LOGIN] = display_login
elif action == CONF_ACTION_CLEAR_AUTH:
values[CONF_TOKEN] = None
values[CONF_X_TOKEN] = None
values[CONF_ACCOUNT_LOGIN] = None

# In own mode, treat presence of a music token OR a stored x_token as
# "authenticated" — both can drive the connection (token directly, or
# x_token via in-memory refresh).
own_authenticated = bool(values.get(CONF_TOKEN) or values.get(CONF_X_TOKEN))
account_login = cast("str | None", values.get(CONF_ACCOUNT_LOGIN))

# ------------------------------------------------------------------
# Status label
# ------------------------------------------------------------------
if borrowing:
ym_name = next((name for inst_id, name in ym_instances if inst_id == selected), selected)
label_text = f"Borrowing credentials from Yandex Music instance '{ym_name}'."
elif selected != YM_INSTANCE_OWN:
# Referenced YM instance is not currently configured
label_text = (
"Selected Yandex Music instance is not available. "
"Re-select below or fall back to manual token."
)
elif action == CONF_ACTION_AUTH_QR:
who = f" as {account_login}" if account_login else ""
label_text = f"Authenticated to Yandex Music{who}. Don't forget to save to complete setup."
elif own_authenticated:
who = f" as {account_login}" if account_login else ""
label_text = f"Authenticated to Yandex Music{who}."
else:
label_text = (
"Not authenticated. Click 'Login with QR code' to scan with the "
"Yandex app, or paste a music token manually below."
)

# Build dropdown options: one per YM instance + "Use own credentials" sentinel
source_options = [
ConfigValueOption(f"Yandex Music: {name}", inst_id) for inst_id, name in ym_instances
]
source_options.append(ConfigValueOption("Use own credentials (QR or token)", YM_INSTANCE_OWN))

# Guard against a stale selection pointing at a removed YM instance — the
# UI would otherwise render with a default that isn't in `options`.
dropdown_default = selected if borrowing or selected == YM_INSTANCE_OWN else YM_INSTANCE_OWN

# Own-mode-only entries are hidden when borrowing.
own_hidden = borrowing
# 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))

return (
ConfigEntry(
key="label_text",
type=ConfigEntryType.LABEL,
label=label_text,
),
ConfigEntry(
key=CONF_YM_INSTANCE,
type=ConfigEntryType.STRING,
label="Yandex Music source",
description="Borrow OAuth credentials from a linked Yandex Music provider "
"instance, or use your own credentials (QR-scan login or manual token paste). "
"Per-instance own credentials let you bind separate players to separate "
"Yandex accounts without sharing tokens with a Yandex Music provider.",
options=source_options,
default_value=dropdown_default,
required=True,
),
# Own-mode: QR login button
ConfigEntry(
key=CONF_ACTION_AUTH_QR,
type=ConfigEntryType.ACTION,
label="Login with QR code",
description="Open a QR code in a popup and scan it with the Yandex app on "
"your phone. Populates the token automatically — no manual paste needed.",
action=CONF_ACTION_AUTH_QR,
action_label="Login with QR code",
hidden=own_hidden or own_authenticated,
),
# Own-mode: remember-session toggle
ConfigEntry(
key=CONF_REMEMBER_SESSION,
type=ConfigEntryType.BOOLEAN,
label="Remember session (auto-refresh token)",
description="Store a long-lived session token (x_token) alongside the music "
"token so this plugin can refresh on its own when the token expires. "
"Disable to keep only the short-lived music token (re-QR required on expiry).",
default_value=True,
hidden=own_hidden or own_authenticated,
),
# Own-mode: reset authentication
ConfigEntry(
key=CONF_ACTION_CLEAR_AUTH,
type=ConfigEntryType.ACTION,
label="Reset authentication",
description="Clear the current authentication details "
"(music token, session token, and stored login).",
action=CONF_ACTION_CLEAR_AUTH,
action_label="Reset authentication",
hidden=own_hidden or not own_authenticated,
),
ConfigEntry(
key=CONF_TOKEN,
type=ConfigEntryType.SECURE_STRING,
label="Yandex Music Token",
description="Manually pasted Yandex Music OAuth token. Populated "
"automatically after a successful QR login; only fill in by hand if "
"you can't use QR (e.g. headless setup).",
required=token_required,
hidden=borrowing,
value=cast("str", values.get(CONF_TOKEN)) if values else None,
),
# Hidden: long-lived session token used for reactive 401 refresh
ConfigEntry(
key=CONF_X_TOKEN,
type=ConfigEntryType.SECURE_STRING,
label="Session token (x_token)",
hidden=True,
required=False,
value=cast("str", values.get(CONF_X_TOKEN)) if values else None,
),
# Hidden: cached display login for the status label
ConfigEntry(
key=CONF_ACCOUNT_LOGIN,
type=ConfigEntryType.STRING,
label="Account login",
hidden=True,
required=False,
value=cast("str", values.get(CONF_ACCOUNT_LOGIN)) if values else None,
),
ConfigEntry(
key=CONF_MASS_PLAYER_ID,
type=ConfigEntryType.STRING,
label="Connected Music Assistant Player",
description="The Music Assistant player connected to this Ynison plugin. "
"When playback is directed to this device in the Yandex Music app, "
"the audio will play on the selected player. "
"Set to 'Auto' to automatically select a currently playing player.",
default_value=PLAYER_ID_AUTO,
options=[
ConfigValueOption("Auto (prefer playing player)", PLAYER_ID_AUTO),
*(
ConfigValueOption(x.display_name, x.player_id)
for x in sorted(
mass.players.all_players(False, False),
key=lambda p: p.display_name.lower(),
)
),
],
required=True,
),
ConfigEntry(
key=CONF_ALLOW_PLAYER_SWITCH,
type=ConfigEntryType.BOOLEAN,
label="Allow manual player switching",
description="When enabled, you can select this plugin as a source on any player "
"to switch playback to that player. When disabled, playback is fixed to the "
"configured default player.",
default_value=True,
),
ConfigEntry(
key=CONF_OUTPUT_SAMPLE_RATE,
type=ConfigEntryType.STRING,
label="Output sample rate",
description="Sample rate for PCM output to the player. "
"'Auto' selects 44.1 kHz for lossy or 48 kHz for lossless sources.",
default_value=OUTPUT_AUTO,
options=[
ConfigValueOption("Auto (from source quality)", OUTPUT_AUTO),
ConfigValueOption("44100 Hz (CD)", "44100"),
ConfigValueOption("48000 Hz", "48000"),
ConfigValueOption("96000 Hz (Hi-Res)", "96000"),
],
advanced=True,
),
ConfigEntry(
key=CONF_OUTPUT_BIT_DEPTH,
type=ConfigEntryType.STRING,
label="Output bit depth",
description="Bit depth for PCM output to the player. "
"'Auto' selects 16-bit for lossy or 24-bit for lossless sources.",
default_value=OUTPUT_AUTO,
options=[
ConfigValueOption("Auto (from source quality)", OUTPUT_AUTO),
ConfigValueOption("16-bit", "16"),
ConfigValueOption("24-bit", "24"),
],
advanced=True,
),
ConfigEntry(
key=CONF_PUBLISH_NAME,
type=ConfigEntryType.STRING,
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_DEVICE_ID,
type=ConfigEntryType.STRING,
label="Device ID",
hidden=True,
required=False,
),
)
70 changes: 70 additions & 0 deletions music_assistant/providers/yandex_ynison/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Yandex Passport authentication helpers.

Delegates Passport interactions to the ``ya-passport-auth`` library. Two
helpers are exposed to the rest of the plugin:

* :func:`perform_qr_auth` — full QR login (UI popup → tokens), used by the
own-mode config flow when the user clicks "Login with QR code".
* :func:`refresh_music_token` — exchange ``x_token`` for a fresh music-scoped
OAuth token. Called both in borrow mode (against the linked yandex_music
provider's x_token) and in own mode (against the plugin's own stored
x_token, when the user opted in to "Remember session").
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from music_assistant_models.errors import LoginFailed
from ya_passport_auth import PassportClient, SecretStr
from ya_passport_auth.exceptions import QRTimeoutError, YaPassportError

if TYPE_CHECKING:
from music_assistant.mass import MusicAssistant


async def perform_qr_auth(mass: MusicAssistant, session_id: str) -> tuple[str, str, str | None]:
"""Run a QR login flow and return ``(x_token, music_token, display_login)``.

Opens the QR popup in the MA frontend via
:class:`music_assistant.helpers.auth.AuthenticationHelper`, polls the
Yandex Passport endpoint until the user confirms the scan in the Yandex
app, then returns the resulting tokens as plain strings (suitable for
MA config storage).

``display_login`` is the Yandex login name when the server returns it
(used by the config UI to render "Logged in as X"); may be ``None``.
"""
# AuthenticationHelper lives in the music_assistant *server* package,
# which isn't always installed in the plugin's standalone test/dev
# environment. Lazy import keeps `provider.auth` importable for unit
# tests that don't exercise this code path.
from music_assistant.helpers.auth import AuthenticationHelper # noqa: PLC0415

try:
async with PassportClient.create() as client:
qr = await client.start_qr_login()
async with AuthenticationHelper(mass, session_id) as auth_helper:
auth_helper.send_url(qr.qr_url)
creds = await client.poll_qr_until_confirmed(qr)

if creds.music_token is None:
raise LoginFailed("QR auth succeeded but no music token was returned")
return (
creds.x_token.get_secret(),
creds.music_token.get_secret(),
creds.display_login,
)
except QRTimeoutError as err:
raise LoginFailed("QR authentication timed out. Please try again.") from err
except YaPassportError as err:
raise LoginFailed(f"Yandex auth error: {err}") from err


async def refresh_music_token(x_token: SecretStr) -> SecretStr:
"""Exchange an x_token for a fresh music-scoped OAuth token."""
try:
async with PassportClient.create() as client:
return await client.refresh_music_token(x_token)
except YaPassportError as err:
raise LoginFailed(f"Failed to refresh music token: {err}") from err
Loading
Loading