From: Marcel van der Veldt Date: Fri, 10 Mar 2023 19:56:18 +0000 (+0100) Subject: Small follow up fixes (#512) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=cf75534cd8b073811e140e6990125384dd72b7e1;p=music-assistant-server.git Small follow up fixes (#512) * remove redundant code * Hide sonos players from DLNA provider by default * do not initialize disabled players * more elegant way to disable a player by default * enabled by default * allow unavailable player when setting config * Fix chromecast turning on unsollicited * add lock to prevent race condition during discovery * fix double encrypt of passwords * fix playlist thumb --- diff --git a/music_assistant/common/models/player.py b/music_assistant/common/models/player.py index 8def0066..57c9accc 100644 --- a/music_assistant/common/models/player.py +++ b/music_assistant/common/models/player.py @@ -40,7 +40,7 @@ class Player(DataClassDictMixin): 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), @@ -53,7 +53,7 @@ class Player(DataClassDictMixin): 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 @@ -66,11 +66,24 @@ class Player(DataClassDictMixin): # 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 diff --git a/music_assistant/server/controllers/config.py b/music_assistant/server/controllers/config.py index 07a37b9e..ffe4e756 100644 --- a/music_assistant/server/controllers/config.py +++ b/music_assistant/server/controllers/config.py @@ -23,13 +23,14 @@ from music_assistant.common.models.enums import ConfigEntryType, EventType, Prov 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) @@ -76,17 +77,19 @@ class ConfigController: 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 @@ -240,31 +243,51 @@ class ConfigController: @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 @@ -303,13 +326,20 @@ class ConfigController: 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( @@ -378,8 +408,13 @@ class ConfigController: 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() diff --git a/music_assistant/server/controllers/metadata.py b/music_assistant/server/controllers/metadata.py index abbcb2bf..61f90e57 100755 --- a/music_assistant/server/controllers/metadata.py +++ b/music_assistant/server/controllers/metadata.py @@ -9,6 +9,7 @@ from base64 import b64encode from random import shuffle from time import time from typing import TYPE_CHECKING +from uuid import uuid4 import aiofiles from aiohttp import web @@ -169,26 +170,33 @@ class MetaDataController: # 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.""" @@ -314,6 +322,10 @@ class MetaDataController: 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 diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index fc16ae54..a55db670 100755 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -61,9 +61,20 @@ class PlayerController: 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( @@ -73,10 +84,10 @@ class PlayerController: ) -> 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: @@ -103,11 +114,17 @@ class PlayerController: 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, diff --git a/music_assistant/server/providers/chromecast/__init__.py b/music_assistant/server/providers/chromecast/__init__.py index d154db6a..f6791efa 100644 --- a/music_assistant/server/providers/chromecast/__init__.py +++ b/music_assistant/server/providers/chromecast/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import logging +import threading import time from dataclasses import dataclass from logging import Logger @@ -19,10 +20,7 @@ from pychromecast.controllers.media import STREAM_TYPE_BUFFERED, STREAM_TYPE_LIV 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, @@ -34,19 +32,18 @@ from music_assistant.common.models.enums import ( 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() @@ -73,9 +70,11 @@ class ChromecastProvider(PlayerProvider): 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) @@ -102,6 +101,16 @@ class ChromecastProvider(PlayerProvider): 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] @@ -204,6 +213,10 @@ class ChromecastProvider(PlayerProvider): 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: @@ -216,66 +229,73 @@ class ChromecastProvider(PlayerProvider): 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.""" diff --git a/music_assistant/server/providers/dlna/__init__.py b/music_assistant/server/providers/dlna/__init__.py index 912b57fb..125e4a00 100644 --- a/music_assistant/server/providers/dlna/__init__.py +++ b/music_assistant/server/providers/dlna/__init__.py @@ -13,7 +13,7 @@ import logging 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 @@ -27,11 +27,15 @@ from music_assistant.common.models.enums import ContentType, PlayerFeature, Play 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, @@ -185,6 +189,11 @@ class DLNAPlayerProvider(PlayerProvider): 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.""" @@ -333,7 +342,7 @@ class DLNAPlayerProvider(PlayerProvider): 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 @@ -366,39 +375,52 @@ class DLNAPlayerProvider(PlayerProvider): 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.""" diff --git a/music_assistant/server/providers/filesystem_local/manifest.json b/music_assistant/server/providers/filesystem_local/manifest.json index 8009a20e..cc6be9cb 100644 --- a/music_assistant/server/providers/filesystem_local/manifest.json +++ b/music_assistant/server/providers/filesystem_local/manifest.json @@ -9,7 +9,7 @@ "key": "path", "type": "string", "label": "Path", - "default_value": "/music" + "default_value": "/media" } ], diff --git a/music_assistant/server/providers/filesystem_smb/manifest.json b/music_assistant/server/providers/filesystem_smb/manifest.json index e2b2a8b6..71e0a2c4 100644 --- a/music_assistant/server/providers/filesystem_smb/manifest.json +++ b/music_assistant/server/providers/filesystem_smb/manifest.json @@ -9,7 +9,7 @@ "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", @@ -42,7 +42,7 @@ "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 diff --git a/music_assistant/server/providers/slimproto/manifest.json b/music_assistant/server/providers/slimproto/manifest.json index b244f5f2..a50bef1b 100644 --- a/music_assistant/server/providers/slimproto/manifest.json +++ b/music_assistant/server/providers/slimproto/manifest.json @@ -7,7 +7,7 @@ "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 diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index 1035f608..0f69f6bb 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -7,7 +7,7 @@ import time 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 @@ -24,9 +24,14 @@ from music_assistant.common.models.enums import ( 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, @@ -206,6 +211,11 @@ class SonosPlayerProvider(PlayerProvider): 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] @@ -376,12 +386,6 @@ class SonosPlayerProvider(PlayerProvider): 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 @@ -394,6 +398,12 @@ class SonosPlayerProvider(PlayerProvider): 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 @@ -483,23 +493,6 @@ class SonosPlayerProvider(PlayerProvider): 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: