Small follow up fixes (#512)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 10 Mar 2023 19:56:18 +0000 (20:56 +0100)
committerGitHub <noreply@github.com>
Fri, 10 Mar 2023 19:56:18 +0000 (20:56 +0100)
* 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

music_assistant/common/models/player.py
music_assistant/server/controllers/config.py
music_assistant/server/controllers/metadata.py
music_assistant/server/controllers/players.py
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/providers/dlna/__init__.py
music_assistant/server/providers/filesystem_local/manifest.json
music_assistant/server/providers/filesystem_smb/manifest.json
music_assistant/server/providers/slimproto/manifest.json
music_assistant/server/providers/sonos/__init__.py

index 8def006653e9a8c1436fc3c6fcdadecd2995a4de..57c9accc11bfc5f9d8f8ee883775905fb8b16a43 100644 (file)
@@ -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
index 07a37b9e60aab3d48b5e570ed7a66248f808e877..ffe4e75644e93a4b08bc3fb3d036259e3d25bbf3 100644 (file)
@@ -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()
index abbcb2bfeed6e60be34baf2652630b9febc75fbd..61f90e5708ae92af3da568fe299450adc982ffeb 100755 (executable)
@@ -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
index fc16ae5460607e36cb13e233f95426dfcc898bfd..a55db67098e425e24dbd1b0c1341c1c66468eac5 100755 (executable)
@@ -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,
index d154db6a3d14087fbfafc7f42041ae7e30f3930e..f6791efa6048f55ec93883162a8de270a6aee76b 100644 (file)
@@ -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."""
index 912b57fbab2b7de04636381634d678fd382e48a9..125e4a0015c83ce6dbaa7b344be7aa423633e7ed 100644 (file)
@@ -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."""
index 8009a20edbe8c9fd298803834e59b5cfb603a9b4..cc6be9cbfb2461a9c3d85aa0dee763f3258960c5 100644 (file)
@@ -9,7 +9,7 @@
       "key": "path",
       "type": "string",
       "label": "Path",
-      "default_value": "/music"
+      "default_value": "/media"
     }
   ],
 
index e2b2a8b6d376f0cb3f44ccc177549ebf70ded90e..71e0a2c43de28c7148302309b55c13c87f4dbbdd 100644 (file)
@@ -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
index b244f5f2cb6fc75f3ce7170df1e16197013d4a17..a50bef1bc893337c29372d5342153690b0118682 100644 (file)
@@ -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
index 1035f608effe69a3c78e9b456ff2550a48922b84..0f69f6bb800bfae30e328ee2eb276fa755be80c5 100644 (file)
@@ -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: