volume_level: int = 100
volume_muted: bool = False
- # group_childs: Return list of player group child id's or synced childs.
+ # group_childs: Return list of player group child id's or synced child`s.
# - If this player is a dedicated group player,
# returns all child id's of the players in the group.
# - If this is a syncgroup of players from the same platform (e.g. sonos),
active_queue: str = ""
# can_sync_with: return tuple of player_ids that can be synced to/with this player
- # ususally this is just a list of all player_ids within the playerprovider
+ # usually this is just a list of all player_ids within the playerprovider
can_sync_with: tuple[str, ...] = field(default=tuple())
# synced_to: player_id of the player this player is currently synced to
# supports_24bit: bool if player supports 24bits (hi res) audio
supports_24bit: bool = True
+ # enabled_by_default: if the player is enabled by default
+ # can be used by a player provider to exclude some sort of players
+ enabled_by_default: bool = True
+
+ #
+ # THE BELOW ATTRIBUTES ARE MANAGED BY CONFIG AND THE PLAYER MANAGER
+ #
+
# enabled: if the player is enabled
# will be set by the player manager based on config
# a disabled player is hidden in the UI and updates will not be processed
enabled: bool = True
+ # hidden_by: if the player is enabled
+ # will be set by the player manager based on config
+ # a disabled player is hidden in the UI only
+ hidden_by: set = field(default_factory=set)
+
# group_volume: if the player is a player group or syncgroup master,
# this will return the average volume of all child players
# if not a group player, this is just the player's volume
from music_assistant.common.models.errors import PlayerUnavailableError, ProviderUnavailableError
from music_assistant.constants import CONF_PLAYERS, CONF_PROVIDERS, CONF_SERVER_ID
from music_assistant.server.helpers.api import api_command
+from music_assistant.server.models.player_provider import PlayerProvider
if TYPE_CHECKING:
- from music_assistant.server.models.player_provider import PlayerProvider
from music_assistant.server.server import MusicAssistant
LOGGER = logging.getLogger(__name__)
DEFAULT_SAVE_DELAY = 30
+ENCRYPT_SUFFIX = "_encrypted_"
isfile = wrap(os.path.isfile)
remove = wrap(os.remove)
subkeys = key.split("/")
for index, subkey in enumerate(subkeys):
if index == (len(subkeys) - 1):
- if setdefault:
- parent.setdefault(subkey, default)
- self.save()
value = parent.get(subkey, default)
+ if value is None and subkey not in parent and setdefault:
+ parent[subkey] = default
+ self.save()
if value is None:
# replace None with default
return default
return value
elif subkey not in parent:
# requesting subkey from a non existing parent
- return default
+ if not setdefault:
+ return default
+ parent.setdefault(subkey, {})
else:
parent = parent[subkey]
return default
@api_command("config/players")
def get_player_configs(self, provider: str | None = None) -> list[PlayerConfig]:
"""Return all known player configurations, optionally filtered by provider domain."""
- player_configs: dict[str, dict] = self.get(CONF_PLAYERS, {})
- # we build a list of all playerids to cover both edge cases:
+ result: dict[str, PlayerConfig] = {}
+ # we use an intermediate dict to cover both edge cases:
# - player does not yet have a config stored persistently
# - player is disabled in config and not available
- all_player_ids = set(player_configs.keys())
+
+ # do all existing players first
for player in self.mass.players:
- all_player_ids.add(player.player_id)
- configs = [self.get_player_config(x) for x in all_player_ids]
- if not provider:
- return configs
- return [x for x in configs if x.provider == provider]
+ if provider is not None and player.provider != provider:
+ continue
+ result[player.player_id] = self.get_player_config(player.player_id)
+
+ # add remaining configs that do have a config stored but are not (yet) available now
+ raw_configs = self.get(CONF_PLAYERS, {})
+ for player_id, raw_conf in raw_configs.items():
+ if player_id in result:
+ continue
+ if provider is not None and raw_conf["provider"] != provider:
+ continue
+ try:
+ prov = self.mass.get_provider(raw_conf["provider"])
+ prov_entries = prov.get_player_config_entries(player_id)
+ except (ProviderUnavailableError, PlayerUnavailableError):
+ prov_entries = tuple()
+
+ entries = DEFAULT_PLAYER_CONFIG_ENTRIES + prov_entries
+ result[player.player_id] = PlayerConfig.parse(entries, raw_conf)
+
+ return list(result.values())
@api_command("config/players/get")
def get_player_config(self, player_id: str) -> PlayerConfig:
"""Return configuration for a single player."""
conf = self.get(f"{CONF_PLAYERS}/{player_id}")
if not conf:
- player = self.mass.players.get(player_id)
- if not player:
- raise PlayerUnavailableError(f"Player {player_id} is not available")
- conf = {"provider": player.provider, "player_id": player_id}
+ player = self.mass.players.get(player_id, raise_unavailable=False)
+ conf = {
+ "provider": player.provider,
+ "player_id": player_id,
+ "enabled": player.enabled_by_default,
+ }
+
try:
prov = self.mass.get_provider(conf["provider"])
prov_entries = prov.get_player_config_entries(player_id)
- except ProviderUnavailableError:
+ except (ProviderUnavailableError, PlayerUnavailableError):
prov_entries = tuple()
entries = DEFAULT_PLAYER_CONFIG_ENTRIES + prov_entries
data=config,
)
# signal update to the player manager
- if player := self.mass.players.get(config.player_id):
+ try:
+ player = self.mass.players.get(config.player_id)
player.enabled = config.enabled
self.mass.players.update(config.player_id)
+ except PlayerUnavailableError:
+ pass
+
# signal player provider that the config changed
- if provider := self.mass.get_provider(config.provider):
- assert isinstance(provider, PlayerProvider)
- provider.on_player_config_changed(config)
+ try:
+ if provider := self.mass.get_provider(config.provider):
+ assert isinstance(provider, PlayerProvider)
+ provider.on_player_config_changed(config)
+ except PlayerUnavailableError:
+ pass
@api_command("config/players/create")
async def create_player_config(
def encrypt_password(self, str_value: str) -> str:
"""Encrypt a (password)string with Fernet."""
- return self._fernet.encrypt(str_value.encode()).decode()
+ if str_value.startswith(ENCRYPT_SUFFIX):
+ return str_value
+ return ENCRYPT_SUFFIX + self._fernet.encrypt(str_value.encode()).decode()
def decrypt_password(self, encrypted_str: str) -> str:
"""Decrypt a (password)string with Fernet."""
+ if not encrypted_str.startswith(ENCRYPT_SUFFIX):
+ return encrypted_str
+ encrypted_str = encrypted_str.replace(ENCRYPT_SUFFIX, "")
return self._fernet.decrypt(encrypted_str.encode()).decode()
from random import shuffle
from time import time
from typing import TYPE_CHECKING
+from uuid import uuid4
import aiofiles
from aiohttp import web
# TODO: retrieve style/mood ?
playlist.metadata.genres = set()
image_urls = set()
- for track in await self.mass.music.playlists.tracks(playlist.item_id, playlist.provider):
- if not playlist.image and track.image:
- image_urls.add(track.image.url)
- if track.media_type != MediaType.TRACK:
- # filter out radio items
- continue
- assert isinstance(track, Track)
- assert isinstance(track.album, Album)
- if track.metadata.genres:
- playlist.metadata.genres.update(track.metadata.genres)
- elif track.album and track.album.metadata.genres:
- playlist.metadata.genres.update(track.album.metadata.genres)
- # create collage thumb/fanart from playlist tracks
- if image_urls:
- img_path = f"playlist.{playlist.provider}.{playlist.item_id}.png"
- img_path = os.path.join(self.mass.storage_path, img_path)
- img_data = await create_collage(self.mass, list(image_urls))
- async with aiofiles.open(img_path, "wb") as _file:
- await _file.write(img_data)
- playlist.metadata.images = [MediaItemImage(ImageType.THUMB, img_path, True)]
+ try:
+ for track in await self.mass.music.playlists.tracks(
+ playlist.item_id, playlist.provider
+ ):
+ if not playlist.image and track.image:
+ image_urls.add(track.image.url)
+ if track.media_type != MediaType.TRACK:
+ # filter out radio items
+ continue
+ assert isinstance(track, Track)
+ assert isinstance(track.album, Album)
+ if track.metadata.genres:
+ playlist.metadata.genres.update(track.metadata.genres)
+ elif track.album and track.album.metadata.genres:
+ playlist.metadata.genres.update(track.album.metadata.genres)
+ # create collage thumb/fanart from playlist tracks
+ if image_urls:
+ if playlist.image and self.mass.storage_path in playlist.image:
+ img_path = playlist.image
+ else:
+ img_path = os.path.join(self.mass.storage_path, f"{uuid4().hex}.png")
+ img_data = await create_collage(self.mass, list(image_urls))
+ async with aiofiles.open(img_path, "wb") as _file:
+ await _file.write(img_data)
+ playlist.metadata.images = [MediaItemImage(ImageType.THUMB, img_path, True)]
+ except Exception as err:
+ LOGGER.debug("Error while creating playlist image", exc_info=err)
async def get_radio_metadata(self, radio: Radio) -> None:
"""Get/update rich metadata for a radio station."""
image_data = await self.get_thumbnail(path, size)
except Exception as err:
LOGGER.exception(str(err), exc_info=err)
+ image_data = None
+
+ if not image_data:
+ return web.Response(status=404)
# we set the cache header to 1 year (forever)
# the client can use the checksum value to refresh when content changes
return iter(self._players.values())
@api_command("players/all")
- def all(self) -> tuple[Player]:
+ def all(
+ self,
+ return_unavailable: bool = True,
+ return_hidden: bool = True,
+ return_disabled: bool = False,
+ ) -> tuple[Player]:
"""Return all registered players."""
- return tuple(self._players.values())
+ return tuple(
+ player
+ for player in self._players.values()
+ if (player.available or return_unavailable)
+ and (not player.hidden_by or return_hidden)
+ and (player.enabled or return_disabled)
+ )
@api_command("players/get")
def get(
) -> Player:
"""Return Player by player_id."""
if player := self._players.get(player_id):
- if not player.available and raise_unavailable:
+ if (not player.available or not player.enabled) and raise_unavailable:
raise PlayerUnavailableError(f"Player {player_id} is not available")
return player
- raise PlayerUnavailableError(f"Player {player_id} does not exist")
+ raise PlayerUnavailableError(f"Player {player_id} is not available")
@api_command("players/get_by_name")
def get_by_name(self, name: str) -> Player | None:
if player_id in self._players:
raise AlreadyRegisteredError(f"Player {player_id} is already registered")
+ player.enabled = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}/enabled", True)
+
# register playerqueue for this player
self.mass.create_task(self.queues.on_player_register(player))
self._players[player_id] = player
+ # ignore disabled players
+ if not player.enabled:
+ return
+
LOGGER.info(
"Player registered: %s/%s",
player_id,
import asyncio
import logging
+import threading
import time
from dataclasses import dataclass
from logging import Logger
from pychromecast.controllers.multizone import MultizoneController, MultizoneManager
from pychromecast.discovery import CastBrowser, SimpleCastListener
from pychromecast.models import CastInfo
-from pychromecast.socket_client import (
- CONNECTION_STATUS_CONNECTED,
- CONNECTION_STATUS_DISCONNECTED,
-)
+from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED
from music_assistant.common.models.enums import (
ContentType,
from music_assistant.common.models.errors import PlayerUnavailableError, QueueEmpty
from music_assistant.common.models.player import DeviceInfo, Player
from music_assistant.common.models.queue_item import QueueItem
-from music_assistant.constants import MASS_LOGO_ONLINE
+from music_assistant.constants import CONF_PLAYERS, MASS_LOGO_ONLINE
from music_assistant.server.helpers.compare import compare_strings
from music_assistant.server.models.player_provider import PlayerProvider
-from music_assistant.server.providers.chromecast.helpers import (
- CastStatusListener,
- ChromecastInfo,
-)
+from music_assistant.server.providers.chromecast.helpers import CastStatusListener, ChromecastInfo
if TYPE_CHECKING:
from pychromecast.controllers.media import MediaStatus
from pychromecast.controllers.receiver import CastStatus
from pychromecast.socket_client import ConnectionStatus
+ from music_assistant.common.models.config_entries import PlayerConfig
+
PLAYER_CONFIG_ENTRIES = tuple()
mz_mgr: MultizoneManager | None = None
browser: CastBrowser | None = None
castplayers: dict[str, CastPlayer]
+ _discover_lock: threading.Lock
async def setup(self) -> None:
"""Handle async initialization of the provider."""
+ self._discover_lock = threading.Lock()
self.castplayers = {}
# silence the cast logger a bit
logging.getLogger("pychromecast.socket_client").setLevel(logging.INFO)
for castplayer in list(self.castplayers.values()):
await self._disconnect_chromecast(castplayer)
+ def on_player_config_changed(self, config: PlayerConfig) -> None: # noqa: ARG002
+ """Call (by config manager) when the configuration of a player changes."""
+
+ # run discovery to catch any re-enabled players
+ async def restart_discovery():
+ await self.mass.loop.run_in_executor(None, self.browser.stop_discovery)
+ await self.mass.loop.run_in_executor(None, self.browser.start_discovery)
+
+ self.mass.create_task(restart_discovery())
+
async def cmd_stop(self, player_id: str) -> None:
"""Send STOP command to given player."""
castplayer = self.castplayers[player_id]
If the player does not need any polling, simply do not override this method.
"""
castplayer = self.castplayers[player_id]
+ # only update status of media controller if player is on
+ if not castplayer.player.powered:
+ return
+
try:
await asyncio.to_thread(castplayer.cc.media_controller.update_status)
except ConnectionResetError as err:
if self.mass.closing:
return
- disc_info: CastInfo = self.browser.devices[uuid]
+ with self._discover_lock:
+ disc_info: CastInfo = self.browser.devices[uuid]
- if disc_info.uuid is None:
- self.logger.error("Discovered chromecast without uuid %s", disc_info)
- return
+ if disc_info.uuid is None:
+ self.logger.error("Discovered chromecast without uuid %s", disc_info)
+ return
- self.logger.debug("Discovered new or updated chromecast %s", disc_info)
- player_id = str(disc_info.uuid)
+ player_id = str(disc_info.uuid)
- castplayer = self.castplayers.get(player_id)
- if not castplayer:
- cast_info = ChromecastInfo.from_cast_info(disc_info)
- cast_info.fill_out_missing_chromecast_info(self.mass.zeroconf)
- if cast_info.is_dynamic_group:
- self.logger.warning("Discovered a dynamic cast group which will be ignored.")
+ enabled = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}/enabled", True)
+ if not enabled:
+ self.logger.debug("Ignoring disabled player: %s", player_id)
return
- # Instantiate chromecast object
- castplayer = CastPlayer(
- player_id,
- cast_info=cast_info,
- cc=get_chromecast_from_cast_info(
- disc_info,
- self.mass.zeroconf,
- ),
- player=Player(
- player_id=player_id,
- provider=self.domain,
- type=PlayerType.GROUP if cast_info.is_audio_group else PlayerType.PLAYER,
- name=cast_info.friendly_name,
- available=False,
- powered=False,
- device_info=DeviceInfo(
- model=cast_info.model_name,
- address=cast_info.host,
- manufacturer=cast_info.manufacturer,
+ self.logger.debug("Discovered new or updated chromecast %s", disc_info)
+
+ castplayer = self.castplayers.get(player_id)
+ if not castplayer:
+ cast_info = ChromecastInfo.from_cast_info(disc_info)
+ cast_info.fill_out_missing_chromecast_info(self.mass.zeroconf)
+ if cast_info.is_dynamic_group:
+ self.logger.warning("Discovered a dynamic cast group which will be ignored.")
+ return
+
+ # Instantiate chromecast object
+ castplayer = CastPlayer(
+ player_id,
+ cast_info=cast_info,
+ cc=get_chromecast_from_cast_info(
+ disc_info,
+ self.mass.zeroconf,
),
- supported_features=(
- PlayerFeature.POWER,
- PlayerFeature.VOLUME_MUTE,
- PlayerFeature.VOLUME_SET,
+ player=Player(
+ player_id=player_id,
+ provider=self.domain,
+ type=PlayerType.GROUP if cast_info.is_audio_group else PlayerType.PLAYER,
+ name=cast_info.friendly_name,
+ available=False,
+ powered=False,
+ device_info=DeviceInfo(
+ model=cast_info.model_name,
+ address=cast_info.host,
+ manufacturer=cast_info.manufacturer,
+ ),
+ supported_features=(
+ PlayerFeature.POWER,
+ PlayerFeature.VOLUME_MUTE,
+ PlayerFeature.VOLUME_SET,
+ ),
+ max_sample_rate=96000,
),
- max_sample_rate=96000,
- ),
- logger=self.logger.getChild(cast_info.friendly_name),
- )
- self.castplayers[player_id] = castplayer
-
- castplayer.status_listener = CastStatusListener(self, castplayer, self.mz_mgr)
- if cast_info.is_audio_group:
- mz_controller = MultizoneController(cast_info.uuid)
- castplayer.cc.register_handler(mz_controller)
- castplayer.mz_controller = mz_controller
- castplayer.cc.start()
-
- self.mass.loop.call_soon_threadsafe(self.mass.players.register, castplayer.player)
-
- # if player was already added, the player will take care of reconnects itself.
- castplayer.cast_info.update(disc_info)
- self.mass.loop.call_soon_threadsafe(self.mass.players.update, player_id)
+ logger=self.logger.getChild(cast_info.friendly_name),
+ )
+ self.castplayers[player_id] = castplayer
+
+ castplayer.status_listener = CastStatusListener(self, castplayer, self.mz_mgr)
+ if cast_info.is_audio_group:
+ mz_controller = MultizoneController(cast_info.uuid)
+ castplayer.cc.register_handler(mz_controller)
+ castplayer.mz_controller = mz_controller
+ castplayer.cc.start()
+
+ self.mass.loop.call_soon_threadsafe(self.mass.players.register, castplayer.player)
+
+ # if player was already added, the player will take care of reconnects itself.
+ castplayer.cast_info.update(disc_info)
+ self.mass.loop.call_soon_threadsafe(self.mass.players.update, player_id)
def _on_chromecast_removed(self, uuid, service, cast_info): # noqa: ARG002
"""Handle zeroconf discovery of a removed Chromecast."""
import time
from collections.abc import Awaitable, Callable, Coroutine, Sequence
from dataclasses import dataclass, field
-from typing import Any, Concatenate, ParamSpec, TypeVar
+from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar
from async_upnp_client.aiohttp import AiohttpSessionRequester
from async_upnp_client.client import UpnpRequester, UpnpService, UpnpStateVariable
from music_assistant.common.models.errors import PlayerUnavailableError, QueueEmpty
from music_assistant.common.models.player import DeviceInfo, Player
from music_assistant.common.models.queue_item import QueueItem
+from music_assistant.constants import CONF_PLAYERS
from music_assistant.server.helpers.didl_lite import create_didl_metadata
from music_assistant.server.models.player_provider import PlayerProvider
from .helpers import DLNANotifyServer
+if TYPE_CHECKING:
+ from music_assistant.common.models.config_entries import PlayerConfig
+
PLAYER_FEATURES = (
PlayerFeature.SET_MEMBERS,
PlayerFeature.SYNC,
self.notify_server = DLNANotifyServer(self.requester, self.mass)
self.mass.create_task(self._run_discovery())
+ def on_player_config_changed(self, config: PlayerConfig) -> None: # noqa: ARG002
+ """Call (by config manager) when the configuration of a player changes."""
+ # run discovery to catch any re-enabled players
+ self.mass.create_task(self._run_discovery())
+
@catch_request_errors
async def cmd_stop(self, player_id: str) -> None:
"""Send STOP command to given player."""
await self._device_discovered(ssdp_udn, discovery_info["location"])
- await async_search(on_response, 30)
+ await async_search(on_response, 60)
finally:
self._discovery_running = False
async def _device_discovered(self, udn: str, description_url: str) -> None:
"""Handle discovered DLNA player."""
- if dlna_player := self.dlnaplayers.get(udn):
- # existing player
- if dlna_player.description_url == description_url and dlna_player.player.available:
- # nothing to do, device is already connected
- return
- # update description url to newly discovered one
- dlna_player.description_url = description_url
- else:
- # new player detected, setup our DLNAPlayer wrapper
- dlna_player = DLNAPlayer(
- udn=udn,
- player=Player(
- player_id=udn,
- provider=self.domain,
- type=PlayerType.PLAYER,
- name=udn,
- available=False,
- powered=False,
- supported_features=PLAYER_FEATURES,
- # device info will be discovered later after connect
- device_info=DeviceInfo(
- model="unknown",
- address=description_url,
- manufacturer="unknown",
+ async with self.lock:
+ if dlna_player := self.dlnaplayers.get(udn):
+ # existing player
+ if dlna_player.description_url == description_url and dlna_player.player.available:
+ # nothing to do, device is already connected
+ return
+ # update description url to newly discovered one
+ dlna_player.description_url = description_url
+ else:
+ # new player detected, setup our DLNAPlayer wrapper
+
+ conf_key = f"{CONF_PLAYERS}/{udn}/enabled"
+ # disable sonos players by default in dlna provider to
+ # prevent duplicate with sonos provider
+ enabled_by_default = "rincon" not in udn.lower()
+ enabled = self.mass.config.get(conf_key, default=enabled_by_default)
+ if not enabled:
+ self.logger.debug("Ignoring disabled player: %s", udn)
+ return
+
+ dlna_player = DLNAPlayer(
+ udn=udn,
+ player=Player(
+ player_id=udn,
+ provider=self.domain,
+ type=PlayerType.PLAYER,
+ name=udn,
+ available=False,
+ powered=False,
+ supported_features=PLAYER_FEATURES,
+ # device info will be discovered later after connect
+ device_info=DeviceInfo(
+ model="unknown",
+ address=description_url,
+ manufacturer="unknown",
+ ),
+ enabled_by_default=enabled_by_default,
),
- ),
- description_url=description_url,
- )
- self.dlnaplayers[udn] = dlna_player
+ description_url=description_url,
+ )
+ self.dlnaplayers[udn] = dlna_player
- await self._device_connect(dlna_player)
- dlna_player.update_attributes()
- self.mass.players.register_or_update(dlna_player.player)
+ await self._device_connect(dlna_player)
+
+ dlna_player.update_attributes()
+ self.mass.players.register_or_update(dlna_player.player)
async def _device_connect(self, dlna_player: DLNAPlayer) -> None:
"""Connect DLNA/DMR Device."""
"key": "path",
"type": "string",
"label": "Path",
- "default_value": "/music"
+ "default_value": "/media"
}
],
"key": "path",
"type": "string",
"label": "Path",
- "description": "Full SMB path to the files, e.g. \\\\server\\share\folder or smb://server/share"
+ "description": "Full SMB path to the files, e.g. \\\\server\\share\\folder or smb://server/share"
},
{
"key": "username",
"key": "use_ntlm_v2",
"type": "boolean",
"label": "Use NTLM v2",
- "default_value": false,
+ "default_value": true,
"description": "Indicates whether pysmb should be NTLMv1 or NTLMv2 authentication algorithm for authentication. The choice of NTLMv1 and NTLMv2 is configured on the remote server, and there is no mechanism to auto-detect which algorithm has been configured. Hence, we can only “guess” or try both algorithms. On Sambda, Windows Vista and Windows 7, NTLMv2 is enabled by default. On Windows XP, we can use NTLMv1 before NTLMv2.",
"advanced": true,
"required": false
"config_entries": [
],
"requirements": ["aioslimproto==2.2.0"],
- "documentation": "",
+ "documentation": "https://github.com/music-assistant/hass-music-assistant/discussions/1123",
"multi_instance": false,
"builtin": true,
"load_by_default": true
import xml.etree.ElementTree as ET # noqa: N817
from contextlib import suppress
from dataclasses import dataclass, field
-from typing import Any
+from typing import TYPE_CHECKING, Any
import soco
from soco.events_base import Event as SonosEvent
from music_assistant.common.models.errors import PlayerUnavailableError, QueueEmpty
from music_assistant.common.models.player import DeviceInfo, Player
from music_assistant.common.models.queue_item import QueueItem
+from music_assistant.constants import CONF_PLAYERS
from music_assistant.server.helpers.didl_lite import create_didl_metadata
from music_assistant.server.models.player_provider import PlayerProvider
+if TYPE_CHECKING:
+ from music_assistant.common.models.config_entries import PlayerConfig
+
+
PLAYER_FEATURES = (
PlayerFeature.SET_MEMBERS,
PlayerFeature.SYNC,
for player in self.sonosplayers.values():
player.soco.end_direct_control_session
+ def on_player_config_changed(self, config: PlayerConfig) -> None: # noqa: ARG002
+ """Call (by config manager) when the configuration of a player changes."""
+ # run discovery to catch any re-enabled players
+ self.mass.create_task(self._run_discovery())
+
async def cmd_stop(self, player_id: str) -> None:
"""Send STOP command to given player."""
sonos_player = self.sonosplayers[player_id]
continue
await self._device_discovered(device)
- # handle groups
- # if soco_player := next(iter(discovered_devices), None):
- # self._process_groups(soco_player.all_groups)
- # else:
- # self._process_groups(set())
-
finally:
self._discovery_running = False
async def _device_discovered(self, soco_device: soco.SoCo) -> None:
"""Handle discovered Sonos player."""
player_id = soco_device.uid
+
+ enabled = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}/enabled", True)
+ if not enabled:
+ self.logger.debug("Ignoring disabled player: %s", player_id)
+ return
+
speaker_info = await asyncio.to_thread(soco_device.get_speaker_info, True)
assert player_id not in self.sonosplayers
sonos_player.group_info_updated = time.time()
asyncio.run_coroutine_threadsafe(self._update_player(sonos_player), self.mass.loop)
- def _process_groups(self, sonos_groups: list[soco.SoCo]) -> None:
- """Process all sonos groups."""
- all_group_ids = set()
- for sonos_player in sonos_groups:
- all_group_ids.add(sonos_player.uid)
- if sonos_player.uid not in self.sonosplayers:
- # unknown player ?!
- continue
-
- # mass_player = self.mass.players.get(sonos_player.uid)
- # sonos_player.is_coordinator
- # # check members
- # group_player.is_group_player = True
- # group_player.name = group.label
- # group_player.group_childs = [item.uid for item in group.members]
- # create_task(self.mass.players.update_player(group_player))
-
async def _enqueue_next_track(
self, sonos_player: SonosPlayer, current_queue_item_id: str
) -> None: