From: Marcel van der Veldt Date: Wed, 19 Apr 2023 05:25:38 +0000 (+0200) Subject: Add Universal group Player provider (#632) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=990ecef55818b87a04b6def5f218a631a7ffaa75;p=music-assistant-server.git Add Universal group Player provider (#632) Adds a "virtual" player provider to create a universal group from all player types. * Supports sync of all supported players (within the same ecosystem) * Sync between different ecocystems or players that do not support sync is not implemented --- diff --git a/.vscode/settings.json b/.vscode/settings.json index 44b46c5f..ae9f98ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,5 @@ "source.organizeImports": true } }, - "python.formatting.provider": "black" + "python.formatting.provider": "black", } diff --git a/music_assistant/common/models/config_entries.py b/music_assistant/common/models/config_entries.py index 756f38a2..f16223d1 100644 --- a/music_assistant/common/models/config_entries.py +++ b/music_assistant/common/models/config_entries.py @@ -15,6 +15,8 @@ from music_assistant.constants import ( CONF_EQ_MID, CONF_EQ_TREBLE, CONF_FLOW_MODE, + CONF_GROUPED_POWER_ON, + CONF_HIDE_GROUP_CHILDS, CONF_LOG_LEVEL, CONF_OUTPUT_CHANNELS, CONF_OUTPUT_CODEC, @@ -30,7 +32,7 @@ LOGGER = logging.getLogger(__name__) ENCRYPT_CALLBACK: callable[[str], str] | None = None DECRYPT_CALLBACK: callable[[str], str] | None = None -ConfigValueType = str | int | float | bool | None +ConfigValueType = str | int | float | bool | list[str] | list[int] | None ConfigEntryTypeMap = { ConfigEntryType.BOOLEAN: bool, @@ -99,7 +101,7 @@ class ConfigEntry(DataClassDictMixin): allow_none: bool = True, ) -> ConfigValueType: """Parse value from the config entry details and plain value.""" - expected_type = ConfigEntryTypeMap.get(self.type, NoneType) + expected_type = list if self.multi_value else ConfigEntryTypeMap.get(self.type, NoneType) if value is None: value = self.default_value if value is None and (not self.required or allow_none): @@ -260,98 +262,25 @@ class PlayerConfig(Config): default_name: str | None = None -DEFAULT_PROVIDER_CONFIG_ENTRIES = ( - ConfigEntry( - key=CONF_LOG_LEVEL, - type=ConfigEntryType.STRING, - label="Log level", - options=[ - ConfigValueOption("global", "GLOBAL"), - ConfigValueOption("info", "INFO"), - ConfigValueOption("warning", "WARNING"), - ConfigValueOption("error", "ERROR"), - ConfigValueOption("debug", "DEBUG"), - ], - default_value="GLOBAL", - description="Set the log verbosity for this provider", - advanced=True, - ), +CONF_ENTRY_LOG_LEVEL = ConfigEntry( + key=CONF_LOG_LEVEL, + type=ConfigEntryType.STRING, + label="Log level", + options=[ + ConfigValueOption("global", "GLOBAL"), + ConfigValueOption("info", "INFO"), + ConfigValueOption("warning", "WARNING"), + ConfigValueOption("error", "ERROR"), + ConfigValueOption("debug", "DEBUG"), + ], + default_value="GLOBAL", + description="Set the log verbosity for this provider", + advanced=True, ) +DEFAULT_PROVIDER_CONFIG_ENTRIES = (CONF_ENTRY_LOG_LEVEL,) -DEFAULT_PLAYER_CONFIG_ENTRIES = ( - ConfigEntry( - key=CONF_VOLUME_NORMALISATION, - type=ConfigEntryType.BOOLEAN, - label="Enable volume normalization (EBU-R128 based)", - default_value=True, - description="Enable volume normalization based on the EBU-R128 " - "standard without affecting dynamic range", - ), - ConfigEntry( - key=CONF_FLOW_MODE, - type=ConfigEntryType.BOOLEAN, - label="Enable queue flow mode", - default_value=False, - description='Enable "flow" mode where all queue tracks are sent as a continuous ' - "audio stream. Use for players that do not natively support gapless and/or " - "crossfading or if the player has trouble transitioning between tracks.", - advanced=True, - ), - ConfigEntry( - key=CONF_VOLUME_NORMALISATION_TARGET, - type=ConfigEntryType.INTEGER, - range=(-30, 0), - default_value=-14, - label="Target level for volume normalisation", - description="Adjust average (perceived) loudness to this target level, " - "default is -14 LUFS", - depends_on=CONF_VOLUME_NORMALISATION, - advanced=True, - ), - ConfigEntry( - key=CONF_EQ_BASS, - type=ConfigEntryType.INTEGER, - range=(-10, 10), - default_value=0, - label="Equalizer: bass", - description="Use the builtin basic equalizer to adjust the bass of audio.", - advanced=True, - ), - ConfigEntry( - key=CONF_EQ_MID, - type=ConfigEntryType.INTEGER, - range=(-10, 10), - default_value=0, - label="Equalizer: midrange", - description="Use the builtin basic equalizer to adjust the midrange of audio.", - advanced=True, - ), - ConfigEntry( - key=CONF_EQ_TREBLE, - type=ConfigEntryType.INTEGER, - range=(-10, 10), - default_value=0, - label="Equalizer: treble", - description="Use the builtin basic equalizer to adjust the treble of audio.", - advanced=True, - ), - ConfigEntry( - key=CONF_OUTPUT_CHANNELS, - type=ConfigEntryType.STRING, - options=[ - ConfigValueOption("Stereo (both channels)", "stereo"), - ConfigValueOption("Left channel", "left"), - ConfigValueOption("Right channel", "right"), - ConfigValueOption("Mono (both channels)", "mono"), - ], - default_value="stereo", - label="Output Channel Mode", - description="You can configure this player to play only the left or right channel, " - "for example to a create a stereo pair with 2 players.", - advanced=True, - ), -) +# some reusable player config entries CONF_ENTRY_OUTPUT_CODEC = ConfigEntry( key=CONF_OUTPUT_CODEC, @@ -370,3 +299,119 @@ CONF_ENTRY_OUTPUT_CODEC = ConfigEntry( "Change this setting only if needed for your device/environment.", advanced=True, ) + +CONF_ENTRY_FLOW_MODE = ConfigEntry( + key=CONF_FLOW_MODE, + type=ConfigEntryType.BOOLEAN, + label="Enable queue flow mode", + default_value=False, + description='Enable "flow" mode where all queue tracks are sent as a continuous ' + "audio stream. Use for players that do not natively support gapless and/or " + "crossfading or if the player has trouble transitioning between tracks.", + advanced=False, +) + +CONF_ENTRY_OUTPUT_CHANNELS = ConfigEntry( + key=CONF_OUTPUT_CHANNELS, + type=ConfigEntryType.STRING, + options=[ + ConfigValueOption("Stereo (both channels)", "stereo"), + ConfigValueOption("Left channel", "left"), + ConfigValueOption("Right channel", "right"), + ConfigValueOption("Mono (both channels)", "mono"), + ], + default_value="stereo", + label="Output Channel Mode", + description="You can configure this player to play only the left or right channel, " + "for example to a create a stereo pair with 2 players.", + advanced=True, +) + +CONF_ENTRY_VOLUME_NORMALISATION = ConfigEntry( + key=CONF_VOLUME_NORMALISATION, + type=ConfigEntryType.BOOLEAN, + label="Enable volume normalization (EBU-R128 based)", + default_value=True, + description="Enable volume normalization based on the EBU-R128 " + "standard without affecting dynamic range", +) + +CONF_ENTRY_VOLUME_NORMALISATION_TARGET = ConfigEntry( + key=CONF_VOLUME_NORMALISATION_TARGET, + type=ConfigEntryType.INTEGER, + range=(-30, 0), + default_value=-14, + label="Target level for volume normalisation", + description="Adjust average (perceived) loudness to this target level, " "default is -14 LUFS", + depends_on=CONF_VOLUME_NORMALISATION, + advanced=True, +) + +CONF_ENTRY_EQ_BASS = ConfigEntry( + key=CONF_EQ_BASS, + type=ConfigEntryType.INTEGER, + range=(-10, 10), + default_value=0, + label="Equalizer: bass", + description="Use the builtin basic equalizer to adjust the bass of audio.", + advanced=True, +) + +CONF_ENTRY_EQ_MID = ConfigEntry( + key=CONF_EQ_MID, + type=ConfigEntryType.INTEGER, + range=(-10, 10), + default_value=0, + label="Equalizer: midrange", + description="Use the builtin basic equalizer to adjust the midrange of audio.", + advanced=True, +) + +CONF_ENTRY_EQ_TREBLE = ConfigEntry( + key=CONF_EQ_TREBLE, + type=ConfigEntryType.INTEGER, + range=(-10, 10), + default_value=0, + label="Equalizer: treble", + description="Use the builtin basic equalizer to adjust the treble of audio.", + advanced=True, +) + +CONF_ENTRY_HIDE_GROUP_MEMBERS = ConfigEntry( + key=CONF_HIDE_GROUP_CHILDS, + type=ConfigEntryType.STRING, + options=[ + ConfigValueOption("Always", "always"), + ConfigValueOption("Only if the group is active/powered", "active"), + ConfigValueOption("Never", "never"), + ], + default_value="active", + label="Hide playergroup members in UI", + description="Hide the individual player entry for the members of this group " + "in the user interface.", + advanced=False, +) + +CONF_ENTRY_GROUPED_POWER_ON = ConfigEntry( + key=CONF_GROUPED_POWER_ON, + type=ConfigEntryType.BOOLEAN, + default_value=True, + label="Forced Power ON of all group members", + description="Power ON all child players when the group player is powered on " + "(or playback started). \n" + "If this setting is disabled, playback will only start on players that " + "are already powered ON at the time of playback start.\n" + "When turning OFF the group player, all group members are turned off, " + "regardless of this setting.", + advanced=False, +) + +DEFAULT_PLAYER_CONFIG_ENTRIES = ( + CONF_ENTRY_VOLUME_NORMALISATION, + CONF_ENTRY_FLOW_MODE, + CONF_ENTRY_VOLUME_NORMALISATION_TARGET, + CONF_ENTRY_EQ_BASS, + CONF_ENTRY_EQ_MID, + CONF_ENTRY_EQ_TREBLE, + CONF_ENTRY_OUTPUT_CHANNELS, +) diff --git a/music_assistant/common/models/player.py b/music_assistant/common/models/player.py index c23033a4..89aa57a4 100644 --- a/music_assistant/common/models/player.py +++ b/music_assistant/common/models/player.py @@ -13,9 +13,9 @@ from .enums import PlayerFeature, PlayerState, PlayerType class DeviceInfo(DataClassDictMixin): """Model for a player's deviceinfo.""" - model: str = "unknown" - address: str = "unknown" - manufacturer: str = "unknown" + model: str = "Unknown model" + address: str = "" + manufacturer: str = "Unknown Manufacturer" @dataclass diff --git a/music_assistant/common/models/queue_item.py b/music_assistant/common/models/queue_item.py index a3338c7c..fe387174 100644 --- a/music_assistant/common/models/queue_item.py +++ b/music_assistant/common/models/queue_item.py @@ -8,10 +8,10 @@ from uuid import uuid4 from mashumaro import DataClassDictMixin from .enums import MediaType -from .media_items import ItemMapping, MediaItemImage, Radio, StreamDetails, Track +from .media_items import ItemMapping, MediaItemImage, StreamDetails if TYPE_CHECKING: - pass + from .media_items import Album, Radio, Track @dataclass @@ -85,6 +85,6 @@ def get_image(media_item: Track | Radio | None) -> MediaItemImage | None: return None if media_item.image: return media_item.image - if isinstance(media_item, Track) and media_item.album and getattr(media_item.album, "image"): + if isinstance(media_item, Track) and isinstance(media_item.album, Album): return media_item.album.image return None diff --git a/music_assistant/constants.py b/music_assistant/constants.py index d841463e..698a7750 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -46,6 +46,7 @@ CONF_FLOW_MODE: Final[str] = "flow_mode" CONF_LOG_LEVEL: Final[str] = "log_level" CONF_HIDE_GROUP_CHILDS: Final[str] = "hide_group_childs" CONF_OUTPUT_CODEC: Final[str] = "output_codec" +CONF_GROUPED_POWER_ON: Final[str] = "grouped_power_on" # config default values DEFAULT_HOST: Final[str] = "0.0.0.0" diff --git a/music_assistant/server/controllers/config.py b/music_assistant/server/controllers/config.py index ba517d64..5e9a8b3f 100644 --- a/music_assistant/server/controllers/config.py +++ b/music_assistant/server/controllers/config.py @@ -282,24 +282,25 @@ class ConfigController: raw_conf["available"] = False raw_conf["name"] = raw_conf.get("name") raw_conf["default_name"] = raw_conf.get("default_name") or raw_conf["player_id"] - entries = DEFAULT_PLAYER_CONFIG_ENTRIES + prov_entries + prov_entries = prov.get_player_config_entries(player_id) + prov_entries_keys = {x.key for x in prov_entries} + # combine provider defined entries with default player config entries + entries = prov_entries + tuple( + x for x in DEFAULT_PLAYER_CONFIG_ENTRIES if x.key not in prov_entries_keys + ) return PlayerConfig.parse(entries, raw_conf) raise KeyError(f"No config found for player id {player_id}") @api_command("config/players/get_value") def get_player_config_value(self, player_id: str, key: str) -> ConfigValueType: """Return single configentry value for a player.""" - conf = self.get(f"{CONF_PLAYERS}/{player_id}") - if not conf: - player = self.mass.players.get(player_id, True) - conf = {"provider": player.provider, "player_id": player_id, "values": {}} - prov = self.mass.get_provider(conf["provider"]) - entries = DEFAULT_PLAYER_CONFIG_ENTRIES + prov.get_player_config_entries(player_id) - for entry in entries: - if entry.key == key: - # always create a copy to prevent we're altering the base object - return ConfigEntry.from_dict(entry.to_dict()).parse_value(conf["values"].get(key)) - raise KeyError(f"ConfigEntry {key} is invalid") + conf = self.get_player_config(player_id) + # always create a copy to prevent we're altering the base object + return ( + conf.values[key].value + if conf.values[key].value is not None + else conf.values[key].default_value + ) @api_command("config/players/save") def save_player_config( diff --git a/music_assistant/server/controllers/player_queues.py b/music_assistant/server/controllers/player_queues.py index 830529fe..5c291137 100755 --- a/music_assistant/server/controllers/player_queues.py +++ b/music_assistant/server/controllers/player_queues.py @@ -443,6 +443,9 @@ class PlayerQueuesController: # track is already played for > 90% - skip to next resume_item = next_item resume_pos = 0 + elif queue.current_index is not None and len(queue_items) > 0: + resume_item = self.get_item(queue_id, queue.current_index) + resume_pos = 0 elif queue.current_index is None and len(queue_items) > 0: # items available in queue but no previous track, start at 0 resume_item = self.get_item(queue_id, 0) @@ -628,7 +631,7 @@ class PlayerQueuesController: cur_item = self.get_item(queue.queue_id, cur_index) next_index = self.get_next_index(queue.queue_id, cur_index) next_item = self.get_item(queue.queue_id, next_index) - if not next_item: + if not cur_item or not next_item: raise QueueEmpty("No more tracks left in the queue.") queue.index_in_buffer = next_index # work out crossfade diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index 21d42dff..dbcc2132 100755 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -136,6 +136,8 @@ class PlayerController: player.name, ) self.mass.signal_event(EventType.PLAYER_ADDED, object_id=player.player_id, data=player) + # always call update to fix special attributes like display name, group volume etc. + self.update(player.player_id) @api_command("players/register_or_update") def register_or_update(self, player: Player) -> None: @@ -228,11 +230,12 @@ class PlayerController: for child_player_id in player.group_childs: if child_player_id == player_id: continue - self.update(child_player_id, skip_forward=True, force_update=force_update) + self.update(child_player_id, skip_forward=True) # update group player(s) when child updates for group_player in self._get_player_groups(player_id): - self.update(group_player.player_id, skip_forward=True, force_update=force_update) + player_prov = self.get_player_provider(group_player.player_id) + player_prov.on_child_state(group_player.player_id, player, changed_keys) def get_player_provider(self, player_id: str) -> PlayerProvider: """Return PlayerProvider for given player.""" @@ -317,9 +320,12 @@ class PlayerController: player = self.get(player_id, True) if player.powered == powered: return - # stop player at power off - if not powered and player.state in (PlayerState.PLAYING, PlayerState.PAUSED): + # send stop at power off + if not powered: await self.cmd_stop(player_id) + # unsync player at power off + if not powered and player.synced_to is not None: + await self.cmd_unsync(player_id) if PlayerFeature.POWER not in player.supported_features: player.powered = powered self.update(player_id) @@ -358,7 +364,7 @@ class PlayerController: group_player = self.get(player_id, True) assert group_player # handle group volume by only applying the volume to powered members - cur_volume = group_player.volume_level + cur_volume = group_player.group_volume new_volume = volume_level volume_dif = new_volume - cur_volume volume_dif_percent = 1 + new_volume / 100 if cur_volume == 0 else volume_dif / cur_volume @@ -477,29 +483,27 @@ class PlayerController: """Return the active_source id for given player.""" # if player is synced, return master/group leader if player.synced_to and player.synced_to in self._players: - return self._get_active_source(self.get(player.synced_to)) + return player.synced_to # iterate player groups to find out if one is playing if group_players := self._get_player_groups(player.player_id): # prefer the first playing (or paused) group parent for group_player in group_players: if group_player.state in (PlayerState.PLAYING, PlayerState.PAUSED): - return group_player.active_source + return group_player.player_id # fallback to the first powered group player for group_player in group_players: if group_player.powered: - return group_player.active_source - # defaults to the player's own player id + return group_player.player_id + # guess source from player's current url if player.current_url: if self.mass.webserver.base_url in player.current_url: return player.player_id - elif ":" in player.current_url: + if ":" in player.current_url: # extract source from uri/url return player.current_url.split(":")[0] return player.current_item_id or player.current_url - elif not player.powered: - # reset active source when player powers off - return player.player_id - return player.active_source + # defaults to the player's own player id + return player.player_id def _get_group_volume_level(self, player: Player) -> int: """Calculate a group volume from the grouped members.""" diff --git a/music_assistant/server/helpers/audio.py b/music_assistant/server/helpers/audio.py index 89384b3c..111c1b0a 100644 --- a/music_assistant/server/helpers/audio.py +++ b/music_assistant/server/helpers/audio.py @@ -508,12 +508,16 @@ async def get_radio_stream( meta_int = int(headers.get("icy-metaint", "0")) # stream with ICY Metadata if meta_int: + LOGGER.debug("Start streaming radio with ICY metadata from url %s", url) while True: - audio_chunk = await resp.content.readexactly(meta_int) - yield audio_chunk - meta_byte = await resp.content.readexactly(1) - meta_length = ord(meta_byte) * 16 - meta_data = await resp.content.readexactly(meta_length) + try: + audio_chunk = await resp.content.readexactly(meta_int) + yield audio_chunk + meta_byte = await resp.content.readexactly(1) + meta_length = ord(meta_byte) * 16 + meta_data = await resp.content.readexactly(meta_length) + except asyncio.exceptions.IncompleteReadError: + break if not meta_data: continue meta_data = meta_data.rstrip(b"\0") @@ -525,8 +529,10 @@ async def get_radio_stream( streamdetails.stream_title = stream_title # Regular HTTP stream else: + LOGGER.debug("Start streaming radio without ICY metadata from url %s", url) async for chunk in resp.content.iter_any(): yield chunk + LOGGER.debug("Finished streaming radio from url %s", url) async def get_http_stream( diff --git a/music_assistant/server/models/player_provider.py b/music_assistant/server/models/player_provider.py index becb661e..3222ad0e 100644 --- a/music_assistant/server/models/player_provider.py +++ b/music_assistant/server/models/player_provider.py @@ -141,6 +141,11 @@ class PlayerProvider(Provider): If the player does not need any polling, simply do not override this method. """ + def on_child_state(self, player_id: str, child_player: Player, changed_keys: set[str]) -> None: + """Call when the state of a child player updates.""" + # default implementation: simply update the state of the group player + self.mass.players.update(player_id, skip_forward=True) + # DO NOT OVERRIDE BELOW @property diff --git a/music_assistant/server/providers/chromecast/__init__.py b/music_assistant/server/providers/chromecast/__init__.py index f36d2a74..25c2b2d7 100644 --- a/music_assistant/server/providers/chromecast/__init__.py +++ b/music_assistant/server/providers/chromecast/__init__.py @@ -20,9 +20,9 @@ from pychromecast.models import CastInfo from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED from music_assistant.common.models.config_entries import ( + CONF_ENTRY_HIDE_GROUP_MEMBERS, CONF_ENTRY_OUTPUT_CODEC, ConfigEntry, - ConfigValueOption, ConfigValueType, ) from music_assistant.common.models.enums import ( @@ -36,12 +36,7 @@ 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_HIDE_GROUP_CHILDS, - CONF_OUTPUT_CODEC, - CONF_PLAYERS, - MASS_LOGO_ONLINE, -) +from music_assistant.constants import CONF_OUTPUT_CODEC, CONF_PLAYERS, MASS_LOGO_ONLINE from music_assistant.server.models.player_provider import PlayerProvider from .helpers import CastStatusListener, ChromecastInfo @@ -173,22 +168,7 @@ class ChromecastProvider(PlayerProvider): and cast_player.cast_info.is_audio_group and not cast_player.cast_info.is_multichannel_group ): - entries = entries + ( - ConfigEntry( - key=CONF_HIDE_GROUP_CHILDS, - type=ConfigEntryType.STRING, - options=[ - ConfigValueOption("Always", "always"), - ConfigValueOption("Only if the group is active/powered", "active"), - ConfigValueOption("Never", "never"), - ], - default_value="active", - label="Hide playergroup members in UI", - description="Hide the individual player entry for the members of this group " - "in the user interface.", - advanced=True, - ), - ) + entries = entries + (CONF_ENTRY_HIDE_GROUP_MEMBERS,) return entries def on_player_config_changed( diff --git a/music_assistant/server/providers/lms_cli/__init__.py b/music_assistant/server/providers/lms_cli/__init__.py index 2edc7e4a..ef0e77f3 100644 --- a/music_assistant/server/providers/lms_cli/__init__.py +++ b/music_assistant/server/providers/lms_cli/__init__.py @@ -157,6 +157,9 @@ class LmsCli(PluginProvider): str(kwargs), ) cmd_result: list[str] = handler(player_id, *args, **kwargs) + if asyncio.iscoroutine(cmd_result): + cmd_result = await cmd_result + if isinstance(cmd_result, dict): result_parts = dict_to_strings(cmd_result) result_str = " ".join(urllib.parse.quote(x) for x in result_parts) @@ -177,6 +180,8 @@ class LmsCli(PluginProvider): response += "\n" writer.write(response.encode("utf-8")) await writer.drain() + except ConnectionResetError: + pass except Exception as err: self.logger.debug("Error handling CLI command", exc_info=err) finally: @@ -204,6 +209,9 @@ class LmsCli(PluginProvider): str(kwargs), ) cmd_result = handler(player_id, *args, **kwargs) + if asyncio.iscoroutine(cmd_result): + cmd_result = await cmd_result + if cmd_result is None: cmd_result = {} elif not isinstance(cmd_result, dict): @@ -244,7 +252,7 @@ class LmsCli(PluginProvider): players.append(player_item_from_mass(start_index + index, mass_player)) return PlayersResponse(count=len(players), players_loop=players) - def _handle_status( + async def _handle_status( self, player_id: str, *args, @@ -260,9 +268,14 @@ class LmsCli(PluginProvider): assert queue is not None if start_index == "-": start_index = queue.current_index or 0 - queue_items = self.mass.players.queues.items(queue.queue_id)[ - start_index : start_index + limit - ] + queue_items = [] + index = 0 + async for item in self.mass.players.queues.items(queue.queue_id): + if index >= start_index: + queue_items.append(item) + if len(queue_items) == limit: + break + index += 1 # we ignore the tags, just always send all info return player_status_from_mass( self.mass, player=player, queue=queue, queue_items=queue_items diff --git a/music_assistant/server/providers/universal_group/__init__.py b/music_assistant/server/providers/universal_group/__init__.py new file mode 100644 index 00000000..e42a0037 --- /dev/null +++ b/music_assistant/server/providers/universal_group/__init__.py @@ -0,0 +1,320 @@ +""" +Universal Group Player provider. + +This is more like a "virtual" player provider, +allowing the user to create player groups from all players known in the system. +""" +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from music_assistant.common.models.config_entries import ( + CONF_ENTRY_FLOW_MODE, + CONF_ENTRY_GROUPED_POWER_ON, + CONF_ENTRY_HIDE_GROUP_MEMBERS, + CONF_ENTRY_OUTPUT_CHANNELS, + ConfigEntry, + ConfigValueOption, + ConfigValueType, +) +from music_assistant.common.models.enums import ( + ConfigEntryType, + PlayerFeature, + PlayerState, + PlayerType, +) +from music_assistant.common.models.player import DeviceInfo, Player +from music_assistant.common.models.queue_item import QueueItem +from music_assistant.constants import CONF_GROUPED_POWER_ON +from music_assistant.server.models.player_provider import PlayerProvider + +if TYPE_CHECKING: + from music_assistant.common.models.config_entries import ProviderConfig + from music_assistant.common.models.provider import ProviderManifest + from music_assistant.server import MusicAssistant + from music_assistant.server.models import ProviderInstanceType + + +CONF_GROUP_MEMBERS = "group_members" + +CONF_ENTRY_OUTPUT_CHANNELS_FORCED_STEREO = ConfigEntry.from_dict( + { + **CONF_ENTRY_OUTPUT_CHANNELS.to_dict(), + "hidden": True, + "default_value": "stereo", + "value": "stereo", + } +) +CONF_ENTRY_FORCED_FLOW_MODE = ConfigEntry.from_dict( + {**CONF_ENTRY_FLOW_MODE.to_dict(), "hidden": True, "default_value": True, "value": True} +) +# ruff: noqa: ARG002 + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + prov = UniversalGroupProvider(mass, manifest, config) + await prov.handle_setup() + return prov + + +async def get_config_entries( + mass: MusicAssistant, + instance_id: str | None = None, + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, +) -> tuple[ConfigEntry, ...]: + """ + Return Config entries to setup this provider. + + instance_id: id of an existing provider instance (None if new instance setup). + action: [optional] action key called from config entries UI. + values: the (intermediate) raw values for config entries sent with the action. + """ + # ruff: noqa: ARG001 + all_players = tuple( + ConfigValueOption(x.display_name, x.player_id) + for x in mass.players.all(True, True, False) + if x.player_id != instance_id + ) + return ( + ConfigEntry( + key=CONF_GROUP_MEMBERS, + type=ConfigEntryType.STRING, + label="Group members", + default_value=[], + options=all_players, + description="Select all players you want to be part of this group", + multi_value=True, + ), + ) + + +class UniversalGroupProvider(PlayerProvider): + """Base/builtin provider for universally grouping players.""" + + prev_sync_leaders: tuple[str] | None = None + + async def handle_setup(self) -> None: + """Handle async initialization of the provider.""" + self.player = Player( + player_id=self.instance_id, + provider=self.domain, + type=PlayerType.GROUP, + name=self.name, + available=True, + powered=False, + device_info=DeviceInfo(model=self.manifest.name, manufacturer="Music Assistant"), + # TODO: derive playerfeatures from (all) underlying child players + supported_features=( + PlayerFeature.POWER, + PlayerFeature.PAUSE, + PlayerFeature.VOLUME_SET, + PlayerFeature.VOLUME_MUTE, + PlayerFeature.SET_MEMBERS, + ), + active_source=self.instance_id, + group_childs=self.config.get_value(CONF_GROUP_MEMBERS), + ) + self.mass.players.register_or_update(self.player) + + async def unload(self) -> None: + """Handle close/cleanup of the provider.""" + self.mass.players.remove(self.instance_id) + + def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry]: # noqa: ARG002 + """Return all (provider/player specific) Config Entries for the given player (if any).""" + return ( + CONF_ENTRY_HIDE_GROUP_MEMBERS, + CONF_ENTRY_GROUPED_POWER_ON, + CONF_ENTRY_OUTPUT_CHANNELS_FORCED_STEREO, + CONF_ENTRY_FORCED_FLOW_MODE, + ) + + async def cmd_stop(self, player_id: str) -> None: + """Send STOP command to given player.""" + # forward command to player and any connected sync child's + async with asyncio.TaskGroup() as tg: + for member in self._get_active_members(only_powered=True, skip_sync_childs=True): + if member.state == PlayerState.IDLE: + continue + tg.create_task(self.mass.players.cmd_stop(member.player_id)) + + async def cmd_play(self, player_id: str) -> None: + """Send PLAY command to given player.""" + async with asyncio.TaskGroup() as tg: + for member in self._get_active_members(only_powered=True, skip_sync_childs=True): + tg.create_task(self.mass.players.cmd_play(member.player_id)) + + async def cmd_play_media( + self, + player_id: str, + queue_item: QueueItem, + seek_position: int = 0, + fade_in: bool = False, + flow_mode: bool = False, + ) -> None: + """Send PLAY MEDIA command to given player. + + This is called when the Queue wants the player to start playing a specific QueueItem. + The player implementation can decide how to process the request, such as playing + queue items one-by-one or enqueue all/some items. + + - player_id: player_id of the player to handle the command. + - queue_item: the QueueItem to start playing on the player. + - seek_position: start playing from this specific position. + - fade_in: fade in the music at start (e.g. at resume). + """ + # send stop first + await self.cmd_stop(player_id) + # power ON + await self.cmd_power(player_id, True) + # issue sync command (just in case) + await self._sync_players() + # forward command to all (powered) group child's + async with asyncio.TaskGroup() as tg: + for member in self._get_active_members(only_powered=True, skip_sync_childs=True): + player_prov = self.mass.players.get_player_provider(member.player_id) + tg.create_task( + player_prov.cmd_play_media( + member.player_id, + queue_item=queue_item, + seek_position=seek_position, + fade_in=fade_in, + flow_mode=flow_mode, + ) + ) + + async def cmd_pause(self, player_id: str) -> None: + """Send PAUSE command to given player.""" + async with asyncio.TaskGroup() as tg: + for member in self._get_active_members(only_powered=True, skip_sync_childs=True): + tg.create_task(self.mass.players.cmd_pause(member.player_id)) + + async def cmd_power(self, player_id: str, powered: bool) -> None: + """Send POWER command to given player.""" + if self.player.powered == powered: + return # nothing to do + group_power_on = self.mass.config.get_player_config_value(player_id, CONF_GROUPED_POWER_ON) + if powered and not group_power_on: + return # nothing to do + + async def set_child_power(child_player: Player) -> None: + await self.mass.players.cmd_power(child_player.player_id, powered) + # set optimistic state on child player to prevent race conditions in other actions + child_player.powered = powered + + async with asyncio.TaskGroup() as tg: + for member in self._get_active_members( + only_powered=not powered, skip_sync_childs=False + ): + tg.create_task(set_child_power(member)) + + self.player.powered = powered + self.mass.players.update(self.instance_id) + if powered: + # sync all players on power on + await self._sync_players() + + async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: + """Send VOLUME_SET command to given player.""" + + async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: + """Send VOLUME MUTE command to given player.""" + + async def poll_player(self, player_id: str) -> None: + """Poll player for state updates.""" + self.update_attributes() + self.mass.players.update(player_id, skip_forward=True) + + def update_attributes(self) -> None: + """Update player attributes.""" + all_members = self._get_active_members(only_powered=False, skip_sync_childs=False) + self.player.group_childs = list(x.player_id for x in all_members) + # read the state from the first powered child player + for member in all_members: + if member.synced_to: + continue + if not member.powered: + continue + if member.state not in (PlayerState.PLAYING, PlayerState.PAUSED): + continue + self.player.current_item_id = member.current_item_id + self.player.current_url = member.current_url + self.player.elapsed_time = member.elapsed_time + self.player.elapsed_time_last_updated = member.elapsed_time_last_updated + self.player.state = member.state + break + else: + self.player.state = PlayerState.IDLE + self.player.current_item_id = None + self.player.current_url = None + + def on_child_state(self, player_id: str, child_player: Player, changed_keys: set[str]) -> None: + """Call when the state of a child player updates.""" + # TODO: handle a sync leader powerin off + powered_players = self._get_active_members(True, False) + if "powered" in changed_keys: + if child_player.powered and self.player.state == PlayerState.PLAYING: + # a child player turned ON while the group player is already playing + # we need to resync/resume + self.mass.create_task(self.mass.players.queues.resume, player_id) + elif not child_player.powered and len(powered_players) == 0: + # the last player of a group turned off + # turn off the group + self.mass.create_task(self.cmd_power, player_id, False) + self.update_attributes() + self.mass.players.update(player_id, skip_forward=True) + + def _get_active_members( + self, only_powered: bool = False, skip_sync_childs: bool = True + ) -> list[Player]: + """Get (child) players attached to a grouped player.""" + child_players: list[Player] = [] + conf_members: list[str] = self.config.get_value(CONF_GROUP_MEMBERS) + ignore_ids = set() + for child_id in conf_members: + if child_player := self.mass.players.get(child_id, False): + if not (not only_powered or child_player.powered): + continue + if child_player.synced_to and skip_sync_childs: + continue + allowed_sources = [child_player.player_id, self.instance_id] + conf_members + if child_player.active_source not in allowed_sources: + # edge case: the child player has another group already active! + continue + if child_player.synced_to and child_player.synced_to not in conf_members: + # edge case: the child player is already synced to another player + continue + child_players.append(child_player) + # handle edge case where a group is in the group and both the group + # and (one of its) child's are added to this universal group. + if child_player.type == PlayerType.GROUP: + ignore_ids.update( + x for x in child_player.group_childs if x != child_player.player_id + ) + return [x for x in child_players if x.player_id not in ignore_ids] + + async def _sync_players(self) -> None: + """Sync all (possible) players.""" + sync_leaders = set() + # TODO: sort members on sync master priority attribute ? + for member in self._get_active_members(only_powered=True): + if member.synced_to is not None: + continue + if not member.can_sync_with: + continue + # check if we can join this player to an already chosen sync leader + if existing_leader := next( + (x for x in member.can_sync_with if x in sync_leaders), None + ): + await self.mass.players.cmd_sync(member.player_id, existing_leader) + # set optimistic state to prevent race condition in play media + member.synced_to = existing_leader + continue + # pick this member as new sync leader + sync_leaders.add(member.player_id) + self.prev_sync_leaders = tuple(sync_leaders) diff --git a/music_assistant/server/providers/universal_group/manifest.json b/music_assistant/server/providers/universal_group/manifest.json new file mode 100644 index 00000000..3d613128 --- /dev/null +++ b/music_assistant/server/providers/universal_group/manifest.json @@ -0,0 +1,13 @@ +{ + "type": "player", + "domain": "universal_group", + "name": "Universal Group Player", + "description": "Create a Player Group with your favorite players, regardless of type and model.", + "codeowners": ["@music-assistant"], + "requirements": [], + "documentation": "", + "multi_instance": true, + "builtin": false, + "load_by_default": false, + "icon": "mdi:mdi-speaker-multiple" +}